@btravstack/temporal-worker 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -36,9 +36,9 @@ class DuplicateOrder extends TaggedError("DuplicateOrder")<{ readonly id: string
36
36
  class InvalidOrderId extends TaggedError("InvalidOrderId")<{ readonly id: string }> {}
37
37
  class InvalidQuantity extends TaggedError("InvalidQuantity")<{ readonly id: string }> {}
38
38
  declare const TenantId: (value: string) => string;
39
+ class Tenant extends Port("Tenant")<string> {}
39
40
  class PlaceOrder extends Port("PlaceOrder")<{
40
41
  readonly execute: (
41
- tenantId: string,
42
42
  id: string,
43
43
  quantity: number,
44
44
  ) => AsyncResult<
@@ -46,7 +46,9 @@ class PlaceOrder extends Port("PlaceOrder")<{
46
46
  DuplicateOrder | InvalidOrderId | InvalidQuantity
47
47
  >;
48
48
  }> {}
49
- declare const OrderApplicationModule: Module<PlaceOrder, never, never>;
49
+ // The application's own vertical, composed INSIDE the unit: its use case is
50
+ // bound to whichever tenant the attempt named.
51
+ declare const OrderApplicationModule: Module<PlaceOrder, never, Tenant>;
50
52
  declare const OrderPersistenceModule: AnyModule;
51
53
 
52
54
  // The contract this README's worker serves — one workflow, one activity, one
@@ -57,7 +59,7 @@ const contract = defineContract({
57
59
  placeOrder: defineWorkflow({
58
60
  input: z.object({ tenantId: z.uuidv7(), orderId: z.uuidv7(), quantity: z.number() }),
59
61
  output: z.object({ id: z.uuidv7() }),
60
- idempotency: "allow-duplicate",
62
+ startPolicy: "allow-duplicate",
61
63
  activities: {
62
64
  place: defineActivity({
63
65
  input: z.object({ tenantId: z.uuidv7(), orderId: z.uuidv7(), quantity: z.number() }),
@@ -75,30 +77,49 @@ const contract = defineContract({
75
77
  });
76
78
 
77
79
  const AppModule = Module("App")({
78
- imports: [OrderApplicationModule, OrderPersistenceModule, observability(), otel()],
79
- exports: [PlaceOrder, Logger],
80
+ imports: [OrderPersistenceModule, observability(), otel()],
81
+ exports: [Logger],
80
82
  });
81
83
  -->
82
84
 
83
85
  ```ts
84
86
  import { runMain, Logger } from "@btravstack/core";
87
+ import { Provider } from "@btravstack/di";
85
88
  import {
89
+ ActivityInput,
86
90
  TemporalActivities,
87
91
  TemporalModule,
88
92
  } from "@btravstack/temporal-worker";
89
93
  import { P } from "unthrown";
90
94
 
91
- // The application's half: its activities, as a service built from the use
92
- // cases it declares — closures over them, no context read at call time — on
93
- // the starter's own activities port, typed by the contract (a worker serves
94
- // one activities record, so there is nothing to name).
95
+ // The module forked around every activity attempt, SEEDED with the validated
96
+ // input on `ActivityInput(contract)`. The tenant the contract carries is
97
+ // claimed here once — not at each activity — and the application's vertical
98
+ // is composed over it, so `PlaceOrder` is bound before any activity runs.
99
+ const ActivityUnit = Module("ActivityUnit")({
100
+ needs: [ActivityInput(contract)],
101
+ imports: [OrderApplicationModule],
102
+ provides: [
103
+ Provider(Tenant)({
104
+ inject: { input: ActivityInput(contract) },
105
+ sync: ({ input }) => TenantId(input.tenantId),
106
+ }),
107
+ ],
108
+ exports: [Tenant, PlaceOrder],
109
+ });
110
+
111
+ // The application's half: its activities, on the starter's own activities
112
+ // port, typed by the contract (a worker serves one activities record, so
113
+ // there is nothing to name). What the leaves read off `context.unit` is
114
+ // declared once, beside `inject`.
95
115
  const orderActivities = TemporalActivities(contract)({
96
- inject: { place: PlaceOrder },
97
- sync: ({ place }) => ({
116
+ inject: {},
117
+ unit: { place: PlaceOrder },
118
+ sync: () => ({
98
119
  placeOrder: {
99
- place: ({ errors, input }) =>
100
- place
101
- .execute(TenantId(input.tenantId), input.orderId, input.quantity)
120
+ place: ({ errors, context, input }) =>
121
+ context.unit.place
122
+ .execute(input.orderId, input.quantity)
102
123
  .mapErrCases((matcher) =>
103
124
  matcher
104
125
  .with(P.tag("DuplicateOrder"), (error) =>
@@ -124,6 +145,7 @@ const OrderWorker = TemporalModule("OrderWorker")({
124
145
  workflowsPath: workflowsPathFromURL(import.meta.url, "./workflows.js"),
125
146
  },
126
147
  imports: [AppModule],
148
+ unit: { activity: ActivityUnit },
127
149
  });
128
150
 
129
151
  await runMain(OrderWorker);
@@ -140,7 +162,7 @@ startup `Err`, not a defect. `runtimeInfo()` reads `{ taskQueue, namespace }`
140
162
  back once the worker is polling.
141
163
 
142
164
  A worker polling for several workflows can be several slices instead of one
143
- record: `TemporalWorkflowActivities(contract, key)({ inject: { name: Dep }, ...arm })` mints a
165
+ record: `TemporalWorkflowActivities(contract, key)({ inject: { name: Dep }, unit, sync })` mints a
144
166
  provider for ONE workflow's activities (or a contract-global activity),
145
167
  typed by the key alone, and `TemporalActivities(contract)([...])` composes an
146
168
  array of them into the same activities provider `TemporalModule` takes — the
@@ -153,6 +175,16 @@ di's duplicate-provider defect at build once **both** pieces are discharged as
153
175
  providers in one graph — wire in only one and the other's implementation is
154
176
  silently never registered.
155
177
 
178
+ A piece's `unit` names the ports its activities read off `context.unit`,
179
+ resolved out of the per-attempt fork of the module bound on `unit.activity` —
180
+ which is seeded with the validated input on `ActivityInput(contract)`, so a unit
181
+ module derives a tenant from the invocation rather than from an ambient record.
182
+ The record arm above takes the same `unit:` — one record for every entry in it —
183
+ so a worker that has not outgrown one function reaches `context.unit` without
184
+ slicing first. The root is checked against what was declared either way: bind a
185
+ module that does not export one of those ports and `TemporalModule` refuses it,
186
+ naming the port.
187
+
156
188
  Its own test suite needs a **Docker daemon**: the specs run against the shared
157
189
  `temporalio/auto-setup` container `internal/test-infra` starts once per machine and every
158
190
  workspace reuses. Nothing else here does.
@@ -184,6 +216,7 @@ supplies it:
184
216
  | `namespace` | pins the namespace instead of reading `TEMPORAL_NAMESPACE` |
185
217
  | `gracePeriod` | pins `TEMPORAL_GRACE_PERIOD_MS` — Temporal's `shutdownGraceTime`, a `Duration` (default `10_000` ms) |
186
218
  | `forceAfter` | pins `TEMPORAL_FORCE_AFTER_MS` — Temporal's `shutdownForceTime` (default `15_000` ms); keep it under `DRAIN_TIMEOUT_MS` |
219
+ | `unit` | `{ activity }` — the module the worker forks around every activity it dispatches, seeded with the validated input |
187
220
 
188
221
  The full table — required/optional, defaults, and the reasoning — lives on
189
222
  [the reference page](https://btravstack.github.io/btravstack/reference/temporal-worker),
package/dist/index.cjs CHANGED
@@ -7,10 +7,123 @@ let _temporalio_common = require("@temporalio/common");
7
7
  let _temporalio_worker = require("@temporalio/worker");
8
8
  let unthrown = require("unthrown");
9
9
  let _temporalio_activity = require("@temporalio/activity");
10
+ //#region src/unit.ts
11
+ /**
12
+ * Where `activityUnits` leaves the forked context for the piece wrappers to
13
+ * read. A symbol rather than a name: the middleware merges it into the context
14
+ * every activity sees, and a string key would appear on the record an activity
15
+ * written against the `{ inject, sync }` arm destructures.
16
+ */
17
+ const UNIT_SCOPE = Symbol("@btravstack/temporal-worker/unit-scope");
18
+ /**
19
+ * The declared record, as a getter per name resolved on read out of the fork.
20
+ * Neither writable nor configurable — an activity reads what the fork holds, and
21
+ * cannot reshape the record under the next attempt. With no `unit.activity`
22
+ * bound there is nothing to resolve from, so the record is empty whatever was
23
+ * declared.
24
+ */
25
+ const unitRecordOf = (forked, record) => {
26
+ const unit = {};
27
+ if (forked === void 0) return unit;
28
+ for (const [name, port] of Object.entries(record)) Object.defineProperty(unit, name, {
29
+ enumerable: true,
30
+ get: () => forked.get(port)
31
+ });
32
+ return unit;
33
+ };
34
+ /**
35
+ * One entry of the activities record — a contract-global activity's own
36
+ * implementation, or the record of them a workflow key carries — with its
37
+ * piece's declared record put on `context.unit`. Applied once per piece as di
38
+ * constructs it, so an attempt costs one record and one context object.
39
+ */
40
+ const withUnit = (record, entry) => {
41
+ const wrap = (implementation) => (helpers, input) => {
42
+ const { [UNIT_SCOPE]: forked, ...rest } = helpers.context ?? {};
43
+ return implementation({
44
+ ...helpers,
45
+ context: {
46
+ ...rest,
47
+ unit: unitRecordOf(forked, record)
48
+ }
49
+ }, input);
50
+ };
51
+ return typeof entry === "function" ? wrap(entry) : Object.fromEntries(Object.entries(entry).map(([name, implementation]) => [name, wrap(implementation)]));
52
+ };
53
+ //#endregion
54
+ //#region src/workflow-activities.ts
55
+ /** One id, declared once, so no contract instantiating it warns about a duplicate. */
56
+ const ActivityInputPort = (0, _btravstack_di.Port)("ActivityInput");
57
+ /**
58
+ * The validated activity input as a port: the one thing the worker seeds the
59
+ * fork with, so a `unit.activity` module derives a tenant — or anything else —
60
+ * from the invocation rather than from an ambient record.
61
+ *
62
+ * ```ts
63
+ * const Input = ActivityInput(orderContract);
64
+ * const ActivityUnit = Module("ActivityUnit")({
65
+ * needs: [Input],
66
+ * provides: [Provider(Tenant)({ inject: { input: Input }, sync: ({ input }) => input.tenantId })],
67
+ * exports: [Tenant],
68
+ * });
69
+ * ```
70
+ *
71
+ * `contract` is read for its TYPE only. One `Port(...)` call fixed per contract
72
+ * at the type level, the move `TemporalActivitiesPort` makes, so a module built
73
+ * for one contract cannot read another's input.
74
+ */
75
+ const ActivityInput = (contract) => {
76
+ return ActivityInputPort;
77
+ };
78
+ /** The prefix a piece's port id carries; the composing form strips it to recover the key. */
79
+ const WORKFLOW_ACTIVITIES_PREFIX = "TemporalWorkflowActivities:";
80
+ /**
81
+ * One workflow's activities, as a provider on a port of its own.
82
+ *
83
+ * A worker that polls one queue for several workflows is several pieces, each
84
+ * declaring the services its own activities call;
85
+ * `TemporalActivities(contract)([...])` composes them. `contract` is read for
86
+ * its TYPE only, and types both `key` and the record — so an activity the
87
+ * workflow does not declare is a compile error here rather than at startup.
88
+ *
89
+ * `key` is any top-level key of the activities record, which includes a
90
+ * contract-global activity as well as a workflow; the name is imprecise in that
91
+ * one case deliberately, since narrowing it would lock such a contract out of
92
+ * the split.
93
+ *
94
+ * There is no name to give: the key IS the port's name.
95
+ *
96
+ * `unit` declares the ports the activities read off `context.unit`, resolved
97
+ * out of the fork the attempt opened; the root's `unit.activity` module must
98
+ * export every one of them.
99
+ */
100
+ const TemporalWorkflowActivities = (contract, key) => {
101
+ const port = class extends (0, _btravstack_di.Port)(`${WORKFLOW_ACTIVITIES_PREFIX}${key}`) {};
102
+ return (options) => {
103
+ const record = options.unit ?? {};
104
+ return Object.assign((0, _btravstack_di.Provider)(port)({
105
+ inject: options.inject,
106
+ sync: (services) => withUnit(record, options.sync(services))
107
+ }), { unit: record });
108
+ };
109
+ };
110
+ //#endregion
10
111
  //#region src/activity-units.ts
11
112
  /**
12
- * Open one kernel unit per activity attempt. It injects nothing — `next()`
13
- * unchanged — so the ambient `currentUnit()` record is what an adapter reads.
113
+ * Open one kernel unit per activity attempt, forking `unit` — when one is
114
+ * bound — after the activity is invoked, before it runs; the fork is torn down
115
+ * when the unit closes. It is seeded with the validated input on
116
+ * `ActivityInput(contract)`, so a unit module derives a tenant from the
117
+ * invocation rather than reading one off an ambient record. With no `unit`
118
+ * bound, `next()` runs unchanged, so the ambient `currentUnit()` record is what
119
+ * an adapter reads.
120
+ *
121
+ * The forked context rides the invocation's context under {@link UNIT_SCOPE},
122
+ * where each piece's own wrapper turns it into the typed `context.unit` record
123
+ * that piece declared — the records live with the pieces, so a hand-composed
124
+ * `temporal()` gets them without threading a second option through the starter,
125
+ * and the middleware never has to map Temporal's FLAT activity name back to the
126
+ * workflow key its piece was minted under.
14
127
  *
15
128
  * **That includes the kernel's per-unit `AbortSignal`**, and it is the only
16
129
  * route to it: an activity has no parameter to receive one through. Temporal's
@@ -20,13 +133,13 @@ let _temporalio_activity = require("@temporalio/activity");
20
133
  * No `Result`-unwrapping boundary, deliberately: `declareActivitiesHandler` owns
21
134
  * the mapping from a settled `Result` to an activity failure.
22
135
  */
23
- const activityUnits = (host, observers) => (_invocation, next) => {
136
+ const activityUnits = (host, observers, unit) => (invocation, next) => {
24
137
  const settle = (0, _btravstack_core.observe)(observers, {
25
138
  component: "temporal",
26
139
  name: "attempt",
27
140
  attributes: { activity: (0, _temporalio_activity.activityInfo)().activityType }
28
141
  });
29
- return host.run(metaFor(), () => next()).tap(() => settle({ outcome: "ok" })).tapFailure((failure) => settle({
142
+ return host.run(metaFor(), (scope) => unit === void 0 ? next() : scope.fork(unit, [[ActivityInputPort, invocation.input]]).flatMap((forked) => next({ context: { [UNIT_SCOPE]: forked } }))).tap(() => settle({ outcome: "ok" })).tapFailure((failure) => settle({
30
143
  outcome: "error",
31
144
  cause: failure.tag === "Err" ? failure.error : failure.cause
32
145
  }));
@@ -164,7 +277,7 @@ const createWorker = (host, connection, config, activities, options, observers)
164
277
  const { namespace } = config;
165
278
  return (0, unthrown.fromThrowable)(() => (0, _temporal_contract_worker_activity.declareActivitiesHandler)({
166
279
  contract: options.contract,
167
- middleware: activityUnits(host, observers),
280
+ middleware: activityUnits(host, observers, options.unit?.activity),
168
281
  activities
169
282
  }), startFailed)().toAsync().flatMap((handler) => (0, unthrown.fromPromise)(_temporalio_worker.Worker.create({
170
283
  connection,
@@ -200,30 +313,6 @@ const poll = (worker, taskQueue, namespace) => {
200
313
  };
201
314
  };
202
315
  //#endregion
203
- //#region src/workflow-activities.ts
204
- /** The prefix a piece's port id carries; the composing form strips it to recover the key. */
205
- const WORKFLOW_ACTIVITIES_PREFIX = "TemporalWorkflowActivities:";
206
- /**
207
- * One workflow's activities, as a provider on a port of its own.
208
- *
209
- * A worker that polls one queue for several workflows is several pieces, each
210
- * declaring the services its own activities call;
211
- * `TemporalActivities(contract)([...])` composes them. `contract` is read for
212
- * its TYPE only, and types both `key` and the record — so an activity the
213
- * workflow does not declare is a compile error here rather than at startup.
214
- *
215
- * `key` is any top-level key of the activities record, which includes a
216
- * contract-global activity as well as a workflow; the name is imprecise in that
217
- * one case deliberately, since narrowing it would lock such a contract out of
218
- * the split.
219
- *
220
- * There is no name to give: the key IS the port's name.
221
- */
222
- const TemporalWorkflowActivities = (contract, key) => {
223
- const port = class extends (0, _btravstack_di.Port)(`${WORKFLOW_ACTIVITIES_PREFIX}${key}`) {};
224
- return (0, _btravstack_di.Provider)(port);
225
- };
226
- //#endregion
227
316
  //#region src/temporal-module.ts
228
317
  /**
229
318
  * `Module(name)({...})` for a Temporal worker deployment: everything a di module
@@ -263,12 +352,12 @@ const TemporalModule = (name) => (options) => {
263
352
  * TemporalActivities(orderContract)([fulfillOrder, chargeOrder])
264
353
  * ```
265
354
  *
266
- * The first is di's own `Provider(port)` on the starter's activities port
267
- * typed for the contract. The second takes the pieces
268
- * `TemporalWorkflowActivities(contract, key)` builds: they are the provider's
269
- * deps, keyed by the contract key each piece's port id carries, so the services
270
- * record IS the activities record. Every key must be covered, and two slices
271
- * claiming one key are di's duplicate-provider defect at build.
355
+ * The first is `{ inject, unit?, sync }`, whose `sync` hands back the whole
356
+ * activities record — one `unit:` record for every entry in it. The second
357
+ * takes the pieces `TemporalWorkflowActivities(contract, key)` builds: they are
358
+ * the provider's deps, keyed by the contract key each piece's port id carries,
359
+ * so the services record IS the activities record. Every key must be covered,
360
+ * and two slices claiming one key are di's duplicate-provider defect at build.
272
361
  */
273
362
  const TemporalActivities = (contract) => {
274
363
  const build = (0, _btravstack_di.Provider)(TemporalActivitiesPort);
@@ -276,9 +365,17 @@ const TemporalActivities = (contract) => {
276
365
  inject: Object.fromEntries(pieces.map((piece) => [piece.port.portId.slice(27), piece.port])),
277
366
  sync: (services) => services
278
367
  });
279
- return ((first) => Array.isArray(first) ? compose(first) : build(first));
368
+ const whole = (options) => {
369
+ const record = options.unit ?? {};
370
+ return build({
371
+ inject: options.inject,
372
+ sync: (services) => Object.fromEntries(Object.entries(options.sync(services)).map(([key, entry]) => [key, withUnit(record, entry)]))
373
+ });
374
+ };
375
+ return ((first) => Array.isArray(first) ? compose(first) : whole(first));
280
376
  };
281
377
  //#endregion
378
+ exports.ActivityInput = ActivityInput;
282
379
  exports.TemporalActivities = TemporalActivities;
283
380
  exports.TemporalConfig = TemporalConfig;
284
381
  exports.TemporalConnection = TemporalConnection;