@smthrs/run-store 0.0.0-stage → 1.0.0-rc.4

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.
Files changed (157) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/LICENSE +21 -0
  3. package/README.md +195 -2
  4. package/dist/cjs/AttemptStore.d.ts +359 -0
  5. package/dist/cjs/AttemptStore.d.ts.map +1 -0
  6. package/dist/cjs/AttemptStore.js +731 -0
  7. package/dist/cjs/AttemptStore.js.map +7 -0
  8. package/dist/cjs/Heartbeat.d.ts +46 -0
  9. package/dist/cjs/Heartbeat.d.ts.map +1 -0
  10. package/dist/cjs/Heartbeat.js +28 -0
  11. package/dist/cjs/Heartbeat.js.map +7 -0
  12. package/dist/cjs/Migrations.d.ts +42 -0
  13. package/dist/cjs/Migrations.d.ts.map +1 -0
  14. package/dist/cjs/Migrations.js +55 -0
  15. package/dist/cjs/Migrations.js.map +7 -0
  16. package/dist/cjs/Ownership.d.ts +275 -0
  17. package/dist/cjs/Ownership.d.ts.map +1 -0
  18. package/dist/cjs/Ownership.js +129 -0
  19. package/dist/cjs/Ownership.js.map +7 -0
  20. package/dist/cjs/RunStore.d.ts +571 -0
  21. package/dist/cjs/RunStore.d.ts.map +1 -0
  22. package/dist/cjs/RunStore.js +1079 -0
  23. package/dist/cjs/RunStore.js.map +7 -0
  24. package/dist/cjs/RunStoreMetrics.d.ts +151 -0
  25. package/dist/cjs/RunStoreMetrics.d.ts.map +1 -0
  26. package/dist/cjs/RunStoreMetrics.js +91 -0
  27. package/dist/cjs/RunStoreMetrics.js.map +7 -0
  28. package/dist/cjs/index.d.ts +47 -0
  29. package/dist/cjs/index.d.ts.map +1 -0
  30. package/dist/cjs/index.js +43 -0
  31. package/dist/cjs/index.js.map +7 -0
  32. package/dist/cjs/internal/Boundary.d.ts +80 -0
  33. package/dist/cjs/internal/Boundary.d.ts.map +1 -0
  34. package/dist/cjs/internal/Boundary.js +92 -0
  35. package/dist/cjs/internal/Boundary.js.map +7 -0
  36. package/dist/cjs/internal/Companion.d.ts +44 -0
  37. package/dist/cjs/internal/Companion.d.ts.map +1 -0
  38. package/dist/cjs/internal/Companion.js +50 -0
  39. package/dist/cjs/internal/Companion.js.map +7 -0
  40. package/dist/cjs/internal/SpanOutcome.d.ts +37 -0
  41. package/dist/cjs/internal/SpanOutcome.d.ts.map +1 -0
  42. package/dist/cjs/internal/SpanOutcome.js +62 -0
  43. package/dist/cjs/internal/SpanOutcome.js.map +7 -0
  44. package/dist/cjs/migrations/0001_initial.d.ts +15 -0
  45. package/dist/cjs/migrations/0001_initial.d.ts.map +1 -0
  46. package/dist/cjs/migrations/0001_initial.js +110 -0
  47. package/dist/cjs/migrations/0001_initial.js.map +7 -0
  48. package/dist/cjs/migrations/0002_lineage.d.ts +27 -0
  49. package/dist/cjs/migrations/0002_lineage.d.ts.map +1 -0
  50. package/dist/cjs/migrations/0002_lineage.js +43 -0
  51. package/dist/cjs/migrations/0002_lineage.js.map +7 -0
  52. package/dist/cjs/migrations/0003_execution_revisions.d.ts +15 -0
  53. package/dist/cjs/migrations/0003_execution_revisions.d.ts.map +1 -0
  54. package/dist/cjs/migrations/0003_execution_revisions.js +77 -0
  55. package/dist/cjs/migrations/0003_execution_revisions.js.map +7 -0
  56. package/dist/cjs/migrations/0004_waiting_request.d.ts +32 -0
  57. package/dist/cjs/migrations/0004_waiting_request.d.ts.map +1 -0
  58. package/dist/cjs/migrations/0004_waiting_request.js +42 -0
  59. package/dist/cjs/migrations/0004_waiting_request.js.map +7 -0
  60. package/dist/cjs/package.json +1 -0
  61. package/dist/cjs/test/TestRunStore.d.ts +17 -0
  62. package/dist/cjs/test/TestRunStore.d.ts.map +1 -0
  63. package/dist/cjs/test/TestRunStore.js +42 -0
  64. package/dist/cjs/test/TestRunStore.js.map +7 -0
  65. package/dist/esm/AttemptStore.d.ts +359 -0
  66. package/dist/esm/AttemptStore.d.ts.map +1 -0
  67. package/dist/esm/AttemptStore.js +835 -0
  68. package/dist/esm/AttemptStore.js.map +1 -0
  69. package/dist/esm/Heartbeat.d.ts +46 -0
  70. package/dist/esm/Heartbeat.d.ts.map +1 -0
  71. package/dist/esm/Heartbeat.js +46 -0
  72. package/dist/esm/Heartbeat.js.map +1 -0
  73. package/dist/esm/Migrations.d.ts +42 -0
  74. package/dist/esm/Migrations.d.ts.map +1 -0
  75. package/dist/esm/Migrations.js +56 -0
  76. package/dist/esm/Migrations.js.map +1 -0
  77. package/dist/esm/Ownership.d.ts +275 -0
  78. package/dist/esm/Ownership.d.ts.map +1 -0
  79. package/dist/esm/Ownership.js +299 -0
  80. package/dist/esm/Ownership.js.map +1 -0
  81. package/dist/esm/RunStore.d.ts +571 -0
  82. package/dist/esm/RunStore.d.ts.map +1 -0
  83. package/dist/esm/RunStore.js +1287 -0
  84. package/dist/esm/RunStore.js.map +1 -0
  85. package/dist/esm/RunStoreMetrics.d.ts +151 -0
  86. package/dist/esm/RunStoreMetrics.d.ts.map +1 -0
  87. package/dist/esm/RunStoreMetrics.js +120 -0
  88. package/dist/esm/RunStoreMetrics.js.map +1 -0
  89. package/dist/esm/index.d.ts +47 -0
  90. package/dist/esm/index.d.ts.map +1 -0
  91. package/dist/esm/index.js +47 -0
  92. package/dist/esm/index.js.map +1 -0
  93. package/dist/esm/internal/Boundary.d.ts +80 -0
  94. package/dist/esm/internal/Boundary.d.ts.map +1 -0
  95. package/dist/esm/internal/Boundary.js +109 -0
  96. package/dist/esm/internal/Boundary.js.map +1 -0
  97. package/dist/esm/internal/Companion.d.ts +44 -0
  98. package/dist/esm/internal/Companion.d.ts.map +1 -0
  99. package/dist/esm/internal/Companion.js +53 -0
  100. package/dist/esm/internal/Companion.js.map +1 -0
  101. package/dist/esm/internal/SpanOutcome.d.ts +37 -0
  102. package/dist/esm/internal/SpanOutcome.d.ts.map +1 -0
  103. package/dist/esm/internal/SpanOutcome.js +48 -0
  104. package/dist/esm/internal/SpanOutcome.js.map +1 -0
  105. package/dist/esm/migrations/0001_initial.d.ts +15 -0
  106. package/dist/esm/migrations/0001_initial.d.ts.map +1 -0
  107. package/dist/esm/migrations/0001_initial.js +88 -0
  108. package/dist/esm/migrations/0001_initial.js.map +1 -0
  109. package/dist/esm/migrations/0002_lineage.d.ts +27 -0
  110. package/dist/esm/migrations/0002_lineage.d.ts.map +1 -0
  111. package/dist/esm/migrations/0002_lineage.js +33 -0
  112. package/dist/esm/migrations/0002_lineage.js.map +1 -0
  113. package/dist/esm/migrations/0003_execution_revisions.d.ts +15 -0
  114. package/dist/esm/migrations/0003_execution_revisions.d.ts.map +1 -0
  115. package/dist/esm/migrations/0003_execution_revisions.js +58 -0
  116. package/dist/esm/migrations/0003_execution_revisions.js.map +1 -0
  117. package/dist/esm/migrations/0004_waiting_request.d.ts +32 -0
  118. package/dist/esm/migrations/0004_waiting_request.d.ts.map +1 -0
  119. package/dist/esm/migrations/0004_waiting_request.js +37 -0
  120. package/dist/esm/migrations/0004_waiting_request.js.map +1 -0
  121. package/dist/esm/test/TestRunStore.d.ts +17 -0
  122. package/dist/esm/test/TestRunStore.d.ts.map +1 -0
  123. package/dist/esm/test/TestRunStore.js +19 -0
  124. package/dist/esm/test/TestRunStore.js.map +1 -0
  125. package/docs/README.md +142 -0
  126. package/docs/api.md +901 -0
  127. package/docs/concepts/durable-values.md +140 -0
  128. package/docs/concepts/execution-revisions.md +53 -0
  129. package/docs/concepts/fencing.md +146 -0
  130. package/docs/concepts/leases.md +154 -0
  131. package/docs/concepts/liveness-evidence.md +142 -0
  132. package/docs/guides/cancel-a-run.md +156 -0
  133. package/docs/guides/claim-and-finish-a-run.md +175 -0
  134. package/docs/guides/compose-the-stores.md +142 -0
  135. package/docs/guides/observe-outcomes.md +182 -0
  136. package/docs/guides/record-step-attempts.md +170 -0
  137. package/docs/guides/recover-a-stalled-run.md +173 -0
  138. package/docs/guides/testing.md +141 -0
  139. package/docs/installation.md +109 -0
  140. package/docs/quickstart.md +201 -0
  141. package/docs/troubleshooting.md +255 -0
  142. package/package.json +183 -3
  143. package/src/AttemptStore.ts +1195 -0
  144. package/src/Heartbeat.ts +47 -0
  145. package/src/Migrations.ts +59 -0
  146. package/src/Ownership.ts +407 -0
  147. package/src/RunStore.ts +2063 -0
  148. package/src/RunStoreMetrics.ts +165 -0
  149. package/src/index.ts +51 -0
  150. package/src/internal/Boundary.ts +130 -0
  151. package/src/internal/Companion.ts +64 -0
  152. package/src/internal/SpanOutcome.ts +71 -0
  153. package/src/migrations/0001_initial.ts +114 -0
  154. package/src/migrations/0002_lineage.ts +38 -0
  155. package/src/migrations/0003_execution_revisions.ts +69 -0
  156. package/src/migrations/0004_waiting_request.ts +39 -0
  157. package/src/test/TestRunStore.ts +22 -0
@@ -0,0 +1,835 @@
1
+ /**
2
+ * Durable storage for individual step attempts.
3
+ *
4
+ * Attempt metadata is deliberately opaque to this module. Its shape belongs to
5
+ * the step executor, and is persisted unchanged across attempt state changes.
6
+ *
7
+ * The running-state and owner fences mean stale attempts and repeated terminal
8
+ * transitions never overwrite the winning row.
9
+ *
10
+ * @since 0.1.0
11
+ */
12
+ import * as Dialect from "@smthrs/database/Dialect";
13
+ import { DatabaseError, DurableWriter, fromSqlError } from "@smthrs/database/DurableWriter";
14
+ import * as Context from "effect/Context";
15
+ import * as Effect from "effect/Effect";
16
+ import * as Layer from "effect/Layer";
17
+ import * as Option from "effect/Option";
18
+ import * as Schema from "effect/Schema";
19
+ import * as SqlClient from "effect/unstable/sql/SqlClient";
20
+ import * as SqlError from "effect/unstable/sql/SqlError";
21
+ import * as Boundary from "./internal/Boundary.js";
22
+ import { observeExit, observeOutcome } from "./internal/SpanOutcome.js";
23
+ const NonNegativeSafeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(Number.MAX_SAFE_INTEGER));
24
+ const durableIdentifier = Schema.NonEmptyString.check(Schema.isMaxLength(Boundary.maximumIdentifierLength), Schema.makeFilter((value) => Boundary.isDurableText(value), { title: "durableIdentifier" }));
25
+ /**
26
+ * Absolute ceiling for {@link Options.maxCheckpointBytes}.
27
+ *
28
+ * This bounds only the configured checkpoint policy; metadata, errors, and
29
+ * outcomes have no byte ceiling.
30
+ *
31
+ * @category constants
32
+ * @since 1.0.0-rc.0
33
+ */
34
+ export const maximumCheckpointBytes = 16 * 1024 * 1024;
35
+ /**
36
+ * Maximum nesting admitted for durable attempt JSON.
37
+ *
38
+ * @category constants
39
+ * @since 1.0.0-rc.0
40
+ */
41
+ export const maximumJsonDepth = 128;
42
+ /**
43
+ * Maximum values and members admitted for durable attempt JSON.
44
+ *
45
+ * @category constants
46
+ * @since 1.0.0-rc.0
47
+ */
48
+ export const maximumJsonNodes = 100_000;
49
+ const jsonLimits = (maxBytes) => ({
50
+ ...(maxBytes === undefined ? {} : { maxBytes, maxStringBytes: maxBytes }),
51
+ maxDepth: maximumJsonDepth,
52
+ maxMembers: maximumJsonNodes,
53
+ maxNodes: maximumJsonNodes
54
+ });
55
+ /**
56
+ * Strict JSON value accepted as executable attempt state.
57
+ *
58
+ * The store still takes an inert snapshot at effect start; this schema is the
59
+ * public declaration contract shared by `Attempt`, `FinishAttempt`, and
60
+ * `AttemptPatch`.
61
+ *
62
+ * @category schemas
63
+ * @since 1.0.0-rc.0
64
+ */
65
+ export const JsonValue = Schema.declare((value) => Boundary.admitJson(value, jsonLimits()).ok, { identifier: "@smthrs/run-store/JsonValue" });
66
+ /**
67
+ * Stable error codes returned by attempt persistence operations.
68
+ *
69
+ * @category models
70
+ * @since 0.1.0
71
+ */
72
+ export const AttemptStoreErrorCode = Schema.Literals([
73
+ "invalid_attempt",
74
+ "constraint",
75
+ "decode_failed",
76
+ "persistence_failed",
77
+ "unknown"
78
+ ]);
79
+ /**
80
+ * Error raised by attempt persistence operations.
81
+ *
82
+ * The identity string equals the defining module path, which is how every
83
+ * tagged identity in this package is named.
84
+ *
85
+ * @category errors
86
+ * @since 0.1.0
87
+ */
88
+ export class AttemptStoreError extends Schema.TaggedError()("@smthrs/run-store/AttemptStoreError", {
89
+ code: AttemptStoreErrorCode,
90
+ method: Schema.String,
91
+ message: Schema.String,
92
+ cause: Schema.optional(Schema.Unknown)
93
+ }) {
94
+ }
95
+ /**
96
+ * Identifies one execution of a content-addressed step within a run.
97
+ *
98
+ * @category models
99
+ * @since 0.1.0
100
+ */
101
+ export const AttemptId = Schema.Struct({
102
+ runId: durableIdentifier,
103
+ stepKeyDigest: durableIdentifier,
104
+ attempt: NonNegativeSafeInt
105
+ });
106
+ /**
107
+ * A durable attempt row.
108
+ *
109
+ * @category models
110
+ * @since 0.1.0
111
+ */
112
+ export const Attempt = Schema.Struct({
113
+ ...AttemptId.fields,
114
+ state: durableIdentifier,
115
+ startedAtMs: NonNegativeSafeInt,
116
+ finishedAtMs: Schema.optionalKey(NonNegativeSafeInt),
117
+ heartbeatAtMs: Schema.optionalKey(NonNegativeSafeInt),
118
+ checkpoint: Schema.optionalKey(JsonValue),
119
+ error: Schema.optionalKey(JsonValue),
120
+ outcome: Schema.optionalKey(JsonValue),
121
+ meta: JsonValue
122
+ });
123
+ /**
124
+ * Input used to finish an existing attempt. `error`, `outcome`, and `meta`
125
+ * follow the same rule as {@link AttemptPatch}: an omitted field is left as
126
+ * recorded, so a terminal transition never erases a value written mid-flight
127
+ * by `put` or `patch`. Supplying one replaces it atomically with the terminal
128
+ * state, which lets an executor durably record what it discovered while
129
+ * handling a failure.
130
+ *
131
+ * @category models
132
+ * @since 0.1.0
133
+ */
134
+ export const FinishAttempt = Schema.Struct({
135
+ ...AttemptId.fields,
136
+ state: durableIdentifier,
137
+ finishedAtMs: NonNegativeSafeInt,
138
+ error: Schema.optionalKey(JsonValue),
139
+ outcome: Schema.optionalKey(JsonValue),
140
+ meta: Schema.optionalKey(JsonValue)
141
+ });
142
+ /**
143
+ * Fields a patch may rewrite.
144
+ *
145
+ * A patch never touches `state`, `started_at_ms`, or `finished_at_ms`: those
146
+ * are the lifecycle, and only `put`/`heartbeat`/`finish` move them. An
147
+ * omitted field is left as recorded.
148
+ *
149
+ * @category models
150
+ * @since 0.1.0
151
+ */
152
+ export const AttemptPatch = Schema.Struct({
153
+ checkpoint: Schema.optionalKey(JsonValue),
154
+ error: Schema.optionalKey(JsonValue),
155
+ outcome: Schema.optionalKey(JsonValue),
156
+ meta: Schema.optionalKey(JsonValue)
157
+ });
158
+ /**
159
+ * Service tag for durable step attempts.
160
+ *
161
+ * The identity string equals the defining module path, which is how every
162
+ * service in this package is named.
163
+ *
164
+ * @category services
165
+ * @since 0.1.0
166
+ */
167
+ export class AttemptStore extends Context.Service()("@smthrs/run-store/AttemptStore") {
168
+ }
169
+ const AttemptRow = Schema.Struct({
170
+ run_id: durableIdentifier,
171
+ step_key_digest: durableIdentifier,
172
+ attempt: NonNegativeSafeInt,
173
+ state: durableIdentifier,
174
+ started_at_ms: NonNegativeSafeInt,
175
+ finished_at_ms: Schema.NullOr(NonNegativeSafeInt),
176
+ heartbeat_at_ms: Schema.NullOr(NonNegativeSafeInt),
177
+ checkpoint_json: Schema.NullOr(Schema.String),
178
+ error_json: Schema.NullOr(Schema.String),
179
+ outcome_json: Schema.NullOr(Schema.String),
180
+ meta_json: Schema.String
181
+ });
182
+ const attemptStoreError = (method, code, detail, cause) => new AttemptStoreError({
183
+ code,
184
+ method,
185
+ message: `${code}: AttemptStore.${method}: ${detail}`,
186
+ cause
187
+ });
188
+ const error = (method, code, detail) => attemptStoreError(method, code, detail);
189
+ const encode = (method, value, field, maxBytes) => Effect.suspend(() => {
190
+ const admitted = Boundary.admitJson(value, jsonLimits(maxBytes));
191
+ return admitted.ok
192
+ ? Effect.succeed(JSON.stringify(admitted.value))
193
+ : Effect.fail(error(method, "invalid_attempt", `${field} ${admitted.complaint}`));
194
+ });
195
+ const encodeOptionalWith = (method) => (value, field) => value === undefined ? Effect.succeed(null) : encode(method, value, field);
196
+ const defaultMaxCheckpointBytes = 1024 * 1024;
197
+ const defaultInProgressStates = ["running"];
198
+ const encodeCheckpointWith = (method, maxBytes) => (value) => value === undefined ? Effect.succeed(null) : encode(method, value, "checkpoint", maxBytes);
199
+ const decode = (method, value, field, maxBytes) => Effect.suspend(() => {
200
+ if (value === null)
201
+ return Effect.succeed(undefined);
202
+ const admitted = Boundary.admitJsonText(value, jsonLimits(maxBytes));
203
+ return admitted.ok
204
+ ? Effect.succeed(admitted.json)
205
+ : Effect.fail(error(method, "decode_failed", `${field} ${admitted.complaint}`));
206
+ });
207
+ const decodeRequired = (method, value, field, maxBytes) =>
208
+ // A non-null SQL column cannot take `decode`'s missing-value branch.
209
+ decode(method, value, field, maxBytes);
210
+ const validateId = (method, id) => Boundary.isDurableText(id.runId) &&
211
+ Boundary.isDurableText(id.stepKeyDigest) &&
212
+ Number.isSafeInteger(id.attempt) &&
213
+ id.attempt >= 0
214
+ ? Effect.void
215
+ : Effect.fail(error(method, "invalid_attempt", "attempt identity violates the durable identifier contract"));
216
+ const inspectRecord = (input, required, optional, field) => {
217
+ if (typeof input !== "object" || input === null)
218
+ throw new TypeError(field);
219
+ const prototype = Object.getPrototypeOf(input);
220
+ if (prototype !== Object.prototype && prototype !== null)
221
+ throw new TypeError(field);
222
+ const allowed = new Set([...required, ...optional]);
223
+ const output = Object.create(null);
224
+ for (const name of required) {
225
+ const descriptor = Object.getOwnPropertyDescriptor(input, name);
226
+ if (descriptor === undefined || !("value" in descriptor) || !descriptor.enumerable)
227
+ throw new TypeError(field);
228
+ output[name] = descriptor.value;
229
+ }
230
+ for (const name of optional) {
231
+ const descriptor = Object.getOwnPropertyDescriptor(input, name);
232
+ if (descriptor === undefined)
233
+ continue;
234
+ if (!("value" in descriptor) || !descriptor.enumerable)
235
+ throw new TypeError(field);
236
+ if (descriptor.value !== undefined)
237
+ output[name] = descriptor.value;
238
+ }
239
+ for (const key of Reflect.ownKeys(input)) {
240
+ if (typeof key !== "string" || !allowed.has(key)) {
241
+ if (Object.getOwnPropertyDescriptor(input, key)?.enumerable)
242
+ throw new TypeError(field);
243
+ }
244
+ }
245
+ return output;
246
+ };
247
+ /**
248
+ * Captures the fencing owner once, as a frozen inert record. Every span, SQL
249
+ * predicate, and fence comparison reads this copy, so an accessor, a proxy, or
250
+ * a mutation during an await cannot put two different owners into one write.
251
+ */
252
+ const snapshotOwner = (method, input) => Effect.suspend(() => {
253
+ try {
254
+ const values = inspectRecord(input, ["hostId", "pid", "nonce"], [], "owner");
255
+ const owner = Object.freeze({ hostId: values.hostId, pid: values.pid, nonce: values.nonce });
256
+ if (Boundary.isDurableText(owner.hostId) && Boundary.isDurableText(owner.nonce) &&
257
+ Number.isSafeInteger(owner.pid) && owner.pid >= 0) {
258
+ return Effect.succeed(owner);
259
+ }
260
+ }
261
+ catch {
262
+ // Falls through to the one refusal below.
263
+ }
264
+ return Effect.fail(error(method, "invalid_attempt", "owner must be an inert, valid owner identity"));
265
+ });
266
+ const snapshotJson = (method, value, field, maxBytes) => {
267
+ const admitted = Boundary.admitJson(value, jsonLimits(maxBytes));
268
+ return admitted.ok
269
+ ? { ok: true, value: admitted.value }
270
+ : { ok: false, error: error(method, "invalid_attempt", `${field} ${admitted.complaint}`) };
271
+ };
272
+ /**
273
+ * Structural admission for an attempt identity. `Attempt` and `FinishAttempt`
274
+ * extend `AttemptId`, so a stored attempt must pass wherever an id is typed:
275
+ * extra own data fields are ignored, while a non-plain prototype, any
276
+ * enumerable accessor, or a missing or inherited required field is refused.
277
+ */
278
+ const inspectIdRecord = (input, field) => {
279
+ if (typeof input !== "object" || input === null)
280
+ throw new TypeError(field);
281
+ const prototype = Object.getPrototypeOf(input);
282
+ if (prototype !== Object.prototype && prototype !== null)
283
+ throw new TypeError(field);
284
+ for (const key of Reflect.ownKeys(input)) {
285
+ const descriptor = Object.getOwnPropertyDescriptor(input, key);
286
+ if (descriptor !== undefined && descriptor.enumerable && !("value" in descriptor))
287
+ throw new TypeError(field);
288
+ }
289
+ const output = Object.create(null);
290
+ for (const name of ["runId", "stepKeyDigest", "attempt"]) {
291
+ const descriptor = Object.getOwnPropertyDescriptor(input, name);
292
+ if (descriptor === undefined || !("value" in descriptor) || !descriptor.enumerable)
293
+ throw new TypeError(field);
294
+ output[name] = descriptor.value;
295
+ }
296
+ return output;
297
+ };
298
+ const snapshotId = (method, input) => Effect.suspend(() => {
299
+ try {
300
+ const values = inspectIdRecord(input, "attempt id");
301
+ const id = Object.freeze({
302
+ runId: values.runId,
303
+ stepKeyDigest: values.stepKeyDigest,
304
+ attempt: values.attempt
305
+ });
306
+ return Effect.as(validateId(method, id), id);
307
+ }
308
+ catch {
309
+ return Effect.fail(error(method, "invalid_attempt", "attempt id must be an inert data record"));
310
+ }
311
+ });
312
+ const snapshotAttempt = (method, input) => Effect.suspend(() => {
313
+ try {
314
+ const values = inspectRecord(input, ["runId", "stepKeyDigest", "attempt", "state", "startedAtMs", "meta"], ["finishedAtMs", "heartbeatAtMs", "checkpoint", "error", "outcome"], "attempt");
315
+ const checkpoint = values.checkpoint === undefined
316
+ ? undefined
317
+ : snapshotJson(method, values.checkpoint, "checkpoint", maximumCheckpointBytes);
318
+ const attemptError = values.error === undefined ? undefined : snapshotJson(method, values.error, "error");
319
+ const outcome = values.outcome === undefined ? undefined : snapshotJson(method, values.outcome, "outcome");
320
+ const meta = snapshotJson(method, values.meta, "meta");
321
+ if (checkpoint !== undefined && !checkpoint.ok)
322
+ return Effect.fail(checkpoint.error);
323
+ if (attemptError !== undefined && !attemptError.ok)
324
+ return Effect.fail(attemptError.error);
325
+ if (outcome !== undefined && !outcome.ok)
326
+ return Effect.fail(outcome.error);
327
+ if (!meta.ok)
328
+ return Effect.fail(meta.error);
329
+ const candidate = Object.freeze({
330
+ runId: values.runId,
331
+ stepKeyDigest: values.stepKeyDigest,
332
+ attempt: values.attempt,
333
+ state: values.state,
334
+ startedAtMs: values.startedAtMs,
335
+ ...(values.finishedAtMs === undefined ? {} : { finishedAtMs: values.finishedAtMs }),
336
+ ...(values.heartbeatAtMs === undefined ? {} : { heartbeatAtMs: values.heartbeatAtMs }),
337
+ ...(checkpoint === undefined ? {} : { checkpoint: checkpoint.value }),
338
+ ...(attemptError === undefined ? {} : { error: attemptError.value }),
339
+ ...(outcome === undefined ? {} : { outcome: outcome.value }),
340
+ meta: meta.value
341
+ });
342
+ return Schema.decodeUnknownEffect(Attempt)(candidate).pipe(Effect.mapError(() => error(method, "invalid_attempt", "attempt violates the persistence contract")));
343
+ }
344
+ catch {
345
+ return Effect.fail(error(method, "invalid_attempt", "attempt must be an inert data record"));
346
+ }
347
+ });
348
+ const snapshotFinish = (method, input) => Effect.suspend(() => {
349
+ try {
350
+ const values = inspectRecord(input, ["runId", "stepKeyDigest", "attempt", "state", "finishedAtMs"], ["error", "outcome", "meta"], "finished attempt");
351
+ const attemptError = values.error === undefined ? undefined : snapshotJson(method, values.error, "error");
352
+ const outcome = values.outcome === undefined ? undefined : snapshotJson(method, values.outcome, "outcome");
353
+ const meta = values.meta === undefined ? undefined : snapshotJson(method, values.meta, "meta");
354
+ if (attemptError !== undefined && !attemptError.ok)
355
+ return Effect.fail(attemptError.error);
356
+ if (outcome !== undefined && !outcome.ok)
357
+ return Effect.fail(outcome.error);
358
+ if (meta !== undefined && !meta.ok)
359
+ return Effect.fail(meta.error);
360
+ return Schema.decodeUnknownEffect(FinishAttempt)(Object.freeze({
361
+ runId: values.runId,
362
+ stepKeyDigest: values.stepKeyDigest,
363
+ attempt: values.attempt,
364
+ state: values.state,
365
+ finishedAtMs: values.finishedAtMs,
366
+ ...(attemptError === undefined ? {} : { error: attemptError.value }),
367
+ ...(outcome === undefined ? {} : { outcome: outcome.value }),
368
+ ...(meta === undefined ? {} : { meta: meta.value })
369
+ })).pipe(Effect.mapError(() => error(method, "invalid_attempt", "finished attempt violates the contract")));
370
+ }
371
+ catch {
372
+ return Effect.fail(error(method, "invalid_attempt", "finished attempt must be an inert data record"));
373
+ }
374
+ });
375
+ const snapshotPatch = (method, input) => Effect.suspend(() => {
376
+ try {
377
+ const values = inspectRecord(input, [], ["checkpoint", "error", "outcome", "meta"], "attempt patch");
378
+ const checkpoint = values.checkpoint === undefined
379
+ ? undefined
380
+ : snapshotJson(method, values.checkpoint, "checkpoint", maximumCheckpointBytes);
381
+ const attemptError = values.error === undefined ? undefined : snapshotJson(method, values.error, "error");
382
+ const outcome = values.outcome === undefined ? undefined : snapshotJson(method, values.outcome, "outcome");
383
+ const meta = values.meta === undefined ? undefined : snapshotJson(method, values.meta, "meta");
384
+ if (checkpoint !== undefined && !checkpoint.ok)
385
+ return Effect.fail(checkpoint.error);
386
+ if (attemptError !== undefined && !attemptError.ok)
387
+ return Effect.fail(attemptError.error);
388
+ if (outcome !== undefined && !outcome.ok)
389
+ return Effect.fail(outcome.error);
390
+ if (meta !== undefined && !meta.ok)
391
+ return Effect.fail(meta.error);
392
+ return Effect.succeed(Object.freeze({
393
+ ...(checkpoint === undefined ? {} : { checkpoint: checkpoint.value }),
394
+ ...(attemptError === undefined ? {} : { error: attemptError.value }),
395
+ ...(outcome === undefined ? {} : { outcome: outcome.value }),
396
+ ...(meta === undefined ? {} : { meta: meta.value })
397
+ }));
398
+ }
399
+ catch {
400
+ return Effect.fail(error(method, "invalid_attempt", "attempt patch must be an inert data record"));
401
+ }
402
+ });
403
+ const snapshotOptions = (method, input) => Effect.suspend(() => {
404
+ try {
405
+ const values = inspectRecord(input, [], ["inProgressStates", "maxCheckpointBytes", "putMode"], "attempt store options");
406
+ const rawStates = values.inProgressStates ?? defaultInProgressStates;
407
+ const admitted = Boundary.admitJson(rawStates, {
408
+ ...jsonLimits(64 * 1024),
409
+ maxDepth: 2,
410
+ maxMembers: 256,
411
+ maxNodes: 257
412
+ });
413
+ if (!admitted.ok || !Array.isArray(admitted.value)) {
414
+ return Effect.fail(error(method, "invalid_attempt", "inProgressStates must be an inert string array"));
415
+ }
416
+ const states = admitted.value;
417
+ if (states.length === 0 ||
418
+ states.some((state) => !Boundary.isDurableText(state)) ||
419
+ new Set(states).size !== states.length) {
420
+ return Effect.fail(error(method, "invalid_attempt", "inProgressStates must contain unique durable state names"));
421
+ }
422
+ const maxCheckpointBytes = values.maxCheckpointBytes ?? defaultMaxCheckpointBytes;
423
+ if (typeof maxCheckpointBytes !== "number" || !Number.isSafeInteger(maxCheckpointBytes) ||
424
+ maxCheckpointBytes <= 0 ||
425
+ maxCheckpointBytes > maximumCheckpointBytes) {
426
+ return Effect.fail(error(method, "invalid_attempt", `maxCheckpointBytes must be between 1 and ${maximumCheckpointBytes}`));
427
+ }
428
+ const putMode = values.putMode ?? "insert";
429
+ if (putMode !== "insert" && putMode !== "upsert") {
430
+ return Effect.fail(error(method, "invalid_attempt", "putMode must be insert or upsert"));
431
+ }
432
+ return Effect.succeed(Object.freeze({
433
+ inProgressStates: Object.freeze([...states]),
434
+ maxCheckpointBytes,
435
+ putMode
436
+ }));
437
+ }
438
+ catch {
439
+ return Effect.fail(error(method, "invalid_attempt", "attempt store options must be an inert data record"));
440
+ }
441
+ });
442
+ const ownsRunningRun = (row, owner) => row.status === "running" &&
443
+ row.owner_host_id === owner.hostId &&
444
+ row.owner_pid === owner.pid &&
445
+ row.owner_nonce === owner.nonce;
446
+ const sameAttempt = (stored, expected) => stored.state === expected.state &&
447
+ stored.startedAtMs === expected.startedAtMs &&
448
+ stored.finishedAtMs === expected.finishedAtMs &&
449
+ stored.heartbeatAtMs === expected.heartbeatAtMs &&
450
+ Boundary.sameJson(stored.checkpoint, expected.checkpoint) &&
451
+ Boundary.sameJson(stored.error, expected.error) &&
452
+ Boundary.sameJson(stored.outcome, expected.outcome) &&
453
+ Boundary.sameJson(stored.meta, expected.meta);
454
+ /**
455
+ * Curried so each operation names itself once at construction and every
456
+ * failure travels through the one mapper, rather than through a per-call-site
457
+ * closure that only that operation's failing test could ever reach.
458
+ */
459
+ const mapPersistenceError = (method) => (cause) => {
460
+ if (Schema.is(AttemptStoreError)(cause)) {
461
+ return cause;
462
+ }
463
+ const sqlError = SqlError.isSqlError(cause);
464
+ const databaseError = Schema.is(DatabaseError)(cause);
465
+ const constraint = sqlError
466
+ ? cause.reason instanceof SqlError.ConstraintError || cause.reason instanceof SqlError.UniqueViolation
467
+ : databaseError && cause.code === "constraint";
468
+ const code = constraint ? "constraint" : "persistence_failed";
469
+ // Causes reach logs and telemetry. Driver failures may retain bound attempt
470
+ // payloads, so publish only a stable, payload-free descriptor.
471
+ return attemptStoreError(method, code, "attempt persistence failed", {
472
+ category: code,
473
+ reason: sqlError ? cause.reason._tag : databaseError ? cause.code : "unknown",
474
+ // The outer writer must still recognize a nested savepoint's conflict.
475
+ ...(sqlError || databaseError
476
+ ? { cause: new DatabaseError({ code: sqlError ? fromSqlError(cause).code : cause.code }) }
477
+ : {})
478
+ });
479
+ };
480
+ const decodeRow = (method, input) => Schema.decodeUnknownEffect(AttemptRow)(input).pipe(Effect.mapError(() => error(method, "decode_failed", "could not decode flows_attempts row")), Effect.flatMap((row) => Effect.all({
481
+ checkpoint: decode(method, row.checkpoint_json, "checkpoint_json", maximumCheckpointBytes),
482
+ error: decode(method, row.error_json, "error_json"),
483
+ outcome: decode(method, row.outcome_json, "outcome_json"),
484
+ meta: decodeRequired(method, row.meta_json, "meta_json")
485
+ }).pipe(Effect.map(({ checkpoint, error: attemptError, outcome, meta }) => Object.freeze({
486
+ runId: row.run_id,
487
+ stepKeyDigest: row.step_key_digest,
488
+ attempt: row.attempt,
489
+ state: row.state,
490
+ startedAtMs: row.started_at_ms,
491
+ ...(row.finished_at_ms === null ? {} : { finishedAtMs: row.finished_at_ms }),
492
+ ...(row.heartbeat_at_ms === null ? {} : { heartbeatAtMs: row.heartbeat_at_ms }),
493
+ ...(checkpoint === undefined ? {} : { checkpoint }),
494
+ ...(attemptError === undefined ? {} : { error: attemptError }),
495
+ ...(outcome === undefined ? {} : { outcome }),
496
+ meta
497
+ })))));
498
+ /**
499
+ * Builds the SQL-backed attempt store under an explicit policy.
500
+ *
501
+ * @category constructors
502
+ * @since 0.1.0
503
+ */
504
+ export const makeWith = (options = {}) => Effect.gen(function* () {
505
+ const sql = yield* Effect.service(SqlClient.SqlClient);
506
+ const writer = yield* DurableWriter;
507
+ const configured = yield* snapshotOptions("makeWith", options);
508
+ const inProgressStates = configured.inProgressStates;
509
+ const maxCheckpointBytes = configured.maxCheckpointBytes;
510
+ const upsert = configured.putMode === "upsert";
511
+ const putEncodeOptional = encodeOptionalWith("put");
512
+ const putEncodeCheckpoint = encodeCheckpointWith("put", maxCheckpointBytes);
513
+ const heartbeatEncodeCheckpoint = encodeCheckpointWith("heartbeat", maxCheckpointBytes);
514
+ const finishEncodeOptional = encodeOptionalWith("finish");
515
+ const patchEncodeOptional = encodeOptionalWith("patch");
516
+ const patchEncodeCheckpoint = encodeCheckpointWith("patch", maxCheckpointBytes);
517
+ const inProgress = sql.in("state", [...inProgressStates]);
518
+ const put = Effect.fn("AttemptStore.put")((input, ownerInput) => Effect.gen(function* () {
519
+ const owner = yield* snapshotOwner("put", ownerInput);
520
+ const attempt = yield* snapshotAttempt("put", input);
521
+ yield* Effect.annotateCurrentSpan({
522
+ runId: attempt.runId,
523
+ stepKeyDigest: attempt.stepKeyDigest,
524
+ attempt: attempt.attempt,
525
+ ownerHostId: owner.hostId
526
+ });
527
+ const checkpoint = yield* putEncodeCheckpoint(attempt.checkpoint);
528
+ const attemptError = yield* putEncodeOptional(attempt.error, "error");
529
+ const outcome = yield* putEncodeOptional(attempt.outcome, "outcome");
530
+ const meta = yield* encode("put", attempt.meta, "meta");
531
+ return yield* writer.write(Effect.gen(function* () {
532
+ const inserted = yield* sql `
533
+ INSERT INTO flows_attempts (
534
+ run_id, step_key_digest, attempt, state, started_at_ms, finished_at_ms,
535
+ heartbeat_at_ms, checkpoint_json, error_json, outcome_json, meta_json
536
+ )
537
+ SELECT
538
+ ${attempt.runId}, ${attempt.stepKeyDigest}, ${attempt.attempt}, ${attempt.state},
539
+ ${attempt.startedAtMs}, ${attempt.finishedAtMs ?? null}, ${attempt.heartbeatAtMs ?? null},
540
+ ${checkpoint}, ${attemptError}, ${outcome}, ${meta}
541
+ WHERE EXISTS (
542
+ SELECT 1 FROM flows_runs
543
+ WHERE run_id = ${attempt.runId}
544
+ AND status = 'running'
545
+ AND owner_host_id = ${owner.hostId}
546
+ AND owner_pid = ${owner.pid}
547
+ AND owner_nonce = ${owner.nonce}
548
+ )
549
+ ON CONFLICT (run_id, step_key_digest, attempt) DO NOTHING
550
+ RETURNING attempt
551
+ `;
552
+ if (inserted.length > 0) {
553
+ return { _tag: "Inserted" };
554
+ }
555
+ if (upsert) {
556
+ const replaced = yield* sql `
557
+ UPDATE flows_attempts
558
+ SET
559
+ state = ${attempt.state},
560
+ started_at_ms = ${attempt.startedAtMs},
561
+ finished_at_ms = ${attempt.finishedAtMs ?? null},
562
+ heartbeat_at_ms = ${attempt.heartbeatAtMs ?? null},
563
+ checkpoint_json = ${checkpoint},
564
+ error_json = ${attemptError},
565
+ outcome_json = ${outcome},
566
+ meta_json = ${meta}
567
+ WHERE run_id = ${attempt.runId}
568
+ AND step_key_digest = ${attempt.stepKeyDigest}
569
+ AND attempt = ${attempt.attempt}
570
+ AND ${inProgress}
571
+ AND EXISTS (
572
+ SELECT 1 FROM flows_runs
573
+ WHERE run_id = ${attempt.runId}
574
+ AND status = 'running'
575
+ AND owner_host_id = ${owner.hostId}
576
+ AND owner_pid = ${owner.pid}
577
+ AND owner_nonce = ${owner.nonce}
578
+ )
579
+ RETURNING attempt
580
+ `;
581
+ if (replaced.length > 0) {
582
+ return { _tag: "Upserted" };
583
+ }
584
+ }
585
+ const runRows = yield* sql `
586
+ SELECT status, owner_host_id, owner_pid, owner_nonce
587
+ FROM flows_runs WHERE run_id = ${attempt.runId}
588
+ `;
589
+ if (runRows.length === 0) {
590
+ return { _tag: "RunNotFound" };
591
+ }
592
+ if (!ownsRunningRun(runRows[0], owner)) {
593
+ return { _tag: "FenceLost" };
594
+ }
595
+ const rows = yield* sql `
596
+ SELECT run_id, step_key_digest, attempt, state, started_at_ms, finished_at_ms,
597
+ heartbeat_at_ms, checkpoint_json, error_json, outcome_json, meta_json
598
+ FROM flows_attempts
599
+ WHERE run_id = ${attempt.runId}
600
+ AND step_key_digest = ${attempt.stepKeyDigest}
601
+ AND attempt = ${attempt.attempt}
602
+ `;
603
+ /* v8 ignore next -- the owned run and conflicting row are read in the same serialized write transaction */
604
+ if (rows.length === 0) {
605
+ return yield* Effect.fail(error("put", "unknown", "attempt disappeared during put"));
606
+ }
607
+ const stored = yield* decodeRow("put", rows[0]);
608
+ return sameAttempt(stored, attempt)
609
+ ? { _tag: "ExistingSame" }
610
+ : { _tag: "Conflict" };
611
+ })).pipe(Effect.mapError(mapPersistenceError("put")));
612
+ }).pipe(observeOutcome()));
613
+ const get = Effect.fn("AttemptStore.get")((input) => Effect.gen(function* () {
614
+ const id = yield* snapshotId("get", input);
615
+ yield* Effect.annotateCurrentSpan({
616
+ runId: id.runId,
617
+ stepKeyDigest: id.stepKeyDigest,
618
+ attempt: id.attempt
619
+ });
620
+ const rows = yield* sql `
621
+ SELECT run_id, step_key_digest, attempt, state, started_at_ms, finished_at_ms,
622
+ heartbeat_at_ms, checkpoint_json, error_json, outcome_json, meta_json
623
+ FROM flows_attempts
624
+ WHERE run_id = ${id.runId} AND step_key_digest = ${id.stepKeyDigest} AND attempt = ${id.attempt}
625
+ `.pipe(Effect.mapError(mapPersistenceError("get")));
626
+ return rows.length === 0 ? Option.none() : yield* Effect.map(decodeRow("get", rows[0]), Option.some);
627
+ }).pipe(observeExit));
628
+ const heartbeat = Effect.fn("AttemptStore.heartbeat")((runId, stepKeyDigest, attempt, ownerInput, nowMs, checkpointValue) => Effect.gen(function* () {
629
+ const owner = yield* snapshotOwner("heartbeat", ownerInput);
630
+ yield* validateId("heartbeat", { runId, stepKeyDigest, attempt });
631
+ if (!Number.isSafeInteger(nowMs) || nowMs < 0) {
632
+ return yield* Effect.fail(error("heartbeat", "invalid_attempt", "nowMs must be a non-negative safe integer"));
633
+ }
634
+ yield* Effect.annotateCurrentSpan({ runId, stepKeyDigest, attempt, ownerHostId: owner.hostId });
635
+ const checkpoint = yield* heartbeatEncodeCheckpoint(checkpointValue);
636
+ return yield* writer.write(Effect.gen(function* () {
637
+ const updated = yield* sql `
638
+ UPDATE flows_attempts
639
+ SET
640
+ heartbeat_at_ms = ${Dialect.greatest(sql)}(COALESCE(heartbeat_at_ms, ${nowMs}), ${nowMs}),
641
+ checkpoint_json = COALESCE(${checkpoint}, checkpoint_json)
642
+ WHERE run_id = ${runId}
643
+ AND step_key_digest = ${stepKeyDigest}
644
+ AND attempt = ${attempt}
645
+ AND ${inProgress}
646
+ AND EXISTS (
647
+ SELECT 1 FROM flows_runs
648
+ WHERE run_id = ${runId}
649
+ AND status = 'running'
650
+ AND owner_host_id = ${owner.hostId}
651
+ AND owner_pid = ${owner.pid}
652
+ AND owner_nonce = ${owner.nonce}
653
+ )
654
+ RETURNING attempt
655
+ `;
656
+ if (updated.length > 0) {
657
+ return { _tag: "Updated" };
658
+ }
659
+ const found = yield* sql `
660
+ SELECT state FROM flows_attempts
661
+ WHERE run_id = ${runId} AND step_key_digest = ${stepKeyDigest} AND attempt = ${attempt}
662
+ `;
663
+ if (found.length === 0) {
664
+ return { _tag: "NotFound" };
665
+ }
666
+ const runRows = yield* sql `
667
+ SELECT status, owner_host_id, owner_pid, owner_nonce
668
+ FROM flows_runs WHERE run_id = ${runId}
669
+ `;
670
+ if (runRows.length === 0 || !ownsRunningRun(runRows[0], owner)) {
671
+ return { _tag: "FenceLost" };
672
+ }
673
+ return { _tag: "StateChanged" };
674
+ })).pipe(Effect.mapError(mapPersistenceError("heartbeat")));
675
+ }).pipe(observeOutcome()));
676
+ const finish = Effect.fn("AttemptStore.finish")((input, ownerInput) => Effect.gen(function* () {
677
+ const owner = yield* snapshotOwner("finish", ownerInput);
678
+ const attempt = yield* snapshotFinish("finish", input);
679
+ yield* Effect.annotateCurrentSpan({
680
+ runId: attempt.runId,
681
+ stepKeyDigest: attempt.stepKeyDigest,
682
+ attempt: attempt.attempt,
683
+ ownerHostId: owner.hostId
684
+ });
685
+ if (inProgressStates.includes(attempt.state) ||
686
+ !Boundary.isDurableText(attempt.state)) {
687
+ return yield* Effect.fail(error("finish", "invalid_attempt", "finish requires a terminal state and valid timestamp"));
688
+ }
689
+ const attemptError = yield* finishEncodeOptional(attempt.error, "error");
690
+ const outcome = yield* finishEncodeOptional(attempt.outcome, "outcome");
691
+ const meta = yield* finishEncodeOptional(attempt.meta, "meta");
692
+ return yield* writer.write(Effect.gen(function* () {
693
+ const updated = yield* sql `
694
+ UPDATE flows_attempts
695
+ SET
696
+ state = ${attempt.state},
697
+ finished_at_ms = ${attempt.finishedAtMs},
698
+ error_json = COALESCE(${attemptError}, error_json),
699
+ outcome_json = COALESCE(${outcome}, outcome_json),
700
+ meta_json = COALESCE(${meta}, meta_json)
701
+ WHERE run_id = ${attempt.runId}
702
+ AND step_key_digest = ${attempt.stepKeyDigest}
703
+ AND attempt = ${attempt.attempt}
704
+ AND ${inProgress}
705
+ AND EXISTS (
706
+ SELECT 1 FROM flows_runs
707
+ WHERE run_id = ${attempt.runId}
708
+ AND status = 'running'
709
+ AND owner_host_id = ${owner.hostId}
710
+ AND owner_pid = ${owner.pid}
711
+ AND owner_nonce = ${owner.nonce}
712
+ )
713
+ RETURNING attempt
714
+ `;
715
+ if (updated.length > 0) {
716
+ return { _tag: "Finished" };
717
+ }
718
+ const found = yield* sql `
719
+ SELECT state FROM flows_attempts
720
+ WHERE run_id = ${attempt.runId}
721
+ AND step_key_digest = ${attempt.stepKeyDigest}
722
+ AND attempt = ${attempt.attempt}
723
+ `;
724
+ if (found.length === 0) {
725
+ return { _tag: "NotFound" };
726
+ }
727
+ const runRows = yield* sql `
728
+ SELECT status, owner_host_id, owner_pid, owner_nonce
729
+ FROM flows_runs WHERE run_id = ${attempt.runId}
730
+ `;
731
+ if (runRows.length === 0 || !ownsRunningRun(runRows[0], owner)) {
732
+ return { _tag: "FenceLost" };
733
+ }
734
+ return { _tag: "StateChanged" };
735
+ })).pipe(Effect.mapError(mapPersistenceError("finish")));
736
+ }).pipe(observeOutcome()));
737
+ const patch = Effect.fn("AttemptStore.patch")((idInput, patchInput, ownerInput) => Effect.gen(function* () {
738
+ const owner = yield* snapshotOwner("patch", ownerInput);
739
+ const id = yield* snapshotId("patch", idInput);
740
+ const fields = yield* snapshotPatch("patch", patchInput);
741
+ yield* Effect.annotateCurrentSpan({
742
+ runId: id.runId,
743
+ stepKeyDigest: id.stepKeyDigest,
744
+ attempt: id.attempt,
745
+ ownerHostId: owner.hostId
746
+ });
747
+ const checkpoint = yield* patchEncodeCheckpoint(fields.checkpoint);
748
+ const attemptError = yield* patchEncodeOptional(fields.error, "error");
749
+ const outcome = yield* patchEncodeOptional(fields.outcome, "outcome");
750
+ const meta = yield* patchEncodeOptional(fields.meta, "meta");
751
+ return yield* writer.write(Effect.gen(function* () {
752
+ // Unlike `heartbeat`/`finish` there is no state predicate: a patch
753
+ // may touch a terminal row (evidence quarantine does), so the run
754
+ // fence is the only gate. After a terminal run transition the
755
+ // owner columns are cleared and every patch is refused.
756
+ const updated = yield* sql `
757
+ UPDATE flows_attempts
758
+ SET
759
+ checkpoint_json = COALESCE(${checkpoint}, checkpoint_json),
760
+ error_json = COALESCE(${attemptError}, error_json),
761
+ outcome_json = COALESCE(${outcome}, outcome_json),
762
+ meta_json = COALESCE(${meta}, meta_json)
763
+ WHERE run_id = ${id.runId}
764
+ AND step_key_digest = ${id.stepKeyDigest}
765
+ AND attempt = ${id.attempt}
766
+ AND EXISTS (
767
+ SELECT 1 FROM flows_runs
768
+ WHERE run_id = ${id.runId}
769
+ AND status = 'running'
770
+ AND owner_host_id = ${owner.hostId}
771
+ AND owner_pid = ${owner.pid}
772
+ AND owner_nonce = ${owner.nonce}
773
+ )
774
+ RETURNING attempt
775
+ `;
776
+ if (updated.length > 0) {
777
+ return { _tag: "Patched" };
778
+ }
779
+ const found = yield* sql `
780
+ SELECT state FROM flows_attempts
781
+ WHERE run_id = ${id.runId} AND step_key_digest = ${id.stepKeyDigest} AND attempt = ${id.attempt}
782
+ `;
783
+ return found.length === 0
784
+ ? { _tag: "NotFound" }
785
+ : { _tag: "FenceLost" };
786
+ })).pipe(Effect.mapError(mapPersistenceError("patch")));
787
+ }).pipe(observeOutcome()));
788
+ return { put, get, heartbeat, finish, patch };
789
+ });
790
+ /**
791
+ * Builds the SQL-backed attempt store with default policy.
792
+ *
793
+ * @category constructors
794
+ * @since 0.1.0
795
+ */
796
+ export const make = Effect.orDie(makeWith());
797
+ /**
798
+ * Creates an attempt store from an implementation.
799
+ *
800
+ * @category constructors
801
+ * @since 0.1.0
802
+ */
803
+ export const makeNoop = (overrides = {}) => {
804
+ const unavailable = (method) => Effect.fail(attemptStoreError(method, "unknown", "the store is unavailable in this environment"));
805
+ return {
806
+ put: Effect.fn("AttemptStore.put")(() => unavailable("put")),
807
+ get: Effect.fn("AttemptStore.get")(() => unavailable("get")),
808
+ heartbeat: Effect.fn("AttemptStore.heartbeat")(() => unavailable("heartbeat")),
809
+ finish: Effect.fn("AttemptStore.finish")(() => unavailable("finish")),
810
+ patch: Effect.fn("AttemptStore.patch")(() => unavailable("patch")),
811
+ ...overrides
812
+ };
813
+ };
814
+ /**
815
+ * Provides a no-op attempt store.
816
+ *
817
+ * @category layers
818
+ * @since 0.1.0
819
+ */
820
+ export const layerNoop = (overrides = {}) => Layer.succeed(AttemptStore)(makeNoop(overrides));
821
+ /**
822
+ * Provides the SQL-backed attempt store.
823
+ *
824
+ * @category layers
825
+ * @since 0.1.0
826
+ */
827
+ export const layer = Layer.effect(AttemptStore)(make);
828
+ /**
829
+ * Provides the SQL-backed attempt store under an explicit policy.
830
+ *
831
+ * @category layers
832
+ * @since 0.1.0
833
+ */
834
+ export const layerWith = (options) => Layer.effect(AttemptStore)(makeWith(options));
835
+ //# sourceMappingURL=AttemptStore.js.map