@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.
@@ -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
+ })