@effect-app/infra 4.0.0-beta.257 → 4.0.0-beta.258
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/CHANGELOG.md +11 -0
- package/dist/WorkflowEngineSqlite.d.ts +24 -0
- package/dist/WorkflowEngineSqlite.d.ts.map +1 -0
- package/dist/WorkflowEngineSqlite.js +550 -0
- package/package.json +7 -206
- package/src/WorkflowEngineCosmos.ts +719 -0
- package/src/WorkflowEngineSqlite.ts +813 -0
- package/test/dist/workflow-engine-sqlite.test.d.ts.map +1 -0
- package/test/workflow-engine-cosmos.test.ts +354 -0
- package/test/workflow-engine-sqlite.test.ts +299 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"workflow-engine-sqlite.test.d.ts","sourceRoot":"","sources":["../workflow-engine-sqlite.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
/**
|
|
3
|
+
* Workflow engine conformance tests.
|
|
4
|
+
*
|
|
5
|
+
* The `runSuite` body is engine-agnostic: it runs against
|
|
6
|
+
* `WorkflowEngine.layerMemory` (fast, always) and — when `COSMOS_TEST_URL`
|
|
7
|
+
* is set — against the Cosmos adapter (`layerCosmos`) pointed at a Cosmos
|
|
8
|
+
* emulator. Both must satisfy the same observable contract: idempotent
|
|
9
|
+
* execution, activity replay (no double side effects), suspend → durable-
|
|
10
|
+
* deferred completion → resume with a correctly decoded result, interrupt
|
|
11
|
+
* propagation, and first-writer-wins deferred completion.
|
|
12
|
+
*
|
|
13
|
+
* A second describe block (Cosmos emulator only) exercises adapter
|
|
14
|
+
* internals — the recovery poller (driving execs whose lease has lapsed)
|
|
15
|
+
* and the clock poller (firing past-due clocks) — by seeding rows
|
|
16
|
+
* directly against the Cosmos container.
|
|
17
|
+
*/
|
|
18
|
+
import { assert, describe, it } from "@effect/vitest"
|
|
19
|
+
import { Context, Duration, Effect, Exit, Layer, Option, Redacted, Schema } from "effect"
|
|
20
|
+
import { Activity, DurableDeferred, Workflow, WorkflowEngine } from "effect/unstable/workflow"
|
|
21
|
+
import { CosmosClient, CosmosClientLayer } from "../src/cosmos-client.js"
|
|
22
|
+
import { layerCosmos } from "../src/WorkflowEngineCosmos.js"
|
|
23
|
+
|
|
24
|
+
// --- Shared mutable counter for activity-side-effect assertions --------
|
|
25
|
+
|
|
26
|
+
class CounterRef extends Context.Service<CounterRef, { count: number }>()("CounterRef") {
|
|
27
|
+
static readonly layer = Layer.effect(CounterRef, Effect.sync(() => ({ count: 0 })))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// --- Workflow definitions ----------------------------------------------
|
|
31
|
+
|
|
32
|
+
const IncrementWorkflow = Workflow.make({
|
|
33
|
+
name: "WorkflowEngineCosmos/IncrementWorkflow",
|
|
34
|
+
payload: { value: Schema.Number },
|
|
35
|
+
success: Schema.Number,
|
|
36
|
+
idempotencyKey: ({ value }) => String(value)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const IncrementHandler = IncrementWorkflow.toLayer(({ value }) => Effect.succeed(value + 1))
|
|
40
|
+
|
|
41
|
+
// Counts activity body invocations across re-executes so the test can
|
|
42
|
+
// prove side-effects don't repeat when a persisted result is available.
|
|
43
|
+
const CounterWorkflow = Workflow.make({
|
|
44
|
+
name: "WorkflowEngineCosmos/CounterWorkflow",
|
|
45
|
+
payload: { id: Schema.String },
|
|
46
|
+
success: Schema.Number,
|
|
47
|
+
idempotencyKey: ({ id }) => id
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
const CounterHandler = CounterWorkflow.toLayer(Effect.fn(function*() {
|
|
51
|
+
const counter = yield* CounterRef
|
|
52
|
+
return yield* Activity.make({
|
|
53
|
+
name: "Bump",
|
|
54
|
+
success: Schema.Number,
|
|
55
|
+
execute: Effect.sync(() => {
|
|
56
|
+
counter.count++
|
|
57
|
+
return counter.count
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
}))
|
|
61
|
+
|
|
62
|
+
// Suspends on a deferred, then composes the persisted activity result
|
|
63
|
+
// with the resumed deferred value. Exercises Result/Exit round-trip.
|
|
64
|
+
const Trigger = DurableDeferred.make("WorkflowEngineCosmos/Trigger", { success: Schema.String })
|
|
65
|
+
|
|
66
|
+
const SuspendWorkflow = Workflow.make({
|
|
67
|
+
name: "WorkflowEngineCosmos/SuspendWorkflow",
|
|
68
|
+
payload: { id: Schema.String },
|
|
69
|
+
success: Schema.String,
|
|
70
|
+
idempotencyKey: ({ id }) => id
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const SuspendHandler = SuspendWorkflow.toLayer(Effect.fn(function*({ id }) {
|
|
74
|
+
const n = yield* Activity.make({
|
|
75
|
+
name: "step",
|
|
76
|
+
success: Schema.Number,
|
|
77
|
+
execute: Effect.succeed(1)
|
|
78
|
+
})
|
|
79
|
+
const v = yield* DurableDeferred.await(Trigger)
|
|
80
|
+
return `${id}:${n}:${v}`
|
|
81
|
+
}))
|
|
82
|
+
|
|
83
|
+
// Plain durable-deferred await — used to assert first-writer-wins on done().
|
|
84
|
+
const AwaitOnly = Workflow.make({
|
|
85
|
+
name: "WorkflowEngineCosmos/AwaitOnly",
|
|
86
|
+
payload: { id: Schema.String },
|
|
87
|
+
success: Schema.String,
|
|
88
|
+
idempotencyKey: ({ id }) => id
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
const AwaitOnlyHandler = AwaitOnly.toLayer(Effect.fn(function*() {
|
|
92
|
+
return yield* DurableDeferred.await(Trigger)
|
|
93
|
+
}))
|
|
94
|
+
|
|
95
|
+
const Handlers = Layer.mergeAll(IncrementHandler, SuspendHandler, CounterHandler, AwaitOnlyHandler)
|
|
96
|
+
|
|
97
|
+
// Helper that polls until the workflow completes (or `maxIterations` elapse).
|
|
98
|
+
const waitForComplete = (
|
|
99
|
+
workflow: { readonly poll: (id: string) => Effect.Effect<Option.Option<Workflow.Result<any, any>>, never, any> },
|
|
100
|
+
executionId: string,
|
|
101
|
+
step = Duration.millis(10),
|
|
102
|
+
maxIterations = 200
|
|
103
|
+
) =>
|
|
104
|
+
Effect.gen(function*() {
|
|
105
|
+
for (let i = 0; i < maxIterations; i++) {
|
|
106
|
+
const polled = yield* workflow.poll(executionId)
|
|
107
|
+
if (Option.isSome(polled) && polled.value._tag === "Complete") {
|
|
108
|
+
return polled.value
|
|
109
|
+
}
|
|
110
|
+
yield* Effect.sleep(step)
|
|
111
|
+
}
|
|
112
|
+
return undefined
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
const runSuite = (engineLayer: Layer.Layer<WorkflowEngine.WorkflowEngine>) => {
|
|
116
|
+
const TestLayer = Handlers.pipe(
|
|
117
|
+
Layer.provideMerge(CounterRef.layer),
|
|
118
|
+
Layer.provideMerge(engineLayer)
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
it.live("executes a workflow and polls the result", () =>
|
|
122
|
+
Effect
|
|
123
|
+
.gen(function*() {
|
|
124
|
+
const executionId = yield* IncrementWorkflow.execute({ value: 41 }, { discard: true })
|
|
125
|
+
const result = yield* IncrementWorkflow.execute({ value: 41 })
|
|
126
|
+
const polled = yield* IncrementWorkflow.poll(executionId)
|
|
127
|
+
|
|
128
|
+
assert.strictEqual(result, 42)
|
|
129
|
+
assert(
|
|
130
|
+
Option.isSome(polled)
|
|
131
|
+
&& polled.value._tag === "Complete"
|
|
132
|
+
&& Exit.isSuccess(polled.value.exit)
|
|
133
|
+
)
|
|
134
|
+
assert.strictEqual(polled.value.exit.value, 42)
|
|
135
|
+
})
|
|
136
|
+
.pipe(Effect.provide(TestLayer)))
|
|
137
|
+
|
|
138
|
+
it.live("re-executing the same id is idempotent", () =>
|
|
139
|
+
Effect
|
|
140
|
+
.gen(function*() {
|
|
141
|
+
const a = yield* IncrementWorkflow.execute({ value: 7 })
|
|
142
|
+
const b = yield* IncrementWorkflow.execute({ value: 7 })
|
|
143
|
+
assert.strictEqual(a, b)
|
|
144
|
+
})
|
|
145
|
+
.pipe(Effect.provide(TestLayer)))
|
|
146
|
+
|
|
147
|
+
it.live("activity side effects run once across re-executions", () =>
|
|
148
|
+
Effect
|
|
149
|
+
.gen(function*() {
|
|
150
|
+
const counter = yield* CounterRef
|
|
151
|
+
yield* CounterWorkflow.execute({ id: "once" })
|
|
152
|
+
yield* CounterWorkflow.execute({ id: "once" })
|
|
153
|
+
yield* CounterWorkflow.execute({ id: "once" })
|
|
154
|
+
assert.strictEqual(counter.count, 1)
|
|
155
|
+
})
|
|
156
|
+
.pipe(Effect.provide(TestLayer)))
|
|
157
|
+
|
|
158
|
+
it.live("suspends on a durable deferred, then resumes with the decoded result", () =>
|
|
159
|
+
Effect
|
|
160
|
+
.gen(function*() {
|
|
161
|
+
// The execution suspends on `Trigger`; completing the deferred resumes
|
|
162
|
+
// it (the workflow body replays, the `step` activity is served from its
|
|
163
|
+
// persisted result), and the final value round-trips through the engine.
|
|
164
|
+
const executionId = yield* SuspendWorkflow.execute({ id: "abc" }, { discard: true })
|
|
165
|
+
|
|
166
|
+
const token = yield* DurableDeferred.tokenFromPayload(Trigger, {
|
|
167
|
+
workflow: SuspendWorkflow,
|
|
168
|
+
payload: { id: "abc" }
|
|
169
|
+
})
|
|
170
|
+
yield* DurableDeferred.done(Trigger, { token, exit: Exit.succeed("ok") })
|
|
171
|
+
|
|
172
|
+
const done = yield* waitForComplete(SuspendWorkflow, executionId)
|
|
173
|
+
assert(done && Exit.isSuccess(done.exit))
|
|
174
|
+
assert.strictEqual(done.exit.value, "abc:1:ok")
|
|
175
|
+
})
|
|
176
|
+
.pipe(Effect.provide(TestLayer)))
|
|
177
|
+
|
|
178
|
+
it.live("deferredDone is idempotent (first-writer-wins)", () =>
|
|
179
|
+
Effect
|
|
180
|
+
.gen(function*() {
|
|
181
|
+
const executionId = yield* AwaitOnly.execute({ id: "dup" }, { discard: true })
|
|
182
|
+
const token = yield* DurableDeferred.tokenFromPayload(Trigger, {
|
|
183
|
+
workflow: AwaitOnly,
|
|
184
|
+
payload: { id: "dup" }
|
|
185
|
+
})
|
|
186
|
+
yield* DurableDeferred.done(Trigger, { token, exit: Exit.succeed("first") })
|
|
187
|
+
// Second completion must lose; the workflow body sees "first".
|
|
188
|
+
yield* DurableDeferred.done(Trigger, { token, exit: Exit.succeed("second") })
|
|
189
|
+
|
|
190
|
+
const done = yield* waitForComplete(AwaitOnly, executionId)
|
|
191
|
+
assert(done && Exit.isSuccess(done.exit))
|
|
192
|
+
assert.strictEqual(done.exit.value, "first")
|
|
193
|
+
})
|
|
194
|
+
.pipe(Effect.provide(TestLayer)))
|
|
195
|
+
|
|
196
|
+
it.live("interrupt eventually completes a suspended execution", () =>
|
|
197
|
+
Effect
|
|
198
|
+
.gen(function*() {
|
|
199
|
+
const executionId = yield* AwaitOnly.execute({ id: "int" }, { discard: true })
|
|
200
|
+
// Give the workflow time to suspend on the deferred.
|
|
201
|
+
yield* Effect.sleep(Duration.millis(50))
|
|
202
|
+
yield* AwaitOnly.interrupt(executionId)
|
|
203
|
+
|
|
204
|
+
// The execution should stop reporting as "running" — a subsequent poll
|
|
205
|
+
// returns either Complete (engine collapses the interrupt into a
|
|
206
|
+
// completion) or None (engine surfaces it as not-yet-complete and the
|
|
207
|
+
// wrapper sleep loop eventually ends). Both are acceptable as long as
|
|
208
|
+
// the workflow no longer makes forward progress.
|
|
209
|
+
yield* Effect.sleep(Duration.millis(150))
|
|
210
|
+
const polled = yield* AwaitOnly.poll(executionId)
|
|
211
|
+
if (Option.isSome(polled)) {
|
|
212
|
+
assert.strictEqual(polled.value._tag, "Complete")
|
|
213
|
+
}
|
|
214
|
+
})
|
|
215
|
+
.pipe(Effect.provide(TestLayer)))
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
describe("WorkflowEngine (in-memory)", () => {
|
|
219
|
+
runSuite(WorkflowEngine.layerMemory)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
// --- Cosmos-emulator-only adapter tests --------------------------------
|
|
223
|
+
//
|
|
224
|
+
// Opt-in. Run with e.g.
|
|
225
|
+
// COSMOS_TEST_URL="https://localhost:8081" COSMOS_TEST_DB="workflow-test" \
|
|
226
|
+
// NODE_TLS_REJECT_UNAUTHORIZED=0 pnpm vitest run workflow-engine-cosmos
|
|
227
|
+
const cosmosUrl = process.env["COSMOS_TEST_URL"]
|
|
228
|
+
const cosmosDb = process.env["COSMOS_TEST_DB"] ?? "workflow-test"
|
|
229
|
+
|
|
230
|
+
describe.skipIf(!cosmosUrl)("WorkflowEngine (Cosmos) — conformance", () => {
|
|
231
|
+
runSuite(
|
|
232
|
+
layerCosmos({
|
|
233
|
+
url: Redacted.make(cosmosUrl ?? ""),
|
|
234
|
+
dbName: cosmosDb,
|
|
235
|
+
prefix: `test-${Date.now()}-`,
|
|
236
|
+
// Tight cadences so the suite doesn't wait on the background pollers.
|
|
237
|
+
recoveryInterval: Duration.seconds(2),
|
|
238
|
+
clockPollInterval: Duration.seconds(1)
|
|
239
|
+
})
|
|
240
|
+
)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
describe.skipIf(!cosmosUrl)("WorkflowEngine (Cosmos) — adapter internals", () => {
|
|
244
|
+
// Each test gets its own container prefix so seeded docs don't leak.
|
|
245
|
+
const prefixFor = (label: string) => `test-${Date.now()}-${label}-`
|
|
246
|
+
|
|
247
|
+
const adapterLayer = (prefix: string) =>
|
|
248
|
+
Layer
|
|
249
|
+
.mergeAll(
|
|
250
|
+
IncrementHandler,
|
|
251
|
+
AwaitOnlyHandler,
|
|
252
|
+
CounterRef.layer,
|
|
253
|
+
CosmosClientLayer(cosmosUrl ?? "", cosmosDb)
|
|
254
|
+
)
|
|
255
|
+
.pipe(
|
|
256
|
+
Layer.provideMerge(
|
|
257
|
+
layerCosmos({
|
|
258
|
+
url: Redacted.make(cosmosUrl ?? ""),
|
|
259
|
+
dbName: cosmosDb,
|
|
260
|
+
prefix,
|
|
261
|
+
recoveryInterval: Duration.millis(500),
|
|
262
|
+
clockPollInterval: Duration.millis(500)
|
|
263
|
+
})
|
|
264
|
+
)
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
it.live("recovery poller drives execs with stale leases", () => {
|
|
268
|
+
const prefix = prefixFor("recovery")
|
|
269
|
+
const containerId = `${prefix}workflow-engine`
|
|
270
|
+
return Effect
|
|
271
|
+
.gen(function*() {
|
|
272
|
+
const { db } = yield* CosmosClient
|
|
273
|
+
const container = db.container(containerId)
|
|
274
|
+
// Pre-seed a running exec whose lease has already expired and whose
|
|
275
|
+
// payload is the schema-encoded form of `{ value: 99 }`.
|
|
276
|
+
yield* Effect.promise(() =>
|
|
277
|
+
container.items.upsert({
|
|
278
|
+
id: "exec",
|
|
279
|
+
_partitionKey: "recover-1",
|
|
280
|
+
type: "exec",
|
|
281
|
+
workflowName: IncrementWorkflow.name,
|
|
282
|
+
payload: JSON.stringify({ value: 99 }),
|
|
283
|
+
status: "running",
|
|
284
|
+
suspended: false,
|
|
285
|
+
interrupted: false,
|
|
286
|
+
worker: "ghost",
|
|
287
|
+
leaseExpiresAt: new Date(Date.now() - 60_000).toISOString(),
|
|
288
|
+
etag: "seed"
|
|
289
|
+
})
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
// Wait for the recovery poller (500ms cadence) to drive it.
|
|
293
|
+
yield* Effect.sleep(Duration.seconds(2))
|
|
294
|
+
|
|
295
|
+
const polled = yield* IncrementWorkflow.poll("recover-1")
|
|
296
|
+
assert(
|
|
297
|
+
Option.isSome(polled)
|
|
298
|
+
&& polled.value._tag === "Complete"
|
|
299
|
+
&& Exit.isSuccess(polled.value.exit)
|
|
300
|
+
)
|
|
301
|
+
assert.strictEqual(polled.value.exit.value, 100)
|
|
302
|
+
})
|
|
303
|
+
.pipe(Effect.provide(adapterLayer(prefix)))
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it.live("clock poller fires past-due clocks", () => {
|
|
307
|
+
const prefix = prefixFor("clocks")
|
|
308
|
+
const containerId = `${prefix}workflow-engine`
|
|
309
|
+
return Effect
|
|
310
|
+
.gen(function*() {
|
|
311
|
+
const { db } = yield* CosmosClient
|
|
312
|
+
const container = db.container(containerId)
|
|
313
|
+
// Seed an exec + a clock that fired in the past. No in-process timer
|
|
314
|
+
// exists (we never called scheduleClock), so only the poller can
|
|
315
|
+
// resolve the deferred.
|
|
316
|
+
yield* Effect.promise(() =>
|
|
317
|
+
container.items.upsert({
|
|
318
|
+
id: "exec",
|
|
319
|
+
_partitionKey: "exec-clock",
|
|
320
|
+
type: "exec",
|
|
321
|
+
workflowName: AwaitOnly.name,
|
|
322
|
+
payload: JSON.stringify({ id: "wake" }),
|
|
323
|
+
status: "running",
|
|
324
|
+
suspended: false,
|
|
325
|
+
interrupted: false,
|
|
326
|
+
etag: "seed"
|
|
327
|
+
})
|
|
328
|
+
)
|
|
329
|
+
yield* Effect.promise(() =>
|
|
330
|
+
container.items.upsert({
|
|
331
|
+
id: "clock::wake",
|
|
332
|
+
_partitionKey: "exec-clock",
|
|
333
|
+
type: "clock",
|
|
334
|
+
workflowName: AwaitOnly.name,
|
|
335
|
+
deferredName: Trigger.name,
|
|
336
|
+
fireAt: new Date(Date.now() - 60_000).toISOString()
|
|
337
|
+
})
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
yield* Effect.sleep(Duration.seconds(2))
|
|
341
|
+
|
|
342
|
+
// The clock fire is a deferred-complete; assert the deferred row
|
|
343
|
+
// now exists for this execution.
|
|
344
|
+
const deferred = yield* Effect.promise(() =>
|
|
345
|
+
container.item(`deferred::${Trigger.name}`, "exec-clock").read<{ exit: string }>()
|
|
346
|
+
)
|
|
347
|
+
assert(deferred.resource !== undefined)
|
|
348
|
+
// And the clock doc has been deleted.
|
|
349
|
+
const clockGone = yield* Effect.promise(() => container.item("clock::wake", "exec-clock").read())
|
|
350
|
+
assert.strictEqual(clockGone.statusCode, 404)
|
|
351
|
+
})
|
|
352
|
+
.pipe(Effect.provide(adapterLayer(prefix)))
|
|
353
|
+
})
|
|
354
|
+
})
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import { SqliteClient } from "@effect/sql-sqlite-node"
|
|
3
|
+
import { assert, describe, it } from "@effect/vitest"
|
|
4
|
+
import * as Context from "effect-app/Context"
|
|
5
|
+
import * as Effect from "effect-app/Effect"
|
|
6
|
+
import * as Layer from "effect-app/Layer"
|
|
7
|
+
import * as Option from "effect-app/Option"
|
|
8
|
+
import * as S from "effect-app/Schema"
|
|
9
|
+
import * as Duration from "effect/Duration"
|
|
10
|
+
import * as Exit from "effect/Exit"
|
|
11
|
+
import * as Schema from "effect/Schema"
|
|
12
|
+
import { SqlClient } from "effect/unstable/sql"
|
|
13
|
+
import { Activity, DurableDeferred, Workflow } from "effect/unstable/workflow"
|
|
14
|
+
import { layerSqlite } from "../src/WorkflowEngineSqlite.js"
|
|
15
|
+
|
|
16
|
+
// --- Shared mutable counter service -----------------------------------
|
|
17
|
+
|
|
18
|
+
class CounterRef extends Context.Service<CounterRef, { count: number }>()("CounterRef") {
|
|
19
|
+
static readonly layer = Layer.effect(CounterRef, Effect.sync(() => ({ count: 0 })))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// --- Workflow definitions --------------------------------------------
|
|
23
|
+
|
|
24
|
+
const Increment = Workflow.make({
|
|
25
|
+
name: "Sqlite/Increment",
|
|
26
|
+
payload: { value: Schema.Number },
|
|
27
|
+
success: Schema.Number,
|
|
28
|
+
idempotencyKey: ({ value }) => `inc-${value}`
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const IncrementHandler = Increment.toLayer(Effect.fn(function*({ value }) {
|
|
32
|
+
const counter = yield* CounterRef
|
|
33
|
+
yield* Activity.make({
|
|
34
|
+
name: "Bump",
|
|
35
|
+
success: Schema.Number,
|
|
36
|
+
execute: Effect.sync(() => {
|
|
37
|
+
counter.count++
|
|
38
|
+
return counter.count
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
return value + 1
|
|
42
|
+
}))
|
|
43
|
+
|
|
44
|
+
const TickHandler = Increment.toLayer(({ value }) => Effect.succeed(value + 1))
|
|
45
|
+
|
|
46
|
+
const EmailReceived = DurableDeferred.make("EmailReceived", { success: Schema.String })
|
|
47
|
+
|
|
48
|
+
const AwaitEmail = Workflow.make({
|
|
49
|
+
name: "Sqlite/AwaitEmail",
|
|
50
|
+
payload: { id: Schema.String },
|
|
51
|
+
success: Schema.String,
|
|
52
|
+
idempotencyKey: ({ id }) => `email-${id}`
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const AwaitEmailHandler = AwaitEmail.toLayer(Effect.fn(function*() {
|
|
56
|
+
return yield* DurableDeferred.await(EmailReceived)
|
|
57
|
+
}))
|
|
58
|
+
|
|
59
|
+
// Reproduces the adapter's opaque activity-result codec so tests can seed
|
|
60
|
+
// schema-encoded values into the activity table directly.
|
|
61
|
+
const ActivityResultCodec = S.fromJsonString(
|
|
62
|
+
S.toCodecJson(Workflow.Result({ success: S.Union([S.Any, S.Void]), error: S.Union([S.Any, S.Void]) }))
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
// --- Layer wiring ----------------------------------------------------
|
|
66
|
+
|
|
67
|
+
type TestOpts = {
|
|
68
|
+
readonly recoveryInterval?: Duration.Duration
|
|
69
|
+
readonly clockPollInterval?: Duration.Duration
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const makeBase = (opts?: TestOpts) => {
|
|
73
|
+
const Sqlite = SqliteClient.layer({ filename: ":memory:" })
|
|
74
|
+
const Engine = layerSqlite({
|
|
75
|
+
recoveryInterval: opts?.recoveryInterval ?? Duration.millis(100),
|
|
76
|
+
clockPollInterval: opts?.clockPollInterval ?? Duration.millis(100)
|
|
77
|
+
})
|
|
78
|
+
.pipe(Layer.provide(Sqlite))
|
|
79
|
+
return { Sqlite, Engine }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const incrementLayer = (opts?: TestOpts) => {
|
|
83
|
+
const { Sqlite, Engine } = makeBase(opts)
|
|
84
|
+
return IncrementHandler.pipe(
|
|
85
|
+
Layer.provideMerge(CounterRef.layer),
|
|
86
|
+
Layer.provideMerge(Engine),
|
|
87
|
+
Layer.provideMerge(Sqlite)
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const tickLayer = (opts?: TestOpts) => {
|
|
92
|
+
const { Sqlite, Engine } = makeBase(opts)
|
|
93
|
+
return TickHandler.pipe(Layer.provideMerge(Engine), Layer.provideMerge(Sqlite))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const awaitEmailLayer = (opts?: TestOpts) => {
|
|
97
|
+
const { Sqlite, Engine } = makeBase(opts)
|
|
98
|
+
return AwaitEmailHandler.pipe(Layer.provideMerge(Engine), Layer.provideMerge(Sqlite))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// --- Tests ------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
describe("WorkflowEngine (SQLite)", () => {
|
|
104
|
+
it.live("executes a workflow and persists completion", () =>
|
|
105
|
+
Effect
|
|
106
|
+
.gen(function*() {
|
|
107
|
+
const executionId = yield* Increment.execute({ value: 10 }, { discard: true })
|
|
108
|
+
const result = yield* Increment.execute({ value: 10 })
|
|
109
|
+
const polled = yield* Increment.poll(executionId)
|
|
110
|
+
|
|
111
|
+
assert.strictEqual(result, 11)
|
|
112
|
+
assert(
|
|
113
|
+
Option.isSome(polled)
|
|
114
|
+
&& polled.value._tag === "Complete"
|
|
115
|
+
&& Exit.isSuccess(polled.value.exit)
|
|
116
|
+
)
|
|
117
|
+
assert.strictEqual(polled.value.exit.value, 11)
|
|
118
|
+
})
|
|
119
|
+
.pipe(Effect.provide(incrementLayer())))
|
|
120
|
+
|
|
121
|
+
it.live("re-executing the same id is idempotent (activity runs once)", () =>
|
|
122
|
+
Effect
|
|
123
|
+
.gen(function*() {
|
|
124
|
+
const counter = yield* CounterRef
|
|
125
|
+
yield* Increment.execute({ value: 1 })
|
|
126
|
+
yield* Increment.execute({ value: 1 })
|
|
127
|
+
yield* Increment.execute({ value: 1 })
|
|
128
|
+
assert.strictEqual(counter.count, 1)
|
|
129
|
+
})
|
|
130
|
+
.pipe(Effect.provide(incrementLayer())))
|
|
131
|
+
|
|
132
|
+
it.live("persists activity results across replay", () =>
|
|
133
|
+
Effect
|
|
134
|
+
.gen(function*() {
|
|
135
|
+
const sql = yield* SqlClient.SqlClient
|
|
136
|
+
yield* Increment.execute({ value: 2 })
|
|
137
|
+
const rows = yield* sql
|
|
138
|
+
.unsafe(
|
|
139
|
+
`SELECT execution_id, name, attempt, result FROM workflow_activity`
|
|
140
|
+
)
|
|
141
|
+
.pipe(Effect.orDie)
|
|
142
|
+
assert.strictEqual(rows.length, 1)
|
|
143
|
+
assert.strictEqual((rows[0] as any).name, "Bump")
|
|
144
|
+
// Stored value is schema-encoded JSON, not a raw runtime object.
|
|
145
|
+
const decoded = S.decodeSync(ActivityResultCodec)((rows[0] as any).result)
|
|
146
|
+
assert.strictEqual(decoded._tag, "Complete")
|
|
147
|
+
})
|
|
148
|
+
.pipe(Effect.provide(incrementLayer())))
|
|
149
|
+
|
|
150
|
+
it.live("durable deferred completion resumes a suspended workflow", () =>
|
|
151
|
+
Effect
|
|
152
|
+
.gen(function*() {
|
|
153
|
+
const completion = Effect.gen(function*() {
|
|
154
|
+
yield* Effect.sleep(Duration.millis(50))
|
|
155
|
+
const token = yield* DurableDeferred.tokenFromPayload(EmailReceived, {
|
|
156
|
+
workflow: AwaitEmail,
|
|
157
|
+
payload: { id: "x" }
|
|
158
|
+
})
|
|
159
|
+
yield* DurableDeferred.done(EmailReceived, { token, exit: Exit.succeed("delivered") })
|
|
160
|
+
})
|
|
161
|
+
const [result] = yield* Effect.all(
|
|
162
|
+
[AwaitEmail.execute({ id: "x" }), completion],
|
|
163
|
+
{ concurrency: "unbounded" }
|
|
164
|
+
)
|
|
165
|
+
assert.strictEqual(result, "delivered")
|
|
166
|
+
})
|
|
167
|
+
.pipe(Effect.provide(awaitEmailLayer())))
|
|
168
|
+
|
|
169
|
+
it.live("interrupt marks the execution row", () =>
|
|
170
|
+
Effect
|
|
171
|
+
.gen(function*() {
|
|
172
|
+
const sql = yield* SqlClient.SqlClient
|
|
173
|
+
const executionId = yield* AwaitEmail.executionId({ id: "i" })
|
|
174
|
+
// Start the workflow as discard (does not block) so it suspends in the
|
|
175
|
+
// background; then mark it interrupted.
|
|
176
|
+
yield* AwaitEmail.execute({ id: "i" }, { discard: true })
|
|
177
|
+
yield* Effect.sleep(Duration.millis(50))
|
|
178
|
+
yield* AwaitEmail.interrupt(executionId)
|
|
179
|
+
yield* Effect.sleep(Duration.millis(50))
|
|
180
|
+
const rows = yield* sql
|
|
181
|
+
.unsafe(
|
|
182
|
+
`SELECT interrupted FROM workflow_exec WHERE execution_id = ?`,
|
|
183
|
+
[executionId] as any
|
|
184
|
+
)
|
|
185
|
+
.pipe(Effect.orDie)
|
|
186
|
+
assert.strictEqual((rows[0] as any).interrupted, 1)
|
|
187
|
+
})
|
|
188
|
+
.pipe(Effect.provide(awaitEmailLayer())))
|
|
189
|
+
|
|
190
|
+
it.live("clock poller fires past-due clocks", () =>
|
|
191
|
+
Effect
|
|
192
|
+
.gen(function*() {
|
|
193
|
+
const sql = yield* SqlClient.SqlClient
|
|
194
|
+
yield* sql
|
|
195
|
+
.unsafe(
|
|
196
|
+
`INSERT INTO workflow_exec (execution_id, workflow_name, payload, status, suspended, interrupted, etag)
|
|
197
|
+
VALUES ('exec-clock', 'Sqlite/AwaitEmail', '{"id":"x"}', 'running', 0, 0, 'e1')`
|
|
198
|
+
)
|
|
199
|
+
.pipe(Effect.orDie)
|
|
200
|
+
yield* sql
|
|
201
|
+
.unsafe(
|
|
202
|
+
`INSERT INTO workflow_clock (execution_id, name, workflow_name, deferred_name, fire_at)
|
|
203
|
+
VALUES ('exec-clock', 'wake', 'Sqlite/AwaitEmail', 'EmailReceived', ?)`,
|
|
204
|
+
[Date.now() - 1000] as any
|
|
205
|
+
)
|
|
206
|
+
.pipe(Effect.orDie)
|
|
207
|
+
|
|
208
|
+
yield* Effect.sleep(Duration.millis(400))
|
|
209
|
+
|
|
210
|
+
const deferred = yield* sql
|
|
211
|
+
.unsafe(
|
|
212
|
+
`SELECT exit FROM workflow_deferred WHERE execution_id = 'exec-clock' AND name = 'EmailReceived'`
|
|
213
|
+
)
|
|
214
|
+
.pipe(Effect.orDie)
|
|
215
|
+
const clockGone = yield* sql
|
|
216
|
+
.unsafe(
|
|
217
|
+
`SELECT execution_id FROM workflow_clock WHERE execution_id = 'exec-clock' AND name = 'wake'`
|
|
218
|
+
)
|
|
219
|
+
.pipe(Effect.orDie)
|
|
220
|
+
assert.strictEqual(deferred.length, 1)
|
|
221
|
+
assert.strictEqual(clockGone.length, 0)
|
|
222
|
+
})
|
|
223
|
+
.pipe(Effect.provide(awaitEmailLayer())))
|
|
224
|
+
|
|
225
|
+
it.live("recovery poller drives execs with stale leases", () =>
|
|
226
|
+
Effect
|
|
227
|
+
.gen(function*() {
|
|
228
|
+
const sql = yield* SqlClient.SqlClient
|
|
229
|
+
yield* sql
|
|
230
|
+
.unsafe(
|
|
231
|
+
`INSERT INTO workflow_exec (execution_id, workflow_name, payload, status, suspended, interrupted, lease_expires_at, etag)
|
|
232
|
+
VALUES ('recover-1', 'Sqlite/Increment', '{"value":99}', 'running', 0, 0, 0, 'e0')`
|
|
233
|
+
)
|
|
234
|
+
.pipe(Effect.orDie)
|
|
235
|
+
|
|
236
|
+
yield* Effect.sleep(Duration.millis(500))
|
|
237
|
+
|
|
238
|
+
const rows = yield* sql
|
|
239
|
+
.unsafe(
|
|
240
|
+
`SELECT status, completed_result FROM workflow_exec WHERE execution_id = 'recover-1'`
|
|
241
|
+
)
|
|
242
|
+
.pipe(Effect.orDie)
|
|
243
|
+
assert.strictEqual((rows[0] as any).status, "complete")
|
|
244
|
+
const completedResult = JSON.parse((rows[0] as any).completed_result) as { _tag: string }
|
|
245
|
+
assert.strictEqual(completedResult._tag, "Complete")
|
|
246
|
+
})
|
|
247
|
+
.pipe(Effect.provide(tickLayer())))
|
|
248
|
+
|
|
249
|
+
it.live("activity dedup: a pre-existing persisted result wins over a fresh run", () =>
|
|
250
|
+
Effect
|
|
251
|
+
.gen(function*() {
|
|
252
|
+
const sql = yield* SqlClient.SqlClient
|
|
253
|
+
const counter = yield* CounterRef
|
|
254
|
+
const executionId = yield* Increment.executionId({ value: 7 })
|
|
255
|
+
yield* sql
|
|
256
|
+
.unsafe(
|
|
257
|
+
`INSERT INTO workflow_exec (execution_id, workflow_name, payload, status, suspended, interrupted, etag)
|
|
258
|
+
VALUES (?, 'Sqlite/Increment', '{"value":7}', 'running', 0, 0, 'e0')`,
|
|
259
|
+
[executionId] as any
|
|
260
|
+
)
|
|
261
|
+
.pipe(Effect.orDie)
|
|
262
|
+
const seeded = S.encodeSync(ActivityResultCodec)(
|
|
263
|
+
new Workflow.Complete({ exit: Exit.succeed(999) })
|
|
264
|
+
)
|
|
265
|
+
yield* sql
|
|
266
|
+
.unsafe(
|
|
267
|
+
`INSERT INTO workflow_activity (execution_id, name, attempt, result)
|
|
268
|
+
VALUES (?, 'Bump', 1, ?)`,
|
|
269
|
+
[executionId, seeded] as any
|
|
270
|
+
)
|
|
271
|
+
.pipe(Effect.orDie)
|
|
272
|
+
|
|
273
|
+
const result = yield* Increment.execute({ value: 7 })
|
|
274
|
+
assert.strictEqual(result, 8)
|
|
275
|
+
// Bump's user effect must NOT have run — counter stays at 0.
|
|
276
|
+
assert.strictEqual(counter.count, 0)
|
|
277
|
+
})
|
|
278
|
+
.pipe(Effect.provide(incrementLayer())))
|
|
279
|
+
|
|
280
|
+
it.live("deferredDone is idempotent (first-writer-wins)", () =>
|
|
281
|
+
Effect
|
|
282
|
+
.gen(function*() {
|
|
283
|
+
const completion = Effect.gen(function*() {
|
|
284
|
+
yield* Effect.sleep(Duration.millis(50))
|
|
285
|
+
const token = yield* DurableDeferred.tokenFromPayload(EmailReceived, {
|
|
286
|
+
workflow: AwaitEmail,
|
|
287
|
+
payload: { id: "dup" }
|
|
288
|
+
})
|
|
289
|
+
yield* DurableDeferred.done(EmailReceived, { token, exit: Exit.succeed("first") })
|
|
290
|
+
yield* DurableDeferred.done(EmailReceived, { token, exit: Exit.succeed("second") })
|
|
291
|
+
})
|
|
292
|
+
const [result] = yield* Effect.all(
|
|
293
|
+
[AwaitEmail.execute({ id: "dup" }), completion],
|
|
294
|
+
{ concurrency: "unbounded" }
|
|
295
|
+
)
|
|
296
|
+
assert.strictEqual(result, "first")
|
|
297
|
+
})
|
|
298
|
+
.pipe(Effect.provide(awaitEmailLayer())))
|
|
299
|
+
})
|