@owlmeans/planning-postgres 0.1.18-rc.0

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 (93) hide show
  1. package/README.md +88 -0
  2. package/agent-meta/manifest.json +16 -0
  3. package/agent-meta/skills/planning-postgres/SKILL.md +292 -0
  4. package/build/consts.d.ts +45 -0
  5. package/build/consts.d.ts.map +1 -0
  6. package/build/consts.js +45 -0
  7. package/build/consts.js.map +1 -0
  8. package/build/errors.d.ts +10 -0
  9. package/build/errors.d.ts.map +1 -0
  10. package/build/errors.js +13 -0
  11. package/build/errors.js.map +1 -0
  12. package/build/index.d.ts +9 -0
  13. package/build/index.d.ts.map +1 -0
  14. package/build/index.js +8 -0
  15. package/build/index.js.map +1 -0
  16. package/build/resource.d.ts +32 -0
  17. package/build/resource.d.ts.map +1 -0
  18. package/build/resource.js +94 -0
  19. package/build/resource.js.map +1 -0
  20. package/build/schemas.d.ts +20 -0
  21. package/build/schemas.d.ts.map +1 -0
  22. package/build/schemas.js +64 -0
  23. package/build/schemas.js.map +1 -0
  24. package/build/service.d.ts +16 -0
  25. package/build/service.d.ts.map +1 -0
  26. package/build/service.js +34 -0
  27. package/build/service.js.map +1 -0
  28. package/build/sql.d.ts +61 -0
  29. package/build/sql.d.ts.map +1 -0
  30. package/build/sql.js +90 -0
  31. package/build/sql.js.map +1 -0
  32. package/build/store/bus.d.ts +52 -0
  33. package/build/store/bus.d.ts.map +1 -0
  34. package/build/store/bus.js +167 -0
  35. package/build/store/bus.js.map +1 -0
  36. package/build/store/cards.d.ts +35 -0
  37. package/build/store/cards.d.ts.map +1 -0
  38. package/build/store/cards.js +112 -0
  39. package/build/store/cards.js.map +1 -0
  40. package/build/store/fold.d.ts +56 -0
  41. package/build/store/fold.d.ts.map +1 -0
  42. package/build/store/fold.js +453 -0
  43. package/build/store/fold.js.map +1 -0
  44. package/build/store/index.d.ts +22 -0
  45. package/build/store/index.d.ts.map +1 -0
  46. package/build/store/index.js +206 -0
  47. package/build/store/index.js.map +1 -0
  48. package/build/store/links.d.ts +18 -0
  49. package/build/store/links.d.ts.map +1 -0
  50. package/build/store/links.js +54 -0
  51. package/build/store/links.js.map +1 -0
  52. package/build/store/schemas.d.ts +32 -0
  53. package/build/store/schemas.d.ts.map +1 -0
  54. package/build/store/schemas.js +143 -0
  55. package/build/store/schemas.js.map +1 -0
  56. package/build/store/specs.d.ts +10 -0
  57. package/build/store/specs.d.ts.map +1 -0
  58. package/build/store/specs.js +47 -0
  59. package/build/store/specs.js.map +1 -0
  60. package/build/store/transitions.d.ts +44 -0
  61. package/build/store/transitions.d.ts.map +1 -0
  62. package/build/store/transitions.js +164 -0
  63. package/build/store/transitions.js.map +1 -0
  64. package/build/types.d.ts +72 -0
  65. package/build/types.d.ts.map +1 -0
  66. package/build/types.js +2 -0
  67. package/build/types.js.map +1 -0
  68. package/package.json +56 -0
  69. package/src/consts.ts +54 -0
  70. package/src/errors.ts +15 -0
  71. package/src/index.ts +9 -0
  72. package/src/resource.ts +133 -0
  73. package/src/schemas.ts +84 -0
  74. package/src/service.ts +45 -0
  75. package/src/sql.ts +135 -0
  76. package/src/store/bus.ts +217 -0
  77. package/src/store/cards.ts +153 -0
  78. package/src/store/fold.ts +560 -0
  79. package/src/store/index.ts +237 -0
  80. package/src/store/links.ts +77 -0
  81. package/src/store/schemas.ts +174 -0
  82. package/src/store/specs.ts +72 -0
  83. package/src/store/transitions.ts +221 -0
  84. package/src/types.ts +73 -0
  85. package/tests/bus.spec.ts +142 -0
  86. package/tests/conformance.spec.ts +37 -0
  87. package/tests/context.ts +143 -0
  88. package/tests/fold.spec.ts +213 -0
  89. package/tests/schema.spec.ts +89 -0
  90. package/tests/sql.spec.ts +50 -0
  91. package/tests/sync.spec.ts +61 -0
  92. package/tests/tsconfig.json +17 -0
  93. package/tsconfig.json +11 -0
@@ -0,0 +1,560 @@
1
+ import {
2
+ CommitState, isProject, PlanningUnsupported, TransitionAction,
3
+ } from '@owlmeans/planning'
4
+ import type { CommitEvent, PlanningStore, Transition, Workcard } from '@owlmeans/planning'
5
+ import { advisoryKey } from '@owlmeans/postgres-resource'
6
+ import { failPending, foldPending } from '@owlmeans/server-planning/store'
7
+ import type { CommitHub, CommitListener, FoldResult } from '@owlmeans/server-planning/store'
8
+ import type { Pool } from 'pg'
9
+ import {
10
+ DEFAULT_RECOVER_LIMIT, LOST_ALLOCATION, MAX_FOLD_PASSES, MAX_FOLD_ROUNDS, PLANNING_POSTGRES_STORE,
11
+ } from '../consts.js'
12
+ import { clientRunner, col, insertOf, LOCK_NOT_AVAILABLE, pgFault } from '../sql.js'
13
+ import type { PlanningTables, SqlContext } from '../sql.js'
14
+ import type { PlanningPostgresLimits } from '../types.js'
15
+ import type { PlanningBus } from './bus.js'
16
+ import { dropCard, readCard, readCardRow, writeCard } from './cards.js'
17
+ import { dropLinks, listLinks, putLink } from './links.js'
18
+ import { bumpRevision, purgeSchemaLayers } from './schemas.js'
19
+ import { commitTransition, listTransitions, readTransition, readTransitionByKey } from './transitions.js'
20
+
21
+ const EMPTY: FoldResult = Object.freeze({ card: null, folded: 0, failed: 0, followUp: false })
22
+
23
+ const safely = async (label: string, run: () => Promise<unknown> | unknown): Promise<void> => {
24
+ try {
25
+ await run()
26
+ } catch (error) {
27
+ console.error(`planning-postgres: ${label} failed:`, error)
28
+ }
29
+ }
30
+
31
+ /** One open fold transaction: its runner, a savepoint helper, and what must happen after it commits. */
32
+ export interface FoldContext extends SqlContext {
33
+ /** Run as one unit: a failure rolls back to before it, and the transaction stays usable. */
34
+ savepoint: <R>(run: () => Promise<R>) => Promise<R>
35
+ /** Settled events, delivered in order once the transaction commits. */
36
+ events: CommitEvent[]
37
+ /** Organizations whose schema revision moved in this transaction. */
38
+ schemas: Set<string>
39
+ }
40
+
41
+ export interface FoldEngineDeps {
42
+ pool: () => Promise<Pool>
43
+ tables: () => Promise<PlanningTables>
44
+ limits: PlanningPostgresLimits
45
+ ids: () => string
46
+ now: () => string
47
+ bus: PlanningBus
48
+ hub: CommitHub
49
+ committed: () => CommitListener | undefined
50
+ /** Told of every organization whose schemas this process changed, after the commit. */
51
+ schemasTouched: (entityId: string) => void
52
+ closed: () => boolean
53
+ }
54
+
55
+ export interface FoldEngine {
56
+ /** Fold now, waiting for another folder's lock — what `project()` runs. */
57
+ fold: (cardId: string) => Promise<FoldResult>
58
+ /** Fold in the background unless another folder holds the card — deduplicated per card. */
59
+ heal: (cardId: string) => void
60
+ recover: (opts?: { olderThanMs?: number, limit?: number }) => Promise<number>
61
+ /** Purge a project (outside a fold) in one transaction under the project's lock. */
62
+ purge: (project: string, entityId: string) => Promise<number>
63
+ }
64
+
65
+ type LockMode = 'wait' | 'try'
66
+
67
+ /** A transaction a statement failed in — rolled back, never committed. */
68
+ class Poisoned extends Error { }
69
+
70
+ const poisoned = (lock: string, cause: unknown): Poisoned => {
71
+ const failure = new Poisoned(`planning-postgres: fold of ${lock} aborted by a failed statement`)
72
+ ;(failure as { cause?: unknown }).cause = cause
73
+ return failure
74
+ }
75
+
76
+ const lockTimedOut = (error: unknown): boolean => pgFault(error).code === LOCK_NOT_AVAILABLE
77
+
78
+ const strip = (event: CommitEvent): Omit<CommitEvent, 'record'> => {
79
+ const { record: _record, ...bare } = event
80
+ return bare
81
+ }
82
+
83
+ /**
84
+ * The inline fold of a Postgres planning store.
85
+ *
86
+ * Every fold of a card is ONE transaction under `pg_advisory_xact_lock(planning:<card table>:<card>)`,
87
+ * with `SET LOCAL lock_timeout`. Inside it: the prelude, then `foldPending` over a view of the ports
88
+ * bound to that transaction (each transition's writes in a savepoint), then a NOTIFY per settled
89
+ * event. Events are delivered to the commit hub and the `after` hooks only once it COMMITS — never
90
+ * inside the transaction or the lock, so a hook may write to the card it saw commit.
91
+ *
92
+ * A transaction a statement failed in is poisoned and never committed. The fold is retried once;
93
+ * failing again, the card's pending transitions are failed in a fresh transaction (`failPending`)
94
+ * so no waiter is left to its timeout. A lock that could not be taken means another process is
95
+ * folding the card: nothing is failed, and a heal follows.
96
+ */
97
+ export const makeFoldEngine = (deps: FoldEngineDeps): FoldEngine => {
98
+ const { limits } = deps
99
+ const chains = new Map<string, Promise<unknown>>()
100
+ const healing = new Set<string>()
101
+
102
+ /** One transaction of this process per card at a time — the advisory lock serializes processes. */
103
+ const serial = <R>(card: string, run: () => Promise<R>): Promise<R> => {
104
+ const previous = chains.get(card) ?? Promise.resolve()
105
+ const next = previous.catch(() => undefined).then(run)
106
+ chains.set(card, next)
107
+ const release = (): void => {
108
+ if (chains.get(card) === next) {
109
+ chains.delete(card)
110
+ }
111
+ }
112
+ void next.then(release, release)
113
+ return next
114
+ }
115
+
116
+ const transaction = async <R>(
117
+ lock: string, mode: LockMode, run: (ctx: FoldContext) => Promise<R>
118
+ ): Promise<{ skipped: true } | { skipped: false, result: R, ctx: FoldContext }> => {
119
+ const tables = await deps.tables()
120
+ const client = await (await deps.pool()).connect()
121
+ let poison: unknown
122
+ let savepoints = 0
123
+ const runner = clientRunner(client, error => { poison ??= error })
124
+ const ctx: FoldContext = {
125
+ runner,
126
+ tables,
127
+ events: [],
128
+ schemas: new Set(),
129
+ savepoint: async <T>(unit: () => Promise<T>): Promise<T> => {
130
+ const name = `planning_unit_${++savepoints}`
131
+ const before = poison
132
+ await runner.query(`SAVEPOINT ${name}`)
133
+ try {
134
+ const value = await unit()
135
+ await runner.query(`RELEASE SAVEPOINT ${name}`)
136
+ return value
137
+ } catch (error) {
138
+ try {
139
+ await client.query(`ROLLBACK TO SAVEPOINT ${name}`)
140
+ await client.query(`RELEASE SAVEPOINT ${name}`)
141
+ // Rolled back to before the unit: whatever it broke is undone, the transaction is usable.
142
+ poison = before
143
+ } catch {
144
+ // The rollback itself failed — the transaction stays poisoned.
145
+ }
146
+ throw error
147
+ }
148
+ },
149
+ }
150
+
151
+ try {
152
+ await client.query('BEGIN')
153
+ await client.query(`SET LOCAL lock_timeout = '${Math.max(0, Math.floor(limits.lockTimeoutMs))}ms'`)
154
+ const [first, second] = advisoryKey(`planning:${tables.card.qualified}:${lock}`)
155
+ if (mode === 'try') {
156
+ const taken = await client.query<{ ok: boolean }>('SELECT pg_try_advisory_xact_lock($1, $2) AS ok', [first, second])
157
+ if (taken.rows[0]?.ok !== true) {
158
+ await client.query('ROLLBACK')
159
+ return { skipped: true }
160
+ }
161
+ } else {
162
+ await client.query('SELECT pg_advisory_xact_lock($1, $2)', [first, second])
163
+ }
164
+ const result = await run(ctx)
165
+ if (poison != null) {
166
+ throw poisoned(lock, poison)
167
+ }
168
+ await client.query('COMMIT')
169
+ return { skipped: false, result, ctx }
170
+ } catch (error) {
171
+ await client.query('ROLLBACK').catch(() => undefined)
172
+ // The statement that broke the transaction is the cause — not the "transaction is aborted"
173
+ // every statement after it answers.
174
+ throw poison != null && !(error instanceof Poisoned) ? poisoned(lock, poison) : error
175
+ } finally {
176
+ client.release()
177
+ }
178
+ }
179
+
180
+ // ─── The transaction-bound view foldPending and failPending run over ──────────────────────────
181
+
182
+ const refuse = (what: string) => async (): Promise<never> => { throw new PlanningUnsupported(`fold-view:${what}`) }
183
+
184
+ const viewOf = (ctx: FoldContext): PlanningStore => ({
185
+ alias: PLANNING_POSTGRES_STORE,
186
+ transitions: {
187
+ append: refuse('append'),
188
+ get: async id => await readTransition(ctx, id),
189
+ byKey: async (entityId, key) => await readTransitionByKey(ctx, entityId, key),
190
+ list: async (where, opts) => await listTransitions(ctx, where, opts?.size ?? 0),
191
+ nextSeq: refuse('nextSeq'),
192
+ head: refuse('head'),
193
+ commit: async (id, commit) => { await commitTransition(ctx, id, commit) },
194
+ purge: refuse('purge'),
195
+ },
196
+ cards: {
197
+ get: async (id, entityId) => await readCard(ctx, id, entityId),
198
+ list: refuse('list'),
199
+ count: refuse('count'),
200
+ summary: refuse('summary'),
201
+ put: async card => { await writeCard(ctx, card) },
202
+ drop: async (id, entityId) => { await dropCard(ctx, id, entityId) },
203
+ project: refuse('project'),
204
+ purge: async (project, entityId) => await purgeInside(ctx, project, entityId),
205
+ },
206
+ links: {
207
+ list: async where => await listLinks(ctx, where),
208
+ put: async link => await putLink(ctx, link, deps.ids),
209
+ drop: async where => await dropLinks(ctx, where),
210
+ },
211
+ })
212
+
213
+ // ─── Purge ─────────────────────────────────────────────────────────────────────────────────────
214
+
215
+ /**
216
+ * Everything under a project, in the open transaction: the recursive walk over `parents` finds
217
+ * every doomed card (nested projects too); then links, transitions, the doomed projects' schema
218
+ * layers and the cards themselves go — in that order. The project's own `delete` transitions stay
219
+ * as its tombstone, so a waiter still reads the delete committed.
220
+ */
221
+ const purgeInside = async (ctx: FoldContext, project: string, entityId: string): Promise<number> => {
222
+ const { card: cards, transition: log, link: links } = ctx.tables
223
+ const parents = cards.byProperty.parents
224
+ const doomed = (await ctx.runner.query<{ id: string }>(
225
+ `WITH RECURSIVE doomed(id) AS (`
226
+ + ` SELECT $1::text`
227
+ + ` UNION SELECT child.${col(cards, 'id')}::text FROM ${cards.qualified} AS child JOIN doomed`
228
+ + ` ON child.${col(cards, 'parents')} @> ARRAY[doomed.id]::${parents.sqlType}`
229
+ + ` WHERE child.${col(cards, 'entityId')} = $2`
230
+ + `) SELECT id FROM doomed`,
231
+ [project, entityId]
232
+ )).map(row => row.id)
233
+
234
+ let count = 0
235
+ count += (await ctx.runner.query(
236
+ `DELETE FROM ${links.qualified} WHERE ${col(links, 'entityId')} = $1 AND (${col(links, 'from')} = ANY($2)`
237
+ + ` OR ${col(links, 'to')} = ANY($2) OR ${col(links, 'project')} = ANY($2)) RETURNING ${col(links, 'id')}`,
238
+ [entityId, doomed]
239
+ )).length
240
+ count += (await ctx.runner.query(
241
+ `DELETE FROM ${log.qualified} WHERE ${col(log, 'entityId')} = $1 AND (${col(log, 'card')} = ANY($2) OR ${col(log, 'project')} = ANY($2))`
242
+ + ` AND NOT (${col(log, 'card')} = $3 AND ${col(log, 'action')} = $4) RETURNING ${col(log, 'id')}`,
243
+ [entityId, doomed, project, TransitionAction.Delete]
244
+ )).length
245
+ const layers = await purgeSchemaLayers(ctx, entityId, doomed)
246
+ if (layers > 0) {
247
+ await bumpRevision(ctx, entityId, deps.now(), deps.ids)
248
+ await deps.bus.notify(ctx.runner, { t: 's', e: entityId })
249
+ ctx.schemas.add(entityId)
250
+ count += layers
251
+ }
252
+ count += (await ctx.runner.query(
253
+ `DELETE FROM ${cards.qualified} WHERE ${col(cards, 'entityId')} = $1 AND ${col(cards, 'id')} = ANY($2) RETURNING ${col(cards, 'id')}`,
254
+ [entityId, doomed]
255
+ )).length
256
+
257
+ return count
258
+ }
259
+
260
+ // ─── The prelude ───────────────────────────────────────────────────────────────────────────────
261
+
262
+ /** The organization of a card that has a log but no row (its create has not folded). */
263
+ const logEntityOf = async (ctx: FoldContext, cardId: string): Promise<string | null> => {
264
+ const log = ctx.tables.transition
265
+ const rows = await ctx.runner.query<{ entityId: string }>(
266
+ `SELECT ${col(log, 'entityId')} AS "entityId" FROM ${log.qualified} WHERE ${col(log, 'card')} = $1 ORDER BY ${col(log, 'seq')} LIMIT 1`,
267
+ [cardId]
268
+ )
269
+ return rows[0]?.entityId ?? null
270
+ }
271
+
272
+ /** Failed rows over allocations that never got one — what lets the fold step over a hole. */
273
+ const placeholders = async (
274
+ ctx: FoldContext, card: Workcard, from: number, to: number, project?: string
275
+ ): Promise<boolean> => {
276
+ const at = deps.now()
277
+ for (let seq = from; seq <= to; seq++) {
278
+ const row: Transition = {
279
+ id: deps.ids(), entityId: card.entityId, card: card.id!, kind: card.kind, type: card.type,
280
+ ...(project != null ? { project } : {}),
281
+ seq, action: TransitionAction.Update, changes: {}, actor: { service: PLANNING_POSTGRES_STORE },
282
+ cause: LOST_ALLOCATION, at, commit: { state: CommitState.Failed, at, error: LOST_ALLOCATION },
283
+ }
284
+ const log = ctx.tables.transition
285
+ const statement = insertOf(log, row as unknown as Record<string, unknown>, {
286
+ tail: `ON CONFLICT (${col(log, 'card')}, ${col(log, 'seq')}) DO NOTHING RETURNING ${col(log, 'id')}`,
287
+ })
288
+ if ((await ctx.runner.query(statement.text, statement.params)).length === 0) {
289
+ // The real row landed after all: the fold reads it on its next round.
290
+ return false
291
+ }
292
+ }
293
+ return true
294
+ }
295
+
296
+ /** What one pass of the prelude found: the card's organization and how much `foldPending` may fold. */
297
+ interface Plan {
298
+ entityId: string
299
+ /** Consecutive pending rows at the cursor — exactly what `foldPending` folds this pass. */
300
+ run: number
301
+ /** The log goes on after the run in a way the next pass handles (a failed row, a gap). */
302
+ more: boolean
303
+ /** The read hit `foldBatch`: a next round has more to read. */
304
+ full: boolean
305
+ }
306
+
307
+ /**
308
+ * Walk the log past `card.seq`: a FAILED row at the cursor moves the cursor past it; PENDING rows
309
+ * from the cursor on are the run `foldPending` folds; a GAP (a row past the expected seq, or an
310
+ * allocated head with no row at all) is an append still in flight while it is younger than
311
+ * `gapGraceMs` — the fold stops there, and the appender's own `project()` folds it — and a lost
312
+ * allocation after that: one failed `lost-allocation` placeholder per missing seq, and on.
313
+ * `nextSeq` and `append` are two round trips here, so without this a transient gap or an
314
+ * already-failed row would make the next write fail as out of order.
315
+ *
316
+ * A card with no row folds its log as it is: a create from seq 1, and anything else fails out of
317
+ * order. `null` when there is neither a row nor a log.
318
+ */
319
+ const prelude = async (ctx: FoldContext, cardId: string): Promise<Plan | null> => {
320
+ const card = await readCardRow(ctx, cardId)
321
+ if (card == null) {
322
+ const entityId = await logEntityOf(ctx, cardId)
323
+ if (entityId == null) {
324
+ return null
325
+ }
326
+ const pending = await listTransitions(ctx, { entityId, card: cardId, state: CommitState.Pending }, limits.foldBatch)
327
+ return { entityId, run: pending.items.length, more: false, full: pending.total > pending.items.length }
328
+ }
329
+ const entityId = card.entityId
330
+ const rows = (await listTransitions(ctx, { entityId, card: cardId, sinceSeq: card.seq }, limits.foldBatch)).items
331
+
332
+ const now = Date.now()
333
+ let expected = card.seq + 1
334
+ let advanced = card.seq
335
+ let run = 0
336
+ let more = false
337
+ let consumed = true
338
+ for (const row of rows) {
339
+ if (row.seq < expected) {
340
+ continue
341
+ }
342
+ if (row.seq > expected) {
343
+ if (run > 0) {
344
+ // The run ends at the hole; the next pass meets it at the cursor.
345
+ more = true
346
+ consumed = false
347
+ break
348
+ }
349
+ if (now - Date.parse(row.at) < limits.gapGraceMs || !await placeholders(ctx, card, expected, row.seq - 1, row.project)) {
350
+ consumed = false
351
+ break
352
+ }
353
+ advanced = row.seq - 1
354
+ expected = row.seq
355
+ }
356
+ if (row.commit.state === CommitState.Failed) {
357
+ if (run > 0) {
358
+ more = true
359
+ consumed = false
360
+ break
361
+ }
362
+ advanced = row.seq
363
+ expected = row.seq + 1
364
+ continue
365
+ }
366
+ if (row.commit.state === CommitState.Pending) {
367
+ run++
368
+ expected++
369
+ continue
370
+ }
371
+ // Committed yet past the card's seq: nothing a fold may touch.
372
+ consumed = false
373
+ break
374
+ }
375
+ const full = rows.length >= limits.foldBatch
376
+ if (consumed && run === 0 && !full) {
377
+ const head = card.head ?? card.seq
378
+ if (head > advanced && card.headAt != null && now - Date.parse(card.headAt) >= limits.gapGraceMs
379
+ && await placeholders(ctx, card, advanced + 1, head, rows.at(-1)?.project ?? (isProject(card) ? card.id : undefined))) {
380
+ advanced = head
381
+ }
382
+ }
383
+ if (advanced > card.seq) {
384
+ const spec = ctx.tables.card
385
+ await ctx.runner.query(
386
+ `UPDATE ${spec.qualified} SET ${col(spec, 'seq')} = $2, ${col(spec, 'head')} = GREATEST(COALESCE(${col(spec, 'head')}, ${col(spec, 'seq')}), $2)`
387
+ + ` WHERE ${col(spec, 'id')} = $1`,
388
+ [cardId, advanced]
389
+ )
390
+ }
391
+
392
+ return { entityId, run, more, full: full && consumed }
393
+ }
394
+
395
+ // ─── Folding ───────────────────────────────────────────────────────────────────────────────────
396
+
397
+ const notifyAll = async (ctx: FoldContext): Promise<void> => {
398
+ for (const event of ctx.events) {
399
+ await deps.bus.notify(ctx.runner, { t: 'c', e: strip(event) })
400
+ }
401
+ }
402
+
403
+ /**
404
+ * Passes of prelude + `foldPending`, each folding exactly the run the prelude measured, until the
405
+ * log is folded, a young gap stops it, or a pass makes no progress.
406
+ */
407
+ const foldInside = async (ctx: FoldContext, cardId: string): Promise<FoldResult> => {
408
+ const total: FoldResult = { card: null, folded: 0, failed: 0, followUp: false }
409
+ for (let pass = 0; pass < MAX_FOLD_PASSES; pass++) {
410
+ const plan = await prelude(ctx, cardId)
411
+ if (plan == null) {
412
+ break
413
+ }
414
+ total.followUp = plan.full
415
+ if (plan.run === 0) {
416
+ total.card = await readCard(ctx, cardId, plan.entityId)
417
+ break
418
+ }
419
+ const result = await foldPending(viewOf(ctx), cardId, plan.entityId, {
420
+ now: deps.now,
421
+ limit: plan.run,
422
+ unit: ctx.savepoint,
423
+ publish: async event => { ctx.events.push(event) },
424
+ })
425
+ total.card = result.card
426
+ total.folded += result.folded
427
+ total.failed += result.failed
428
+ if (!plan.more || result.folded + result.failed === 0) {
429
+ break
430
+ }
431
+ }
432
+ await notifyAll(ctx)
433
+ return total
434
+ }
435
+
436
+ const failInside = async (ctx: FoldContext, cardId: string, reason: string): Promise<FoldResult> => {
437
+ const entityId = (await readCardRow(ctx, cardId))?.entityId ?? await logEntityOf(ctx, cardId)
438
+ if (entityId == null) {
439
+ return EMPTY
440
+ }
441
+ const failed = await failPending(viewOf(ctx), cardId, entityId, reason, {
442
+ now: deps.now, publish: async event => { ctx.events.push(event) },
443
+ })
444
+ await notifyAll(ctx)
445
+ return { card: null, folded: 0, failed, followUp: false }
446
+ }
447
+
448
+ /** After the commit, in order: the hub (this process's subscribers and waiters), then the hooks. */
449
+ const deliver = async (ctx: FoldContext): Promise<void> => {
450
+ for (const entityId of ctx.schemas) {
451
+ deps.schemasTouched(entityId)
452
+ }
453
+ for (const event of ctx.events) {
454
+ await safely('commit publish', () => deps.hub.publish(event))
455
+ if (event.state === CommitState.Committed) {
456
+ const committed = deps.committed()
457
+ if (committed != null) {
458
+ await safely('committed hooks', () => committed(event))
459
+ }
460
+ }
461
+ }
462
+ }
463
+
464
+ const reasonOf = (error: unknown): string => {
465
+ const cause = (error as { cause?: unknown } | null)?.cause ?? error
466
+ return cause instanceof Error ? cause.message : `${cause}`
467
+ }
468
+
469
+ /** One round: fold, retry once, then fail what is pending — or yield to a folder holding the lock. */
470
+ const round = async (cardId: string, mode: LockMode): Promise<FoldResult> => {
471
+ const attempt = () => serial(cardId, () => transaction(cardId, mode, ctx => foldInside(ctx, cardId)))
472
+ let outcome: Awaited<ReturnType<typeof attempt>>
473
+ try {
474
+ outcome = await attempt()
475
+ } catch (first) {
476
+ if (lockTimedOut(first)) {
477
+ engine.heal(cardId)
478
+ return EMPTY
479
+ }
480
+ try {
481
+ outcome = await attempt()
482
+ } catch (second) {
483
+ if (lockTimedOut(second)) {
484
+ engine.heal(cardId)
485
+ return EMPTY
486
+ }
487
+ console.error(`planning-postgres: fold of ${cardId} failed twice, failing its pending transitions:`, second)
488
+ try {
489
+ outcome = await serial(cardId, () => transaction(cardId, 'wait', ctx => failInside(ctx, cardId, reasonOf(second))))
490
+ } catch (third) {
491
+ console.error(`planning-postgres: cannot fail the pending transitions of ${cardId}:`, third)
492
+ return EMPTY
493
+ }
494
+ }
495
+ }
496
+ if (outcome.skipped) {
497
+ return EMPTY
498
+ }
499
+ await deliver(outcome.ctx)
500
+ return outcome.result
501
+ }
502
+
503
+ const foldWith = async (cardId: string, mode: LockMode): Promise<FoldResult> => {
504
+ let result: FoldResult = EMPTY
505
+ for (let rounds = 0; rounds < MAX_FOLD_ROUNDS && !deps.closed(); rounds++) {
506
+ result = await round(cardId, mode)
507
+ if (!result.followUp) {
508
+ break
509
+ }
510
+ }
511
+ return result
512
+ }
513
+
514
+ const engine: FoldEngine = {
515
+ fold: cardId => foldWith(cardId, 'wait'),
516
+
517
+ heal: cardId => {
518
+ if (healing.has(cardId) || deps.closed()) {
519
+ return
520
+ }
521
+ healing.add(cardId)
522
+ void foldWith(cardId, 'try')
523
+ .catch(error => console.error(`planning-postgres: heal of ${cardId} failed:`, error))
524
+ .finally(() => { healing.delete(cardId) })
525
+ },
526
+
527
+ recover: async opts => {
528
+ const tables = await deps.tables()
529
+ const log = tables.transition
530
+ const cutoff = new Date(Date.now() - (opts?.olderThanMs ?? limits.recoverAfterMs)).toISOString()
531
+ const pool = await deps.pool()
532
+ const stale = (await pool.query<{ card: string }>(
533
+ `SELECT ${col(log, 'card')} AS card, MIN(${col(log, 'at')}) AS first FROM ${log.qualified}`
534
+ + ` WHERE (${col(log, 'commit')}->>'state') = 'pending' AND ${col(log, 'at')} < $1`
535
+ + ` GROUP BY ${col(log, 'card')} ORDER BY first LIMIT $2`,
536
+ [cutoff, opts?.limit ?? DEFAULT_RECOVER_LIMIT]
537
+ )).rows
538
+ let folded = 0
539
+ for (const { card } of stale) {
540
+ if (deps.closed()) {
541
+ break
542
+ }
543
+ await foldWith(card, 'try')
544
+ folded++
545
+ }
546
+ return folded
547
+ },
548
+
549
+ purge: async (project, entityId) => {
550
+ const outcome = await serial(project, () => transaction(project, 'wait', ctx => purgeInside(ctx, project, entityId)))
551
+ if (outcome.skipped) {
552
+ return 0
553
+ }
554
+ await deliver(outcome.ctx)
555
+ return outcome.result
556
+ },
557
+ }
558
+
559
+ return engine
560
+ }