@effect-app/infra 4.0.0-beta.263 → 4.0.0-beta.265

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.
@@ -34,6 +34,7 @@ import * as Effect from "effect-app/Effect"
34
34
  import * as Layer from "effect-app/Layer"
35
35
  import * as Option from "effect-app/Option"
36
36
  import * as S from "effect-app/Schema"
37
+ import * as Cause from "effect/Cause"
37
38
  import * as Duration from "effect/Duration"
38
39
  import * as Exit from "effect/Exit"
39
40
  import * as Fiber from "effect/Fiber"
@@ -109,12 +110,24 @@ interface ClockDoc {
109
110
  readonly fireAt: string
110
111
  }
111
112
 
113
+ // Cosmos forbids '/', '\', '#', '?' in a resource id; workflow/deferred/clock
114
+ // names routinely contain '/', so every doc id is URI-encoded (mirrors
115
+ // `cosmosId` in ClusterCosmos). Partition keys (executionId) are exempt.
116
+ const cosmosId = (id: string) => encodeURIComponent(id)
112
117
  const execId = "exec" as const
113
- const activityKey = (name: string, attempt: number) => `activity::${name}::${attempt}`
114
- const deferredKey = (name: string) => `deferred::${name}`
115
- const clockKey = (name: string) => `clock::${name}`
118
+ const activityKey = (name: string, attempt: number) => cosmosId(`activity::${name}::${attempt}`)
119
+ const deferredKey = (name: string) => cosmosId(`deferred::${name}`)
120
+ const clockKey = (name: string) => cosmosId(`clock::${name}`)
116
121
 
117
- const isOptimisticStatus = (code: number) => code === 409 || code === 412 || code === 404
122
+ // Single-item Cosmos writes (`replace`) *throw* on 409/412/404 rather than
123
+ // returning the status code (only `read` and batch ops surface codes), so OCC
124
+ // conflicts must be matched on the thrown error — mirrors ClusterCosmos.
125
+ const isCosmosStatus = (u: unknown, code: number): boolean =>
126
+ Cause.isUnknownError(u)
127
+ ? isCosmosStatus(u.cause, code)
128
+ : typeof u === "object" && u !== null && "code" in u && u.code === code
129
+
130
+ const isOptimisticError = (u: unknown) => isCosmosStatus(u, 409) || isCosmosStatus(u, 412) || isCosmosStatus(u, 404)
118
131
 
119
132
  // --- Storage codecs ----------------------------------------------------------
120
133
  // Values flowing through the engine's activity / deferred boundary are already
@@ -122,7 +135,7 @@ const isOptimisticStatus = (code: number) => code === 409 || code === 412 || cod
122
135
  // opaque (mirrors the cluster engine's `AnyOrVoid` usage).
123
136
  const AnyOrVoid = S.Union([S.Any, S.Void])
124
137
  const ActivityResultCodec = S.fromJsonString(S.toCodecJson(Workflow.Result({ success: AnyOrVoid, error: AnyOrVoid })))
125
- const DeferredExitCodec = S.fromJsonString(S.toCodecJson(S.Exit(AnyOrVoid, AnyOrVoid, S.Defect)))
138
+ const DeferredExitCodec = S.fromJsonString(S.toCodecJson(S.Exit(AnyOrVoid, AnyOrVoid, S.Defect())))
126
139
 
127
140
  const encodeActivityResult = (r: Workflow.Result<unknown, unknown>) =>
128
141
  Effect.orDie(S.encodeEffect(ActivityResultCodec)(r))
@@ -183,10 +196,10 @@ const makeCosmosWorkflowEngine = Effect.fnUntraced(function*(cfg: WorkflowEngine
183
196
  const makePayloadCodec = (workflow: Workflow.Any) => S.fromJsonString(S.toCodecJson(workflow.payloadSchema))
184
197
  const payloadCodecCache = new Map<string, ReturnType<typeof makePayloadCodec>>()
185
198
  const payloadCodecFor = (workflow: Workflow.Any) => {
186
- let c = payloadCodecCache.get(workflow.name)
199
+ let c = payloadCodecCache.get(workflow._tag)
187
200
  if (!c) {
188
201
  c = makePayloadCodec(workflow)
189
- payloadCodecCache.set(workflow.name, c)
202
+ payloadCodecCache.set(workflow._tag, c)
190
203
  }
191
204
  return c
192
205
  }
@@ -195,10 +208,10 @@ const makeCosmosWorkflowEngine = Effect.fnUntraced(function*(cfg: WorkflowEngine
195
208
  S.fromJsonString(S.toCodecJson(Workflow.Result({ success: workflow.successSchema, error: workflow.errorSchema })))
196
209
  const resultCodecCache = new Map<string, ReturnType<typeof makeResultCodec>>()
197
210
  const resultCodecFor = (workflow: Workflow.Any) => {
198
- let c = resultCodecCache.get(workflow.name)
211
+ let c = resultCodecCache.get(workflow._tag)
199
212
  if (!c) {
200
213
  c = makeResultCodec(workflow)
201
- resultCodecCache.set(workflow.name, c)
214
+ resultCodecCache.set(workflow._tag, c)
202
215
  }
203
216
  return c
204
217
  }
@@ -228,22 +241,24 @@ const makeCosmosWorkflowEngine = Effect.fnUntraced(function*(cfg: WorkflowEngine
228
241
  const replaceExec = (doc: ExecDoc) =>
229
242
  Effect
230
243
  .gen(function*() {
231
- const resp = yield* Effect.promise(() =>
244
+ const resp = yield* Effect.tryPromise(() =>
232
245
  container.item(execId, doc._partitionKey).replace<ExecDoc>(doc, {
233
246
  accessCondition: { type: "IfMatch", condition: doc._etag ?? "" }
234
247
  })
235
248
  )
236
249
  yield* annotateCosmosResponse({ requestCharge: resp.requestCharge, statusCode: resp.statusCode })
237
- if (isOptimisticStatus(resp.statusCode)) {
238
- return yield* new OptimisticConcurrencyException({
239
- type: "workflow.exec",
240
- id: doc._partitionKey,
241
- code: resp.statusCode
242
- })
243
- }
244
250
  return { ...doc, _etag: resp.etag }
245
251
  })
246
- .pipe(annotate("replaceExec", doc._partitionKey))
252
+ .pipe(
253
+ Effect.catch((u) =>
254
+ isOptimisticError(u)
255
+ ? Effect.fail(
256
+ new OptimisticConcurrencyException({ type: "workflow.exec", id: doc._partitionKey, code: 412 })
257
+ )
258
+ : Effect.die(u)
259
+ ),
260
+ annotate("replaceExec", doc._partitionKey)
261
+ )
247
262
 
248
263
  // Atomic create-or-noop using a single-op batch — returns true if created.
249
264
  const createIfMissing = <T extends { readonly id: string; readonly _partitionKey: string }>(
@@ -390,7 +405,10 @@ const makeCosmosWorkflowEngine = Effect.fnUntraced(function*(cfg: WorkflowEngine
390
405
  ...current.value,
391
406
  status: isComplete ? "complete" : current.value.status,
392
407
  suspended: result._tag === "Suspended",
393
- interrupted: instance.interrupted,
408
+ // Never downgrade a persisted interrupt: a concurrent `interrupt` may
409
+ // have set the flag while this driver was suspending. Losing it would
410
+ // leave a re-drive unable to collapse the suspension.
411
+ interrupted: instance.interrupted || current.value.interrupted,
394
412
  completedResult,
395
413
  // Release lease on completion so the doc isn't seen as orphaned.
396
414
  worker: isComplete ? undefined : current.value.worker,
@@ -449,38 +467,59 @@ const makeCosmosWorkflowEngine = Effect.fnUntraced(function*(cfg: WorkflowEngine
449
467
  )
450
468
  })
451
469
 
470
+ // Persist `interrupted: true`, retrying on OCC. A suspending driver's
471
+ // onComplete (or a heartbeat renewal) can win the etag race; silently
472
+ // dropping the flag would leave the subsequent re-drive reading
473
+ // `interrupted: false` and re-suspending forever. Retry until the flag is
474
+ // durably set (or the exec is already complete/gone). Unlike SQLite — whose
475
+ // synchronous writes never interleave here — Cosmos round-trips open a real
476
+ // race window, so the write must converge rather than swallow the conflict.
477
+ const markInterrupted = (executionId: string): Effect.Effect<void> =>
478
+ Effect.gen(function*() {
479
+ while (true) {
480
+ const current = yield* readExec(executionId)
481
+ if (Option.isNone(current) || current.value.status === "complete" || current.value.interrupted) {
482
+ return
483
+ }
484
+ const persisted = yield* replaceExec({ ...current.value, interrupted: true }).pipe(
485
+ Effect.as(true),
486
+ Effect.catchTag("OptimisticConcurrencyException", () => Effect.succeed(false))
487
+ )
488
+ if (persisted) return
489
+ }
490
+ })
491
+
452
492
  // --- Encoded engine ----------------------------------------------------
453
493
 
454
494
  const encoded: Encoded = {
455
495
  register: Effect.fnUntraced(function*(workflow, execute) {
456
- workflows.set(workflow.name, {
496
+ workflows.set(workflow._tag, {
457
497
  workflow,
458
498
  execute,
459
499
  scope: yield* Effect.scope
460
500
  })
461
501
  }),
462
502
  execute: Effect.fnUntraced(function*(workflow, options) {
463
- const entry = workflows.get(workflow.name)
503
+ const entry = workflows.get(workflow._tag)
464
504
  if (!entry) {
465
- return yield* Effect.orDie(Effect.fail(`Workflow ${workflow.name} is not registered`))
505
+ return yield* Effect.orDie(Effect.fail(`Workflow ${workflow._tag} is not registered`))
466
506
  }
467
507
 
468
508
  const initial: ExecDoc = {
469
509
  id: execId,
470
510
  _partitionKey: options.executionId,
471
511
  type: "exec",
472
- workflowName: workflow.name,
512
+ workflowName: workflow._tag,
473
513
  payload: yield* encodePayload(workflow, options.payload),
474
514
  parent: options.parent?.executionId,
475
515
  status: "running",
476
516
  suspended: false,
477
517
  interrupted: false
478
518
  }
479
- const created = yield* createIfMissing(initial).pipe(annotate("execute.claim", options.executionId))
480
-
481
- if (created || !locals.has(options.executionId)) {
482
- yield* drive(options.executionId, options.payload, options.parent?.executionId, entry)
483
- }
519
+ yield* createIfMissing(initial).pipe(annotate("execute.claim", options.executionId))
520
+ // Drive unconditionally; `drive`'s own guard short-circuits a still-running
521
+ // or completed fiber and re-drives a suspended one (matches Sqlite).
522
+ yield* drive(options.executionId, options.payload, options.parent?.executionId, entry)
484
523
 
485
524
  if (options.discard) return undefined as any
486
525
 
@@ -515,22 +554,13 @@ const makeCosmosWorkflowEngine = Effect.fnUntraced(function*(cfg: WorkflowEngine
515
554
  interrupt: Effect.fnUntraced(function*(_workflow, executionId) {
516
555
  const local = locals.get(executionId)
517
556
  if (local) local.instance.interrupted = true
518
- const current = yield* readExec(executionId)
519
- if (Option.isNone(current) || current.value.status === "complete") return
520
- yield* replaceExec({ ...current.value, interrupted: true }).pipe(
521
- Effect.catchTag("OptimisticConcurrencyException", () => Effect.void)
522
- )
557
+ yield* markInterrupted(executionId)
523
558
  yield* driveById(executionId)
524
559
  }),
525
560
  interruptUnsafe: Effect.fnUntraced(function*(_workflow, executionId) {
526
561
  const local = locals.get(executionId)
527
562
  if (local) local.instance.interrupted = true
528
- const current = yield* readExec(executionId)
529
- if (Option.isSome(current) && current.value.status !== "complete") {
530
- yield* replaceExec({ ...current.value, interrupted: true }).pipe(
531
- Effect.catchTag("OptimisticConcurrencyException", () => Effect.void)
532
- )
533
- }
563
+ yield* markInterrupted(executionId)
534
564
  if (local?.fiber) yield* Fiber.interrupt(local.fiber)
535
565
  }),
536
566
  resume: (_workflow, executionId) => driveById(executionId),
@@ -602,7 +632,7 @@ const makeCosmosWorkflowEngine = Effect.fnUntraced(function*(cfg: WorkflowEngine
602
632
  id: clockKey(options.clock.name),
603
633
  _partitionKey: options.executionId,
604
634
  type: "clock",
605
- workflowName: workflow.name,
635
+ workflowName: workflow._tag,
606
636
  deferredName: options.clock.deferred.name,
607
637
  fireAt
608
638
  }
@@ -117,7 +117,7 @@ const parseExec = (row: ExecRow): ExecState => ({
117
117
  // opaque (mirrors the cluster engine's `AnyOrVoid` usage).
118
118
  const AnyOrVoid = S.Union([S.Any, S.Void])
119
119
  const ActivityResultCodec = S.fromJsonString(S.toCodecJson(Workflow.Result({ success: AnyOrVoid, error: AnyOrVoid })))
120
- const DeferredExitCodec = S.fromJsonString(S.toCodecJson(S.Exit(AnyOrVoid, AnyOrVoid, S.Defect)))
120
+ const DeferredExitCodec = S.fromJsonString(S.toCodecJson(S.Exit(AnyOrVoid, AnyOrVoid, S.Defect())))
121
121
 
122
122
  const encodeActivityResult = (r: Workflow.Result<unknown, unknown>) =>
123
123
  Effect.orDie(S.encodeEffect(ActivityResultCodec)(r))
@@ -229,10 +229,10 @@ const makeSqliteWorkflowEngine = Effect.fnUntraced(function*(cfg: WorkflowEngine
229
229
  const makePayloadCodec = (workflow: Workflow.Any) => S.fromJsonString(S.toCodecJson(workflow.payloadSchema))
230
230
  const payloadCodecCache = new Map<string, ReturnType<typeof makePayloadCodec>>()
231
231
  const payloadCodecFor = (workflow: Workflow.Any) => {
232
- let c = payloadCodecCache.get(workflow.name)
232
+ let c = payloadCodecCache.get(workflow._tag)
233
233
  if (!c) {
234
234
  c = makePayloadCodec(workflow)
235
- payloadCodecCache.set(workflow.name, c)
235
+ payloadCodecCache.set(workflow._tag, c)
236
236
  }
237
237
  return c
238
238
  }
@@ -241,10 +241,10 @@ const makeSqliteWorkflowEngine = Effect.fnUntraced(function*(cfg: WorkflowEngine
241
241
  S.fromJsonString(S.toCodecJson(Workflow.Result({ success: workflow.successSchema, error: workflow.errorSchema })))
242
242
  const resultCodecCache = new Map<string, ReturnType<typeof makeResultCodec>>()
243
243
  const resultCodecFor = (workflow: Workflow.Any) => {
244
- let c = resultCodecCache.get(workflow.name)
244
+ let c = resultCodecCache.get(workflow._tag)
245
245
  if (!c) {
246
246
  c = makeResultCodec(workflow)
247
- resultCodecCache.set(workflow.name, c)
247
+ resultCodecCache.set(workflow._tag, c)
248
248
  }
249
249
  return c
250
250
  }
@@ -602,20 +602,20 @@ const makeSqliteWorkflowEngine = Effect.fnUntraced(function*(cfg: WorkflowEngine
602
602
 
603
603
  const encoded: Encoded = {
604
604
  register: Effect.fnUntraced(function*(workflow, execute) {
605
- workflows.set(workflow.name, {
605
+ workflows.set(workflow._tag, {
606
606
  workflow,
607
607
  execute,
608
608
  scope: yield* Effect.scope
609
609
  })
610
610
  }),
611
611
  execute: Effect.fnUntraced(function*(workflow, options) {
612
- const entry = workflows.get(workflow.name)
612
+ const entry = workflows.get(workflow._tag)
613
613
  if (!entry) {
614
- return yield* Effect.orDie(Effect.fail(`Workflow ${workflow.name} is not registered`))
614
+ return yield* Effect.orDie(Effect.fail(`Workflow ${workflow._tag} is not registered`))
615
615
  }
616
616
  const initial: ExecState = {
617
617
  executionId: options.executionId,
618
- workflowName: workflow.name,
618
+ workflowName: workflow._tag,
619
619
  payload: yield* encodePayload(workflow, options.payload),
620
620
  parent: options.parent?.executionId,
621
621
  status: "running",
@@ -730,7 +730,7 @@ const makeSqliteWorkflowEngine = Effect.fnUntraced(function*(cfg: WorkflowEngine
730
730
  yield* insertClock(
731
731
  options.executionId,
732
732
  options.clock.name,
733
- workflow.name,
733
+ workflow._tag,
734
734
  options.clock.deferred.name,
735
735
  fireAt
736
736
  )
@@ -11,6 +11,7 @@ const cosmosUrl = process.env["COSMOS_TEST_URL"]
11
11
  const cosmosDb = process.env["COSMOS_TEST_DB"] ?? "cluster-test"
12
12
  const testRunId = `${Date.now()}-${process.pid}-${Math.random().toString(16).slice(2)}`
13
13
  const runnerPortBase = 10000 + Date.now() % 40000
14
+ const liveSnowflake = Layer.effect(Snowflake.Generator, TestClock.withLive(Snowflake.makeGenerator))
14
15
 
15
16
  const layerFor = () =>
16
17
  layerCosmos({
@@ -19,7 +20,7 @@ const layerFor = () =>
19
20
  prefix: "test-cluster-"
20
21
  })
21
22
  .pipe(
22
- Layer.provideMerge(Snowflake.layerGenerator),
23
+ Layer.provideMerge(liveSnowflake),
23
24
  Layer.provide(ShardingConfig.layerDefaults)
24
25
  )
25
26
 
@@ -182,6 +183,28 @@ describe.skipIf(!cosmosUrl)("ClusterCosmos RunnerStorage", () => {
182
183
  })
183
184
  .pipe(Effect.provide(layerFor())))
184
185
 
186
+ it.effect("allocates distinct machine ids for distinct runners", () =>
187
+ Effect
188
+ .gen(function*() {
189
+ const storage = yield* RunnerStorage.RunnerStorage
190
+ const address1 = testRunnerAddress(6)
191
+ const address2 = testRunnerAddress(7)
192
+ const runner1 = Runner.make({ address: address1, groups: ["default"], weight: 1 })
193
+ const runner2 = Runner.make({ address: address2, groups: ["default"], weight: 1 })
194
+
195
+ const id1 = yield* storage.register(runner1, true)
196
+ const id2 = yield* storage.register(runner2, true)
197
+ // Distinct runners must get distinct machine ids (would otherwise risk
198
+ // colliding Snowflake ids).
199
+ assert(id1 !== id2)
200
+ // Re-registration is stable per address.
201
+ assert.deepStrictEqual(yield* storage.register(runner1, true), id1)
202
+
203
+ yield* storage.unregister(address1)
204
+ yield* storage.unregister(address2)
205
+ })
206
+ .pipe(Effect.provide(layerFor())))
207
+
185
208
  it.effect("preserves shard lock ownership when two runners acquire concurrently", () =>
186
209
  Effect
187
210
  .gen(function*() {
@@ -342,8 +365,7 @@ const CosmosRpcEntityLayer = CosmosRpcEntity.toLayer(
342
365
  const CosmosDeferred = DurableDeferred.make("ClusterCosmos/Deferred", { success: Schema.String })
343
366
 
344
367
  const CosmosDeferredWorkflow = Workflow
345
- .make({
346
- name: "ClusterCosmos/DeferredWorkflow",
368
+ .make("ClusterCosmos/DeferredWorkflow", {
347
369
  payload: { id: Schema.String },
348
370
  success: Schema.String,
349
371
  idempotencyKey: ({ id }) => id
@@ -67,8 +67,7 @@ class StepLog extends Context.Service<StepLog, { readonly steps: Array<string> }
67
67
  const SqliteDeferred = DurableDeferred.make("ClusterSqlite/Deferred", { success: Schema.String })
68
68
 
69
69
  const SqliteDeferredWorkflow = Workflow
70
- .make({
71
- name: "ClusterSqlite/DeferredWorkflow",
70
+ .make("ClusterSqlite/DeferredWorkflow", {
72
71
  payload: { id: Schema.String },
73
72
  success: Schema.String,
74
73
  idempotencyKey: ({ id }) => id
@@ -85,8 +84,7 @@ const SequentialStep2 = DurableDeferred.make("ClusterSqlite/SequentialStep2", {
85
84
  const SequentialStep3 = DurableDeferred.make("ClusterSqlite/SequentialStep3", { success: Schema.String })
86
85
 
87
86
  const SequentialDeferredWorkflow = Workflow
88
- .make({
89
- name: "ClusterSqlite/SequentialDeferredWorkflow",
87
+ .make("ClusterSqlite/SequentialDeferredWorkflow", {
90
88
  payload: { id: Schema.String },
91
89
  success: Schema.String,
92
90
  idempotencyKey: ({ id }) => id
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cluster-azure-sql.test.d.ts","sourceRoot":"","sources":["../cluster-azure-sql.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cluster-storage-parity-sqlite.test.d.ts","sourceRoot":"","sources":["../cluster-storage-parity-sqlite.test.ts"],"names":[],"mappings":""}
@@ -29,8 +29,7 @@ class CounterRef extends Context.Service<CounterRef, { count: number }>()("Count
29
29
 
30
30
  // --- Workflow definitions ----------------------------------------------
31
31
 
32
- const IncrementWorkflow = Workflow.make({
33
- name: "WorkflowEngineCosmos/IncrementWorkflow",
32
+ const IncrementWorkflow = Workflow.make("WorkflowEngineCosmos/IncrementWorkflow", {
34
33
  payload: { value: Schema.Number },
35
34
  success: Schema.Number,
36
35
  idempotencyKey: ({ value }) => String(value)
@@ -40,8 +39,7 @@ const IncrementHandler = IncrementWorkflow.toLayer(({ value }) => Effect.succeed
40
39
 
41
40
  // Counts activity body invocations across re-executes so the test can
42
41
  // prove side-effects don't repeat when a persisted result is available.
43
- const CounterWorkflow = Workflow.make({
44
- name: "WorkflowEngineCosmos/CounterWorkflow",
42
+ const CounterWorkflow = Workflow.make("WorkflowEngineCosmos/CounterWorkflow", {
45
43
  payload: { id: Schema.String },
46
44
  success: Schema.Number,
47
45
  idempotencyKey: ({ id }) => id
@@ -63,8 +61,7 @@ const CounterHandler = CounterWorkflow.toLayer(Effect.fn(function*() {
63
61
  // with the resumed deferred value. Exercises Result/Exit round-trip.
64
62
  const Trigger = DurableDeferred.make("WorkflowEngineCosmos/Trigger", { success: Schema.String })
65
63
 
66
- const SuspendWorkflow = Workflow.make({
67
- name: "WorkflowEngineCosmos/SuspendWorkflow",
64
+ const SuspendWorkflow = Workflow.make("WorkflowEngineCosmos/SuspendWorkflow", {
68
65
  payload: { id: Schema.String },
69
66
  success: Schema.String,
70
67
  idempotencyKey: ({ id }) => id
@@ -81,8 +78,7 @@ const SuspendHandler = SuspendWorkflow.toLayer(Effect.fn(function*({ id }) {
81
78
  }))
82
79
 
83
80
  // Plain durable-deferred await — used to assert first-writer-wins on done().
84
- const AwaitOnly = Workflow.make({
85
- name: "WorkflowEngineCosmos/AwaitOnly",
81
+ const AwaitOnly = Workflow.make("WorkflowEngineCosmos/AwaitOnly", {
86
82
  payload: { id: Schema.String },
87
83
  success: Schema.String,
88
84
  idempotencyKey: ({ id }) => id
@@ -201,16 +197,11 @@ const runSuite = (engineLayer: Layer.Layer<WorkflowEngine.WorkflowEngine>) => {
201
197
  yield* Effect.sleep(Duration.millis(50))
202
198
  yield* AwaitOnly.interrupt(executionId)
203
199
 
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
- }
200
+ // Interrupt collapses the suspended execution into a completion. A
201
+ // durable engine re-drives across several storage round-trips, so poll
202
+ // until it reports Complete rather than asserting a fixed delay.
203
+ const done = yield* waitForComplete(AwaitOnly, executionId)
204
+ assert(done !== undefined && done._tag === "Complete")
214
205
  })
215
206
  .pipe(Effect.provide(TestLayer)))
216
207
  }
@@ -278,7 +269,7 @@ describe.skipIf(!cosmosUrl)("WorkflowEngine (Cosmos) — adapter internals", ()
278
269
  id: "exec",
279
270
  _partitionKey: "recover-1",
280
271
  type: "exec",
281
- workflowName: IncrementWorkflow.name,
272
+ workflowName: IncrementWorkflow._tag,
282
273
  payload: JSON.stringify({ value: 99 }),
283
274
  status: "running",
284
275
  suspended: false,
@@ -318,7 +309,7 @@ describe.skipIf(!cosmosUrl)("WorkflowEngine (Cosmos) — adapter internals", ()
318
309
  id: "exec",
319
310
  _partitionKey: "exec-clock",
320
311
  type: "exec",
321
- workflowName: AwaitOnly.name,
312
+ workflowName: AwaitOnly._tag,
322
313
  payload: JSON.stringify({ id: "wake" }),
323
314
  status: "running",
324
315
  suspended: false,
@@ -331,7 +322,7 @@ describe.skipIf(!cosmosUrl)("WorkflowEngine (Cosmos) — adapter internals", ()
331
322
  id: "clock::wake",
332
323
  _partitionKey: "exec-clock",
333
324
  type: "clock",
334
- workflowName: AwaitOnly.name,
325
+ workflowName: AwaitOnly._tag,
335
326
  deferredName: Trigger.name,
336
327
  fireAt: new Date(Date.now() - 60_000).toISOString()
337
328
  })
@@ -342,7 +333,7 @@ describe.skipIf(!cosmosUrl)("WorkflowEngine (Cosmos) — adapter internals", ()
342
333
  // The clock fire is a deferred-complete; assert the deferred row
343
334
  // now exists for this execution.
344
335
  const deferred = yield* Effect.promise(() =>
345
- container.item(`deferred::${Trigger.name}`, "exec-clock").read<{ exit: string }>()
336
+ container.item(encodeURIComponent(`deferred::${Trigger.name}`), "exec-clock").read<{ exit: string }>()
346
337
  )
347
338
  assert(deferred.resource !== undefined)
348
339
  // And the clock doc has been deleted.
@@ -21,8 +21,7 @@ class CounterRef extends Context.Service<CounterRef, { count: number }>()("Count
21
21
 
22
22
  // --- Workflow definitions --------------------------------------------
23
23
 
24
- const Increment = Workflow.make({
25
- name: "Sqlite/Increment",
24
+ const Increment = Workflow.make("Sqlite/Increment", {
26
25
  payload: { value: Schema.Number },
27
26
  success: Schema.Number,
28
27
  idempotencyKey: ({ value }) => `inc-${value}`
@@ -45,8 +44,7 @@ const TickHandler = Increment.toLayer(({ value }) => Effect.succeed(value + 1))
45
44
 
46
45
  const EmailReceived = DurableDeferred.make("EmailReceived", { success: Schema.String })
47
46
 
48
- const AwaitEmail = Workflow.make({
49
- name: "Sqlite/AwaitEmail",
47
+ const AwaitEmail = Workflow.make("Sqlite/AwaitEmail", {
50
48
  payload: { id: Schema.String },
51
49
  success: Schema.String,
52
50
  idempotencyKey: ({ id }) => `email-${id}`