@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 +38 -26
- package/dist/index.cjs +40 -18
- package/dist/index.d.cts +4 -4
- package/dist/index.d.mts +4 -4
- package/dist/index.mjs +41 -19
- package/dist/schedule.cjs +51 -0
- package/dist/schedule.d.cts +50 -0
- package/dist/schedule.d.mts +50 -0
- package/dist/schedule.mjs +50 -0
- package/package.json +34 -17
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
|
-
|
|
81
|
-
|
|
82
|
-
place
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
.
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
.
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
.
|
|
94
|
-
|
|
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) =>
|
|
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)({
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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.
|
|
259
|
+
* The activities as a provider, from the contract. Two call forms, one port.
|
|
241
260
|
*
|
|
242
261
|
* ```ts
|
|
243
|
-
* TemporalActivities(orderContract)({ place: PlaceOrder },
|
|
262
|
+
* TemporalActivities(orderContract)({ inject: { place: PlaceOrder }, sync: ({ place }) => ({ fulfillOrder: { … } }) })
|
|
244
263
|
* TemporalActivities(orderContract)([fulfillOrder, chargeOrder])
|
|
245
264
|
* ```
|
|
246
265
|
*
|
|
247
|
-
* The first
|
|
248
|
-
* typed for the contract. The
|
|
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(
|
|
257
|
-
|
|
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.
|
|
216
|
+
* The activities as a provider, from the contract. Two call forms, one port.
|
|
217
217
|
*
|
|
218
218
|
* ```ts
|
|
219
|
-
* TemporalActivities(orderContract)({ place: PlaceOrder },
|
|
219
|
+
* TemporalActivities(orderContract)({ inject: { place: PlaceOrder }, sync: ({ place }) => ({ fulfillOrder: { … } }) })
|
|
220
220
|
* TemporalActivities(orderContract)([fulfillOrder, chargeOrder])
|
|
221
221
|
* ```
|
|
222
222
|
*
|
|
223
|
-
* The first
|
|
224
|
-
* typed for the contract. The
|
|
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.
|
|
216
|
+
* The activities as a provider, from the contract. Two call forms, one port.
|
|
217
217
|
*
|
|
218
218
|
* ```ts
|
|
219
|
-
* TemporalActivities(orderContract)({ place: PlaceOrder },
|
|
219
|
+
* TemporalActivities(orderContract)({ inject: { place: PlaceOrder }, sync: ({ place }) => ({ fulfillOrder: { … } }) })
|
|
220
220
|
* TemporalActivities(orderContract)([fulfillOrder, chargeOrder])
|
|
221
221
|
* ```
|
|
222
222
|
*
|
|
223
|
-
* The first
|
|
224
|
-
* typed for the contract. The
|
|
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) =>
|
|
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)({
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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.
|
|
258
|
+
* The activities as a provider, from the contract. Two call forms, one port.
|
|
240
259
|
*
|
|
241
260
|
* ```ts
|
|
242
|
-
* TemporalActivities(orderContract)({ place: PlaceOrder },
|
|
261
|
+
* TemporalActivities(orderContract)({ inject: { place: PlaceOrder }, sync: ({ place }) => ({ fulfillOrder: { … } }) })
|
|
243
262
|
* TemporalActivities(orderContract)([fulfillOrder, chargeOrder])
|
|
244
263
|
* ```
|
|
245
264
|
*
|
|
246
|
-
* The first
|
|
247
|
-
* typed for the contract. The
|
|
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(
|
|
256
|
-
|
|
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.
|
|
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/
|
|
49
|
-
"@temporal-contract/
|
|
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.
|
|
63
|
-
"@btravstack/config": "0.
|
|
64
|
-
"@btravstack/core": "0.
|
|
65
|
-
"@btravstack/di": "0.
|
|
66
|
-
"@btravstack/
|
|
67
|
-
"@btravstack/
|
|
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/
|
|
71
|
-
"@temporal-contract/
|
|
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.
|
|
77
|
-
"@btravstack/core": "^0.
|
|
78
|
-
"@btravstack/di": "^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"
|