diff --git a/packages/app-compose/src/__tests__/is.test.ts b/packages/app-compose/src/__tests__/is.test.ts index 65c4c26..22cee07 100644 --- a/packages/app-compose/src/__tests__/is.test.ts +++ b/packages/app-compose/src/__tests__/is.test.ts @@ -1,4 +1,5 @@ -import { createTask, tag } from "@runnable" +import { literal } from "@computable" +import { createTask, createWire, tag } from "@runnable" import { describe, expect, it } from "vitest" import { is } from "../is" @@ -33,3 +34,19 @@ describe("is.task", () => { expect(is.task({})).toBe(false) }) }) + +describe("is.wire", () => { + it("returns true for a wire", () => { + const wire = createWire({ from: literal(1), to: tag("x") }) + + expect(is.wire(wire)).toBe(true) + }) + + it("returns false for a non object", () => { + expect(is.wire(1)).toBe(false) + }) + + it("returns false for an object", () => { + expect(is.wire({})).toBe(false) + }) +}) diff --git a/packages/app-compose/src/compose/__tests__/compose.bench.ts b/packages/app-compose/src/compose/__tests__/compose.bench.ts index a8763a0..976e2d1 100644 --- a/packages/app-compose/src/compose/__tests__/compose.bench.ts +++ b/packages/app-compose/src/compose/__tests__/compose.bench.ts @@ -1,16 +1,15 @@ +import { compose } from "@compose" import { literal, shape, type Spot } from "@computable" -import { createWire, tag, createTask } from "@runnable" +import { createTask, createWire, tag } from "@runnable" import { bench, describe, vi } from "vitest" -import { compose } from "../compose" const double = (x: number) => x * 2 describe("multi layer, compute shapes", () => { const rootTag = tag("root") + const wire = createWire({ from: literal(1), to: rootTag }) - let app = compose() - .meta({ name: "bench" }) - .step(createWire(rootTag, literal(1))) + let app = compose().meta({ name: "bench" }).step(wire) let previous: Spot = rootTag.value @@ -31,8 +30,8 @@ describe("multi layer, compute shapes", () => { }) app = app - .step([createWire(a, shape(l, double)), createWire(b, shape(l, double))]) - .step([createWire(c, shape(l, double)), createWire(d, shape(l, double))]) + .step([createWire({ from: shape(l, double), to: a }), createWire({ from: shape(l, double), to: b })]) + .step([createWire({ from: shape(l, double), to: c }), createWire({ from: shape(l, double), to: d })]) .step(task) previous = task.result diff --git a/packages/app-compose/src/compose/__tests__/compose.test.ts b/packages/app-compose/src/compose/__tests__/compose.test.ts index 40729b2..6ee8f46 100644 --- a/packages/app-compose/src/compose/__tests__/compose.test.ts +++ b/packages/app-compose/src/compose/__tests__/compose.test.ts @@ -20,11 +20,11 @@ describe("compose", () => { describe("guard", () => { it("throws on warning graph", () => { const alpha = tag("alpha") - const wire = createWire(alpha, literal(1)) + const wire = createWire({ from: literal(1), to: alpha }) const app = compose().step(wire) - const message = `Unused Wire found with name Tag[alpha] in step root > #1.` + const message = `Unused Wire found with name Wire[alpha] for Tag[alpha] in step root > #1.` expect(() => app.guard()).toThrowError(message) }) @@ -33,10 +33,10 @@ describe("compose", () => { const alpha = tag("alpha") const app = compose() - .step(createWire(alpha, literal(1))) - .step(createWire(alpha, literal(2))) + .step(createWire({ from: literal(1), to: alpha })) + .step(createWire({ from: literal(2), to: alpha })) - const message = `A duplicate Wire found with name Tag[alpha] in step root > #2.` + const message = `A duplicate Wire found with name Wire[alpha] in step root > #2.` expect(() => app.guard()).toThrowError(message) }) @@ -47,7 +47,7 @@ describe("compose", () => { const alpha = tag("alpha") const task = createTask({ name: "task", run: { fn: () => {} } }) - const wire = createWire(alpha, literal(1)) + const wire = createWire({ from: literal(1), to: alpha }) const graph = compose().meta({ name: "app" }).step(wire).step(task).graph() diff --git a/packages/app-compose/src/compose/__tests__/graph.test.ts b/packages/app-compose/src/compose/__tests__/graph.test.ts index 0602c9f..c377597 100644 --- a/packages/app-compose/src/compose/__tests__/graph.test.ts +++ b/packages/app-compose/src/compose/__tests__/graph.test.ts @@ -138,7 +138,11 @@ describe("graph", () => { const valueTag = tag("value") const betaTask = createTask({ name: "beta", run: { fn: vi.fn(), context: valueTag.value } }) - const app = compose().step(alphaTask).step(createWire(valueTag, alphaTask.result.value)).step(betaTask) + const app = compose() + .step(alphaTask) + .step(createWire({ from: alphaTask.result.value, to: valueTag })) + .step(betaTask) + const result = graph(app[Node$]) const expected = { @@ -170,12 +174,12 @@ describe("graph", () => { }) it("dependency on a task via a tag [optional]", () => { - const valueTag = tag("value") + const valueTag = tag("value") const betaTask = createTask({ name: "beta", run: { fn: vi.fn(), context: optional(valueTag.value) } }) const app = compose() .step(alphaTask) - .step(createWire(valueTag, optional(alphaTask.result.value))) + .step(createWire({ from: optional(alphaTask.result.value), to: valueTag })) .step(betaTask) const result = graph(app[Node$]) @@ -213,7 +217,7 @@ describe("graph", () => { const betaTask = createTask({ name: "beta", run: { fn: vi.fn(), context: valueTag.value } }) const app = compose() - .step(createWire(valueTag, literal(false))) + .step(createWire({ from: literal(false), to: valueTag })) .step(betaTask) const result = graph(app[Node$]) @@ -274,7 +278,7 @@ describe("graph", () => { const app = compose() .step(alphaTask) - .step(createWire(fn, literal(vi.fn()))) + .step(createWire({ from: literal(vi.fn()), to: fn })) .step(betaTask) const result = graph(app[Node$]) @@ -510,4 +514,52 @@ describe("graph", () => { expect(result).toStrictEqual(expected) }) }) + + describe("multi-tag wire", () => { + it("multi-tag wire satisfies multiple downstream tasks", () => { + const a = tag("a") + const b = tag("b") + + const taskA = createTask({ name: "useA", run: { fn: vi.fn(), context: a.value } }) + const taskB = createTask({ name: "useB", run: { fn: vi.fn(), context: b.value } }) + + const wire = createWire({ from: literal({ a: 1, b: "x" }), to: { a, b } }) + const app = compose().step(wire).step([taskA, taskB]) + + const result = graph(app[Node$]) + + const expected = { + type: "seq", + meta: { name: undefined }, + children: [ + { + type: "run", + id: 0, + meta: { name: "a + b", kind: "wire" }, + dependencies: { required: [], optional: [] }, + }, + { + type: "con", + meta: { name: undefined }, + children: [ + { + type: "run", + id: 1, + meta: { name: "useA", kind: "task" }, + dependencies: { required: [0], optional: [] }, + }, + { + type: "run", + id: 2, + meta: { name: "useB", kind: "task" }, + dependencies: { required: [0], optional: [] }, + }, + ], + }, + ], + } + + expect(result).toStrictEqual(expected) + }) + }) }) diff --git a/packages/app-compose/src/compose/__tests__/guard.test.ts b/packages/app-compose/src/compose/__tests__/guard.test.ts index 39bccca..1a8b26c 100644 --- a/packages/app-compose/src/compose/__tests__/guard.test.ts +++ b/packages/app-compose/src/compose/__tests__/guard.test.ts @@ -17,22 +17,25 @@ describe("duplicate guard", () => { describe("calls error on duplicate wire", () => { it("in the same step (concurrent)", () => { - const app = compose().step([createWire(alpha, literal(1)), createWire(alpha, literal(2))]) + const app = compose().step([ + createWire({ from: literal(1), to: alpha }), + createWire({ from: literal(2), to: alpha }), + ]) guard(app[Node$]) - const message = "A duplicate Wire found with name Tag[alpha] in step root > #1 > #2." + const message = "A duplicate Wire found with name Wire[alpha] in step root > #1 > #2." expect(handler.error).toHaveBeenCalledExactlyOnceWith(message) }) it("in different steps (sequential)", () => { const app = compose() - .step(createWire(alpha, literal(1))) - .step(createWire(alpha, literal(2))) + .step(createWire({ from: literal(1), to: alpha })) + .step(createWire({ from: literal(2), to: alpha })) guard(app[Node$]) - const message = "A duplicate Wire found with name Tag[alpha] in step root > #2." + const message = "A duplicate Wire found with name Wire[alpha] in step root > #2." expect(handler.error).toHaveBeenCalledExactlyOnceWith(message) }) }) @@ -60,16 +63,16 @@ describe("duplicate guard", () => { it("provides rich path", () => { const app = compose() .meta({ name: "MyApp" }) - .step(createWire(alpha, literal(1))) + .step(createWire({ from: literal(1), to: alpha })) .step([ compose() .meta({ name: "Layout" }) - .step(createWire(alpha, literal(2))), + .step(createWire({ from: literal(2), to: alpha })), ]) guard(app[Node$]) - const message = "A duplicate Wire found with name Tag[alpha] in step MyApp > #2 > #1 (Layout) > #1." + const message = "A duplicate Wire found with name Wire[alpha] in step MyApp > #2 > #1 (Layout) > #1." expect(handler.error).toHaveBeenCalledExactlyOnceWith(message) }) @@ -80,7 +83,7 @@ describe("duplicate guard", () => { const task = createTask({ name: "beta", run: { fn, context: alpha.value } }) const app = compose() - .step(createWire(alpha, literal(1))) + .step(createWire({ from: literal(1), to: alpha })) .step(task) guard(app[Node$]) @@ -122,11 +125,12 @@ describe("unsatisfied guard", () => { const alpha = tag("alpha") const beta = tag("beta") - const app = compose().step(createWire(beta, alpha.value)) + const app = compose().step(createWire({ from: alpha.value, to: beta })) guard(app[Node$]) - const message = "Unsatisfied dependencies found for Wire with name Tag[beta] in step root > #1: missing Tag[alpha]." + const message = + "Unsatisfied dependencies found for Wire with name Wire[beta] in step root > #1: missing Tag[alpha]." expect(handler.error).toHaveBeenCalledExactlyOnceWith(message) }) @@ -134,12 +138,12 @@ describe("unsatisfied guard", () => { const provider = createTask({ name: "alpha", run: { fn: () => 0 } }) const intermediate = tag("beta") - const app = compose().step(createWire(intermediate, provider.status)) + const app = compose().step(createWire({ from: provider.status, to: intermediate })) guard(app[Node$]) const message = - "Unsatisfied dependencies found for Wire with name Tag[beta] in step root > #1: missing Task[alpha]::status." + "Unsatisfied dependencies found for Wire with name Wire[beta] in step root > #1: missing Task[alpha]::status." expect(handler.error).toHaveBeenCalledExactlyOnceWith(message) }) @@ -151,7 +155,7 @@ describe("unsatisfied guard", () => { const app = compose().step([ compose() - .step(createWire(provider, literal(1))) + .step(createWire({ from: literal(1), to: provider })) .step(taskA), compose().step(taskB), ]) @@ -181,7 +185,7 @@ describe("unsatisfied guard", () => { it("only reports missing dependencies for the task", () => { const app = compose() - .step(createWire(alpha, literal(1))) + .step(createWire({ from: literal(1), to: alpha })) .step(task) guard(app[Node$]) @@ -207,17 +211,35 @@ describe("unsatisfied guard", () => { expect(handler.error).toHaveBeenCalledExactlyOnceWith(message) }) - it("passes when satisfied", () => { - const provider = tag("alpha") - const consumer = createTask({ name: "beta", run: { fn, context: provider.value } }) + describe("passes when satisfied", () => { + it("plain wire to task", () => { + const provider = tag("alpha") - const app = compose() - .step(createWire(provider, literal(1))) - .step(consumer) + const consumer = createTask({ name: "beta", run: { fn, context: provider.value } }) + const wire = createWire({ from: literal(1), to: provider }) - guard(app[Node$]) + const app = compose().step(wire).step(consumer) - expect(handler.error).not.toHaveBeenCalled() + guard(app[Node$]) + + expect(handler.error).not.toHaveBeenCalled() + expect(handler.warn).not.toHaveBeenCalled() + }) + + it("multiwire to task", () => { + const a = tag("a") + const b = tag("b") + + const consumer = createTask({ name: "gamma", run: { fn: vi.fn(), context: { a: a.value, b: b.value } } }) + const wire = createWire({ from: literal({ a: 1, b: "x" }), to: { a, b } }) + + const app = compose().step(wire).step(consumer) + + guard(app[Node$]) + + expect(handler.error).not.toHaveBeenCalled() + expect(handler.warn).not.toHaveBeenCalled() + }) }) it("handles unknown dependency", () => { @@ -238,11 +260,11 @@ describe("unused guard", () => { const alpha = tag("alpha") it("warns on unused wire (seq)", () => { - const node = compose().step(createWire(alpha, literal(1))) + const node = compose().step(createWire({ from: literal(1), to: alpha })) guard(node[Node$]) - const message = "Unused Wire found with name Tag[alpha] in step root > #1." + const message = "Unused Wire found with name Wire[alpha] for Tag[alpha] in step root > #1." expect(handler.warn).toHaveBeenCalledWith(message) }) @@ -251,27 +273,41 @@ describe("unused guard", () => { const task = createTask({ name: "test", run: { fn: vi.fn(), context: beta.value } }) const node = compose() - .step([createWire(alpha, literal(1)), createWire(beta, literal(2))]) + .step([createWire({ from: literal(1), to: alpha }), createWire({ from: literal(2), to: beta })]) .step(task) guard(node[Node$]) - const message = "Unused Wire found with name Tag[alpha] in step root > #1 > #1." + const message = "Unused Wire found with name Wire[alpha] for Tag[alpha] in step root > #1 > #1." expect(handler.warn).toHaveBeenCalledWith(message) }) + it("warns on partially unused multiwire", () => { + const a = tag("a") + const b = tag("b") + + const consumer = createTask({ name: "gamma", run: { fn: vi.fn(), context: a.value } }) + const wire = createWire({ from: literal({ a: 1, b: "x" }), to: { a, b } }) + + const app = compose().step(wire).step(consumer) + + guard(app[Node$]) + + expect(handler.warn).toHaveBeenCalledWith("Unused Wire found with name Wire[a + b] for Tag[b] in step root > #1.") + }) + it("provides rich path", () => { const node = compose() .meta({ name: "MyApp" }) .step( compose() .meta({ name: "Layout" }) - .step(createWire(alpha, literal(2))), + .step(createWire({ from: literal(2), to: alpha })), ) guard(node[Node$]) - const message = "Unused Wire found with name Tag[alpha] in step MyApp > #1 (Layout) > #1." + const message = "Unused Wire found with name Wire[alpha] for Tag[alpha] in step MyApp > #1 (Layout) > #1." expect(handler.warn).toHaveBeenCalledWith(message) }) }) diff --git a/packages/app-compose/src/compose/__tests__/observer.test.ts b/packages/app-compose/src/compose/__tests__/observer.test.ts index de5ebfa..6e62fba 100644 --- a/packages/app-compose/src/compose/__tests__/observer.test.ts +++ b/packages/app-compose/src/compose/__tests__/observer.test.ts @@ -38,7 +38,10 @@ describe("observer", () => { throw new Error("error") }) - const app = compose().meta({ name: "app", hooks: { onTaskFail } }).step(createWire(test, value)).step(task) + const app = compose() + .meta({ name: "app", hooks: { onTaskFail } }) + .step(createWire({ from: value, to: test })) + .step(task) await app.run() diff --git a/packages/app-compose/src/compose/__tests__/runner.test.ts b/packages/app-compose/src/compose/__tests__/runner.test.ts index a0ec5c8..ca7ff3f 100644 --- a/packages/app-compose/src/compose/__tests__/runner.test.ts +++ b/packages/app-compose/src/compose/__tests__/runner.test.ts @@ -240,7 +240,7 @@ describe("runner", () => { it("can read tag value from scope", async () => { const test = tag("test") - const app = compose().step(createWire(test, literal("value"))) + const app = compose().step(createWire({ from: literal("value"), to: test })) const scope = await run(app[Node$]) diff --git a/packages/app-compose/src/compose/guard.ts b/packages/app-compose/src/compose/guard.ts index 705d1ca..a4030e1 100644 --- a/packages/app-compose/src/compose/guard.ts +++ b/packages/app-compose/src/compose/guard.ts @@ -10,7 +10,6 @@ type GuardHandler = Record<"warn" | "error", (message: string) => void> const UNKNOWN_NAME = "" const TypeMap = { task: "Task", wire: "Wire" } satisfies Record -const NameMap = { task: "Task", wire: "Tag" } satisfies Record const metaOf = (node: ComposeNode, key: K): ComposeMeta[K] => "meta" in node && node.meta?.[key] ? node.meta[key] : undefined @@ -26,19 +25,22 @@ const stackToName = (stack: NotifyEntry[]): string => const createNotify = (handler: GuardHandler) => ({ duplicate: ({ type, name, stack }: NotifyContext) => { - const message = `A duplicate ${TypeMap[type]} found with name ${NameMap[type]}[${name}] in step ${stackToName(stack)}.` + const message = `A duplicate ${TypeMap[type]} found with name ${TypeMap[type]}[${name}] in step ${stackToName(stack)}.` + handler.error(message) }, notSatisfied: ({ type, name, stack, missing: set }: NotifyContext & { missing: Set }) => { const list = Array.from(set, (id) => id.description ?? UNKNOWN_NAME).join(", ") + const message = `Unsatisfied dependencies found for ${TypeMap[type]} with name ${TypeMap[type]}[${name}] in step ${stackToName(stack)}: missing ${list}.` - const message = `Unsatisfied dependencies found for ${TypeMap[type]} with name ${NameMap[type]}[${name}] in step ${stackToName(stack)}: missing ${list}.` handler.error(message) }, - unused: ({ type, name, stack }: NotifyContext) => { - const message = `Unused ${TypeMap[type]} found with name ${NameMap[type]}[${name}] in step ${stackToName(stack)}.` + unused: ({ type, name, stack, id }: NotifyContext & { id: symbol }) => { + const target = `${TypeMap[type]}[${name}] for ${id.description ?? UNKNOWN_NAME}` + const message = `Unused ${TypeMap[type]} found with name ${target} in step ${stackToName(stack)}.` + handler.warn(message) }, }) @@ -71,7 +73,7 @@ const createGuard = ({ handler }: GuardConfig) => { } const unused = (root: ComposeNode) => { - const candidates: Map = new Map() + const candidates: Map = new Map() const traverse = (stack: NotifyEntry[]) => { const { node: current } = stack.at(-1)! @@ -82,7 +84,7 @@ const createGuard = ({ handler }: GuardConfig) => { if (current.type === "run") { const { type, display, writes, dependencies } = analyzer.get(current.value as RunnableInternal) - if (type === "wire") writes.forEach((id) => candidates.set(id, { type, name: display.name, stack })) + if (type === "wire") writes.forEach((id) => candidates.set(id, { id, type, name: display.name, stack })) dependencies.required.forEach((id) => candidates.delete(id)) dependencies.optional.forEach((id) => candidates.delete(id)) diff --git a/packages/app-compose/src/is.ts b/packages/app-compose/src/is.ts index 3335272..9616647 100644 --- a/packages/app-compose/src/is.ts +++ b/packages/app-compose/src/is.ts @@ -1,9 +1,12 @@ -import { type Tag, Tag$, type Task, Task$ } from "@runnable" +import { Execute$, type Runnable, type RunnableKind, type Tag, Tag$, type Task, type Wire } from "@runnable" import { isObject } from "@shared" const is = { tag: (x: unknown): x is Tag => isObject(x) && Tag$ in x, - task: (x: unknown): x is Task => isObject(x) && Task$ in x, + runnable: (x: unknown): x is Runnable & RunnableKind => isObject(x) && Execute$ in x, + + task: (x: unknown): x is Task => is.runnable(x) && x.kind === "task", + wire: (x: unknown): x is Wire => is.runnable(x) && x.kind === "wire", } export { is } diff --git a/packages/app-compose/src/runnable/__tests__/wire.test-d.ts b/packages/app-compose/src/runnable/__tests__/wire.test-d.ts new file mode 100644 index 0000000..ac01c8f --- /dev/null +++ b/packages/app-compose/src/runnable/__tests__/wire.test-d.ts @@ -0,0 +1,101 @@ +import { literal } from "@computable" +import { describe, expectTypeOf, it } from "vitest" +import { createWire, tag, type Wire } from "../wire" + +describe("createWire", () => { + it("single tag (baseline)", () => { + const a = tag("a") + + const wire = createWire({ from: literal(1), to: a }) + + expectTypeOf(wire).toEqualTypeOf() + }) + + it("shape of tags with individual spots", () => { + const a = tag("a") + const b = tag("b") + + const wire = createWire({ from: { a: literal(1), b: literal("x") }, to: { a, b } }) + + expectTypeOf(wire).toEqualTypeOf() + }) + + it("shape of tags with a single spot covering the whole shape", () => { + const a = tag("a") + const b = tag("b") + + const wire = createWire({ from: literal({ a: 1, b: "x" }), to: { a, b } }) + + expectTypeOf(wire).toEqualTypeOf() + }) + + it("array spot to array of tags", () => { + const a = tag("a") + const b = tag("b") + + const wire = createWire({ from: literal([1, "x"]), to: [a, b] }) + + expectTypeOf(wire).toEqualTypeOf() + }) + + it("array spot to nested array of tags", () => { + const a = tag("a") + const b = tag("b") + + const wire = createWire({ from: { b: literal([1, "x"]) }, to: { b: [a, b] } }) + + expectTypeOf(wire).toEqualTypeOf() + }) + + it("nested shape of tags", () => { + const a = tag("a") + const b = tag("b") + + const wire = createWire({ from: { x: { a: literal(1) }, b: literal("hi") }, to: { x: { a }, b } }) + + expectTypeOf(wire).toEqualTypeOf() + }) + + it("rejects mismatched types", () => { + const a = tag("a") + + const wire = createWire({ + // @ts-expect-error - string is not assignable to number + from: literal("x"), + to: a, + }) + + expectTypeOf(wire).toEqualTypeOf() + }) + + it("rejects wider from into narrower to", () => { + const a = tag<1 | 2>("a") + + const wire = createWire({ + // @ts-expect-error - number is wider than 1 | 2 + from: literal(1), + to: a, + }) + + expectTypeOf(wire).toEqualTypeOf() + }) + + it("accepts narrower from into wider to", () => { + const a = tag("a") + + const wire = createWire({ from: literal<1 | 2>(1), to: a }) + + expectTypeOf(wire).toEqualTypeOf() + }) + + it("rejects non-tag to", () => { + const wire = createWire({ + // @ts-expect-error - to must be a tag or shape of tags + from: literal({ a: "test" }), + // @ts-expect-error - to must be a tag or shape of tags + to: { a: "error-value" }, + }) + + expectTypeOf(wire).toEqualTypeOf() + }) +}) diff --git a/packages/app-compose/src/runnable/__tests__/wire.test.ts b/packages/app-compose/src/runnable/__tests__/wire.test.ts new file mode 100644 index 0000000..1bc38fb --- /dev/null +++ b/packages/app-compose/src/runnable/__tests__/wire.test.ts @@ -0,0 +1,129 @@ +import { literal } from "@computable" +import { LIBRARY_NAME } from "@shared" +import { describe, expect, it } from "vitest" +import { compose, Node$ } from "../../compose/compose" +import { run } from "../../compose/runner" +import { createTask } from "../task" +import { createWire, tag } from "../wire" + +describe("createWire", () => { + describe("in base no-shape mode", () => { + it("wires up a spot to a single tag", async () => { + const a = tag("a") + const wire = createWire({ from: literal(42), to: a }) + + const app = compose().step(wire) + + const scope = await run(app[Node$]) + + expect(scope.get(a.value)).toBe(42) + }) + }) + + describe("in gather mode", () => { + it("wires merged spots into a single tag", async () => { + const combined = tag<{ x: number; y: string }>("combined") + + const wire = createWire({ + from: { x: literal(42), y: literal("beta") }, + to: combined, + }) + + const app = compose().step(wire) + const scope = await run(app[Node$]) + + expect(scope.get(combined.value)).toStrictEqual({ x: 42, y: "beta" }) + }) + }) + + describe("in spread mode", () => { + it("wires a single spot into multiple tags", async () => { + const a = tag("a") + const b = tag("b") + + const wire = createWire({ + from: literal({ a: 42, b: "beta" }), + to: { a, b }, + }) + + const app = compose().step(wire) + const scope = await run(app[Node$]) + + expect(scope.get(a.value)).toBe(42) + expect(scope.get(b.value)).toBe("beta") + }) + }) + + describe("in multi mode", () => { + it("routes individual spots to their corresponding tags", async () => { + const a = tag("a") + const b = tag("b") + + const wire = createWire({ + from: { a: literal(5), b: literal("beta") }, + to: { a, b }, + }) + + const app = compose().step(wire) + const scope = await run(app[Node$]) + + expect(scope.get(a.value)).toBe(5) + expect(scope.get(b.value)).toBe("beta") + }) + + it("routes through nested structure", async () => { + const a = tag("a") + const b = tag("b") + + const task = createTask({ name: "test", run: { fn: () => "beta" } }) + + const wire = createWire({ + from: { nested: { a: literal(42) }, b: task.result }, + to: { nested: { a }, b }, + }) + + const app = compose().step(task).step(wire) + const scope = await run(app[Node$]) + + expect(scope.get(a.value)).toBe(42) + expect(scope.get(b.value)).toBe("beta") + }) + + it("routes through a tuple structure", async () => { + const a = tag("a") + const b = tag("b") + + const wire = createWire({ + from: literal([42, "beta"] as [number, string]), + to: [a, b], + }) + + const app = compose().step(wire) + const scope = await run(app[Node$]) + + expect(scope.get(a.value)).toBe(42) + expect(scope.get(b.value)).toBe("beta") + }) + }) + + describe("validation", () => { + it("throws on duplicate tag in destination", () => { + const a = tag("a") + const error = `${LIBRARY_NAME} Duplicate tag "a" found in Wire destination.` + + expect(() => createWire({ from: literal({ x: 1, y: 2 }), to: { x: a, y: a } as any })).toThrow(error) + }) + + it("throws on invalid shape leaf", () => { + const error = `${LIBRARY_NAME} Invalid shape not-a-tag provided to Wire at path "".` + expect(() => createWire({ from: literal(1), to: "not-a-tag" as any })).toThrow(error) + }) + + it("throws on invalid shape leaf at nested path", () => { + const a = tag("a") + const error = `${LIBRARY_NAME} Invalid shape 42 provided to Wire at path "b".` + + expect(() => createWire({ from: literal({ a: 1, b: 2 }), to: { a, b: 42 } as any })).toThrow(error) + }) + }) +}) diff --git a/packages/app-compose/src/runnable/index.ts b/packages/app-compose/src/runnable/index.ts index 3a523bd..f2166d6 100644 --- a/packages/app-compose/src/runnable/index.ts +++ b/packages/app-compose/src/runnable/index.ts @@ -1,3 +1,3 @@ export { Context$, Dispatch$, Execute$, type Runnable, type RunnableInternal, type RunnableKind } from "./definition" -export { Tag$, createWire, tag, type Tag, type Wire } from "./tag" -export { Task$, createTask, type Task, type TaskExecutionValue, type TaskResult, type TaskStatus } from "./task" +export { createTask, type Task, type TaskExecutionValue, type TaskResult, type TaskStatus } from "./task" +export { Tag$, createWire, tag, type Tag, type Wire } from "./wire" diff --git a/packages/app-compose/src/runnable/tag.ts b/packages/app-compose/src/runnable/tag.ts deleted file mode 100644 index 5deccdf..0000000 --- a/packages/app-compose/src/runnable/tag.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { ContextToSpot } from "@computable" -import { build, reference, type SpotProvider } from "@computable" -import { identity } from "@shared" -import { Context$, Dispatch$, Execute$, type Runnable, type RunnableInternal, type RunnableKind } from "./definition" - -const Tag$ = Symbol("$tag") - -type Tag = { [Tag$]: symbol; name: string; value: SpotProvider } - -const tag = (name: string): Tag => { - const id = Symbol(`Tag[${name}]`) - - return { [Tag$]: id, name, value: reference.lensed(id) } -} - -type Wire = { name: string } & Runnable & RunnableKind<"wire"> - -const createWire = (tag: Tag, value: ContextToSpot): Wire => { - const runnable: RunnableInternal & Wire = { - name: tag.name, - kind: "wire", - - [Context$]: build(value), - [Execute$]: identity, - [Dispatch$]: { [tag[Tag$]]: identity }, - } - - return runnable -} - -export { createWire, tag, Tag$, type Tag, type Wire } diff --git a/packages/app-compose/src/runnable/task.ts b/packages/app-compose/src/runnable/task.ts index 6743f75..898dfba 100644 --- a/packages/app-compose/src/runnable/task.ts +++ b/packages/app-compose/src/runnable/task.ts @@ -3,8 +3,6 @@ import { build, literal, Missing$, reference, type Spot, type SpotProvider } fro import { T, type Eventual } from "@shared" import { Context$, Dispatch$, Execute$, type Runnable, type RunnableInternal, type RunnableKind } from "./definition" -const Task$ = Symbol("$task") - type WithContext = Context extends void ? { fn: () => Eventual; context?: never } : IsSpot extends true @@ -18,8 +16,6 @@ type TaskConfig = { } type Task = { - [Task$]: true - name: string result: SpotProvider @@ -56,8 +52,6 @@ const createTask = ( type ExecutionContext = { run: SpotToContext; enabled: SpotToContext } | typeof Missing$ const runnable: RunnableInternal & Task = { - [Task$]: true, - name: config.name, kind: "task", @@ -90,4 +84,4 @@ const createTask = ( return runnable } -export { createTask, Task$, type Task, type TaskExecutionValue, type TaskResult, type TaskStatus } +export { createTask, type Task, type TaskExecutionValue, type TaskResult, type TaskStatus } diff --git a/packages/app-compose/src/runnable/wire.ts b/packages/app-compose/src/runnable/wire.ts new file mode 100644 index 0000000..ac96091 --- /dev/null +++ b/packages/app-compose/src/runnable/wire.ts @@ -0,0 +1,75 @@ +import type { ContextToSpot } from "@computable" +import { build, reference, type SpotProvider } from "@computable" +import { is } from "@is" +import { identity, isObject, LIBRARY_NAME } from "@shared" +import { Context$, Dispatch$, Execute$, type Runnable, type RunnableInternal, type RunnableKind } from "./definition" + +const Tag$ = Symbol("$tag") + +type Tag = { [Tag$]: symbol; name: string; value: SpotProvider } + +const tag = (name: string): Tag => { + const id = Symbol(`Tag[${name}]`) + + return { [Tag$]: id, name, value: reference.lensed(id) } +} + +type Wire = { name: string } & Runnable & RunnableKind<"wire"> + +type TagShape = + T extends Tag + ? T + : T extends readonly unknown[] + ? { [K in keyof T & number]: TagShape } + : T extends Record + ? { [K in keyof T & string]: TagShape } + : Tag + +type TagShapeToValue = [S] extends [Tag] + ? V + : S extends Record | readonly unknown[] + ? { [K in keyof S]: TagShapeToValue } + : never + +type WireConfig = { from: NoInfer>>; to: S & TagShape } + +type WireEntry = { tag: Tag; fn: (v: unknown) => unknown } + +const pluck = + (path: PropertyKey[]) => + (value: any): unknown => + path.reduce((acc, key) => acc?.[key], value) + +function* walk(shape: unknown, path: PropertyKey[] = []): Generator { + if (is.tag(shape)) yield { tag: shape, fn: pluck(path) } + else if (Array.isArray(shape)) + for (let index = 0; index < shape.length; index++) yield* walk(shape[index], [...path, index]) + else if (isObject(shape)) + for (const key of Object.keys(shape)) yield* walk(shape[key as keyof typeof shape], [...path, key]) + else throw new Error(`${LIBRARY_NAME} Invalid shape ${String(shape)} provided to Wire at path "${path.join(".")}".`) +} + +const createWire = (config: WireConfig): Wire => { + const tags: Set> = new Set() + const dispatch: RunnableInternal[typeof Dispatch$] = {} + + for (const entry of walk(config.to)) + if (tags.has(entry.tag)) + throw new Error(`${LIBRARY_NAME} Duplicate tag "${entry.tag.name}" found in Wire destination.`) + else void (tags.add(entry.tag), (dispatch[entry.tag[Tag$]] = entry.fn)) + + const name = Array.from(tags) + .map((tag) => tag.name) + .join(" + ") + + return { + name, + kind: "wire", + + [Context$]: build(config.from), + [Execute$]: identity, + [Dispatch$]: dispatch, + } as RunnableInternal & Wire +} + +export { createWire, tag, Tag$, type Tag, type Wire } diff --git a/packages/app-compose/tsconfig.json b/packages/app-compose/tsconfig.json index df419ed..38b5dbc 100644 --- a/packages/app-compose/tsconfig.json +++ b/packages/app-compose/tsconfig.json @@ -6,6 +6,7 @@ "@runnable": ["./src/runnable"], "@compose": ["./src/compose"], "@shared": ["./src/shared"], + "@is": ["./src/is"] } }, "include": ["src"] diff --git a/packages/app-compose/vitest.config.ts b/packages/app-compose/vitest.config.ts index dd74b81..320769f 100644 --- a/packages/app-compose/vitest.config.ts +++ b/packages/app-compose/vitest.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ "@runnable": resolve("./src/runnable"), "@compose": resolve("./src/compose"), "@shared": resolve("./src/shared"), + "@is": resolve("./src/is"), }, }, })