@effect-app/infra 4.0.0-beta.256 → 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 +18 -0
- package/dist/WorkflowEngineCosmos.d.ts +29 -0
- package/dist/WorkflowEngineCosmos.d.ts.map +1 -0
- package/dist/WorkflowEngineCosmos.js +521 -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/_check-agg-infer.test-d.d.ts +2 -0
- package/test/dist/_check-agg-infer.test-d.d.ts.map +1 -0
- package/test/dist/_check-agg-infer.test-d.js +19 -0
- package/test/dist/_check-proj-infer.test-d.d.ts +2 -0
- package/test/dist/_check-proj-infer.test-d.d.ts.map +1 -0
- package/test/dist/_check-proj-infer.test-d.js +16 -0
- package/test/dist/_check-tighten.test-d.d.ts +2 -0
- package/test/dist/_check-tighten.test-d.d.ts.map +1 -0
- package/test/dist/_check-tighten.test-d.js +21 -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,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
|
+
})
|