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

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,201 @@
1
+ ---
2
+ title: "Quickstart"
3
+ description: "Drive one run through its whole durable lifecycle: create, claim, activate, record a step attempt, finish it, and settle the run, against an in-memory database."
4
+ sidebar:
5
+ order: 2
6
+ ---
7
+
8
+ This quickstart takes one run from `pending` to `completed` and records a step
9
+ attempt along the way. The stores are the production ones. Only the database is
10
+ in memory, so the script runs with no files and no configuration.
11
+
12
+ By the end you will have used every operation the durable engine uses on a
13
+ normal run, and read back the row a restart would have found.
14
+
15
+ ## Prerequisites
16
+
17
+ - Node.js 26.4.0 or later.
18
+ - The package installed. Not on npm yet; see [Installation](https://github.com/smithersai/smithers/blob/main/packages/smithers/flows/flow/docs/installation.md#use-the-libraries).
19
+
20
+ ## Name the owner
21
+
22
+ An owner identity is the fence on every write this process makes. It is three
23
+ fields, and all three are compared in the same SQL statement as the mutation
24
+ they guard:
25
+
26
+ ```ts
27
+ import type { OwnerId } from "@smthrs/run-store/Ownership"
28
+
29
+ const owner: OwnerId = { hostId: "quickstart-host", pid: 4102, nonce: "3f9c-8b21" }
30
+ ```
31
+
32
+ `hostId` says which machine, `pid` says which process, and `nonce` distinguishes
33
+ one incarnation of that process from the next. A production host mints this
34
+ through `OwnerIdentity` in [`@smthrs/engine-store`](/api/engine-store) rather
35
+ than writing it down.
36
+
37
+ ## Create the run and claim it
38
+
39
+ `create` inserts a `pending` row whose `stateJson` is the executable state a
40
+ resume would re-enter. Claiming is two phase: `claim` reserves the row, and
41
+ `activate` turns the reservation into ownership.
42
+
43
+ ```ts
44
+ import { RunStore } from "@smthrs/run-store"
45
+ import * as Clock from "effect/Clock"
46
+ import * as Effect from "effect/Effect"
47
+
48
+ const claimRun = Effect.gen(function*() {
49
+ const runs = yield* RunStore.RunStore
50
+
51
+ yield* runs.create("quickstart-1", JSON.stringify({ cursor: 0 }))
52
+
53
+ const pending = yield* runs.get("quickstart-1")
54
+ const expected = {
55
+ status: pending.status,
56
+ owner: pending.owner,
57
+ heartbeatAtMs: pending.heartbeatAtMs
58
+ }
59
+
60
+ const nowMs = yield* Clock.currentTimeMillis
61
+ const claim = yield* runs.claim("quickstart-1", expected, owner, nowMs)
62
+ if (claim._tag !== "Claimed") return yield* Effect.die(`claim lost: ${claim._tag}`)
63
+
64
+ const activation = yield* runs.activate("quickstart-1", owner, claim.claimedAtMs, expected)
65
+ if (activation._tag !== "Activated") return yield* Effect.die(`activation lost: ${activation._tag}`)
66
+ })
67
+ ```
68
+
69
+ `expected` is the row you just read, restated as the three fields a claim
70
+ guards. The compare-and-swap admits the write only while the row still matches,
71
+ so a peer that claimed the run between your read and your write loses the race
72
+ instead of overwriting it.
73
+
74
+ ## Record a step attempt
75
+
76
+ An attempt is addressed by `(runId, stepKeyDigest, attempt)` and is fenced on
77
+ the run you own. `put` starts it, `heartbeat` proves it is still moving and
78
+ saves a checkpoint, and `finish` records how it ended:
79
+
80
+ ```ts
81
+ import { AttemptStore } from "@smthrs/run-store"
82
+
83
+ const runStep = Effect.gen(function*() {
84
+ const attempts = yield* AttemptStore.AttemptStore
85
+ const startedAtMs = yield* Clock.currentTimeMillis
86
+
87
+ yield* attempts.put({
88
+ runId: "quickstart-1",
89
+ stepKeyDigest: "step-fetch",
90
+ attempt: 0,
91
+ state: "running",
92
+ startedAtMs,
93
+ meta: { step: "fetch" }
94
+ }, owner)
95
+
96
+ yield* attempts.heartbeat(
97
+ "quickstart-1",
98
+ "step-fetch",
99
+ 0,
100
+ owner,
101
+ yield* Clock.currentTimeMillis,
102
+ { cursor: 1 }
103
+ )
104
+
105
+ yield* attempts.finish({
106
+ runId: "quickstart-1",
107
+ stepKeyDigest: "step-fetch",
108
+ attempt: 0,
109
+ state: "completed",
110
+ finishedAtMs: yield* Clock.currentTimeMillis,
111
+ outcome: { rows: 12 }
112
+ }, owner)
113
+ })
114
+ ```
115
+
116
+ The sixth argument to `heartbeat` is a checkpoint: the value the step would
117
+ resume from. Every attempt value crosses an admission boundary that copies it as
118
+ inert JSON before persistence can yield, so a getter, a cycle, or a `toJSON`
119
+ method is refused rather than persisted. See
120
+ [Durable values](./concepts/durable-values.md).
121
+
122
+ ## Settle the run
123
+
124
+ `transitionOwned` is the only way to move a run you own. A terminal target
125
+ clears the owner, the heartbeat, and any claim, and stamps `finished_at_ms`:
126
+
127
+ ```ts
128
+ const settleRun = Effect.gen(function*() {
129
+ const runs = yield* RunStore.RunStore
130
+ const outcome = yield* runs.transitionOwned(
131
+ "quickstart-1",
132
+ owner,
133
+ "completed",
134
+ JSON.stringify({ cursor: 1 })
135
+ )
136
+ if (outcome._tag !== "Transitioned") return yield* Effect.die(`fence lost: ${outcome._tag}`)
137
+ return yield* runs.get("quickstart-1")
138
+ })
139
+ ```
140
+
141
+ ## Run it
142
+
143
+ `TestRunStore.layer` provides both stores over a fresh in-memory SQLite
144
+ database with the migrations already applied:
145
+
146
+ ```ts
147
+ import * as TestRunStore from "@smthrs/run-store/test/TestRunStore"
148
+
149
+ const main = claimRun.pipe(
150
+ Effect.andThen(runStep),
151
+ Effect.andThen(settleRun),
152
+ Effect.provide(TestRunStore.layer),
153
+ Effect.scoped
154
+ )
155
+
156
+ console.log(await Effect.runPromise(main))
157
+ ```
158
+
159
+ Run the file with your TypeScript runner. The settled row prints with its owner
160
+ cleared and both lifecycle stamps written:
161
+
162
+ ```text
163
+ {
164
+ runId: 'quickstart-1',
165
+ status: 'completed',
166
+ createdAtMs: 1757000000000,
167
+ startedAtMs: 1757000000000,
168
+ finishedAtMs: 1757000000004,
169
+ owner: null,
170
+ heartbeatAtMs: null,
171
+ claim: null,
172
+ claimedAtMs: null,
173
+ parentRunId: null,
174
+ cancelRequestedAtMs: null,
175
+ lineageId: null,
176
+ roundOrdinal: null,
177
+ stateJson: '{"cursor":1}'
178
+ }
179
+ ```
180
+
181
+ ## What just happened
182
+
183
+ The run row moved `pending` to `running` to `completed`, and every write on the
184
+ way was admitted only because the full `(hostId, pid, nonce)` fence still
185
+ matched the row. Had a second process taken the run over between your
186
+ `activate` and your `transitionOwned`, the transition would have reported
187
+ `FenceLost` and changed nothing: the fence is compared in the same statement as
188
+ the mutation, so there is no window between the check and the write.
189
+
190
+ The `stateJson` you passed to `transitionOwned` is what a restart would
191
+ re-enter. It is stored and returned byte for byte, with nothing rewritten in
192
+ between.
193
+
194
+ ## Next steps
195
+
196
+ - [Claim a run and finish it](./guides/claim-and-finish-a-run.md): the same
197
+ lifecycle with heartbeat supervision and the atomic single-call form.
198
+ - [Fencing and ownership](./concepts/fencing.md): why every refused write is a
199
+ named outcome instead of an error.
200
+ - [Test against the real stores](./guides/testing.md): the layer used here,
201
+ turned into a test habit.
@@ -0,0 +1,255 @@
1
+ ---
2
+ title: "Troubleshooting"
3
+ description: "Every typed failure and refused outcome these stores report, what causes each one, and what to change: invalid input, corrupt rows, lost fences, refused guards, unconfirmed evidence, and clock mistakes."
4
+ ---
5
+
6
+ The stores answer on two channels, and which one you are on decides what to do
7
+ next.
8
+
9
+ - A **refused outcome** is a success value. `FenceLost`, `AlreadyClaimed`,
10
+ `GuardFailed`, and the rest mean the store worked correctly and your write did
11
+ not win. Handle it; do not retry it blindly.
12
+ - A **typed error**, `RunStoreError` or `AttemptStoreError`, means a defect:
13
+ invalid input, a corrupt durable row, or a database that failed.
14
+
15
+ Both carry a `method` naming the operation and a `message` prefixed with the
16
+ code, so `invalid_run: RunStore.claim: run input is invalid` says where to look
17
+ before you read the cause. Causes carry field names, lengths, and validity flags,
18
+ never the value that failed. Every code and outcome is listed in the
19
+ [API reference](./api.md).
20
+
21
+ ## invalid_run and invalid_attempt
22
+
23
+ **What happened.** An input did not meet the durable contract, and nothing was
24
+ written. The cause names the field and what was wrong with it. The common ones:
25
+
26
+ - **An invalid snapshot.** `RunSnapshot` requires own enumerable data fields
27
+ `status`, `owner`, and `heartbeatAtMs`. A `RunRow` returned by `get` is valid
28
+ as `expected`; extra own data fields are ignored. Missing or inherited
29
+ required fields, enumerable accessors on any key, and non-plain prototypes
30
+ are refused. So is a partial ownership pair: `running` requires both an
31
+ owner and a heartbeat, and every other status requires neither.
32
+ - **A timestamp that is not a non-negative safe integer.** Every `nowMs`,
33
+ `claimedAtMs`, `startedAtMs`, and `finishedAtMs` is checked on its own.
34
+ - **A lease reading ahead of the store's clock.** `claim`, `claimAndOwn`,
35
+ `steal`, `heartbeat`, and `recoverClaim` refuse a `nowMs` more than
36
+ `heartbeatSkewAllowance` ahead of the injected `Clock`, with the detail "runs
37
+ ahead of the store clock by more than the heartbeat skew allowance".
38
+ - **An identifier that is not durable text.** Run ids, step key digests, lineage
39
+ ids, owner host ids and nonces, and attempt state names must be non-empty, at
40
+ most 1,024 UTF-16 units, free of NUL, and free of lone surrogates.
41
+ - **`toStatus: "pending"`.** A run does not go back to unstarted.
42
+ - **A malformed `TransitionGuard`.** `cancelRequested` accepts only `"absent"`
43
+ or `"present"`, and the guard object takes no other key.
44
+ - **A value the JSON boundary refused.** The complaint names the rule: "contains
45
+ an accessor", "contains a cycle", "contains a non-plain object", "contains a
46
+ sparse or accessor array member", "contains an enumerable symbol", "contains a
47
+ non-finite number", "contains unbounded or ill-formed text", "exceeds the
48
+ maximum JSON depth of 128", or "contains more than 100000 JSON values".
49
+ - **A checkpoint over the byte ceiling**, "exceeds the JSON byte limit". Only
50
+ checkpoints have one.
51
+ - **An invalid store policy**, reported by `AttemptStore.makeWith` and
52
+ `layerWith`: `inProgressStates` must be a non-empty array of unique durable
53
+ names, `maxCheckpointBytes` must be between 1 and
54
+ `AttemptStore.maximumCheckpointBytes`, and `putMode` must be `"insert"` or
55
+ `"upsert"`.
56
+
57
+ **What to change.** Fix the input. For run state and attempt values that means
58
+ building plain data: no class instances, no `toJSON`, no getters. For a policy it
59
+ means fixing the layer, because the failure arrives when the store is built, not
60
+ when the first bad value does. See [Durable values](./concepts/durable-values.md).
61
+
62
+ ## decode_failed
63
+
64
+ **What happened.** A row came back that these stores could not have written. The
65
+ cause distinguishes two cases: "could not decode flows_runs row" is a schema
66
+ mismatch, and "flows_runs row violates durable invariants" is a row whose
67
+ ownership, claim, or state columns disagree with each other, for example an owner
68
+ without a heartbeat or a `running` row with no owner.
69
+
70
+ **What to change.** Find the other writer. The invariants are also SQL `CHECK`
71
+ constraints, so a row that breaks them was written by raw SQL, a foreign tool, or
72
+ a partially applied migration. Re-run the migration set, or repair the row. Do
73
+ not widen the schema: the check exists so a half-owned row is never handed back
74
+ as executable state.
75
+
76
+ ## constraint
77
+
78
+ **What happened.** The database refused the write. The usual cause is
79
+ `create` with a `parentRunId` or a foreign key that names a run that does not
80
+ exist, since `flows_runs.parent_run_id` and `flows_attempts.run_id` are both
81
+ foreign keys.
82
+
83
+ **What to change.** Create the parent run first, or drop the `parentRunId`
84
+ option. A lineage is ordinary metadata; the ancestry edge is not.
85
+
86
+ ## not_found_row
87
+
88
+ **What happened.** `RunStore.get` was called for a run id with no row. It is the
89
+ one read that fails rather than reporting absence, because callers use it to
90
+ follow a fence they already believe in.
91
+
92
+ **What to change.** Treat it as "this run was never created", not as a race.
93
+ Every compare-and-swap reports `NotFound` as a value instead, and
94
+ `AttemptStore.get` returns `Option.none()`.
95
+
96
+ ## persistence_failed
97
+
98
+ **What happened.** Either the database operation failed, or the composition is
99
+ running `RunStore.layerNoop()`, whose `create` and `get` fail with the message
100
+ "no run store in this environment".
101
+
102
+ **What to change.** If `get` fails immediately on a fresh database, the
103
+ migrations did not run: `Migrations.layer` has to be provided beneath both
104
+ stores, and `Layer.provideMerge` is what keeps the client and the writer in
105
+ context. See
106
+ [Compose the stores into a host](./guides/compose-the-stores.md). If it is the
107
+ stub, provide a real store, or override the one operation the test needs.
108
+
109
+ ## unknown
110
+
111
+ **What happened.** `AttemptStore.layerNoop()` is in the composition. Every
112
+ operation fails with "the store is unavailable in this environment".
113
+
114
+ **What to change.** Provide `AttemptStore.layer` or `layerWith`, or pass the
115
+ override that supplies the outcome your test wants.
116
+
117
+ ## Every attempt write reports FenceLost after the run settles
118
+
119
+ **What happened.** `AttemptStore` carries no ownership of its own. `put`,
120
+ `heartbeat`, `finish`, and `patch` each require that the run is `running` and
121
+ owned by the caller. A terminal transition clears the owner columns, so from that
122
+ moment every attempt write on that run reports `FenceLost`.
123
+
124
+ **What to change.** Record what you know about an attempt before you settle the
125
+ run. This is deliberate: it is what stops a displaced owner's late write from
126
+ rewriting the winning row. See
127
+ [Record a step attempt](./guides/record-step-attempts.md).
128
+
129
+ ## GuardFailed instead of Transitioned
130
+
131
+ **What happened.** You still own the run, and the guard predicate refused the
132
+ write. `{ cancelRequested: "absent" }` means a cancellation was requested;
133
+ `{ cancelRequested: "present" }` means none was.
134
+
135
+ **What to change.** Take the branch the guard is pointing at. A `GuardFailed`
136
+ completion means the run should settle as `cancelled` instead. Ownership is
137
+ checked first, so this is never a fence problem: `FenceLost` would have been
138
+ reported for that. See [Cancel a run](./guides/cancel-a-run.md).
139
+
140
+ ## FenceLost while you believe you own the run
141
+
142
+ **What happened.** Another owner holds the row, or the run is no longer
143
+ `running`. The fence is the complete `(hostId, pid, nonce)` triple, so an
144
+ identity that differs only by `nonce` is a different owner: a restarted process
145
+ does not inherit its predecessor's runs.
146
+
147
+ **What to change.** Stop working on the run. `Ownership.heartbeatLoop` exists to
148
+ make that automatic: race it against the owned work with `Effect.raceFirst` and a
149
+ lost fence interrupts the work. See [The heartbeat lease](./concepts/leases.md).
150
+
151
+ ## EvidenceRequired from claimAndOwn
152
+
153
+ **What happened.** Your snapshot is still current, the row's different owner has
154
+ a stale heartbeat, and you supplied no matching `LivenessEvidence`. No
155
+ compare-and-swap ran.
156
+
157
+ **What to change.** Re-reading and retrying cannot make progress. Supply evidence
158
+ built at the same `nowMs`, or use `steal` followed by `activate`. A row whose
159
+ heartbeat is still fresh reports `HeartbeatFresh` instead, and evidence cannot
160
+ satisfy that predicate. See
161
+ [Take over a stalled run](./guides/recover-a-stalled-run.md).
162
+
163
+ ## LivenessUnconfirmed rather than SnapshotChanged
164
+
165
+ **What happened.** The evidence did not match the recorded owner, the host
166
+ relation its `kind` requires, or the call's `nowMs`, so the store refused before
167
+ any comparison ran. `SnapshotChanged` is reserved for matching evidence whose
168
+ comparison lost to a row that moved.
169
+
170
+ **What to change.** Check three things, in this order: `expectedOwner` must equal
171
+ the owner on the snapshot; `checkedAtMs` must equal the call's `nowMs` exactly,
172
+ so evidence probed at T is refused at T plus one millisecond; and `kind` must fit
173
+ the hosts, since `same-host-pid-dead` is accepted only on the owner's own host
174
+ and `cross-host-unreachable-stale` only from a different one. See
175
+ [Liveness evidence](./concepts/liveness-evidence.md).
176
+
177
+ ## HeartbeatFresh for an owner you know is dead
178
+
179
+ **What happened.** The persisted heartbeat is still inside
180
+ `heartbeatStaleAfter`. The store judges the row, not the process, and no evidence
181
+ overrides that cutoff.
182
+
183
+ **What to change.** Wait for the lease to expire. If the delay is the problem,
184
+ the answer is a liveness check that can say more than the lease, not a shorter
185
+ window: `Ownership.sameHostPidProbe` asks the operating system. One trap comes
186
+ with it: an owner recorded under the claimant's own pid always probes as alive,
187
+ so an embedded host that re-creates its engine in the same process should keep
188
+ `Ownership.leaseLiveness`, whose timeout does expire.
189
+
190
+ ## AlreadyClaimed that never clears
191
+
192
+ **What happened.** A claimant wrote the claim columns and never activated. The
193
+ row is not owned and not claimable, and `claim` reports `AlreadyClaimed` for
194
+ everyone.
195
+
196
+ **What to change.** The owner of a failed activation should call `abandonClaim`,
197
+ which is why the two-phase path releases its claim when activation loses. For a
198
+ claimant that will not come back, `recoverClaim` clears the claim once it is
199
+ older than `heartbeatStaleAfter` and evidence confirms the claimant is gone. A
200
+ claim still inside the window reports `ClaimFresh`.
201
+
202
+ ## Conflict from a replayed put
203
+
204
+ **What happened.** An attempt row already exists at `(runId, stepKeyDigest,
205
+ attempt)` with different content, and the store is first-writer-wins.
206
+
207
+ **What to change.** A byte-equivalent replay reports `ExistingSame` instead and
208
+ is safe, so a `Conflict` means two writers disagree about what this attempt is.
209
+ Object key order is ignored in that comparison; array order is not. Fix the
210
+ caller, or set `putMode: "upsert"` if overwriting an in-progress row is genuinely
211
+ what you want. A terminal attempt stays immutable in both modes.
212
+
213
+ ## StateChanged from heartbeat or finish
214
+
215
+ **What happened.** You still own the run, and the attempt's `state` is no longer
216
+ one of the store's `inProgressStates`. Something already finished it, and a
217
+ second `finish` reports the same thing rather than overwriting the winning row.
218
+
219
+ **What to change.** Treat the attempt as settled. If your executor uses more than
220
+ one running state, declare them all in `inProgressStates` when you build the
221
+ store.
222
+
223
+ ## requestCancel reports Terminal or AlreadyRequested
224
+
225
+ **What happened.** `Terminal` means the run settled before the request could be
226
+ recorded, and its `status` says which ending it lost to. `AlreadyRequested` means
227
+ a request was already on the row, and `requestedAtMs` is the original time rather
228
+ than the one you passed.
229
+
230
+ **What to change.** Neither is an error. A repeat request is harmless by design:
231
+ the column is first-writer-wins so a retry cannot move the recorded intent. A
232
+ terminal run records nothing, because it has no owner left to observe the intent.
233
+
234
+ ## heartbeat reports Updated but the stamp did not move
235
+
236
+ **What happened.** Heartbeats are monotonic. The write is
237
+ `MAX(heartbeat_at_ms, :nowMs)`, so a pulse delayed past a newer one from the same
238
+ owner still reports `Updated`: the fence held and the write proved liveness, but
239
+ the stamp is not moved backwards where it would make a live run look stale to a
240
+ peer.
241
+
242
+ **What to change.** Nothing, unless your `nowMs` readings are genuinely going
243
+ backwards. That is the sign of a caller mixing clock sources.
244
+
245
+ ## Two clocks in one row
246
+
247
+ **What happened.** Lifecycle stamps come from the injected Effect `Clock`, and
248
+ `nowMs` comes from the caller. A composition that reads `Date.now()` for `nowMs`
249
+ while the store runs on a `TestClock` produces a row with readings from two
250
+ unrelated timelines, and the lease operations then refuse the reading as ahead of
251
+ the store's clock.
252
+
253
+ **What to change.** Take `nowMs` from `Clock.currentTimeMillis`. That is also
254
+ what makes the whole store driveable under `TestClock`. See
255
+ [Test against the real stores](./guides/testing.md).
package/package.json CHANGED
@@ -1,6 +1,186 @@
1
1
  {
2
2
  "name": "@smthrs/run-store",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "type": "module",
4
+ "version": "1.0.0-rc.3",
5
+ "license": "MIT",
6
+ "smthrs": {
7
+ "group": "engine"
8
+ },
9
+ "description": "Effect services for flows run state, attempts, and ownership arbitration",
10
+ "homepage": "https://github.com/smithersai/smithers",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "https://github.com/smithersai/smithers.git",
14
+ "directory": "packages/smithers/flows/run-store"
15
+ },
16
+ "bugs": {
17
+ "url": "https://github.com/smithersai/smithers/issues"
18
+ },
19
+ "tags": [
20
+ "typescript",
21
+ "effect",
22
+ "durability",
23
+ "ownership"
24
+ ],
25
+ "keywords": [
26
+ "typescript",
27
+ "effect",
28
+ "durability",
29
+ "ownership"
30
+ ],
31
+ "engines": {
32
+ "node": ">=26.4.0"
33
+ },
34
+ "sideEffects": [],
35
+ "exports": {
36
+ "./package.json": "./package.json",
37
+ ".": {
38
+ "import": {
39
+ "types": "./dist/esm/index.d.ts",
40
+ "default": "./dist/esm/index.js"
41
+ },
42
+ "require": {
43
+ "types": "./dist/cjs/index.d.ts",
44
+ "default": "./dist/cjs/index.js"
45
+ }
46
+ },
47
+ "./internal/*": null,
48
+ "./migrations/*": null,
49
+ "./*/index": null,
50
+ "./AttemptStore": {
51
+ "import": {
52
+ "types": "./dist/esm/AttemptStore.d.ts",
53
+ "default": "./dist/esm/AttemptStore.js"
54
+ },
55
+ "require": {
56
+ "types": "./dist/cjs/AttemptStore.d.ts",
57
+ "default": "./dist/cjs/AttemptStore.js"
58
+ }
59
+ },
60
+ "./Heartbeat": {
61
+ "import": {
62
+ "types": "./dist/esm/Heartbeat.d.ts",
63
+ "default": "./dist/esm/Heartbeat.js"
64
+ },
65
+ "require": {
66
+ "types": "./dist/cjs/Heartbeat.d.ts",
67
+ "default": "./dist/cjs/Heartbeat.js"
68
+ }
69
+ },
70
+ "./Migrations": {
71
+ "import": {
72
+ "types": "./dist/esm/Migrations.d.ts",
73
+ "default": "./dist/esm/Migrations.js"
74
+ },
75
+ "require": {
76
+ "types": "./dist/cjs/Migrations.d.ts",
77
+ "default": "./dist/cjs/Migrations.js"
78
+ }
79
+ },
80
+ "./Ownership": {
81
+ "import": {
82
+ "types": "./dist/esm/Ownership.d.ts",
83
+ "default": "./dist/esm/Ownership.js"
84
+ },
85
+ "require": {
86
+ "types": "./dist/cjs/Ownership.d.ts",
87
+ "default": "./dist/cjs/Ownership.js"
88
+ }
89
+ },
90
+ "./RunStore": {
91
+ "import": {
92
+ "types": "./dist/esm/RunStore.d.ts",
93
+ "default": "./dist/esm/RunStore.js"
94
+ },
95
+ "require": {
96
+ "types": "./dist/cjs/RunStore.d.ts",
97
+ "default": "./dist/cjs/RunStore.js"
98
+ }
99
+ },
100
+ "./RunStoreMetrics": {
101
+ "import": {
102
+ "types": "./dist/esm/RunStoreMetrics.d.ts",
103
+ "default": "./dist/esm/RunStoreMetrics.js"
104
+ },
105
+ "require": {
106
+ "types": "./dist/cjs/RunStoreMetrics.d.ts",
107
+ "default": "./dist/cjs/RunStoreMetrics.js"
108
+ }
109
+ },
110
+ "./index": {
111
+ "import": {
112
+ "types": "./dist/esm/index.d.ts",
113
+ "default": "./dist/esm/index.js"
114
+ },
115
+ "require": {
116
+ "types": "./dist/cjs/index.d.ts",
117
+ "default": "./dist/cjs/index.js"
118
+ }
119
+ },
120
+ "./test/TestRunStore": {
121
+ "import": {
122
+ "types": "./dist/esm/test/TestRunStore.d.ts",
123
+ "default": "./dist/esm/test/TestRunStore.js"
124
+ },
125
+ "require": {
126
+ "types": "./dist/cjs/test/TestRunStore.d.ts",
127
+ "default": "./dist/cjs/test/TestRunStore.js"
128
+ }
129
+ }
130
+ },
131
+ "files": [
132
+ "src/**/*.ts",
133
+ "dist/**/*.js",
134
+ "dist/**/*.js.map",
135
+ "dist/**/*.d.ts",
136
+ "dist/**/*.d.ts.map",
137
+ "dist/**/package.json",
138
+ "LICENSE",
139
+ "README.md",
140
+ "CHANGELOG.md",
141
+ "docs/"
142
+ ],
143
+ "publishConfig": {
144
+ "access": "public",
145
+ "provenance": true,
146
+ "tag": "next"
147
+ },
148
+ "dependencies": {
149
+ "@smthrs/database": "1.0.0-rc.3",
150
+ "@smthrs/journal": "1.0.0-rc.3",
151
+ "@smthrs/observability": "1.0.0-rc.3",
152
+ "@smthrs/canonical": "1.0.0-rc.3"
153
+ },
154
+ "devDependencies": {
155
+ "@effect/language-service": "0.87.2",
156
+ "@effect/vitest": "4.0.0-rc.115",
157
+ "@eslint/js": "9.39.5",
158
+ "@types/node": "26.4.1",
159
+ "@vitest/coverage-v8": "5.0.0",
160
+ "dprint": "0.57.1",
161
+ "effect": "4.0.0-rc.115",
162
+ "esbuild": "0.28.2",
163
+ "eslint": "9.39.5",
164
+ "eslint-import-resolver-typescript": "4.4.5",
165
+ "eslint-plugin-import": "2.32.0",
166
+ "eslint-plugin-jsdoc": "64.3.4",
167
+ "eslint-plugin-unicorn": "65.0.0",
168
+ "madge": "8.0.0",
169
+ "typescript": "5.9.3",
170
+ "typescript-eslint": "8.69.0",
171
+ "vitest": "5.0.0"
172
+ },
173
+ "peerDependencies": {
174
+ "effect": "4.0.0-rc.115"
175
+ },
176
+ "scripts": {
177
+ "lint": "eslint src --max-warnings=0 && dprint check",
178
+ "format": "dprint fmt",
179
+ "build": "node scripts/build.mjs",
180
+ "check": "tsc -b tsconfig.json && tsc -p tsconfig.test.json --noEmit",
181
+ "circular": "node scripts/circular.mjs",
182
+ "test": "vitest",
183
+ "coverage": "node ../database/scripts/test-matrix.mjs",
184
+ "test:matrix": "node ../database/scripts/test-matrix.mjs"
185
+ }
6
186
  }