Skip to content

Commit 018c56f

Browse files
btraversclaude
andcommitted
feat(contract)!: drop zod meta-validation, ESM-only build, input-less signals/queries/updates
Wave 1 of the v8 review remediation (spec items 1-6): - Activity-collision check now allows the same activity definition object to be referenced from several scopes (reference equality — the flat namespace stays unambiguous); real collisions (different objects, same name) recommend hoisting the shared activity to the contract's global "activities" block. - defineContract rejects a global activity whose name equals a workflow name (they share the root of the worker implementations map). - Replaced the zod-based contract meta-validation with a hand-rolled structural validator over unknown; zod moved from dependencies to devDependencies (tests still use it as a Standard Schema). The contract root is now strict: unknown top-level keys are rejected, matching the strict defaultOptions behavior. - defineSignal/defineQuery/defineUpdate accept an omitted input: they materialize an UndefinedInputSchema (Standard Schema whose validated value is always undefined), so handler inputs infer as undefined without z.void() ceremony. Definition types keep a required input slot so the worker and client compile unchanged (their optional-payload surfaces land in Wave 3). - Deleted the trivial InferContractWorkflows alias; fixed the stale "unthrown 4" comment in errors.ts to describe unthrown 5 semantics. - Dropped the CJS build: ESM-only exports with types conditions (aligned with packages/testing), removed main/module/types top-level fields. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 710bfca commit 018c56f

10 files changed

Lines changed: 725 additions & 260 deletions

File tree

packages/contract/package.json

Lines changed: 11 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -23,53 +23,31 @@
2323
],
2424
"type": "module",
2525
"sideEffects": false,
26-
"main": "./dist/index.cjs",
27-
"module": "./dist/index.mjs",
28-
"types": "./dist/index.d.mts",
2926
"exports": {
3027
".": {
31-
"import": {
32-
"types": "./dist/index.d.mts",
33-
"default": "./dist/index.mjs"
34-
},
35-
"require": {
36-
"types": "./dist/index.d.cts",
37-
"default": "./dist/index.cjs"
38-
}
28+
"types": "./dist/index.d.mts",
29+
"import": "./dist/index.mjs"
3930
},
4031
"./errors": {
41-
"import": {
42-
"types": "./dist/errors.d.mts",
43-
"default": "./dist/errors.mjs"
44-
},
45-
"require": {
46-
"types": "./dist/errors.d.cts",
47-
"default": "./dist/errors.cjs"
48-
}
32+
"types": "./dist/errors.d.mts",
33+
"import": "./dist/errors.mjs"
4934
},
5035
"./result-async": {
51-
"import": {
52-
"types": "./dist/result-async.d.mts",
53-
"default": "./dist/result-async.mjs"
54-
},
55-
"require": {
56-
"types": "./dist/result-async.d.cts",
57-
"default": "./dist/result-async.cjs"
58-
}
36+
"types": "./dist/result-async.d.mts",
37+
"import": "./dist/result-async.mjs"
5938
},
6039
"./package.json": "./package.json"
6140
},
6241
"scripts": {
63-
"build": "tsdown src/index.ts src/errors.ts src/result-async.ts --format cjs,esm --dts --clean",
42+
"build": "tsdown src/index.ts src/errors.ts src/result-async.ts --format esm --dts --clean",
6443
"build:docs": "typedoc",
65-
"dev": "tsdown src/index.ts src/errors.ts src/result-async.ts --format cjs,esm --dts --watch",
44+
"dev": "tsdown src/index.ts src/errors.ts src/result-async.ts --format esm --dts --watch",
6645
"test": "vitest run",
6746
"test:watch": "vitest",
6847
"typecheck": "tsc --noEmit"
6948
},
7049
"dependencies": {
71-
"@standard-schema/spec": "catalog:",
72-
"zod": "catalog:"
50+
"@standard-schema/spec": "catalog:"
7351
},
7452
"devDependencies": {
7553
"@btravstack/tsconfig": "catalog:",
@@ -84,7 +62,8 @@
8462
"typescript": "catalog:",
8563
"unthrown": "catalog:",
8664
"valibot": "catalog:",
87-
"vitest": "catalog:"
65+
"vitest": "catalog:",
66+
"zod": "catalog:"
8867
},
8968
"peerDependencies": {
9069
"unthrown": "^5.0.0"

packages/contract/src/builder.spec.ts

Lines changed: 167 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
import { describe, expect, it } from "vitest";
22
import { z } from "zod";
33

4-
import { defineContract } from "./builder.js";
4+
import {
5+
defineActivity,
6+
defineContract,
7+
defineQuery,
8+
defineSignal,
9+
defineUpdate,
10+
defineWorkflow,
11+
} from "./builder.js";
512

613
describe("Contract Builder", () => {
714
describe("defineContract", () => {
@@ -404,7 +411,7 @@ describe("Contract Builder", () => {
404411
},
405412
}),
406413
).toThrow(
407-
'workflow "processOrder" has activity "sendEmail" that conflicts with a global activity. Consider renaming the workflow-specific activity or removing the global activity "sendEmail".',
414+
'workflow "processOrder" has activity "sendEmail" that conflicts with a different global activity of the same name. Activities share a single flat namespace at runtime — reference the shared definition from the contract\'s global "activities" block, or rename one of them.',
408415
);
409416
});
410417

@@ -436,10 +443,129 @@ describe("Contract Builder", () => {
436443
},
437444
}),
438445
).toThrow(
439-
'workflow "processRefund" has activity "charge" that conflicts with the same-named activity in workflow "processOrder". Activities share a single flat namespace at runtime — rename one of them.',
446+
'workflow "processRefund" has activity "charge" that conflicts with a different same-named activity in workflow "processOrder". Activities share a single flat namespace at runtime — hoist the shared activity to the contract\'s global "activities" block, or rename one of them.',
440447
);
441448
});
442449

450+
it("should allow the same activity object to be shared by two workflows", () => {
451+
const charge = defineActivity({
452+
input: z.object({ amount: z.number() }),
453+
output: z.object({ transactionId: z.string() }),
454+
});
455+
456+
expect(() =>
457+
defineContract({
458+
taskQueue: "test",
459+
workflows: {
460+
processOrder: {
461+
input: z.object({}),
462+
output: z.object({}),
463+
activities: { charge },
464+
},
465+
processRefund: {
466+
input: z.object({}),
467+
output: z.object({}),
468+
activities: { charge },
469+
},
470+
},
471+
}),
472+
).not.toThrow();
473+
});
474+
475+
it("should allow a workflow to reference the same object as a global activity", () => {
476+
const sendEmail = defineActivity({
477+
input: z.object({ to: z.string() }),
478+
output: z.object({ sent: z.boolean() }),
479+
});
480+
481+
expect(() =>
482+
defineContract({
483+
taskQueue: "test",
484+
workflows: {
485+
processOrder: {
486+
input: z.object({}),
487+
output: z.object({}),
488+
activities: { sendEmail },
489+
},
490+
},
491+
activities: { sendEmail },
492+
}),
493+
).not.toThrow();
494+
});
495+
496+
it("should throw when a global activity has the same name as a workflow", () => {
497+
expect(() =>
498+
defineContract({
499+
taskQueue: "test",
500+
workflows: {
501+
processOrder: {
502+
input: z.object({}),
503+
output: z.object({}),
504+
},
505+
},
506+
activities: {
507+
processOrder: {
508+
input: z.object({}),
509+
output: z.object({}),
510+
},
511+
},
512+
}),
513+
).toThrow(
514+
'global activity "processOrder" has the same name as a workflow. Workflows and global activities share the root of the worker implementations map — rename one of them.',
515+
);
516+
});
517+
518+
it("should throw when a workflow has the same name as a global activity", () => {
519+
// Same collision, declared the other way around: the activity name
520+
// comes first alphabetically and the workflow map holds several keys.
521+
expect(() =>
522+
defineContract({
523+
taskQueue: "test",
524+
workflows: {
525+
aWorkflow: {
526+
input: z.object({}),
527+
output: z.object({}),
528+
},
529+
sendEmail: {
530+
input: z.object({}),
531+
output: z.object({}),
532+
},
533+
},
534+
activities: {
535+
sendEmail: {
536+
input: z.object({}),
537+
output: z.object({}),
538+
},
539+
},
540+
}),
541+
).toThrow(
542+
'global activity "sendEmail" has the same name as a workflow. Workflows and global activities share the root of the worker implementations map — rename one of them.',
543+
);
544+
});
545+
546+
it("should allow a workflow-local activity to share a workflow's name", () => {
547+
// Workflow-local activity implementations nest under their owning
548+
// workflow in the worker implementations map, so they never collide
549+
// with workflow names at the root.
550+
expect(() =>
551+
defineContract({
552+
taskQueue: "test",
553+
workflows: {
554+
processOrder: {
555+
input: z.object({}),
556+
output: z.object({}),
557+
activities: {
558+
processOrder: {
559+
input: z.object({}),
560+
output: z.object({}),
561+
},
562+
},
563+
},
564+
},
565+
}),
566+
).not.toThrow();
567+
});
568+
443569
it("should not misclassify a workflow named 'global' as the global activity scope", () => {
444570
// A workflow can legally be named "global" — the collision detector must
445571
// not confuse it with the global activity scope sentinel.
@@ -470,7 +596,7 @@ describe("Contract Builder", () => {
470596
},
471597
}),
472598
).toThrow(
473-
'workflow "other" has activity "send" that conflicts with the same-named activity in workflow "global". Activities share a single flat namespace at runtime — rename one of them.',
599+
'workflow "other" has activity "send" that conflicts with a different same-named activity in workflow "global". Activities share a single flat namespace at runtime — hoist the shared activity to the contract\'s global "activities" block, or rename one of them.',
474600
);
475601
});
476602

@@ -597,6 +723,43 @@ describe("Contract Builder", () => {
597723
}),
598724
).not.toThrow();
599725
});
726+
727+
it("should throw on an unknown top-level key (strict root)", () => {
728+
expect(() =>
729+
defineContract({
730+
taskQueue: "test",
731+
workflows: {
732+
test: {
733+
input: z.object({}),
734+
output: z.object({}),
735+
},
736+
},
737+
// Deliberate typo of `activities` — TypeScript's generic inference
738+
// absorbs the extra key, which is exactly why the runtime root
739+
// check is strict.
740+
activites: {},
741+
}),
742+
).toThrow(
743+
'contract has unknown key "activites" — allowed keys are "taskQueue", "workflows", "activities"',
744+
);
745+
});
746+
747+
it("should accept input-less signal/query/update definitions from the helpers", () => {
748+
expect(() =>
749+
defineContract({
750+
taskQueue: "test",
751+
workflows: {
752+
wf: defineWorkflow({
753+
input: z.object({}),
754+
output: z.object({}),
755+
signals: { shutdown: defineSignal() },
756+
queries: { getStatus: defineQuery({ output: z.string() }) },
757+
updates: { bump: defineUpdate({ output: z.number() }) },
758+
}),
759+
},
760+
}),
761+
).not.toThrow();
762+
});
600763
});
601764

602765
describe("Edge Cases", () => {

0 commit comments

Comments
 (0)