@btravstack/temporal-worker 0.8.0 → 0.10.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
@@ -13,7 +13,7 @@
13
13
  ```sh
14
14
  pnpm add @btravstack/temporal-worker @btravstack/core @btravstack/config @btravstack/di unthrown \
15
15
  @temporalio/worker @temporalio/activity @temporalio/common \
16
- @temporal-contract/worker @temporal-contract/contract
16
+ @temporal-contract/worker@^8.0.0-beta.7 @temporal-contract/contract@^8.0.0-beta.7
17
17
  ```
18
18
 
19
19
  All nine are peer dependencies — install them. Node `>=22`.
@@ -74,30 +74,28 @@ import { P } from "unthrown";
74
74
  // cases it declares — closures over them, no context read at call time — on
75
75
  // the starter's own activities port, typed by the contract (a worker serves
76
76
  // one activities record, so there is nothing to name).
77
- const orderActivities = TemporalActivities(contract)(
78
- { place: PlaceOrder },
79
- {
80
- sync: ({ place }) => ({
81
- placeOrder: {
82
- place: (args, { errors }) =>
83
- place
84
- .execute(TenantId(args.tenantId), args.orderId, args.quantity)
85
- .mapErrCases((matcher) =>
86
- matcher
87
- .with(P.tag("DuplicateOrder"), (error) =>
88
- errors.OrderAlreadyPlaced({ id: error.id }),
89
- )
90
- .with(P.tag("InvalidOrderId"), (error) =>
91
- errors.InvalidOrderId({ id: error.id }),
92
- )
93
- .with(P.tag("InvalidQuantity"), (error) =>
94
- errors.InvalidQuantity({ id: error.id }),
95
- ),
96
- ),
97
- },
98
- }),
99
- },
100
- );
77
+ const orderActivities = TemporalActivities(contract)({
78
+ inject: { place: PlaceOrder },
79
+ sync: ({ place }) => ({
80
+ placeOrder: {
81
+ place: ({ errors, input }) =>
82
+ place
83
+ .execute(TenantId(input.tenantId), input.orderId, input.quantity)
84
+ .mapErrCases((matcher) =>
85
+ matcher
86
+ .with(P.tag("DuplicateOrder"), (error) =>
87
+ errors.OrderAlreadyPlaced({ id: error.id }),
88
+ )
89
+ .with(P.tag("InvalidOrderId"), (error) =>
90
+ errors.InvalidOrderId({ id: error.id }),
91
+ )
92
+ .with(P.tag("InvalidQuantity"), (error) =>
93
+ errors.InvalidQuantity({ id: error.id }),
94
+ ),
95
+ ),
96
+ },
97
+ }),
98
+ });
101
99
 
102
100
  // The composition root: a di module, plus the contract, the activities
103
101
  // provider and the workflow source — and nothing else to know.
@@ -124,7 +122,7 @@ startup `Err`, not a defect. `runtimeInfo()` reads `{ taskQueue, namespace }`
124
122
  back once the worker is polling.
125
123
 
126
124
  A worker polling for several workflows can be several slices instead of one
127
- record: `TemporalWorkflowActivities(contract, key)({ name: Dep }, arm)` mints a
125
+ record: `TemporalWorkflowActivities(contract, key)({ inject: { name: Dep }, ...arm })` mints a
128
126
  provider for ONE workflow's activities (or a contract-global activity),
129
127
  typed by the key alone, and `TemporalActivities(contract)([...])` composes an
130
128
  array of them into the same activities provider `TemporalModule` takes — the
@@ -134,6 +132,20 @@ the pieces' ports, not what they close over. Two slices claiming one key are
134
132
  di's duplicate-provider defect at build, which is the point: a workflow's
135
133
  activities belong to exactly one slice.
136
134
 
135
+ Its own test suite needs a **Docker daemon**: the specs run against the shared
136
+ `temporalio/auto-setup` container `internal/test-infra` starts once per machine and every
137
+ workspace reuses. Nothing else here does.
138
+
139
+ ## Schedules
140
+
141
+ `@btravstack/temporal-worker/schedule` exports `ensureSchedule` — the typed
142
+ schedule client's `create`, made idempotent, so a deploy that runs again
143
+ updates the schedule instead of failing on `ScheduleAlreadyExistsError`.
144
+ `@temporal-contract/client` is an optional peer reached only through that
145
+ subpath. Why scheduled work costs a cluster here, and when it should not be
146
+ this stack at all:
147
+ [Run something on a schedule](https://btravstack.github.io/btravstack/how-to/run-something-on-a-schedule).
148
+
137
149
  ## Options
138
150
 
139
151
  `TemporalModule(name)({...})` takes `temporal()`'s options plus `activities`
package/dist/index.cjs CHANGED
@@ -20,7 +20,17 @@ let _temporalio_activity = require("@temporalio/activity");
20
20
  * No `Result`-unwrapping boundary, deliberately: `declareActivitiesHandler` owns
21
21
  * the mapping from a settled `Result` to an activity failure.
22
22
  */
23
- const activityUnits = (host) => (_invocation, next) => host.run(metaFor(), () => next());
23
+ const activityUnits = (host, observers) => (_invocation, next) => {
24
+ const settle = (0, _btravstack_core.observe)(observers, {
25
+ component: "temporal",
26
+ name: "attempt",
27
+ attributes: { activity: (0, _temporalio_activity.activityInfo)().activityType }
28
+ });
29
+ return host.run(metaFor(), () => next()).tap(() => settle({ outcome: "ok" })).tapFailure((failure) => settle({
30
+ outcome: "error",
31
+ cause: failure.tag === "Err" ? failure.error : failure.cause
32
+ }));
33
+ };
24
34
  /**
25
35
  * `UnitMeta.id` must be unique per unit, and a workflow id is **not** one: an
26
36
  * activity is retried under the same execution. A task token identifies one
@@ -111,22 +121,31 @@ const temporal = (options) => {
111
121
  needs: [_btravstack_config.Env, activities],
112
122
  provides: [
113
123
  config,
114
- (0, _btravstack_di.Provider)(TemporalConnection)({ config: TemporalConfig }, {
124
+ (0, _btravstack_di.Provider)(TemporalConnection)({
125
+ inject: { config: TemporalConfig },
115
126
  acquire: ({ config: bound }) => (0, unthrown.fromPromise)(_temporalio_worker.NativeConnection.connect({ address: bound.address }), (cause) => new TemporalUnreachable({
116
127
  address: bound.address,
117
128
  cause
118
129
  })),
119
130
  release: (connection) => connection.close().catch((cause) => heldByWorker(cause) ? void 0 : Promise.reject(cause))
120
131
  }),
132
+ _btravstack_di.Provider.member(_btravstack_core.Observers)({
133
+ inject: {},
134
+ value: _btravstack_core.noObserver
135
+ }),
121
136
  (0, _btravstack_di.Provider)(TemporalRuntime)({
122
- connection: TemporalConnection,
123
- config: TemporalConfig,
124
- activities
125
- }, { sync: ({ connection, config: bound, activities: impls }) => ({
126
- name: "temporal",
127
- resolves: [],
128
- start: (host) => createWorker(host, connection, bound, impls, options)
129
- }) })
137
+ inject: {
138
+ connection: TemporalConnection,
139
+ config: TemporalConfig,
140
+ activities,
141
+ observers: _btravstack_core.Observers
142
+ },
143
+ sync: ({ connection, config: bound, activities: impls, observers }) => ({
144
+ name: "temporal",
145
+ resolves: [],
146
+ start: (host) => createWorker(host, connection, bound, impls, options, observers)
147
+ })
148
+ })
130
149
  ],
131
150
  exports: [
132
151
  TemporalRuntime,
@@ -140,12 +159,12 @@ const startFailed = (cause) => new _btravstack_core.RuntimeStartFailed({
140
159
  cause
141
160
  });
142
161
  const heldByWorker = (cause) => cause instanceof Error && cause.name === "IllegalStateError";
143
- const createWorker = (host, connection, config, activities, options) => {
162
+ const createWorker = (host, connection, config, activities, options, observers) => {
144
163
  const { taskQueue } = options.contract;
145
164
  const { namespace } = config;
146
165
  return (0, unthrown.fromThrowable)(() => (0, _temporal_contract_worker_activity.declareActivitiesHandler)({
147
166
  contract: options.contract,
148
- middleware: activityUnits(host),
167
+ middleware: activityUnits(host, observers),
149
168
  activities
150
169
  }), startFailed)().toAsync().flatMap((handler) => (0, unthrown.fromPromise)(_temporalio_worker.Worker.create({
151
170
  connection,
@@ -237,15 +256,15 @@ const TemporalModule = (name) => (options) => {
237
256
  });
238
257
  };
239
258
  /**
240
- * The activities as a provider, from the contract. Three call forms, one port.
259
+ * The activities as a provider, from the contract. Two call forms, one port.
241
260
  *
242
261
  * ```ts
243
- * TemporalActivities(orderContract)({ place: PlaceOrder }, { sync: ({ place }) => ({ fulfillOrder: { … } }) })
262
+ * TemporalActivities(orderContract)({ inject: { place: PlaceOrder }, sync: ({ place }) => ({ fulfillOrder: { … } }) })
244
263
  * TemporalActivities(orderContract)([fulfillOrder, chargeOrder])
245
264
  * ```
246
265
  *
247
- * The first two are di's own `Provider(port)` on the starter's activities port
248
- * typed for the contract. The third takes the pieces
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
249
268
  * `TemporalWorkflowActivities(contract, key)` builds: they are the provider's
250
269
  * deps, keyed by the contract key each piece's port id carries, so the services
251
270
  * record IS the activities record. Every key must be covered, and two slices
@@ -253,8 +272,11 @@ const TemporalModule = (name) => (options) => {
253
272
  */
254
273
  const TemporalActivities = (contract) => {
255
274
  const build = (0, _btravstack_di.Provider)(TemporalActivitiesPort);
256
- const compose = (pieces) => build(Object.fromEntries(pieces.map((piece) => [piece.port.portId.slice(27), piece.port])), { sync: (services) => services });
257
- return ((first, second) => second === void 0 && Array.isArray(first) ? compose(first) : build(first, second));
275
+ const compose = (pieces) => build({
276
+ inject: Object.fromEntries(pieces.map((piece) => [piece.port.portId.slice(27), piece.port])),
277
+ sync: (services) => services
278
+ });
279
+ return ((first) => Array.isArray(first) ? compose(first) : build(first));
258
280
  };
259
281
  //#endregion
260
282
  exports.TemporalActivities = TemporalActivities;
package/dist/index.d.cts CHANGED
@@ -213,15 +213,15 @@ type Compose<C extends ContractDefinition> = <const T extends readonly PieceOf<C
213
213
  readonly port: ActivitiesPortOf<C>;
214
214
  };
215
215
  /**
216
- * The activities as a provider, from the contract. Three call forms, one port.
216
+ * The activities as a provider, from the contract. Two call forms, one port.
217
217
  *
218
218
  * ```ts
219
- * TemporalActivities(orderContract)({ place: PlaceOrder }, { sync: ({ place }) => ({ fulfillOrder: { … } }) })
219
+ * TemporalActivities(orderContract)({ inject: { place: PlaceOrder }, sync: ({ place }) => ({ fulfillOrder: { … } }) })
220
220
  * TemporalActivities(orderContract)([fulfillOrder, chargeOrder])
221
221
  * ```
222
222
  *
223
- * The first two are di's own `Provider(port)` on the starter's activities port
224
- * typed for the contract. The third takes the pieces
223
+ * The first is di's own `Provider(port)` on the starter's activities port
224
+ * typed for the contract. The second takes the pieces
225
225
  * `TemporalWorkflowActivities(contract, key)` builds: they are the provider's
226
226
  * deps, keyed by the contract key each piece's port id carries, so the services
227
227
  * record IS the activities record. Every key must be covered, and two slices
package/dist/index.d.mts CHANGED
@@ -213,15 +213,15 @@ type Compose<C extends ContractDefinition> = <const T extends readonly PieceOf<C
213
213
  readonly port: ActivitiesPortOf<C>;
214
214
  };
215
215
  /**
216
- * The activities as a provider, from the contract. Three call forms, one port.
216
+ * The activities as a provider, from the contract. Two call forms, one port.
217
217
  *
218
218
  * ```ts
219
- * TemporalActivities(orderContract)({ place: PlaceOrder }, { sync: ({ place }) => ({ fulfillOrder: { … } }) })
219
+ * TemporalActivities(orderContract)({ inject: { place: PlaceOrder }, sync: ({ place }) => ({ fulfillOrder: { … } }) })
220
220
  * TemporalActivities(orderContract)([fulfillOrder, chargeOrder])
221
221
  * ```
222
222
  *
223
- * The first two are di's own `Provider(port)` on the starter's activities port
224
- * typed for the contract. The third takes the pieces
223
+ * The first is di's own `Provider(port)` on the starter's activities port
224
+ * typed for the contract. The second takes the pieces
225
225
  * `TemporalWorkflowActivities(contract, key)` builds: they are the provider's
226
226
  * deps, keyed by the contract key each piece's port id carries, so the services
227
227
  * record IS the activities record. Every key must be covered, and two slices
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Module, Port, Provider } from "@btravstack/di";
2
2
  import { Config, Env } from "@btravstack/config";
3
- import { RuntimePort, RuntimeStartFailed, releasedBy } from "@btravstack/core";
3
+ import { Observers, RuntimePort, RuntimeStartFailed, noObserver, observe, releasedBy } from "@btravstack/core";
4
4
  import { declareActivitiesHandler } from "@temporal-contract/worker/activity";
5
5
  import { msToNumber } from "@temporalio/common";
6
6
  import { NativeConnection, Worker } from "@temporalio/worker";
@@ -19,7 +19,17 @@ import { activityInfo } from "@temporalio/activity";
19
19
  * No `Result`-unwrapping boundary, deliberately: `declareActivitiesHandler` owns
20
20
  * the mapping from a settled `Result` to an activity failure.
21
21
  */
22
- const activityUnits = (host) => (_invocation, next) => host.run(metaFor(), () => next());
22
+ const activityUnits = (host, observers) => (_invocation, next) => {
23
+ const settle = observe(observers, {
24
+ component: "temporal",
25
+ name: "attempt",
26
+ attributes: { activity: activityInfo().activityType }
27
+ });
28
+ return host.run(metaFor(), () => next()).tap(() => settle({ outcome: "ok" })).tapFailure((failure) => settle({
29
+ outcome: "error",
30
+ cause: failure.tag === "Err" ? failure.error : failure.cause
31
+ }));
32
+ };
23
33
  /**
24
34
  * `UnitMeta.id` must be unique per unit, and a workflow id is **not** one: an
25
35
  * activity is retried under the same execution. A task token identifies one
@@ -110,22 +120,31 @@ const temporal = (options) => {
110
120
  needs: [Env, activities],
111
121
  provides: [
112
122
  config,
113
- Provider(TemporalConnection)({ config: TemporalConfig }, {
123
+ Provider(TemporalConnection)({
124
+ inject: { config: TemporalConfig },
114
125
  acquire: ({ config: bound }) => fromPromise(NativeConnection.connect({ address: bound.address }), (cause) => new TemporalUnreachable({
115
126
  address: bound.address,
116
127
  cause
117
128
  })),
118
129
  release: (connection) => connection.close().catch((cause) => heldByWorker(cause) ? void 0 : Promise.reject(cause))
119
130
  }),
131
+ Provider.member(Observers)({
132
+ inject: {},
133
+ value: noObserver
134
+ }),
120
135
  Provider(TemporalRuntime)({
121
- connection: TemporalConnection,
122
- config: TemporalConfig,
123
- activities
124
- }, { sync: ({ connection, config: bound, activities: impls }) => ({
125
- name: "temporal",
126
- resolves: [],
127
- start: (host) => createWorker(host, connection, bound, impls, options)
128
- }) })
136
+ inject: {
137
+ connection: TemporalConnection,
138
+ config: TemporalConfig,
139
+ activities,
140
+ observers: Observers
141
+ },
142
+ sync: ({ connection, config: bound, activities: impls, observers }) => ({
143
+ name: "temporal",
144
+ resolves: [],
145
+ start: (host) => createWorker(host, connection, bound, impls, options, observers)
146
+ })
147
+ })
129
148
  ],
130
149
  exports: [
131
150
  TemporalRuntime,
@@ -139,12 +158,12 @@ const startFailed = (cause) => new RuntimeStartFailed({
139
158
  cause
140
159
  });
141
160
  const heldByWorker = (cause) => cause instanceof Error && cause.name === "IllegalStateError";
142
- const createWorker = (host, connection, config, activities, options) => {
161
+ const createWorker = (host, connection, config, activities, options, observers) => {
143
162
  const { taskQueue } = options.contract;
144
163
  const { namespace } = config;
145
164
  return fromThrowable(() => declareActivitiesHandler({
146
165
  contract: options.contract,
147
- middleware: activityUnits(host),
166
+ middleware: activityUnits(host, observers),
148
167
  activities
149
168
  }), startFailed)().toAsync().flatMap((handler) => fromPromise(Worker.create({
150
169
  connection,
@@ -236,15 +255,15 @@ const TemporalModule = (name) => (options) => {
236
255
  });
237
256
  };
238
257
  /**
239
- * The activities as a provider, from the contract. Three call forms, one port.
258
+ * The activities as a provider, from the contract. Two call forms, one port.
240
259
  *
241
260
  * ```ts
242
- * TemporalActivities(orderContract)({ place: PlaceOrder }, { sync: ({ place }) => ({ fulfillOrder: { … } }) })
261
+ * TemporalActivities(orderContract)({ inject: { place: PlaceOrder }, sync: ({ place }) => ({ fulfillOrder: { … } }) })
243
262
  * TemporalActivities(orderContract)([fulfillOrder, chargeOrder])
244
263
  * ```
245
264
  *
246
- * The first two are di's own `Provider(port)` on the starter's activities port
247
- * typed for the contract. The third takes the pieces
265
+ * The first is di's own `Provider(port)` on the starter's activities port
266
+ * typed for the contract. The second takes the pieces
248
267
  * `TemporalWorkflowActivities(contract, key)` builds: they are the provider's
249
268
  * deps, keyed by the contract key each piece's port id carries, so the services
250
269
  * record IS the activities record. Every key must be covered, and two slices
@@ -252,8 +271,11 @@ const TemporalModule = (name) => (options) => {
252
271
  */
253
272
  const TemporalActivities = (contract) => {
254
273
  const build = Provider(TemporalActivitiesPort);
255
- const compose = (pieces) => build(Object.fromEntries(pieces.map((piece) => [piece.port.portId.slice(27), piece.port])), { sync: (services) => services });
256
- return ((first, second) => second === void 0 && Array.isArray(first) ? compose(first) : build(first, second));
274
+ const compose = (pieces) => build({
275
+ inject: Object.fromEntries(pieces.map((piece) => [piece.port.portId.slice(27), piece.port])),
276
+ sync: (services) => services
277
+ });
278
+ return ((first) => Array.isArray(first) ? compose(first) : build(first));
257
279
  };
258
280
  //#endregion
259
281
  export { TemporalActivities, TemporalConfig, TemporalConnection, TemporalModule, TemporalRuntime, TemporalUnreachable, TemporalWorkflowActivities, temporal };
@@ -0,0 +1,51 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let unthrown = require("unthrown");
3
+ let _temporal_contract_client = require("@temporal-contract/client");
4
+ //#region src/schedule.ts
5
+ /**
6
+ * Register a schedule, idempotently.
7
+ *
8
+ * `TypedScheduleClient.create` answers `ScheduleAlreadyExistsError` for a
9
+ * schedule id already in use, which is correct and is also the wrong shape for
10
+ * the one place schedules are actually registered: a **deploy**, which runs
11
+ * again on every release. So the second run of a correct deploy script fails,
12
+ * and the usual repair is a `try`/ignore that also swallows the case where the
13
+ * schedule exists with the WRONG spec — a cron nobody notices has stopped
14
+ * matching what the code says.
15
+ *
16
+ * This recovers exactly that one error into an `update`. Every other error
17
+ * stays on the channel, still typed.
18
+ *
19
+ * **`spec` is the only field reconciled**, and the rest of an existing
20
+ * schedule is left as it stands. Two different reasons, and they are worth
21
+ * telling apart:
22
+ *
23
+ * - **`state` is deliberately preserved.** A schedule an operator paused stays
24
+ * paused across a deploy, because unpausing it is a decision a person made
25
+ * and a deploy is not the place to reverse it.
26
+ * - **`args` and the rest of the action are preserved because this cannot
27
+ * reconcile them SAFELY.** `create` validates `args` against the workflow's
28
+ * input schema; the handle's `update` takes Temporal's own
29
+ * `ScheduleUpdateOptions` and validates nothing, so writing them here would
30
+ * push unvalidated input at the server through a door the typed client keeps
31
+ * shut. A deploy that changes what a schedule RUNS — its args, its workflow,
32
+ * its policies — should say so explicitly: delete the schedule and create it,
33
+ * or reach `getHandle(id).update(...)` directly and own the shape.
34
+ *
35
+ * So: after this call the schedule FIRES when the arguments say. What it fires
36
+ * with is whatever it already fired with.
37
+ *
38
+ * ```ts
39
+ * await ensureSchedule(client.for(orderContract).schedule, "sweepStaleOrders", {
40
+ * scheduleId: "sweep-stale-orders",
41
+ * spec: { cronExpressions: ["0 3 * * *"] },
42
+ * args: [{ olderThanDays: 30 }],
43
+ * });
44
+ * ```
45
+ */
46
+ const ensureSchedule = (schedules, workflow, options) => schedules.create(workflow, options).map(() => "created").flatMapErrCases((matcher) => matcher.with(unthrown.P.tag(_temporal_contract_client.SCHEDULE_ALREADY_EXISTS_ERROR_TAG), () => schedules.getHandle(options.scheduleId).update((previous) => ({
47
+ ...previous,
48
+ spec: options.spec
49
+ })).map(() => "updated")).with(unthrown.P.tag("@temporal-contract/WorkflowNotInContractError"), (error) => (0, unthrown.ErrAsync)(error)).with(unthrown.P.tag("@temporal-contract/WorkflowValidationError"), (error) => (0, unthrown.ErrAsync)(error)));
50
+ //#endregion
51
+ exports.ensureSchedule = ensureSchedule;
@@ -0,0 +1,50 @@
1
+ import { ContractDefinition } from "@temporal-contract/contract";
2
+ import { ScheduleNotFoundError, TypedScheduleClient, TypedScheduleCreateOptions, WorkflowNotInContractError, WorkflowValidationError } from "@temporal-contract/client";
3
+ import { AsyncResult } from "unthrown";
4
+ //#region src/schedule.d.ts
5
+ /** Whether the schedule had to be created, or was already there and had its spec reconciled. */
6
+ type ScheduleOutcome = "created" | "updated";
7
+ /**
8
+ * Register a schedule, idempotently.
9
+ *
10
+ * `TypedScheduleClient.create` answers `ScheduleAlreadyExistsError` for a
11
+ * schedule id already in use, which is correct and is also the wrong shape for
12
+ * the one place schedules are actually registered: a **deploy**, which runs
13
+ * again on every release. So the second run of a correct deploy script fails,
14
+ * and the usual repair is a `try`/ignore that also swallows the case where the
15
+ * schedule exists with the WRONG spec — a cron nobody notices has stopped
16
+ * matching what the code says.
17
+ *
18
+ * This recovers exactly that one error into an `update`. Every other error
19
+ * stays on the channel, still typed.
20
+ *
21
+ * **`spec` is the only field reconciled**, and the rest of an existing
22
+ * schedule is left as it stands. Two different reasons, and they are worth
23
+ * telling apart:
24
+ *
25
+ * - **`state` is deliberately preserved.** A schedule an operator paused stays
26
+ * paused across a deploy, because unpausing it is a decision a person made
27
+ * and a deploy is not the place to reverse it.
28
+ * - **`args` and the rest of the action are preserved because this cannot
29
+ * reconcile them SAFELY.** `create` validates `args` against the workflow's
30
+ * input schema; the handle's `update` takes Temporal's own
31
+ * `ScheduleUpdateOptions` and validates nothing, so writing them here would
32
+ * push unvalidated input at the server through a door the typed client keeps
33
+ * shut. A deploy that changes what a schedule RUNS — its args, its workflow,
34
+ * its policies — should say so explicitly: delete the schedule and create it,
35
+ * or reach `getHandle(id).update(...)` directly and own the shape.
36
+ *
37
+ * So: after this call the schedule FIRES when the arguments say. What it fires
38
+ * with is whatever it already fired with.
39
+ *
40
+ * ```ts
41
+ * await ensureSchedule(client.for(orderContract).schedule, "sweepStaleOrders", {
42
+ * scheduleId: "sweep-stale-orders",
43
+ * spec: { cronExpressions: ["0 3 * * *"] },
44
+ * args: [{ olderThanDays: 30 }],
45
+ * });
46
+ * ```
47
+ */
48
+ declare const ensureSchedule: <C extends ContractDefinition, W extends keyof C["workflows"] & string>(schedules: TypedScheduleClient<C>, workflow: W, options: TypedScheduleCreateOptions<C, W>) => AsyncResult<ScheduleOutcome, WorkflowNotInContractError | WorkflowValidationError | ScheduleNotFoundError>;
49
+ //#endregion
50
+ export { ScheduleOutcome, ensureSchedule };
@@ -0,0 +1,50 @@
1
+ import { AsyncResult } from "unthrown";
2
+ import { ScheduleNotFoundError, TypedScheduleClient, TypedScheduleCreateOptions, WorkflowNotInContractError, WorkflowValidationError } from "@temporal-contract/client";
3
+ import { ContractDefinition } from "@temporal-contract/contract";
4
+ //#region src/schedule.d.ts
5
+ /** Whether the schedule had to be created, or was already there and had its spec reconciled. */
6
+ type ScheduleOutcome = "created" | "updated";
7
+ /**
8
+ * Register a schedule, idempotently.
9
+ *
10
+ * `TypedScheduleClient.create` answers `ScheduleAlreadyExistsError` for a
11
+ * schedule id already in use, which is correct and is also the wrong shape for
12
+ * the one place schedules are actually registered: a **deploy**, which runs
13
+ * again on every release. So the second run of a correct deploy script fails,
14
+ * and the usual repair is a `try`/ignore that also swallows the case where the
15
+ * schedule exists with the WRONG spec — a cron nobody notices has stopped
16
+ * matching what the code says.
17
+ *
18
+ * This recovers exactly that one error into an `update`. Every other error
19
+ * stays on the channel, still typed.
20
+ *
21
+ * **`spec` is the only field reconciled**, and the rest of an existing
22
+ * schedule is left as it stands. Two different reasons, and they are worth
23
+ * telling apart:
24
+ *
25
+ * - **`state` is deliberately preserved.** A schedule an operator paused stays
26
+ * paused across a deploy, because unpausing it is a decision a person made
27
+ * and a deploy is not the place to reverse it.
28
+ * - **`args` and the rest of the action are preserved because this cannot
29
+ * reconcile them SAFELY.** `create` validates `args` against the workflow's
30
+ * input schema; the handle's `update` takes Temporal's own
31
+ * `ScheduleUpdateOptions` and validates nothing, so writing them here would
32
+ * push unvalidated input at the server through a door the typed client keeps
33
+ * shut. A deploy that changes what a schedule RUNS — its args, its workflow,
34
+ * its policies — should say so explicitly: delete the schedule and create it,
35
+ * or reach `getHandle(id).update(...)` directly and own the shape.
36
+ *
37
+ * So: after this call the schedule FIRES when the arguments say. What it fires
38
+ * with is whatever it already fired with.
39
+ *
40
+ * ```ts
41
+ * await ensureSchedule(client.for(orderContract).schedule, "sweepStaleOrders", {
42
+ * scheduleId: "sweep-stale-orders",
43
+ * spec: { cronExpressions: ["0 3 * * *"] },
44
+ * args: [{ olderThanDays: 30 }],
45
+ * });
46
+ * ```
47
+ */
48
+ declare const ensureSchedule: <C extends ContractDefinition, W extends keyof C["workflows"] & string>(schedules: TypedScheduleClient<C>, workflow: W, options: TypedScheduleCreateOptions<C, W>) => AsyncResult<ScheduleOutcome, WorkflowNotInContractError | WorkflowValidationError | ScheduleNotFoundError>;
49
+ //#endregion
50
+ export { ScheduleOutcome, ensureSchedule };
@@ -0,0 +1,50 @@
1
+ import { ErrAsync, P } from "unthrown";
2
+ import { SCHEDULE_ALREADY_EXISTS_ERROR_TAG } from "@temporal-contract/client";
3
+ //#region src/schedule.ts
4
+ /**
5
+ * Register a schedule, idempotently.
6
+ *
7
+ * `TypedScheduleClient.create` answers `ScheduleAlreadyExistsError` for a
8
+ * schedule id already in use, which is correct and is also the wrong shape for
9
+ * the one place schedules are actually registered: a **deploy**, which runs
10
+ * again on every release. So the second run of a correct deploy script fails,
11
+ * and the usual repair is a `try`/ignore that also swallows the case where the
12
+ * schedule exists with the WRONG spec — a cron nobody notices has stopped
13
+ * matching what the code says.
14
+ *
15
+ * This recovers exactly that one error into an `update`. Every other error
16
+ * stays on the channel, still typed.
17
+ *
18
+ * **`spec` is the only field reconciled**, and the rest of an existing
19
+ * schedule is left as it stands. Two different reasons, and they are worth
20
+ * telling apart:
21
+ *
22
+ * - **`state` is deliberately preserved.** A schedule an operator paused stays
23
+ * paused across a deploy, because unpausing it is a decision a person made
24
+ * and a deploy is not the place to reverse it.
25
+ * - **`args` and the rest of the action are preserved because this cannot
26
+ * reconcile them SAFELY.** `create` validates `args` against the workflow's
27
+ * input schema; the handle's `update` takes Temporal's own
28
+ * `ScheduleUpdateOptions` and validates nothing, so writing them here would
29
+ * push unvalidated input at the server through a door the typed client keeps
30
+ * shut. A deploy that changes what a schedule RUNS — its args, its workflow,
31
+ * its policies — should say so explicitly: delete the schedule and create it,
32
+ * or reach `getHandle(id).update(...)` directly and own the shape.
33
+ *
34
+ * So: after this call the schedule FIRES when the arguments say. What it fires
35
+ * with is whatever it already fired with.
36
+ *
37
+ * ```ts
38
+ * await ensureSchedule(client.for(orderContract).schedule, "sweepStaleOrders", {
39
+ * scheduleId: "sweep-stale-orders",
40
+ * spec: { cronExpressions: ["0 3 * * *"] },
41
+ * args: [{ olderThanDays: 30 }],
42
+ * });
43
+ * ```
44
+ */
45
+ const ensureSchedule = (schedules, workflow, options) => schedules.create(workflow, options).map(() => "created").flatMapErrCases((matcher) => matcher.with(P.tag(SCHEDULE_ALREADY_EXISTS_ERROR_TAG), () => schedules.getHandle(options.scheduleId).update((previous) => ({
46
+ ...previous,
47
+ spec: options.spec
48
+ })).map(() => "updated")).with(P.tag("@temporal-contract/WorkflowNotInContractError"), (error) => ErrAsync(error)).with(P.tag("@temporal-contract/WorkflowValidationError"), (error) => ErrAsync(error)));
49
+ //#endregion
50
+ export { ensureSchedule };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@btravstack/temporal-worker",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "The Temporal worker runtime for @btravstack/core: one unit per activity attempt, and a drain that honours the kernel's deadline",
5
5
  "keywords": [
6
6
  "graceful-shutdown",
@@ -41,12 +41,23 @@
41
41
  "default": "./dist/index.cjs"
42
42
  }
43
43
  },
44
- "./package.json": "./package.json"
44
+ "./package.json": "./package.json",
45
+ "./schedule": {
46
+ "import": {
47
+ "types": "./dist/schedule.d.mts",
48
+ "default": "./dist/schedule.mjs"
49
+ },
50
+ "require": {
51
+ "types": "./dist/schedule.d.cts",
52
+ "default": "./dist/schedule.cjs"
53
+ }
54
+ }
45
55
  },
46
56
  "devDependencies": {
47
57
  "@btravstack/tsconfig": "0.2.0",
48
- "@temporal-contract/contract": "8.0.0-beta.5",
49
- "@temporal-contract/worker": "8.0.0-beta.5",
58
+ "@temporal-contract/client": "8.0.0-beta.7",
59
+ "@temporal-contract/contract": "8.0.0-beta.7",
60
+ "@temporal-contract/worker": "8.0.0-beta.7",
50
61
  "@temporalio/activity": "1.23.0",
51
62
  "@temporalio/client": "1.23.0",
52
63
  "@temporalio/common": "1.23.0",
@@ -59,30 +70,36 @@
59
70
  "typescript": "7.0.2",
60
71
  "unthrown": "5.7.0",
61
72
  "vitest": "4.1.11",
62
- "zod": "4.5.1",
63
- "@btravstack/config": "0.8.0",
64
- "@btravstack/core": "0.8.0",
65
- "@btravstack/di": "0.8.0",
66
- "@btravstack/testing": "0.8.0",
67
- "@btravstack/internal-test-infra": "0.0.0"
73
+ "zod": "4.5.3",
74
+ "@btravstack/config": "0.10.0",
75
+ "@btravstack/core": "0.10.0",
76
+ "@btravstack/di": "0.10.0",
77
+ "@btravstack/internal-test-infra": "0.0.0",
78
+ "@btravstack/testing": "0.10.0"
68
79
  },
69
80
  "peerDependencies": {
70
- "@temporal-contract/contract": "^8.0.0-beta",
71
- "@temporal-contract/worker": "^8.0.0-beta",
81
+ "@temporal-contract/client": "^8.0.0-beta.7",
82
+ "@temporal-contract/contract": "^8.0.0-beta.7",
83
+ "@temporal-contract/worker": "^8.0.0-beta.7",
72
84
  "@temporalio/activity": "^1.22.0",
73
85
  "@temporalio/common": "^1.22.0",
74
86
  "@temporalio/worker": "^1.22.0",
75
87
  "unthrown": "^5.0.0",
76
- "@btravstack/config": "^0.8.0",
77
- "@btravstack/core": "^0.8.0",
78
- "@btravstack/di": "^0.8.0"
88
+ "@btravstack/config": "^0.10.0",
89
+ "@btravstack/core": "^0.10.0",
90
+ "@btravstack/di": "^0.10.0"
91
+ },
92
+ "peerDependenciesMeta": {
93
+ "@temporal-contract/client": {
94
+ "optional": true
95
+ }
79
96
  },
80
97
  "engines": {
81
98
  "node": ">=22"
82
99
  },
83
100
  "scripts": {
84
- "build": "tsdown src/index.ts --format cjs,esm --dts --clean",
85
- "dev": "tsdown src/index.ts --format cjs,esm --dts --watch",
101
+ "build": "tsdown src/index.ts src/schedule.ts --format cjs,esm --dts --clean",
102
+ "dev": "tsdown src/index.ts src/schedule.ts --format cjs,esm --dts --watch",
86
103
  "test": "vitest run --coverage",
87
104
  "test:types": "tsc --noEmit -p tsconfig.test-d.json",
88
105
  "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test-d.json"