@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,165 @@
1
+ /**
2
+ * Standard metric definitions for fenced run persistence.
3
+ *
4
+ * This module only defines the metric handles, following the shape of Effect's
5
+ * `ClusterMetrics`. `RunStore` updates them as compare-and-swap outcomes are
6
+ * decided, so every driver that persists through the store — the engine, a
7
+ * sweeper, a foreign process — lands in the same counters. No exporter ships
8
+ * in this package; provide one — for example `@smthrs/observability` —
9
+ * and these counters appear in it.
10
+ *
11
+ * Each exported record maps an outcome's `_tag` to an attributed view of the
12
+ * counter, so an update is a lookup rather than a branch:
13
+ * `Metric.update(RunStoreMetrics.claim[outcome._tag], 1)`.
14
+ *
15
+ * @since 0.1.0
16
+ */
17
+
18
+ import * as Metric from "effect/Metric"
19
+
20
+ /**
21
+ * Counter over ownership claim compare-and-swaps, dimensioned by `op`
22
+ * (`claim`, `claim_and_own`, `activate`, `abandon_claim`, `recover_claim`, or
23
+ * `steal`) and `outcome` (the
24
+ * operation's result tag in snake case, or `failure` or `interrupt` when the
25
+ * call did not return one).
26
+ *
27
+ * @category metrics
28
+ * @since 0.1.0
29
+ */
30
+ export const claims = Metric.counter("flows_run_claims", {
31
+ description: "Ownership claim compare-and-swaps by operation and outcome"
32
+ })
33
+
34
+ /**
35
+ * Counter over fenced run state transitions, dimensioned by `outcome`
36
+ * (`transitioned`, `fence_lost`, `not_found`, `guard_failed`, `failure`, or
37
+ * `interrupt`) and `to` (the requested target status).
38
+ *
39
+ * @category metrics
40
+ * @since 0.1.0
41
+ */
42
+ export const transitions = Metric.counter("flows_run_transitions", {
43
+ description: "Fenced run state transitions by outcome and target status"
44
+ })
45
+
46
+ /**
47
+ * Counter over fenced ownership heartbeats, dimensioned by `outcome`
48
+ * (`updated`, `fence_lost`, `not_found`, `failure`, or `interrupt`). A
49
+ * `fence_lost` heartbeat is the fencing event: the writer discovered another
50
+ * owner holds the run.
51
+ *
52
+ * @category metrics
53
+ * @since 0.1.0
54
+ */
55
+ export const heartbeats = Metric.counter("flows_run_heartbeats", {
56
+ description: "Fenced ownership heartbeats by outcome"
57
+ })
58
+
59
+ /** Rewrites an outcome tag (`AlreadyClaimed`) as an attribute value (`already_claimed`). */
60
+ const attributeValue = (tag: string): string => tag.replace(/(?<=[a-z0-9])(?=[A-Z])/g, "_").toLowerCase()
61
+
62
+ /** Builds the `_tag`-keyed record of attributed counter views for one operation. */
63
+ const byOutcome = <const Tags extends ReadonlyArray<string>>(
64
+ metric: Metric.Metric<number, Metric.CounterState<number>>,
65
+ attributes: Readonly<Record<string, string>>,
66
+ tags: Tags
67
+ ): { readonly [Tag in Tags[number]]: Metric.Metric<number, Metric.CounterState<number>> } =>
68
+ Object.fromEntries(
69
+ tags.map((tag) => [tag, Metric.withAttributes(metric, { ...attributes, outcome: attributeValue(tag) })])
70
+ ) as { [Tag in Tags[number]]: Metric.Metric<number, Metric.CounterState<number>> }
71
+
72
+ const claimTags = ["Claimed", "NotFound", "AlreadyClaimed", "HeartbeatFresh", "SnapshotChanged"] as const
73
+
74
+ /**
75
+ * `claims` views for `RunStore.claim`, keyed by `ClaimOutcome` tag.
76
+ *
77
+ * @category metrics
78
+ * @since 0.1.0
79
+ */
80
+ export const claim = byOutcome(claims, { op: "claim" }, claimTags)
81
+
82
+ /**
83
+ * `claims` views for `RunStore.claimAndOwn`, keyed by `ClaimAndOwnOutcome`
84
+ * tag.
85
+ *
86
+ * @category metrics
87
+ * @since 0.1.0
88
+ */
89
+ export const claimAndOwn = byOutcome(
90
+ claims,
91
+ { op: "claim_and_own" },
92
+ ["Activated", "NotFound", "AlreadyClaimed", "HeartbeatFresh", "SnapshotChanged", "EvidenceRequired"] as const
93
+ )
94
+
95
+ /**
96
+ * `claims` views for `RunStore.activate`, keyed by `ActivateOutcome` tag. A
97
+ * `claim_lost` activation is a fencing event: the held claim was recovered or
98
+ * replaced before the activation ran.
99
+ *
100
+ * @category metrics
101
+ * @since 0.1.0
102
+ */
103
+ export const activate = byOutcome(
104
+ claims,
105
+ { op: "activate" },
106
+ ["Activated", "ClaimLost", "SnapshotChanged"] as const
107
+ )
108
+
109
+ /**
110
+ * `claims` views for `RunStore.abandonClaim`, keyed by outcome tag.
111
+ *
112
+ * @category metrics
113
+ * @since 1.0.0-rc.0
114
+ */
115
+ export const abandonClaim = byOutcome(
116
+ claims,
117
+ { op: "abandon_claim" },
118
+ ["Abandoned", "ClaimLost"] as const
119
+ )
120
+
121
+ /**
122
+ * `claims` views for `RunStore.recoverClaim`, keyed by outcome tag.
123
+ *
124
+ * @category metrics
125
+ * @since 1.0.0-rc.0
126
+ */
127
+ export const recoverClaim = byOutcome(
128
+ claims,
129
+ { op: "recover_claim" },
130
+ ["Recovered", "NotFound", "ClaimFresh", "ClaimChanged", "LivenessUnconfirmed"] as const
131
+ )
132
+
133
+ /**
134
+ * `claims` views for `RunStore.steal`, keyed by `StealOutcome` tag.
135
+ *
136
+ * @category metrics
137
+ * @since 0.1.0
138
+ */
139
+ export const steal = byOutcome(
140
+ claims,
141
+ { op: "steal" },
142
+ ["Claimed", "NotFound", "AlreadyClaimed", "HeartbeatFresh", "SnapshotChanged", "LivenessUnconfirmed"] as const
143
+ )
144
+
145
+ /**
146
+ * `heartbeats` views keyed by `HeartbeatOutcome` tag.
147
+ *
148
+ * @category metrics
149
+ * @since 0.1.0
150
+ */
151
+ export const heartbeat = byOutcome(heartbeats, {}, ["Updated", "FenceLost", "NotFound"] as const)
152
+
153
+ /**
154
+ * `transitions` views keyed by `TransitionOutcome` tag. `RunStore` adds the
155
+ * `to` attribute at the update site, because the target status is call input
156
+ * rather than a closed set this module should restate.
157
+ *
158
+ * @category metrics
159
+ * @since 0.1.0
160
+ */
161
+ export const transition = byOutcome(
162
+ transitions,
163
+ {},
164
+ ["Transitioned", "FenceLost", "NotFound", "GuardFailed"] as const
165
+ )
package/src/index.ts ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Durable run state: what is running now, and who owns it.
3
+ *
4
+ * `RunStore` and `AttemptStore` hold the executable authoritative state that
5
+ * recovery reads; `Ownership` supplies the liveness evidence and heartbeat
6
+ * supervision that arbitrate it. The fencing token they trade in, `OwnerId`,
7
+ * is defined by `@smthrs/journal`, because the journal is what it fences, and
8
+ * is re-exported from `Ownership` here, so importing it needs no second
9
+ * package.
10
+ *
11
+ * This entry point is browser-bundleable: every service here is written
12
+ * against the driver-neutral `@smthrs/database` contract. The test double,
13
+ * which binds a Node SQLite database, lives under an explicit subpath:
14
+ *
15
+ * ```ts
16
+ * import { AttemptStore, Ownership, RunStore } from "@smthrs/run-store"
17
+ * import * as TestRunStore from "@smthrs/run-store/test/TestRunStore"
18
+ * ```
19
+ *
20
+ * @since 0.1.0
21
+ */
22
+
23
+ /**
24
+ * @category services
25
+ * @since 0.1.0
26
+ */
27
+ export * as RunStore from "./RunStore.ts"
28
+
29
+ /**
30
+ * @category metrics
31
+ * @since 0.1.0
32
+ */
33
+ export * as RunStoreMetrics from "./RunStoreMetrics.ts"
34
+
35
+ /**
36
+ * @category ownership
37
+ * @since 0.1.0
38
+ */
39
+ export * as Ownership from "./Ownership.ts"
40
+
41
+ /**
42
+ * @category services
43
+ * @since 0.1.0
44
+ */
45
+ export * as AttemptStore from "./AttemptStore.ts"
46
+
47
+ /**
48
+ * @category migrations
49
+ * @since 0.1.0
50
+ */
51
+ export * as Migrations from "./Migrations.ts"
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Inert text and JSON admission for values crossing run-store boundaries.
3
+ *
4
+ * @since 1.0.0-rc.0
5
+ * @private
6
+ */
7
+
8
+ import * as BoundedJson from "@smthrs/canonical/BoundedJson"
9
+
10
+ /**
11
+ * Maximum UTF-16 length of one durable identifier.
12
+ *
13
+ * @since 1.0.0-rc.0
14
+ * @private
15
+ */
16
+ export const maximumIdentifierLength = 1_024
17
+
18
+ /**
19
+ * Resource limits for one admitted JSON tree.
20
+ *
21
+ * @since 1.0.0-rc.0
22
+ * @private
23
+ */
24
+ export type JsonLimits = Omit<BoundedJson.Limits, "maxTotalMembers">
25
+
26
+ /**
27
+ * Detached JSON value accepted by the persistence boundary.
28
+ *
29
+ * @since 1.0.0-rc.0
30
+ * @private
31
+ */
32
+ export type Json = BoundedJson.Json
33
+
34
+ /**
35
+ * Compares admitted JSON trees while ignoring object key order.
36
+ *
37
+ * Array order remains significant. `undefined` represents an absent optional
38
+ * tree and equals only `undefined`.
39
+ *
40
+ * @since 1.0.0-rc.0
41
+ * @private
42
+ */
43
+ export const sameJson = (left: Json | undefined, right: Json | undefined): boolean => {
44
+ if (left === right) return true
45
+ if (left === undefined || right === undefined) return false
46
+ if (left === null || right === null) return false
47
+ if (typeof left !== "object" || typeof right !== "object") return false
48
+ if (Array.isArray(left)) {
49
+ if (!Array.isArray(right) || left.length !== right.length) return false
50
+ for (let index = 0; index < left.length; index++) {
51
+ if (!sameJson(left[index], right[index])) return false
52
+ }
53
+ return true
54
+ }
55
+ if (Array.isArray(right)) return false
56
+ const leftObject = left as { readonly [key: string]: Json }
57
+ const rightObject = right as { readonly [key: string]: Json }
58
+ const leftKeys = Object.keys(leftObject)
59
+ if (leftKeys.length !== Object.keys(rightObject).length) return false
60
+ for (const key of leftKeys) {
61
+ if (!Object.hasOwn(rightObject, key) || !sameJson(leftObject[key], rightObject[key])) return false
62
+ }
63
+ return true
64
+ }
65
+
66
+ /**
67
+ * Result of admitting or refusing an unknown JSON candidate.
68
+ *
69
+ * @since 1.0.0-rc.0
70
+ * @private
71
+ */
72
+ export type JsonResult =
73
+ | { readonly ok: true; readonly value: Json; readonly bytes: number }
74
+ | { readonly ok: false; readonly complaint: string }
75
+
76
+ /**
77
+ * Whether text has a complete UTF-16 encoding and contains no NUL.
78
+ *
79
+ * @since 1.0.0-rc.0
80
+ * @private
81
+ */
82
+ export const isDurableText = (value: unknown, maximum = maximumIdentifierLength): value is string => {
83
+ if (typeof value !== "string" || value.length === 0 || value.length > maximum || value.includes("\0")) return false
84
+ for (let index = 0; index < value.length; index++) {
85
+ const unit = value.charCodeAt(index)
86
+ if (unit >= 0xd800 && unit <= 0xdbff) {
87
+ const low = value.charCodeAt(++index)
88
+ if (!(low >= 0xdc00 && low <= 0xdfff)) return false
89
+ } else if (unit >= 0xdc00 && unit <= 0xdfff) return false
90
+ }
91
+ return true
92
+ }
93
+
94
+ /**
95
+ * Copies an inert JSON tree with a cumulative member budget for run state.
96
+ * @private
97
+ * @since 1.0.0
98
+ */
99
+ export const admitJson = (input: unknown, limits: JsonLimits): JsonResult => {
100
+ const result = BoundedJson.admit(input, { ...limits, maxTotalMembers: limits.maxMembers })
101
+ return result.ok ? result : { ok: false, complaint: result.complaint }
102
+ }
103
+
104
+ /**
105
+ * Parses and bounds JSON text while preserving the caller's original bytes.
106
+ *
107
+ * @since 1.0.0-rc.0
108
+ * @private
109
+ */
110
+ export const admitJsonText = (
111
+ input: unknown,
112
+ limits: JsonLimits
113
+ ):
114
+ | { readonly ok: true; readonly value: string; readonly json: Json }
115
+ | { readonly ok: false; readonly complaint: string } =>
116
+ {
117
+ if (typeof input !== "string" || input.length === 0) return { ok: false, complaint: "must be non-empty JSON text" }
118
+ let parsed: unknown
119
+ try {
120
+ parsed = JSON.parse(input) as unknown
121
+ } catch {
122
+ return { ok: false, complaint: "must be valid JSON text" }
123
+ }
124
+ const admitted = admitJson(parsed, limits)
125
+ if (!admitted.ok) return admitted
126
+ if (limits.maxBytes !== undefined && new TextEncoder().encode(input).byteLength > limits.maxBytes) {
127
+ return { ok: false, complaint: "exceeds the JSON byte limit" }
128
+ }
129
+ return { ok: true, value: input, json: admitted.value }
130
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The run store's companion journal stream.
3
+ *
4
+ * Facts the run store records about a run go to the run's `run-store`
5
+ * companion stream (`JournalEvent.companionRunId`), never to the run's own
6
+ * stream: engine, control, and time-travel consumers read that stream by
7
+ * position, and a rewind or compaction of it must not truncate a store fact.
8
+ *
9
+ * @since 1.0.0
10
+ * @private
11
+ */
12
+
13
+ import { Journal, type JournalError } from "@smthrs/journal/Journal"
14
+ import * as JournalEvent from "@smthrs/journal/JournalEvent"
15
+ import { Effect, Option } from "effect"
16
+
17
+ /**
18
+ * The companion stream name.
19
+ *
20
+ * @since 1.0.0
21
+ * @private
22
+ */
23
+ export const stream = "run-store"
24
+
25
+ /**
26
+ * The producer every run-store fact is appended under.
27
+ *
28
+ * @since 1.0.0
29
+ * @private
30
+ */
31
+ export const sourceId = "flows.run-store"
32
+
33
+ /**
34
+ * Appends one fact to the run's companion stream through the journal in the
35
+ * caller's context.
36
+ *
37
+ * The journal is resolved at call time: the ordinary composition builds the
38
+ * store beside the journal (`Layer.mergeAll(SqlJournal.layer(...),
39
+ * RunStore.layer)`), so the journal reaches the store only through the
40
+ * caller's context. A caller running without a journal records nothing. The
41
+ * append is unfenced — the store arbitrated the change in the same serialized
42
+ * transaction — and joins that transaction as a savepoint, so a rolled-back
43
+ * change leaves no fact.
44
+ *
45
+ * @since 1.0.0
46
+ * @private
47
+ */
48
+ export const append = (
49
+ runId: string,
50
+ eventType: string,
51
+ payload: Readonly<Record<string, unknown>>
52
+ ): Effect.Effect<void, JournalError> =>
53
+ Effect.flatMap(Effect.serviceOption(Journal), (journal) =>
54
+ Option.isNone(journal)
55
+ ? Effect.void
56
+ : journal.value.emitDurableUnfenced(
57
+ new JournalEvent.Input({
58
+ runId: JournalEvent.companionRunId(stream, runId),
59
+ sourceId: sourceId as JournalEvent.SourceId,
60
+ eventType,
61
+ payload: { runId, ...payload },
62
+ meta: { runId }
63
+ })
64
+ ).pipe(Effect.asVoid))
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Closes every store operation span with an `outcome` attribute, so a trace
3
+ * viewer never sees a span that ended without saying how. `RunStore` and
4
+ * `AttemptStore` share these observers so the guide can describe one rule.
5
+ *
6
+ * @since 1.0.0-rc.0
7
+ * @private
8
+ */
9
+
10
+ import * as Cause from "effect/Cause"
11
+ import * as Effect from "effect/Effect"
12
+ import * as Metric from "effect/Metric"
13
+
14
+ /** Maps a PascalCase outcome tag to its snake_case span attribute. */
15
+ const outcomeValue = (tag: string): string => tag.replace(/(?<=[a-z0-9])(?=[A-Z])/g, "_").toLowerCase()
16
+
17
+ /** Classifies a non-success exit for the span `outcome` attribute. */
18
+ const causeOutcome = <E>(cause: Cause.Cause<E>): "failure" | "interrupt" =>
19
+ Cause.hasInterruptsOnly(cause) ? "interrupt" : "failure"
20
+
21
+ /**
22
+ * Observes a store operation's exit onto its span, and, when the operation
23
+ * has an outcome-keyed counter, updates it in the same observation: the
24
+ * domain tag (`claimed`, `fence_lost`) on success, `failure` or `interrupt`
25
+ * otherwise, so a span never closes without saying how and a failed write
26
+ * still lands in the counter. `exitMetric` is the counter view carrying every
27
+ * attribute but `outcome`, which this observer adds for a non-success exit.
28
+ * `Effect.onExit` only reads the exit; the value, cause, and interruption
29
+ * propagate byte-identically.
30
+ *
31
+ * @since 1.0.0-rc.0
32
+ * @private
33
+ */
34
+ export const observeOutcome = <A extends { readonly _tag: string }>(
35
+ metricOf?: ((outcome: A) => Metric.Metric<number, Metric.CounterState<number>>) | undefined,
36
+ exitMetric?: Metric.Metric<number, Metric.CounterState<number>> | undefined
37
+ ) =>
38
+ <E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
39
+ effect.pipe(
40
+ Effect.onExit((exit) => {
41
+ if (exit._tag === "Success") {
42
+ return Effect.annotateCurrentSpan({ outcome: outcomeValue(exit.value._tag) }).pipe(
43
+ Effect.andThen(metricOf === undefined ? Effect.void : Metric.update(metricOf(exit.value), 1))
44
+ )
45
+ }
46
+ const outcome = causeOutcome(exit.cause)
47
+ return Effect.annotateCurrentSpan({ outcome }).pipe(
48
+ Effect.andThen(
49
+ exitMetric === undefined ? Effect.void : Metric.update(Metric.withAttributes(exitMetric, { outcome }), 1)
50
+ )
51
+ )
52
+ })
53
+ )
54
+
55
+ /**
56
+ * `observeOutcome` for operations whose success carries no domain outcome
57
+ * tag: `create` inserts or fails, `get` returns the row or fails, and
58
+ * `acknowledgeCancel` reports whether the guarded update matched. The span
59
+ * still closes with `success`, `failure`, or `interrupt`.
60
+ *
61
+ * @since 1.0.0-rc.0
62
+ * @private
63
+ */
64
+ export const observeExit = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
65
+ effect.pipe(
66
+ Effect.onExit((exit) =>
67
+ Effect.annotateCurrentSpan({
68
+ outcome: exit._tag === "Success" ? "success" : causeOutcome(exit.cause)
69
+ })
70
+ )
71
+ )
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Initial durable run and attempt schema.
3
+ *
4
+ * @since 0.1.0
5
+ */
6
+
7
+ import * as Dialect from "@smthrs/database/Dialect"
8
+ import * as Effect from "effect/Effect"
9
+ import * as SqlClient from "effect/unstable/sql/SqlClient"
10
+
11
+ /**
12
+ * Creates the `flows_runs` and `flows_attempts` tables and their indexes.
13
+ *
14
+ * @category migrations
15
+ * @since 0.1.0
16
+ */
17
+ export const initial: Effect.Effect<void, unknown, SqlClient.SqlClient> = Effect.gen(function*() {
18
+ const sql = yield* SqlClient.SqlClient
19
+
20
+ yield* sql`CREATE TABLE flows_runs (
21
+ ${Dialect.rowId(sql)}
22
+ run_id TEXT PRIMARY KEY CHECK (length(run_id) > 0),
23
+ status TEXT NOT NULL,
24
+ created_at_ms ${Dialect.integer(sql)} NOT NULL CHECK (${
25
+ Dialect.isInteger(sql, sql`created_at_ms`)
26
+ } AND created_at_ms >= 0 AND created_at_ms <= 9007199254740991),
27
+ started_at_ms ${Dialect.integer(sql)} CHECK (started_at_ms IS NULL OR (${
28
+ Dialect.isInteger(sql, sql`started_at_ms`)
29
+ } AND started_at_ms >= 0 AND started_at_ms <= 9007199254740991)),
30
+ finished_at_ms ${Dialect.integer(sql)} CHECK (finished_at_ms IS NULL OR (${
31
+ Dialect.isInteger(sql, sql`finished_at_ms`)
32
+ } AND finished_at_ms >= 0 AND finished_at_ms <= 9007199254740991)),
33
+ owner_host_id TEXT,
34
+ owner_pid ${Dialect.integer(sql)},
35
+ owner_nonce TEXT,
36
+ heartbeat_at_ms ${Dialect.integer(sql)} CHECK (heartbeat_at_ms IS NULL OR (${
37
+ Dialect.isInteger(sql, sql`heartbeat_at_ms`)
38
+ } AND heartbeat_at_ms >= 0 AND heartbeat_at_ms <= 9007199254740991)),
39
+ claim_host_id TEXT,
40
+ claim_pid ${Dialect.integer(sql)},
41
+ claim_nonce TEXT,
42
+ claimed_at_ms ${Dialect.integer(sql)} CHECK (claimed_at_ms IS NULL OR (${
43
+ Dialect.isInteger(sql, sql`claimed_at_ms`)
44
+ } AND claimed_at_ms >= 0 AND claimed_at_ms <= 9007199254740991)),
45
+ parent_run_id TEXT CHECK (parent_run_id IS NULL OR length(parent_run_id) > 0),
46
+ cancel_requested_at_ms ${Dialect.integer(sql)} CHECK (cancel_requested_at_ms IS NULL OR (${
47
+ Dialect.isInteger(sql, sql`cancel_requested_at_ms`)
48
+ } AND cancel_requested_at_ms >= 0 AND cancel_requested_at_ms <= 9007199254740991)),
49
+ waiting_reason TEXT CHECK (waiting_reason IS NULL OR length(waiting_reason) > 0),
50
+ waiting_wake_at_ms ${Dialect.integer(sql)} CHECK (waiting_wake_at_ms IS NULL OR (${
51
+ Dialect.isInteger(sql, sql`waiting_wake_at_ms`)
52
+ } AND waiting_wake_at_ms >= 0 AND waiting_wake_at_ms <= 9007199254740991)),
53
+ waiting_token TEXT CHECK (waiting_token IS NULL OR length(waiting_token) > 0),
54
+ state_json TEXT NOT NULL CHECK (${Dialect.jsonValid(sql, sql`state_json`)}),
55
+ CHECK (status IN ('pending', 'running', 'suspended', 'completed', 'failed', 'cancelled')),
56
+ CHECK (
57
+ (
58
+ status = 'running' AND
59
+ owner_host_id IS NOT NULL AND
60
+ length(owner_host_id) > 0 AND
61
+ owner_pid IS NOT NULL AND
62
+ ${Dialect.isInteger(sql, sql`owner_pid`)} AND owner_pid >= 0 AND
63
+ owner_nonce IS NOT NULL AND
64
+ length(owner_nonce) > 0 AND
65
+ heartbeat_at_ms IS NOT NULL
66
+ ) OR (
67
+ status <> 'running' AND
68
+ owner_host_id IS NULL AND
69
+ owner_pid IS NULL AND
70
+ owner_nonce IS NULL AND
71
+ heartbeat_at_ms IS NULL
72
+ )
73
+ ),
74
+ CHECK (
75
+ (claim_host_id IS NULL AND claim_pid IS NULL AND claim_nonce IS NULL AND claimed_at_ms IS NULL) OR
76
+ (claim_host_id IS NOT NULL AND length(claim_host_id) > 0 AND
77
+ claim_pid IS NOT NULL AND ${Dialect.isInteger(sql, sql`claim_pid`)} AND claim_pid >= 0 AND
78
+ claim_nonce IS NOT NULL AND length(claim_nonce) > 0 AND claimed_at_ms IS NOT NULL)
79
+ ),
80
+ CHECK (
81
+ (waiting_reason IS NULL AND waiting_wake_at_ms IS NULL AND waiting_token IS NULL) OR
82
+ waiting_reason IS NOT NULL
83
+ ),
84
+ FOREIGN KEY (parent_run_id) REFERENCES flows_runs (run_id)
85
+ )`
86
+
87
+ yield* sql`CREATE INDEX flows_runs_parent_run_id_idx ON flows_runs (parent_run_id)`
88
+ yield* sql`CREATE INDEX flows_runs_cancel_requested_idx ON flows_runs (cancel_requested_at_ms) WHERE cancel_requested_at_ms IS NOT NULL`
89
+ yield* sql`CREATE INDEX flows_runs_waiting_reason_wake_at_idx ON flows_runs (waiting_reason, waiting_wake_at_ms) WHERE waiting_reason IS NOT NULL`
90
+
91
+ yield* sql`CREATE TABLE flows_attempts (
92
+ run_id TEXT NOT NULL CHECK (length(run_id) > 0),
93
+ step_key_digest TEXT NOT NULL CHECK (length(step_key_digest) > 0),
94
+ attempt ${Dialect.integer(sql)} NOT NULL CHECK (${
95
+ Dialect.isInteger(sql, sql`attempt`)
96
+ } AND attempt >= 0 AND attempt <= 9007199254740991),
97
+ state TEXT NOT NULL CHECK (length(state) > 0),
98
+ started_at_ms ${Dialect.integer(sql)} NOT NULL CHECK (${
99
+ Dialect.isInteger(sql, sql`started_at_ms`)
100
+ } AND started_at_ms >= 0 AND started_at_ms <= 9007199254740991),
101
+ finished_at_ms ${Dialect.integer(sql)} CHECK (finished_at_ms IS NULL OR (${
102
+ Dialect.isInteger(sql, sql`finished_at_ms`)
103
+ } AND finished_at_ms >= 0 AND finished_at_ms <= 9007199254740991)),
104
+ heartbeat_at_ms ${Dialect.integer(sql)} CHECK (heartbeat_at_ms IS NULL OR (${
105
+ Dialect.isInteger(sql, sql`heartbeat_at_ms`)
106
+ } AND heartbeat_at_ms >= 0 AND heartbeat_at_ms <= 9007199254740991)),
107
+ checkpoint_json TEXT CHECK (checkpoint_json IS NULL OR ${Dialect.jsonValid(sql, sql`checkpoint_json`)}),
108
+ error_json TEXT CHECK (error_json IS NULL OR ${Dialect.jsonValid(sql, sql`error_json`)}),
109
+ outcome_json TEXT CHECK (outcome_json IS NULL OR ${Dialect.jsonValid(sql, sql`outcome_json`)}),
110
+ meta_json TEXT NOT NULL CHECK (${Dialect.jsonValid(sql, sql`meta_json`)}),
111
+ PRIMARY KEY (run_id, step_key_digest, attempt),
112
+ FOREIGN KEY (run_id) REFERENCES flows_runs (run_id)
113
+ )`
114
+ })
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Trampoline lineage columns on `flows_runs`.
3
+ *
4
+ * A round is its own run row, chained to the previous round through the `parent_run_id`
5
+ * column this table already reserved for continue-as-new, and these two
6
+ * columns are what make the chain readable as a lineage rather than as an
7
+ * anonymous ancestry: which lineage a round belongs to, and which round of it
8
+ * this is. They are NOT `flows_run_parents` edges — that table is the subflow
9
+ * DAG cycle detection walks, and a lineage is a chain of successive
10
+ * executions of one run rather than a parent spawning a child.
11
+ *
12
+ * Append-only, as every migration after the first must be: both columns are
13
+ * nullable, so every row an earlier build wrote stays valid and reads back as
14
+ * "round 0 of its own lineage".
15
+ *
16
+ * @since 0.1.0
17
+ */
18
+
19
+ import * as Dialect from "@smthrs/database/Dialect"
20
+ import * as Effect from "effect/Effect"
21
+ import * as SqlClient from "effect/unstable/sql/SqlClient"
22
+
23
+ /**
24
+ * Adds `lineage_id` and `round_ordinal` to `flows_runs` and indexes the pair.
25
+ *
26
+ * @category migrations
27
+ * @since 0.1.0
28
+ */
29
+ export const lineage: Effect.Effect<void, unknown, SqlClient.SqlClient> = Effect.gen(function*() {
30
+ const sql = yield* SqlClient.SqlClient
31
+
32
+ yield* sql`ALTER TABLE flows_runs ADD COLUMN lineage_id TEXT CHECK (lineage_id IS NULL OR length(lineage_id) > 0)`
33
+ yield* sql`ALTER TABLE flows_runs ADD COLUMN round_ordinal ${Dialect.integer(sql)} CHECK (round_ordinal IS NULL OR (${
34
+ Dialect.isInteger(sql, sql`round_ordinal`)
35
+ } AND round_ordinal >= 0 AND round_ordinal <= 9007199254740991))`
36
+
37
+ yield* sql`CREATE UNIQUE INDEX flows_runs_lineage_idx ON flows_runs (lineage_id, round_ordinal)`
38
+ })