@osolmaz/pi-workflows 0.12.1 → 0.13.1

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 (233) hide show
  1. package/README.md +59 -33
  2. package/dist/builtins/autodoc.workflow.d.ts +194 -7
  3. package/dist/builtins/autodoc.workflow.js +156 -30
  4. package/dist/builtins/autodoc.workflow.js.map +1 -1
  5. package/dist/builtins/autoimplement-command-batches.d.ts +1 -0
  6. package/dist/builtins/autoimplement-command-batches.js +29 -31
  7. package/dist/builtins/autoimplement-command-batches.js.map +1 -1
  8. package/dist/builtins/autoimplement.workflow.d.ts +1619 -97
  9. package/dist/builtins/autoimplement.workflow.js +416 -153
  10. package/dist/builtins/autoimplement.workflow.js.map +1 -1
  11. package/dist/builtins/autoplan.workflow.d.ts +126 -13
  12. package/dist/builtins/autoplan.workflow.js +200 -29
  13. package/dist/builtins/autoplan.workflow.js.map +1 -1
  14. package/dist/builtins/catalog.js +7 -5
  15. package/dist/builtins/catalog.js.map +1 -1
  16. package/dist/builtins/change-verification.workflow.d.ts +110 -0
  17. package/dist/builtins/change-verification.workflow.js +860 -0
  18. package/dist/builtins/change-verification.workflow.js.map +1 -0
  19. package/dist/builtins/index.d.ts +2 -1
  20. package/dist/builtins/index.js +1 -0
  21. package/dist/builtins/index.js.map +1 -1
  22. package/dist/builtins/monitor.workflow.d.ts +56 -21
  23. package/dist/builtins/monitor.workflow.js +499 -217
  24. package/dist/builtins/monitor.workflow.js.map +1 -1
  25. package/dist/builtins/plain-summary.workflow.d.ts +32 -0
  26. package/dist/builtins/plain-summary.workflow.js +134 -0
  27. package/dist/builtins/plain-summary.workflow.js.map +1 -0
  28. package/dist/builtins/plan-change.workflow.d.ts +527 -26
  29. package/dist/builtins/plan-change.workflow.js +26 -0
  30. package/dist/builtins/plan-change.workflow.js.map +1 -1
  31. package/dist/builtins/sanity-check.workflow.d.ts +45 -3
  32. package/dist/builtins/sanity-check.workflow.js +45 -7
  33. package/dist/builtins/sanity-check.workflow.js.map +1 -1
  34. package/dist/builtins/workspace-preparation.workflow.d.ts +75 -0
  35. package/dist/builtins/workspace-preparation.workflow.js +498 -0
  36. package/dist/builtins/workspace-preparation.workflow.js.map +1 -0
  37. package/dist/controllers/effects.d.ts +3 -2
  38. package/dist/controllers/effects.js +8 -1
  39. package/dist/controllers/effects.js.map +1 -1
  40. package/dist/controllers/index.d.ts +1 -1
  41. package/dist/controllers/index.js +1 -1
  42. package/dist/controllers/index.js.map +1 -1
  43. package/dist/controllers/manager.d.ts +1 -0
  44. package/dist/controllers/manager.js +41 -17
  45. package/dist/controllers/manager.js.map +1 -1
  46. package/dist/controllers/sqlite.d.ts +107 -85
  47. package/dist/controllers/sqlite.js +1611 -1486
  48. package/dist/controllers/sqlite.js.map +1 -1
  49. package/dist/controllers/store.d.ts +9 -8
  50. package/dist/controllers/store.js +1 -31
  51. package/dist/controllers/store.js.map +1 -1
  52. package/dist/controllers/types.d.ts +3 -0
  53. package/dist/controllers/workflow-engine-scheduler.d.ts +1 -1
  54. package/dist/controllers/workflow-engine-scheduler.js +3 -5
  55. package/dist/controllers/workflow-engine-scheduler.js.map +1 -1
  56. package/dist/controllers/workflows.d.ts +2 -2
  57. package/dist/controllers/workflows.js +11 -10
  58. package/dist/controllers/workflows.js.map +1 -1
  59. package/dist/extension/controller-host.js +2 -2
  60. package/dist/extension/controller-host.js.map +1 -1
  61. package/dist/extension/decision-channels.d.ts +2 -0
  62. package/dist/extension/decision-channels.js +311 -118
  63. package/dist/extension/decision-channels.js.map +1 -1
  64. package/dist/extension/executor.d.ts +9 -2
  65. package/dist/extension/executor.js +90 -6
  66. package/dist/extension/executor.js.map +1 -1
  67. package/dist/extension/herdr-viewer.d.ts +0 -1
  68. package/dist/extension/herdr-viewer.js +0 -4
  69. package/dist/extension/herdr-viewer.js.map +1 -1
  70. package/dist/extension/index.js +156 -117
  71. package/dist/extension/index.js.map +1 -1
  72. package/dist/extension/recorder.d.ts +3 -10
  73. package/dist/extension/recorder.js +13 -22
  74. package/dist/extension/recorder.js.map +1 -1
  75. package/dist/extension/step-message.d.ts +7 -2
  76. package/dist/extension/step-message.js +94 -2
  77. package/dist/extension/step-message.js.map +1 -1
  78. package/dist/extension/widget.js +6 -0
  79. package/dist/extension/widget.js.map +1 -1
  80. package/dist/host/rpc-executor.d.ts +3 -0
  81. package/dist/host/rpc-executor.js +2 -0
  82. package/dist/host/rpc-executor.js.map +1 -1
  83. package/dist/host/runner.d.ts +3 -6
  84. package/dist/host/runner.js +83 -32
  85. package/dist/host/runner.js.map +1 -1
  86. package/dist/render/graph-render.js +14 -8
  87. package/dist/render/graph-render.js.map +1 -1
  88. package/dist/state/database.d.ts +35 -0
  89. package/dist/state/database.js +287 -0
  90. package/dist/state/database.js.map +1 -0
  91. package/dist/state/index.d.ts +4 -0
  92. package/dist/state/index.js +5 -0
  93. package/dist/state/index.js.map +1 -0
  94. package/dist/state/json.d.ts +6 -0
  95. package/dist/state/json.js +38 -0
  96. package/dist/state/json.js.map +1 -0
  97. package/dist/state/mutation.d.ts +57 -0
  98. package/dist/state/mutation.js +255 -0
  99. package/dist/state/mutation.js.map +1 -0
  100. package/dist/state/schema.d.ts +6 -0
  101. package/dist/state/schema.js +473 -0
  102. package/dist/state/schema.js.map +1 -0
  103. package/dist/viewer/cli.d.ts +2 -2
  104. package/dist/viewer/cli.js +142 -122
  105. package/dist/viewer/cli.js.map +1 -1
  106. package/dist/viewer/render.d.ts +4 -4
  107. package/dist/viewer/render.js +2 -17
  108. package/dist/viewer/render.js.map +1 -1
  109. package/dist/viewer/session-reducer.d.ts +1 -1
  110. package/dist/viewer/session-reducer.js +1 -1
  111. package/dist/viewer/tui.d.ts +3 -3
  112. package/dist/viewer/tui.js +15 -13
  113. package/dist/viewer/tui.js.map +1 -1
  114. package/dist/viewer/watch.d.ts +2 -5
  115. package/dist/viewer/watch.js +13 -13
  116. package/dist/viewer/watch.js.map +1 -1
  117. package/dist/workflows/composition.js +12 -2
  118. package/dist/workflows/composition.js.map +1 -1
  119. package/dist/workflows/definition.d.ts +6 -2
  120. package/dist/workflows/definition.js +17 -0
  121. package/dist/workflows/definition.js.map +1 -1
  122. package/dist/workflows/engine.d.ts +6 -6
  123. package/dist/workflows/engine.js +183 -103
  124. package/dist/workflows/engine.js.map +1 -1
  125. package/dist/workflows/errors.d.ts +3 -3
  126. package/dist/workflows/errors.js +3 -3
  127. package/dist/workflows/human-decision.d.ts +29 -4
  128. package/dist/workflows/human-decision.js +547 -283
  129. package/dist/workflows/human-decision.js.map +1 -1
  130. package/dist/workflows/index.d.ts +4 -5
  131. package/dist/workflows/index.js +3 -4
  132. package/dist/workflows/index.js.map +1 -1
  133. package/dist/workflows/schema.js +17 -1
  134. package/dist/workflows/schema.js.map +1 -1
  135. package/dist/workflows/store.d.ts +83 -103
  136. package/dist/workflows/store.js +902 -951
  137. package/dist/workflows/store.js.map +1 -1
  138. package/dist/workflows/types.d.ts +55 -81
  139. package/docs/CONTROLLERS.md +10 -8
  140. package/docs/DEFERRED_TURNS.md +2 -2
  141. package/docs/DESIGN_PHILOSOPHY.md +4 -2
  142. package/docs/HUMAN_DECISIONS.md +13 -14
  143. package/docs/MONITOR.md +179 -231
  144. package/docs/SQLITE_STATE.md +219 -0
  145. package/docs/WORKFLOW_COMPOSITION.md +27 -19
  146. package/docs/WORKFLOW_STEP_MESSAGES.md +20 -18
  147. package/docs/WORKFLOW_UPDATES.md +10 -9
  148. package/docs/development.md +9 -9
  149. package/docs/live-replay-protocol.md +28 -37
  150. package/docs/plans/2026-08-16-workflow-updates-plan.md +1 -1
  151. package/docs/plans/2026-08-19-human-decision-gates-plan.md +1 -1
  152. package/docs/plans/2026-08-21-autoimplement-timeout-fallback-plan.md +1 -1
  153. package/docs/plans/2026-08-21-plan-change-approval-policy-plan.md +1 -1
  154. package/docs/plans/2026-08-21-sanity-check-plan.md +30 -16
  155. package/docs/plans/2026-08-22-goal-finishing-monitor-plan.md +200 -0
  156. package/docs/plans/2026-08-23-assistant-agent-completion-plan.md +701 -0
  157. package/docs/plans/2026-08-23-sqlite-state-plan.md +970 -0
  158. package/docs/plans/2026-08-24-change-scoped-verification-plan.md +419 -0
  159. package/docs/plans/replayable-run-bundles-implementation-plan.md +2 -2
  160. package/docs/plans/session-event-replay-implementation-plan.md +1 -1
  161. package/docs/plans/tui-viewer-implementation-plan.md +1 -1
  162. package/docs/session-event-journal.md +63 -434
  163. package/docs/tui-viewer.md +13 -15
  164. package/docs/workflows.md +111 -62
  165. package/examples/workflows/plain-summary.workflow.ts +1 -0
  166. package/herdr-plugin.toml +1 -1
  167. package/package.json +1 -1
  168. package/plugins/herdr/viewer.mjs +1 -11
  169. package/skills/autodoc/SKILL.md +7 -0
  170. package/skills/autoimplement/SKILL.md +4 -0
  171. package/skills/autoplan/SKILL.md +5 -4
  172. package/skills/monitor/SKILL.md +91 -163
  173. package/skills/pi-workflows/SKILL.md +7 -7
  174. package/skills/sanity-check/SKILL.md +1 -1
  175. package/src/builtins/autodoc.workflow.ts +184 -33
  176. package/src/builtins/autoimplement-command-batches.ts +39 -33
  177. package/src/builtins/autoimplement.workflow.ts +483 -175
  178. package/src/builtins/autoplan.workflow.ts +290 -40
  179. package/src/builtins/catalog.ts +7 -5
  180. package/src/builtins/change-verification.workflow.ts +1143 -0
  181. package/src/builtins/index.ts +7 -1
  182. package/src/builtins/monitor.workflow.ts +667 -250
  183. package/src/builtins/plain-summary.workflow.ts +185 -0
  184. package/src/builtins/plan-change.workflow.ts +35 -0
  185. package/src/builtins/sanity-check.workflow.ts +62 -7
  186. package/src/builtins/workspace-preparation.workflow.ts +668 -0
  187. package/src/controllers/effects.ts +7 -0
  188. package/src/controllers/index.ts +0 -6
  189. package/src/controllers/manager.ts +72 -32
  190. package/src/controllers/sqlite.ts +2420 -1948
  191. package/src/controllers/store.ts +17 -38
  192. package/src/controllers/types.ts +3 -0
  193. package/src/controllers/workflow-engine-scheduler.ts +4 -8
  194. package/src/controllers/workflows.ts +45 -21
  195. package/src/extension/controller-host.ts +1 -2
  196. package/src/extension/decision-channels.ts +427 -144
  197. package/src/extension/executor.ts +106 -6
  198. package/src/extension/herdr-viewer.ts +0 -5
  199. package/src/extension/index.ts +180 -132
  200. package/src/extension/recorder.ts +13 -22
  201. package/src/extension/step-message.ts +106 -4
  202. package/src/extension/widget.ts +8 -0
  203. package/src/host/rpc-executor.ts +4 -0
  204. package/src/host/runner.ts +93 -41
  205. package/src/render/graph-render.ts +11 -4
  206. package/src/state/database.ts +375 -0
  207. package/src/state/index.ts +29 -0
  208. package/src/state/json.ts +42 -0
  209. package/src/state/mutation.ts +435 -0
  210. package/src/state/schema.ts +475 -0
  211. package/src/viewer/cli.ts +151 -144
  212. package/src/viewer/render.ts +12 -24
  213. package/src/viewer/session-reducer.ts +1 -1
  214. package/src/viewer/tui.ts +18 -17
  215. package/src/viewer/watch.ts +13 -17
  216. package/src/workflows/composition.ts +12 -3
  217. package/src/workflows/definition.ts +36 -3
  218. package/src/workflows/engine.ts +212 -116
  219. package/src/workflows/errors.ts +3 -3
  220. package/src/workflows/human-decision.ts +795 -340
  221. package/src/workflows/index.ts +15 -19
  222. package/src/workflows/schema.ts +19 -1
  223. package/src/workflows/store.ts +1295 -1163
  224. package/src/workflows/types.ts +61 -81
  225. package/dist/workflows/artifacts.d.ts +0 -40
  226. package/dist/workflows/artifacts.js +0 -155
  227. package/dist/workflows/artifacts.js.map +0 -1
  228. package/dist/workflows/migrate-sources.d.ts +0 -42
  229. package/dist/workflows/migrate-sources.js +0 -133
  230. package/dist/workflows/migrate-sources.js.map +0 -1
  231. package/docs/run-bundles.md +0 -481
  232. package/src/workflows/artifacts.ts +0 -188
  233. package/src/workflows/migrate-sources.ts +0 -178
@@ -0,0 +1,435 @@
1
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
2
+ import type Database from "better-sqlite3";
3
+ import { StateDatabase } from "./database.js";
4
+
5
+ export type ResourceType =
6
+ | "run"
7
+ | "session"
8
+ | "decision"
9
+ | "controller"
10
+ | "effect"
11
+ | "channel"
12
+ | "notification"
13
+ | "turn_intent";
14
+
15
+ export type ActorType =
16
+ | "session"
17
+ | "host"
18
+ | "controller"
19
+ | "channel"
20
+ | "human"
21
+ | "policy"
22
+ | "control"
23
+ | "system";
24
+
25
+ export type OwnerType = "session" | "host" | "controller" | "channel" | "system";
26
+
27
+ export type MutationActor = {
28
+ type: ActorType;
29
+ id?: string;
30
+ };
31
+
32
+ export type LeaseClaim = {
33
+ resourceId: string;
34
+ ownerType: OwnerType;
35
+ ownerId: string;
36
+ token: string;
37
+ generation: number;
38
+ expiresAt: number;
39
+ resourceRevision: number;
40
+ };
41
+
42
+ export type WritePermit = {
43
+ resourceId: string;
44
+ operation: string;
45
+ actor: MutationActor;
46
+ expectedRevision: number;
47
+ lease?: LeaseClaim;
48
+ };
49
+
50
+ export type MutationResult<T> = {
51
+ value: T;
52
+ revision: number;
53
+ eventId: string;
54
+ };
55
+
56
+ export class StaleResourceError extends Error {
57
+ constructor(message: string) {
58
+ super(message);
59
+ this.name = "StaleResourceError";
60
+ }
61
+ }
62
+
63
+ export class StateMutationStore {
64
+ readonly state: StateDatabase;
65
+
66
+ constructor(state: StateDatabase) {
67
+ this.state = state;
68
+ }
69
+
70
+ ensureResource(type: ResourceType, aggregateKey: string, now: number = Date.now()): string {
71
+ const resourceId = resourceIdFor(type, aggregateKey);
72
+ this.state.connection
73
+ .prepare(
74
+ `INSERT INTO resources(resource_id, resource_type, aggregate_key, revision, created_at, updated_at)
75
+ VALUES (?, ?, ?, 0, ?, ?)
76
+ ON CONFLICT(resource_type, aggregate_key) DO NOTHING`,
77
+ )
78
+ .run(resourceId, type, aggregateKey, now, now);
79
+ const row = this.state.connection
80
+ .prepare(
81
+ `SELECT resource_id AS resourceId, resource_type AS resourceType,
82
+ aggregate_key AS aggregateKey
83
+ FROM resources WHERE resource_id = ?`,
84
+ )
85
+ .get(resourceId);
86
+ /* istanbul ignore if -- deterministic identity makes this a collision guard */
87
+ if (
88
+ !isResourceIdentity(row) ||
89
+ row.resourceType !== type ||
90
+ row.aggregateKey !== aggregateKey
91
+ ) {
92
+ throw new Error(`Resource identity conflict for ${type}:${aggregateKey}`);
93
+ }
94
+ this.state.connection
95
+ .prepare(
96
+ `INSERT INTO leases(resource_id, generation)
97
+ VALUES (?, 0)
98
+ ON CONFLICT(resource_id) DO NOTHING`,
99
+ )
100
+ .run(resourceId);
101
+ return resourceId;
102
+ }
103
+
104
+ claim(options: {
105
+ resourceId: string;
106
+ ownerType: OwnerType;
107
+ ownerId: string;
108
+ expectedRevision: number;
109
+ leaseMs: number;
110
+ now?: number;
111
+ }): LeaseClaim | undefined {
112
+ const now = options.now ?? Date.now();
113
+ if (!Number.isInteger(options.leaseMs) || options.leaseMs <= 0) {
114
+ throw new Error("Lease duration must be a positive integer");
115
+ }
116
+ return this.state.transaction(() => {
117
+ const resource = requireResource(this.state.connection, options.resourceId);
118
+ if (resource.revision !== options.expectedRevision) {
119
+ throw new StaleResourceError("Resource revision changed before ownership claim");
120
+ }
121
+ const lease = requireLease(this.state.connection, options.resourceId);
122
+ if (lease.ownerId !== null && lease.expiresAt !== null && lease.expiresAt > now) {
123
+ return undefined;
124
+ }
125
+ const generation = lease.generation + 1;
126
+ const token = randomBytes(32).toString("base64url");
127
+ const expiresAt = now + options.leaseMs;
128
+ const nextRevision = resource.revision + 1;
129
+ this.state.connection
130
+ .prepare(
131
+ `UPDATE leases
132
+ SET generation = ?, owner_type = ?, owner_id = ?, token_hash = ?,
133
+ acquired_at = ?, heartbeat_at = ?, expires_at = ?
134
+ WHERE resource_id = ? AND generation = ?`,
135
+ )
136
+ .run(
137
+ generation,
138
+ options.ownerType,
139
+ options.ownerId,
140
+ tokenHash(token),
141
+ now,
142
+ now,
143
+ expiresAt,
144
+ options.resourceId,
145
+ lease.generation,
146
+ );
147
+ updateResourceRevision(this.state.connection, options.resourceId, resource.revision, now);
148
+ insertEvent(this.state, {
149
+ resourceId: options.resourceId,
150
+ revision: nextRevision,
151
+ type: "lease.claimed",
152
+ actor: { type: options.ownerType, id: options.ownerId },
153
+ leaseGeneration: generation,
154
+ payload: { expiresAt, ownerType: options.ownerType },
155
+ now,
156
+ });
157
+ return {
158
+ resourceId: options.resourceId,
159
+ ownerType: options.ownerType,
160
+ ownerId: options.ownerId,
161
+ token,
162
+ generation,
163
+ expiresAt,
164
+ resourceRevision: nextRevision,
165
+ };
166
+ });
167
+ }
168
+
169
+ renew(
170
+ claim: LeaseClaim,
171
+ expectedRevision: number,
172
+ leaseMs: number,
173
+ now: number = Date.now(),
174
+ ): LeaseClaim {
175
+ return this.state.transaction(() => {
176
+ const resource = requireResource(this.state.connection, claim.resourceId);
177
+ if (resource.revision !== expectedRevision) {
178
+ throw new StaleResourceError("Resource revision changed before lease renewal");
179
+ }
180
+ requireCurrentLease(this.state.connection, claim, now);
181
+ const expiresAt = now + leaseMs;
182
+ const nextRevision = resource.revision + 1;
183
+ const update = this.state.connection
184
+ .prepare(
185
+ `UPDATE leases SET heartbeat_at = ?, expires_at = ?
186
+ WHERE resource_id = ? AND owner_type = ? AND owner_id = ?
187
+ AND token_hash = ? AND generation = ? AND expires_at > ?`,
188
+ )
189
+ .run(
190
+ now,
191
+ expiresAt,
192
+ claim.resourceId,
193
+ claim.ownerType,
194
+ claim.ownerId,
195
+ tokenHash(claim.token),
196
+ claim.generation,
197
+ now,
198
+ );
199
+ /* istanbul ignore if -- BEGIN IMMEDIATE keeps this compare stable */
200
+ if (update.changes !== 1) {
201
+ throw new StaleResourceError("Lease is no longer current");
202
+ }
203
+ updateResourceRevision(this.state.connection, claim.resourceId, resource.revision, now);
204
+ insertEvent(this.state, {
205
+ resourceId: claim.resourceId,
206
+ revision: nextRevision,
207
+ type: "lease.renewed",
208
+ actor: { type: claim.ownerType, id: claim.ownerId },
209
+ leaseGeneration: claim.generation,
210
+ payload: { expiresAt },
211
+ now,
212
+ });
213
+ return { ...claim, expiresAt, resourceRevision: nextRevision };
214
+ });
215
+ }
216
+
217
+ release(claim: LeaseClaim, expectedRevision: number, now: number = Date.now()): number {
218
+ return this.state.transaction(() => {
219
+ const resource = requireResource(this.state.connection, claim.resourceId);
220
+ if (resource.revision !== expectedRevision) {
221
+ throw new StaleResourceError("Resource revision changed before lease release");
222
+ }
223
+ requireCurrentLease(this.state.connection, claim, now, false);
224
+ const update = this.state.connection
225
+ .prepare(
226
+ `UPDATE leases
227
+ SET owner_type = NULL, owner_id = NULL, token_hash = NULL,
228
+ acquired_at = NULL, heartbeat_at = NULL, expires_at = NULL
229
+ WHERE resource_id = ? AND owner_type = ? AND owner_id = ?
230
+ AND token_hash = ? AND generation = ?`,
231
+ )
232
+ .run(
233
+ claim.resourceId,
234
+ claim.ownerType,
235
+ claim.ownerId,
236
+ tokenHash(claim.token),
237
+ claim.generation,
238
+ );
239
+ /* istanbul ignore if -- BEGIN IMMEDIATE keeps this compare stable */
240
+ if (update.changes !== 1) {
241
+ throw new StaleResourceError("Lease is no longer current");
242
+ }
243
+ const nextRevision = resource.revision + 1;
244
+ updateResourceRevision(this.state.connection, claim.resourceId, resource.revision, now);
245
+ insertEvent(this.state, {
246
+ resourceId: claim.resourceId,
247
+ revision: nextRevision,
248
+ type: "lease.released",
249
+ actor: { type: claim.ownerType, id: claim.ownerId },
250
+ leaseGeneration: claim.generation,
251
+ now,
252
+ });
253
+ return nextRevision;
254
+ });
255
+ }
256
+
257
+ mutate<T>(
258
+ permit: WritePermit,
259
+ eventType: string,
260
+ apply: (context: { database: StateDatabase; nextRevision: number; now: number }) => T,
261
+ options: { payload?: unknown; now?: number } = {},
262
+ ): MutationResult<T> {
263
+ const now = options.now ?? Date.now();
264
+ return this.state.transaction(() => {
265
+ const resource = requireResource(this.state.connection, permit.resourceId);
266
+ if (resource.revision !== permit.expectedRevision) {
267
+ throw new StaleResourceError("Resource revision changed before mutation");
268
+ }
269
+ if (permit.lease !== undefined) {
270
+ if (permit.lease.resourceId !== permit.resourceId) {
271
+ throw new StaleResourceError("Lease does not belong to the mutation resource");
272
+ }
273
+ requireCurrentLease(this.state.connection, permit.lease, now);
274
+ }
275
+ const nextRevision = resource.revision + 1;
276
+ const value = apply({ database: this.state, nextRevision, now });
277
+ updateResourceRevision(this.state.connection, permit.resourceId, resource.revision, now);
278
+ const eventId = insertEvent(this.state, {
279
+ resourceId: permit.resourceId,
280
+ revision: nextRevision,
281
+ type: eventType,
282
+ actor: permit.actor,
283
+ ...(permit.lease !== undefined ? { leaseGeneration: permit.lease.generation } : {}),
284
+ ...(options.payload !== undefined ? { payload: options.payload } : {}),
285
+ now,
286
+ });
287
+ return { value, revision: nextRevision, eventId };
288
+ });
289
+ }
290
+ }
291
+
292
+ export function resourceIdFor(type: ResourceType, aggregateKey: string): string {
293
+ const digest = createHash("sha256").update(`${type}\0${aggregateKey}`).digest("hex").slice(0, 40);
294
+ return `${type}-${digest}`;
295
+ }
296
+
297
+ export function tokenHash(token: string): Buffer {
298
+ return createHash("sha256").update(token).digest();
299
+ }
300
+
301
+ type ResourceRow = { revision: number };
302
+ type ResourceIdentityRow = { resourceId: string; resourceType: string; aggregateKey: string };
303
+ type LeaseRow = {
304
+ generation: number;
305
+ ownerType: string | null;
306
+ ownerId: string | null;
307
+ tokenHash: Buffer | null;
308
+ expiresAt: number | null;
309
+ };
310
+
311
+ function requireResource(database: Database.Database, resourceId: string): ResourceRow {
312
+ const row = database
313
+ .prepare("SELECT revision FROM resources WHERE resource_id = ?")
314
+ .get(resourceId);
315
+ if (!isResourceRow(row)) {
316
+ throw new Error(`Unknown resource: ${resourceId}`);
317
+ }
318
+ return row;
319
+ }
320
+
321
+ function requireLease(database: Database.Database, resourceId: string): LeaseRow {
322
+ const row = database
323
+ .prepare(
324
+ `SELECT generation, owner_type AS ownerType, owner_id AS ownerId,
325
+ token_hash AS tokenHash, expires_at AS expiresAt
326
+ FROM leases WHERE resource_id = ?`,
327
+ )
328
+ .get(resourceId);
329
+ /* istanbul ignore if -- every resource transaction creates its lease row */
330
+ if (!isLeaseRow(row)) {
331
+ throw new Error(`Resource has no lease row: ${resourceId}`);
332
+ }
333
+ return row;
334
+ }
335
+
336
+ function requireCurrentLease(
337
+ database: Database.Database,
338
+ claim: LeaseClaim,
339
+ now: number,
340
+ requireUnexpired: boolean = true,
341
+ ): void {
342
+ const lease = requireLease(database, claim.resourceId);
343
+ if (
344
+ lease.ownerType !== claim.ownerType ||
345
+ lease.ownerId !== claim.ownerId ||
346
+ lease.generation !== claim.generation ||
347
+ lease.tokenHash === null ||
348
+ !lease.tokenHash.equals(tokenHash(claim.token)) ||
349
+ (requireUnexpired && (lease.expiresAt === null || lease.expiresAt <= now))
350
+ ) {
351
+ throw new StaleResourceError("Lease is no longer current");
352
+ }
353
+ }
354
+
355
+ function updateResourceRevision(
356
+ database: Database.Database,
357
+ resourceId: string,
358
+ expectedRevision: number,
359
+ now: number,
360
+ ): void {
361
+ const update = database
362
+ .prepare(
363
+ `UPDATE resources SET revision = revision + 1, updated_at = ?
364
+ WHERE resource_id = ? AND revision = ?`,
365
+ )
366
+ .run(now, resourceId, expectedRevision);
367
+ /* istanbul ignore if -- revision was checked under BEGIN IMMEDIATE */
368
+ if (update.changes !== 1) {
369
+ throw new StaleResourceError("Resource revision changed during mutation");
370
+ }
371
+ }
372
+
373
+ function insertEvent(
374
+ state: StateDatabase,
375
+ options: {
376
+ resourceId: string;
377
+ revision: number;
378
+ type: string;
379
+ actor: MutationActor;
380
+ leaseGeneration?: number;
381
+ payload?: unknown;
382
+ now: number;
383
+ },
384
+ ): string {
385
+ const eventId = `event-${randomUUID()}`;
386
+ const payloadHash =
387
+ options.payload === undefined ? null : state.putJson(options.payload, options.now);
388
+ state.connection
389
+ .prepare(
390
+ `INSERT INTO events(
391
+ event_id, resource_id, resource_revision, event_type,
392
+ actor_type, actor_id, lease_generation, payload_hash, recorded_at
393
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
394
+ )
395
+ .run(
396
+ eventId,
397
+ options.resourceId,
398
+ options.revision,
399
+ options.type,
400
+ options.actor.type,
401
+ options.actor.id ?? null,
402
+ options.leaseGeneration ?? null,
403
+ payloadHash,
404
+ options.now,
405
+ );
406
+ return eventId;
407
+ }
408
+
409
+ function isResourceRow(value: unknown): value is ResourceRow {
410
+ return isRecord(value) && typeof value.revision === "number";
411
+ }
412
+
413
+ function isResourceIdentity(value: unknown): value is ResourceIdentityRow {
414
+ return (
415
+ isRecord(value) &&
416
+ typeof value.resourceId === "string" &&
417
+ typeof value.resourceType === "string" &&
418
+ typeof value.aggregateKey === "string"
419
+ );
420
+ }
421
+
422
+ function isLeaseRow(value: unknown): value is LeaseRow {
423
+ return (
424
+ isRecord(value) &&
425
+ typeof value.generation === "number" &&
426
+ (typeof value.ownerType === "string" || value.ownerType === null) &&
427
+ (typeof value.ownerId === "string" || value.ownerId === null) &&
428
+ (Buffer.isBuffer(value.tokenHash) || value.tokenHash === null) &&
429
+ (typeof value.expiresAt === "number" || value.expiresAt === null)
430
+ );
431
+ }
432
+
433
+ function isRecord(value: unknown): value is Record<string, unknown> {
434
+ return typeof value === "object" && value !== null && !Array.isArray(value);
435
+ }