@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.
- package/CHANGELOG.md +112 -0
- package/LICENSE +21 -0
- package/README.md +195 -2
- package/dist/cjs/AttemptStore.d.ts +359 -0
- package/dist/cjs/AttemptStore.d.ts.map +1 -0
- package/dist/cjs/AttemptStore.js +731 -0
- package/dist/cjs/AttemptStore.js.map +7 -0
- package/dist/cjs/Heartbeat.d.ts +46 -0
- package/dist/cjs/Heartbeat.d.ts.map +1 -0
- package/dist/cjs/Heartbeat.js +28 -0
- package/dist/cjs/Heartbeat.js.map +7 -0
- package/dist/cjs/Migrations.d.ts +42 -0
- package/dist/cjs/Migrations.d.ts.map +1 -0
- package/dist/cjs/Migrations.js +55 -0
- package/dist/cjs/Migrations.js.map +7 -0
- package/dist/cjs/Ownership.d.ts +275 -0
- package/dist/cjs/Ownership.d.ts.map +1 -0
- package/dist/cjs/Ownership.js +129 -0
- package/dist/cjs/Ownership.js.map +7 -0
- package/dist/cjs/RunStore.d.ts +571 -0
- package/dist/cjs/RunStore.d.ts.map +1 -0
- package/dist/cjs/RunStore.js +1079 -0
- package/dist/cjs/RunStore.js.map +7 -0
- package/dist/cjs/RunStoreMetrics.d.ts +151 -0
- package/dist/cjs/RunStoreMetrics.d.ts.map +1 -0
- package/dist/cjs/RunStoreMetrics.js +91 -0
- package/dist/cjs/RunStoreMetrics.js.map +7 -0
- package/dist/cjs/index.d.ts +47 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +43 -0
- package/dist/cjs/index.js.map +7 -0
- package/dist/cjs/internal/Boundary.d.ts +80 -0
- package/dist/cjs/internal/Boundary.d.ts.map +1 -0
- package/dist/cjs/internal/Boundary.js +92 -0
- package/dist/cjs/internal/Boundary.js.map +7 -0
- package/dist/cjs/internal/Companion.d.ts +44 -0
- package/dist/cjs/internal/Companion.d.ts.map +1 -0
- package/dist/cjs/internal/Companion.js +50 -0
- package/dist/cjs/internal/Companion.js.map +7 -0
- package/dist/cjs/internal/SpanOutcome.d.ts +37 -0
- package/dist/cjs/internal/SpanOutcome.d.ts.map +1 -0
- package/dist/cjs/internal/SpanOutcome.js +62 -0
- package/dist/cjs/internal/SpanOutcome.js.map +7 -0
- package/dist/cjs/migrations/0001_initial.d.ts +15 -0
- package/dist/cjs/migrations/0001_initial.d.ts.map +1 -0
- package/dist/cjs/migrations/0001_initial.js +110 -0
- package/dist/cjs/migrations/0001_initial.js.map +7 -0
- package/dist/cjs/migrations/0002_lineage.d.ts +27 -0
- package/dist/cjs/migrations/0002_lineage.d.ts.map +1 -0
- package/dist/cjs/migrations/0002_lineage.js +43 -0
- package/dist/cjs/migrations/0002_lineage.js.map +7 -0
- package/dist/cjs/migrations/0003_execution_revisions.d.ts +15 -0
- package/dist/cjs/migrations/0003_execution_revisions.d.ts.map +1 -0
- package/dist/cjs/migrations/0003_execution_revisions.js +77 -0
- package/dist/cjs/migrations/0003_execution_revisions.js.map +7 -0
- package/dist/cjs/migrations/0004_waiting_request.d.ts +32 -0
- package/dist/cjs/migrations/0004_waiting_request.d.ts.map +1 -0
- package/dist/cjs/migrations/0004_waiting_request.js +42 -0
- package/dist/cjs/migrations/0004_waiting_request.js.map +7 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/test/TestRunStore.d.ts +17 -0
- package/dist/cjs/test/TestRunStore.d.ts.map +1 -0
- package/dist/cjs/test/TestRunStore.js +42 -0
- package/dist/cjs/test/TestRunStore.js.map +7 -0
- package/dist/esm/AttemptStore.d.ts +359 -0
- package/dist/esm/AttemptStore.d.ts.map +1 -0
- package/dist/esm/AttemptStore.js +835 -0
- package/dist/esm/AttemptStore.js.map +1 -0
- package/dist/esm/Heartbeat.d.ts +46 -0
- package/dist/esm/Heartbeat.d.ts.map +1 -0
- package/dist/esm/Heartbeat.js +46 -0
- package/dist/esm/Heartbeat.js.map +1 -0
- package/dist/esm/Migrations.d.ts +42 -0
- package/dist/esm/Migrations.d.ts.map +1 -0
- package/dist/esm/Migrations.js +56 -0
- package/dist/esm/Migrations.js.map +1 -0
- package/dist/esm/Ownership.d.ts +275 -0
- package/dist/esm/Ownership.d.ts.map +1 -0
- package/dist/esm/Ownership.js +299 -0
- package/dist/esm/Ownership.js.map +1 -0
- package/dist/esm/RunStore.d.ts +571 -0
- package/dist/esm/RunStore.d.ts.map +1 -0
- package/dist/esm/RunStore.js +1287 -0
- package/dist/esm/RunStore.js.map +1 -0
- package/dist/esm/RunStoreMetrics.d.ts +151 -0
- package/dist/esm/RunStoreMetrics.d.ts.map +1 -0
- package/dist/esm/RunStoreMetrics.js +120 -0
- package/dist/esm/RunStoreMetrics.js.map +1 -0
- package/dist/esm/index.d.ts +47 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +47 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/internal/Boundary.d.ts +80 -0
- package/dist/esm/internal/Boundary.d.ts.map +1 -0
- package/dist/esm/internal/Boundary.js +109 -0
- package/dist/esm/internal/Boundary.js.map +1 -0
- package/dist/esm/internal/Companion.d.ts +44 -0
- package/dist/esm/internal/Companion.d.ts.map +1 -0
- package/dist/esm/internal/Companion.js +53 -0
- package/dist/esm/internal/Companion.js.map +1 -0
- package/dist/esm/internal/SpanOutcome.d.ts +37 -0
- package/dist/esm/internal/SpanOutcome.d.ts.map +1 -0
- package/dist/esm/internal/SpanOutcome.js +48 -0
- package/dist/esm/internal/SpanOutcome.js.map +1 -0
- package/dist/esm/migrations/0001_initial.d.ts +15 -0
- package/dist/esm/migrations/0001_initial.d.ts.map +1 -0
- package/dist/esm/migrations/0001_initial.js +88 -0
- package/dist/esm/migrations/0001_initial.js.map +1 -0
- package/dist/esm/migrations/0002_lineage.d.ts +27 -0
- package/dist/esm/migrations/0002_lineage.d.ts.map +1 -0
- package/dist/esm/migrations/0002_lineage.js +33 -0
- package/dist/esm/migrations/0002_lineage.js.map +1 -0
- package/dist/esm/migrations/0003_execution_revisions.d.ts +15 -0
- package/dist/esm/migrations/0003_execution_revisions.d.ts.map +1 -0
- package/dist/esm/migrations/0003_execution_revisions.js +58 -0
- package/dist/esm/migrations/0003_execution_revisions.js.map +1 -0
- package/dist/esm/migrations/0004_waiting_request.d.ts +32 -0
- package/dist/esm/migrations/0004_waiting_request.d.ts.map +1 -0
- package/dist/esm/migrations/0004_waiting_request.js +37 -0
- package/dist/esm/migrations/0004_waiting_request.js.map +1 -0
- package/dist/esm/test/TestRunStore.d.ts +17 -0
- package/dist/esm/test/TestRunStore.d.ts.map +1 -0
- package/dist/esm/test/TestRunStore.js +19 -0
- package/dist/esm/test/TestRunStore.js.map +1 -0
- package/docs/README.md +142 -0
- package/docs/api.md +901 -0
- package/docs/concepts/durable-values.md +140 -0
- package/docs/concepts/execution-revisions.md +53 -0
- package/docs/concepts/fencing.md +146 -0
- package/docs/concepts/leases.md +154 -0
- package/docs/concepts/liveness-evidence.md +142 -0
- package/docs/guides/cancel-a-run.md +156 -0
- package/docs/guides/claim-and-finish-a-run.md +175 -0
- package/docs/guides/compose-the-stores.md +142 -0
- package/docs/guides/observe-outcomes.md +182 -0
- package/docs/guides/record-step-attempts.md +170 -0
- package/docs/guides/recover-a-stalled-run.md +173 -0
- package/docs/guides/testing.md +141 -0
- package/docs/installation.md +109 -0
- package/docs/quickstart.md +201 -0
- package/docs/troubleshooting.md +255 -0
- package/package.json +183 -3
- package/src/AttemptStore.ts +1195 -0
- package/src/Heartbeat.ts +47 -0
- package/src/Migrations.ts +59 -0
- package/src/Ownership.ts +407 -0
- package/src/RunStore.ts +2063 -0
- package/src/RunStoreMetrics.ts +165 -0
- package/src/index.ts +51 -0
- package/src/internal/Boundary.ts +130 -0
- package/src/internal/Companion.ts +64 -0
- package/src/internal/SpanOutcome.ts +71 -0
- package/src/migrations/0001_initial.ts +114 -0
- package/src/migrations/0002_lineage.ts +38 -0
- package/src/migrations/0003_execution_revisions.ts +69 -0
- package/src/migrations/0004_waiting_request.ts +39 -0
- 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
|
+
})
|