@affordance/pg 0.2.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.
@@ -0,0 +1,97 @@
1
+ import { mintId } from '@affordance/core/storage';
2
+ import { FRAMEWORK_SCHEMA } from './bootstrap.js';
3
+ import { sqlWhere } from './sql.js';
4
+ const EVENTS = `${FRAMEWORK_SCHEMA}.ingested_events`;
5
+ /**
6
+ * The dedup gate. Inserts the event's row and reports whether this delivery
7
+ * is the one that got it.
8
+ *
9
+ * `on conflict do nothing` is the whole mechanism: exactly one of N
10
+ * concurrent deliveries inserts, and the losers read what the winner wrote.
11
+ * A previous delivery that ended `dead-lettered` for a *transient* reason is
12
+ * reopened rather than deduplicated — a provider retry after "the case was
13
+ * busy" should get its chance, which is precisely what provider retries are
14
+ * for.
15
+ */
16
+ export const claimDelivery = async (db, event, idempotencyKey, reopenable) => {
17
+ const inserted = await db.query(`insert into ${EVENTS} (id, system, external_id, type, idempotency_key, status, event)
18
+ values ($1, $2, $3, $4, $5, 'pending', $6::jsonb)
19
+ on conflict (idempotency_key) do nothing
20
+ returning *`, [
21
+ mintId('event'),
22
+ event.system,
23
+ event.externalId,
24
+ event.type,
25
+ idempotencyKey,
26
+ JSON.stringify(event),
27
+ ]);
28
+ const row = inserted.rows[0];
29
+ if (row)
30
+ return { row: toDelivery(row), fresh: true };
31
+ const retried = await db.query(`update ${EVENTS}
32
+ set status = 'pending', reason = null, detail = null, received_at = now(), event = $2::jsonb
33
+ where idempotency_key = $1 and status = 'dead-lettered' and reason = any($3)
34
+ returning *`, [idempotencyKey, JSON.stringify(event), reopenable]);
35
+ const reopened = retried.rows[0];
36
+ if (reopened)
37
+ return { row: toDelivery(reopened), fresh: true };
38
+ const existing = await db.query(`select * from ${EVENTS} where idempotency_key = $1`, [idempotencyKey]);
39
+ const previous = existing.rows[0];
40
+ if (!previous)
41
+ throw new Error(`${EVENTS}: delivery neither inserted nor found — key ${idempotencyKey}`);
42
+ return { row: toDelivery(previous), fresh: false };
43
+ };
44
+ /** Record how a delivery ended. The row is the dead-letter surface, so this is the only settle path. */
45
+ export const settle = async (db, id, fields) => {
46
+ await db.query(`update ${EVENTS}
47
+ set status = $2, case_id = $3, scope_key = $4, step = $5, reason = $6, detail = $7, execution_id = $8
48
+ where id = $1`, [
49
+ id,
50
+ fields.status,
51
+ fields.caseId ?? null,
52
+ fields.scopeKey ?? null,
53
+ fields.step ?? null,
54
+ fields.reason ?? null,
55
+ fields.detail ?? null,
56
+ fields.executionId ?? null,
57
+ ]);
58
+ };
59
+ const toDelivery = (row) => ({
60
+ id: row.id,
61
+ system: row.system,
62
+ externalId: row.external_id,
63
+ idempotencyKey: row.idempotency_key,
64
+ status: row.status,
65
+ reason: row.reason,
66
+ receivedAt: row.received_at.toISOString(),
67
+ });
68
+ const toDeadLetter = (row) => ({
69
+ id: row.id,
70
+ system: row.system,
71
+ externalId: row.external_id,
72
+ type: row.type,
73
+ idempotencyKey: row.idempotency_key,
74
+ caseId: row.case_id,
75
+ scopeKey: row.scope_key,
76
+ step: row.step,
77
+ reason: row.reason,
78
+ detail: row.detail,
79
+ event: row.event,
80
+ receivedAt: row.received_at.toISOString(),
81
+ });
82
+ /** Read the dead-letter surface, newest first — the ops view of "arrived, did nothing". */
83
+ export const readDeadLetters = async (db, filter = {}) => {
84
+ const { conditions, values, bind, where } = sqlWhere([
85
+ `status = 'dead-lettered'`,
86
+ ]);
87
+ if (filter.system !== undefined)
88
+ conditions.push(`system = ${bind(filter.system)}`);
89
+ if (filter.caseId !== undefined)
90
+ conditions.push(`case_id = ${bind(filter.caseId)}`);
91
+ if (filter.reason !== undefined)
92
+ conditions.push(`reason = ${bind(filter.reason)}`);
93
+ const limit = filter.limit === undefined ? '' : ` limit ${bind(filter.limit)}`;
94
+ const { rows } = await db.query(`select * from ${EVENTS} where ${where()} order by received_at desc${limit}`, values);
95
+ return rows.map(toDeadLetter);
96
+ };
97
+ //# sourceMappingURL=delivery.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"delivery.js","sourceRoot":"","sources":["../src/delivery.ts"],"names":[],"mappings":"AAOA,OAAO,EAAE,MAAM,EAAE,MAAM,0BAA0B,CAAA;AACjD,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAA;AAEjD,OAAO,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAA;AAEnC,MAAM,MAAM,GAAG,GAAG,gBAAgB,kBAAkB,CAAA;AAkBpD;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG,KAAK,EAChC,EAAa,EACb,KAAoB,EACpB,cAAsB,EACtB,UAAuC,EACW,EAAE;IACpD,MAAM,QAAQ,GAAG,MAAM,EAAE,CAAC,KAAK,CAC7B,eAAe,MAAM;;;iBAGR,EACb;QACE,MAAM,CAAC,OAAO,CAAC;QACf,KAAK,CAAC,MAAM;QACZ,KAAK,CAAC,UAAU;QAChB,KAAK,CAAC,IAAI;QACV,cAAc;QACd,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC;KACtB,CACF,CAAA;IACD,MAAM,GAAG,GAAG,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;IAC5B,IAAI,GAAG;QAAE,OAAO,EAAE,GAAG,EAAE,UAAU,CAAC,GAAG,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,CAAA;IAErD,MAAM,OAAO,GAAG,MAAM,EAAE,CAAC,KAAK,CAC5B,UAAU,MAAM;;;iBAGH,EACb,CAAC,cAAc,EAAE,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,EAAE,UAAU,CAAC,CACpD,CAAA;IACD,MAAM,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;IAChC,IAAI,QAAQ;QAAE,OAAO,EAAE,GAAG,EAAE,UAAU,CAAC,QAAQ,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,CAAA;IAE/D,MAAM,QAAQ,GAAG,MAAM,EAAE,CAAC,KAAK,CAC7B,iBAAiB,MAAM,6BAA6B,EACpD,CAAC,cAAc,CAAC,CACjB,CAAA;IACD,MAAM,QAAQ,GAAG,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;IACjC,IAAI,CAAC,QAAQ;QACX,MAAM,IAAI,KAAK,CACb,GAAG,MAAM,+CAA+C,cAAc,EAAE,CACzE,CAAA;IACH,OAAO,EAAE,GAAG,EAAE,UAAU,CAAC,QAAQ,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,CAAA;AACpD,CAAC,CAAA;AAED,wGAAwG;AACxG,MAAM,CAAC,MAAM,MAAM,GAAG,KAAK,EACzB,EAAa,EACb,EAAU,EACV,MAQC,EACc,EAAE;IACjB,MAAM,EAAE,CAAC,KAAK,CACZ,UAAU,MAAM;;mBAED,EACf;QACE,EAAE;QACF,MAAM,CAAC,MAAM;QACb,MAAM,CAAC,MAAM,IAAI,IAAI;QACrB,MAAM,CAAC,QAAQ,IAAI,IAAI;QACvB,MAAM,CAAC,IAAI,IAAI,IAAI;QACnB,MAAM,CAAC,MAAM,IAAI,IAAI;QACrB,MAAM,CAAC,MAAM,IAAI,IAAI;QACrB,MAAM,CAAC,WAAW,IAAI,IAAI;KAC3B,CACF,CAAA;AACH,CAAC,CAAA;AAED,MAAM,UAAU,GAAG,CAAC,GAAa,EAAkB,EAAE,CAAC,CAAC;IACrD,EAAE,EAAE,GAAG,CAAC,EAAE;IACV,MAAM,EAAE,GAAG,CAAC,MAAM;IAClB,UAAU,EAAE,GAAG,CAAC,WAAW;IAC3B,cAAc,EAAE,GAAG,CAAC,eAAe;IACnC,MAAM,EAAE,GAAG,CAAC,MAAkC;IAC9C,MAAM,EAAE,GAAG,CAAC,MAAiC;IAC7C,UAAU,EAAE,GAAG,CAAC,WAAW,CAAC,WAAW,EAAE;CAC1C,CAAC,CAAA;AACF,MAAM,YAAY,GAAG,CAAC,GAAa,EAAc,EAAE,CAAC,CAAC;IACnD,EAAE,EAAE,GAAG,CAAC,EAAE;IACV,MAAM,EAAE,GAAG,CAAC,MAAM;IAClB,UAAU,EAAE,GAAG,CAAC,WAAW;IAC3B,IAAI,EAAE,GAAG,CAAC,IAAI;IACd,cAAc,EAAE,GAAG,CAAC,eAAe;IACnC,MAAM,EAAE,GAAG,CAAC,OAAO;IACnB,QAAQ,EAAE,GAAG,CAAC,SAAS;IACvB,IAAI,EAAE,GAAG,CAAC,IAAI;IACd,MAAM,EAAE,GAAG,CAAC,MAA0B;IACtC,MAAM,EAAE,GAAG,CAAC,MAAM;IAClB,KAAK,EAAE,GAAG,CAAC,KAAK;IAChB,UAAU,EAAE,GAAG,CAAC,WAAW,CAAC,WAAW,EAAE;CAC1C,CAAC,CAAA;AAEF,2FAA2F;AAC3F,MAAM,CAAC,MAAM,eAAe,GAAG,KAAK,EAClC,EAAa,EACb,SAA2B,EAAE,EACG,EAAE;IAClC,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,GAAG,QAAQ,CAAC;QACnD,0BAA0B;KAC3B,CAAC,CAAA;IAEF,IAAI,MAAM,CAAC,MAAM,KAAK,SAAS;QAC7B,UAAU,CAAC,IAAI,CAAC,YAAY,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,CAAA;IACpD,IAAI,MAAM,CAAC,MAAM,KAAK,SAAS;QAC7B,UAAU,CAAC,IAAI,CAAC,aAAa,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,CAAA;IACrD,IAAI,MAAM,CAAC,MAAM,KAAK,SAAS;QAC7B,UAAU,CAAC,IAAI,CAAC,YAAY,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,CAAA;IACpD,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,UAAU,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAA;IAE9E,MAAM,EAAE,IAAI,EAAE,GAAG,MAAM,EAAE,CAAC,KAAK,CAC7B,iBAAiB,MAAM,UAAU,KAAK,EAAE,6BAA6B,KAAK,EAAE,EAC5E,MAAM,CACP,CAAA;IACD,OAAO,IAAI,CAAC,GAAG,CAAC,YAAY,CAAC,CAAA;AAC/B,CAAC,CAAA","sourcesContent":["import type {\n DeadLetter,\n DeadLetterFilter,\n DeadLetterReason,\n ExternalEvent,\n} from '@affordance/core'\nimport type { DeliveryRecord } from '@affordance/core/storage'\nimport { mintId } from '@affordance/core/storage'\nimport { FRAMEWORK_SCHEMA } from './bootstrap.js'\nimport type { Queryable } from './queryable.js'\nimport { sqlWhere } from './sql.js'\n\nconst EVENTS = `${FRAMEWORK_SCHEMA}.ingested_events`\ntype EventRow = {\n id: string\n system: string\n external_id: string\n type: string\n idempotency_key: string\n case_id: string | null\n scope_key: string | null\n step: string | null\n status: string\n reason: string | null\n detail: string | null\n execution_id: string | null\n event: ExternalEvent\n received_at: Date\n}\n\n/**\n * The dedup gate. Inserts the event's row and reports whether this delivery\n * is the one that got it.\n *\n * `on conflict do nothing` is the whole mechanism: exactly one of N\n * concurrent deliveries inserts, and the losers read what the winner wrote.\n * A previous delivery that ended `dead-lettered` for a *transient* reason is\n * reopened rather than deduplicated — a provider retry after \"the case was\n * busy\" should get its chance, which is precisely what provider retries are\n * for.\n */\nexport const claimDelivery = async (\n db: Queryable,\n event: ExternalEvent,\n idempotencyKey: string,\n reopenable: readonly DeadLetterReason[],\n): Promise<{ row: DeliveryRecord; fresh: boolean }> => {\n const inserted = await db.query<EventRow>(\n `insert into ${EVENTS} (id, system, external_id, type, idempotency_key, status, event)\n values ($1, $2, $3, $4, $5, 'pending', $6::jsonb)\n on conflict (idempotency_key) do nothing\n returning *`,\n [\n mintId('event'),\n event.system,\n event.externalId,\n event.type,\n idempotencyKey,\n JSON.stringify(event),\n ],\n )\n const row = inserted.rows[0]\n if (row) return { row: toDelivery(row), fresh: true }\n\n const retried = await db.query<EventRow>(\n `update ${EVENTS}\n set status = 'pending', reason = null, detail = null, received_at = now(), event = $2::jsonb\n where idempotency_key = $1 and status = 'dead-lettered' and reason = any($3)\n returning *`,\n [idempotencyKey, JSON.stringify(event), reopenable],\n )\n const reopened = retried.rows[0]\n if (reopened) return { row: toDelivery(reopened), fresh: true }\n\n const existing = await db.query<EventRow>(\n `select * from ${EVENTS} where idempotency_key = $1`,\n [idempotencyKey],\n )\n const previous = existing.rows[0]\n if (!previous)\n throw new Error(\n `${EVENTS}: delivery neither inserted nor found — key ${idempotencyKey}`,\n )\n return { row: toDelivery(previous), fresh: false }\n}\n\n/** Record how a delivery ended. The row is the dead-letter surface, so this is the only settle path. */\nexport const settle = async (\n db: Queryable,\n id: string,\n fields: {\n status: 'executed' | 'dead-lettered'\n caseId?: string | null\n scopeKey?: string | null\n step?: string | null\n reason?: DeadLetterReason | null\n detail?: string | null\n executionId?: string | null\n },\n): Promise<void> => {\n await db.query(\n `update ${EVENTS}\n set status = $2, case_id = $3, scope_key = $4, step = $5, reason = $6, detail = $7, execution_id = $8\n where id = $1`,\n [\n id,\n fields.status,\n fields.caseId ?? null,\n fields.scopeKey ?? null,\n fields.step ?? null,\n fields.reason ?? null,\n fields.detail ?? null,\n fields.executionId ?? null,\n ],\n )\n}\n\nconst toDelivery = (row: EventRow): DeliveryRecord => ({\n id: row.id,\n system: row.system,\n externalId: row.external_id,\n idempotencyKey: row.idempotency_key,\n status: row.status as DeliveryRecord['status'],\n reason: row.reason as DeadLetterReason | null,\n receivedAt: row.received_at.toISOString(),\n})\nconst toDeadLetter = (row: EventRow): DeadLetter => ({\n id: row.id,\n system: row.system,\n externalId: row.external_id,\n type: row.type,\n idempotencyKey: row.idempotency_key,\n caseId: row.case_id,\n scopeKey: row.scope_key,\n step: row.step,\n reason: row.reason as DeadLetterReason,\n detail: row.detail,\n event: row.event,\n receivedAt: row.received_at.toISOString(),\n})\n\n/** Read the dead-letter surface, newest first — the ops view of \"arrived, did nothing\". */\nexport const readDeadLetters = async (\n db: Queryable,\n filter: DeadLetterFilter = {},\n): Promise<readonly DeadLetter[]> => {\n const { conditions, values, bind, where } = sqlWhere([\n `status = 'dead-lettered'`,\n ])\n\n if (filter.system !== undefined)\n conditions.push(`system = ${bind(filter.system)}`)\n if (filter.caseId !== undefined)\n conditions.push(`case_id = ${bind(filter.caseId)}`)\n if (filter.reason !== undefined)\n conditions.push(`reason = ${bind(filter.reason)}`)\n const limit = filter.limit === undefined ? '' : ` limit ${bind(filter.limit)}`\n\n const { rows } = await db.query<EventRow>(\n `select * from ${EVENTS} where ${where()} order by received_at desc${limit}`,\n values,\n )\n return rows.map(toDeadLetter)\n}\n"]}
@@ -0,0 +1,4 @@
1
+ import type { LifecyclePort } from '@affordance/core/storage';
2
+ import type { DatabaseAccess, Transaction } from './queryable.js';
3
+ /** The production adapter: each port verb implemented as SQL over the claims, journal and cases tables. */
4
+ export declare const pgLifecyclePort: <TCommit>(db: DatabaseAccess, commitContext: (tx: Transaction) => TCommit) => LifecyclePort<TCommit>;
@@ -0,0 +1,89 @@
1
+ import { CaseNotFoundError } from '@affordance/core';
2
+ import { FRAMEWORK_SCHEMA } from './bootstrap.js';
3
+ import { registerCorrelation } from './correlation.js';
4
+ import { appendEntry } from './journal.js';
5
+ import { queryableOf } from './queryable.js';
6
+ import { selectCaseUntyped, updateCaseState } from './store.js';
7
+ import { withTransaction } from './transaction.js';
8
+ const CASES = `${FRAMEWORK_SCHEMA}.cases`;
9
+ const CLAIMS = `${FRAMEWORK_SCHEMA}.claims`;
10
+ const expiryExpression = (parameter) => `now() + (${parameter}::double precision * interval '1 millisecond')`;
11
+ /** The production adapter: each port verb implemented as SQL over the claims, journal and cases tables. */
12
+ export const pgLifecyclePort = (db, commitContext) => {
13
+ // The lease verbs are single self-contained statements; only the
14
+ // case-locked transactions care which arm of the access the caller brought.
15
+ const q = queryableOf(db);
16
+ return {
17
+ withCase: (caseId, fn) => withTransaction(db, async (tx) => {
18
+ // Establish serialization before invoking any domain decisions or app writes.
19
+ const { rows } = await tx.query(`select id from ${CASES} where id = $1 for update`, [caseId]);
20
+ if (rows.length === 0)
21
+ throw new CaseNotFoundError(caseId);
22
+ return fn({
23
+ loadCase: () => selectCaseUntyped(tx, caseId),
24
+ currentClaim: async () => {
25
+ const { rows } = await tx.query(`select execution_id, step, scope_key, attempt, expires_at, expires_at <= now() as expired
26
+ from ${CLAIMS} where case_id = $1`, [caseId]);
27
+ const row = rows[0];
28
+ if (!row)
29
+ return null;
30
+ return {
31
+ executionId: row.execution_id,
32
+ step: row.step,
33
+ scopeKey: row.scope_key,
34
+ attempt: row.attempt,
35
+ expiresAt: row.expires_at.toISOString(),
36
+ expired: row.expired,
37
+ };
38
+ },
39
+ insertClaim: async (executionId, step, scopeKey, ttlMs) => {
40
+ const { rows } = await tx.query(`insert into ${CLAIMS} (case_id, execution_id, step, scope_key, expires_at)
41
+ values ($1, $2, $3, $4, ${expiryExpression('$5')})
42
+ returning claimed_at`, [caseId, executionId, step, scopeKey, ttlMs]);
43
+ return {
44
+ claimedAt: rows[0]?.claimed_at.toISOString() ?? new Date().toISOString(),
45
+ };
46
+ },
47
+ deleteClaim: async (executionId) => {
48
+ await tx.query(`delete from ${CLAIMS} where case_id = $1 and execution_id = $2`, [caseId, executionId]);
49
+ },
50
+ appendEntry: (input) => appendEntry(tx, input),
51
+ updateCaseState: async (state, dormancy) => {
52
+ const updated = await updateCaseState(tx, caseId, state, dormancy);
53
+ return {
54
+ seq: updated.seq,
55
+ endedAt: updated.endedAt === null ? null : updated.endedAt.toISOString(),
56
+ };
57
+ },
58
+ applyEffects: async (effects) => {
59
+ const context = commitContext(tx);
60
+ for (const effect of effects) {
61
+ if (effect.kind === 'write')
62
+ await effect.write(context);
63
+ else
64
+ await registerCorrelation(tx, effect.registration);
65
+ }
66
+ },
67
+ });
68
+ }),
69
+ appendEntry: (input) => appendEntry(q, input),
70
+ heartbeat: async (caseId, executionId, ttlMs) => {
71
+ await q
72
+ .query(`update ${CLAIMS}
73
+ set heartbeat_at = now(), expires_at = ${expiryExpression('$3')}
74
+ where case_id = $1 and execution_id = $2`, [caseId, executionId, ttlMs])
75
+ .catch(() => undefined);
76
+ },
77
+ bumpAttempt: async (caseId, executionId, attempt) => {
78
+ await q
79
+ .query(`update ${CLAIMS} set attempt = $3 where case_id = $1 and execution_id = $2`, [caseId, executionId, attempt])
80
+ .catch(() => undefined);
81
+ },
82
+ releaseClaim: async (caseId, executionId) => {
83
+ await q
84
+ .query(`delete from ${CLAIMS} where case_id = $1 and execution_id = $2`, [caseId, executionId])
85
+ .catch(() => undefined);
86
+ },
87
+ };
88
+ };
89
+ //# sourceMappingURL=execution.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"execution.js","sourceRoot":"","sources":["../src/execution.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAA;AAEpD,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAA;AACjD,OAAO,EAAE,mBAAmB,EAAE,MAAM,kBAAkB,CAAA;AACtD,OAAO,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAE1C,OAAO,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AAC5C,OAAO,EAAE,iBAAiB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAC/D,OAAO,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAA;AAElD,MAAM,KAAK,GAAG,GAAG,gBAAgB,QAAQ,CAAA;AACzC,MAAM,MAAM,GAAG,GAAG,gBAAgB,SAAS,CAAA;AAC3C,MAAM,gBAAgB,GAAG,CAAC,SAAiB,EAAU,EAAE,CACrD,YAAY,SAAS,gDAAgD,CAAA;AAWvE,2GAA2G;AAC3G,MAAM,CAAC,MAAM,eAAe,GAAG,CAC7B,EAAkB,EAClB,aAA2C,EACnB,EAAE;IAC1B,iEAAiE;IACjE,4EAA4E;IAC5E,MAAM,CAAC,GAAG,WAAW,CAAC,EAAE,CAAC,CAAA;IACzB,OAAO;QACL,QAAQ,EAAE,CAAC,MAAM,EAAE,EAAE,EAAE,EAAE,CACvB,eAAe,CAAC,EAAE,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE;YAC/B,8EAA8E;YAC9E,MAAM,EAAE,IAAI,EAAE,GAAG,MAAM,EAAE,CAAC,KAAK,CAC7B,kBAAkB,KAAK,2BAA2B,EAClD,CAAC,MAAM,CAAC,CACT,CAAA;YACD,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC;gBAAE,MAAM,IAAI,iBAAiB,CAAC,MAAM,CAAC,CAAA;YAC1D,OAAO,EAAE,CAAC;gBACR,QAAQ,EAAE,GAAG,EAAE,CAAC,iBAAiB,CAAC,EAAE,EAAE,MAAM,CAAC;gBAC7C,YAAY,EAAE,KAAK,IAAI,EAAE;oBACvB,MAAM,EAAE,IAAI,EAAE,GAAG,MAAM,EAAE,CAAC,KAAK,CAC7B;oBACM,MAAM,qBAAqB,EACjC,CAAC,MAAM,CAAC,CACT,CAAA;oBACD,MAAM,GAAG,GAAG,IAAI,CAAC,CAAC,CAAC,CAAA;oBACnB,IAAI,CAAC,GAAG;wBAAE,OAAO,IAAI,CAAA;oBACrB,OAAO;wBACL,WAAW,EAAE,GAAG,CAAC,YAAY;wBAC7B,IAAI,EAAE,GAAG,CAAC,IAAI;wBACd,QAAQ,EAAE,GAAG,CAAC,SAAS;wBACvB,OAAO,EAAE,GAAG,CAAC,OAAO;wBACpB,SAAS,EAAE,GAAG,CAAC,UAAU,CAAC,WAAW,EAAE;wBACvC,OAAO,EAAE,GAAG,CAAC,OAAO;qBACrB,CAAA;gBACH,CAAC;gBACD,WAAW,EAAE,KAAK,EAAE,WAAW,EAAE,IAAI,EAAE,QAAQ,EAAE,KAAK,EAAE,EAAE;oBACxD,MAAM,EAAE,IAAI,EAAE,GAAG,MAAM,EAAE,CAAC,KAAK,CAC7B,eAAe,MAAM;uCACI,gBAAgB,CAAC,IAAI,CAAC;kCAC3B,EACpB,CAAC,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,QAAQ,EAAE,KAAK,CAAC,CAC7C,CAAA;oBACD,OAAO;wBACL,SAAS,EACP,IAAI,CAAC,CAAC,CAAC,EAAE,UAAU,CAAC,WAAW,EAAE,IAAI,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;qBAChE,CAAA;gBACH,CAAC;gBACD,WAAW,EAAE,KAAK,EAAE,WAAW,EAAE,EAAE;oBACjC,MAAM,EAAE,CAAC,KAAK,CACZ,eAAe,MAAM,2CAA2C,EAChE,CAAC,MAAM,EAAE,WAAW,CAAC,CACtB,CAAA;gBACH,CAAC;gBACD,WAAW,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,WAAW,CAAC,EAAE,EAAE,KAAK,CAAC;gBAC9C,eAAe,EAAE,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,EAAE;oBACzC,MAAM,OAAO,GAAG,MAAM,eAAe,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,QAAQ,CAAC,CAAA;oBAClE,OAAO;wBACL,GAAG,EAAE,OAAO,CAAC,GAAG;wBAChB,OAAO,EACL,OAAO,CAAC,OAAO,KAAK,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC,OAAO,CAAC,WAAW,EAAE;qBAClE,CAAA;gBACH,CAAC;gBACD,YAAY,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE;oBAC9B,MAAM,OAAO,GAAG,aAAa,CAAC,EAAE,CAAC,CAAA;oBACjC,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;wBAC7B,IAAI,MAAM,CAAC,IAAI,KAAK,OAAO;4BAAE,MAAM,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,CAAA;;4BACnD,MAAM,mBAAmB,CAAC,EAAE,EAAE,MAAM,CAAC,YAAY,CAAC,CAAA;oBACzD,CAAC;gBACH,CAAC;aACF,CAAC,CAAA;QACJ,CAAC,CAAC;QACJ,WAAW,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,WAAW,CAAC,CAAC,EAAE,KAAK,CAAC;QAC7C,SAAS,EAAE,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,KAAK,EAAE,EAAE;YAC9C,MAAM,CAAC;iBACJ,KAAK,CACJ,UAAU,MAAM;oDAC0B,gBAAgB,CAAC,IAAI,CAAC;oDACtB,EAC1C,CAAC,MAAM,EAAE,WAAW,EAAE,KAAK,CAAC,CAC7B;iBACA,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAA;QAC3B,CAAC;QACD,WAAW,EAAE,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE,EAAE;YAClD,MAAM,CAAC;iBACJ,KAAK,CACJ,UAAU,MAAM,4DAA4D,EAC5E,CAAC,MAAM,EAAE,WAAW,EAAE,OAAO,CAAC,CAC/B;iBACA,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAA;QAC3B,CAAC;QACD,YAAY,EAAE,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,EAAE;YAC1C,MAAM,CAAC;iBACJ,KAAK,CACJ,eAAe,MAAM,2CAA2C,EAChE,CAAC,MAAM,EAAE,WAAW,CAAC,CACtB;iBACA,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAA;QAC3B,CAAC;KACF,CAAA;AACH,CAAC,CAAA","sourcesContent":["import { CaseNotFoundError } from '@affordance/core'\nimport type { LifecyclePort } from '@affordance/core/storage'\nimport { FRAMEWORK_SCHEMA } from './bootstrap.js'\nimport { registerCorrelation } from './correlation.js'\nimport { appendEntry } from './journal.js'\nimport type { DatabaseAccess, Transaction } from './queryable.js'\nimport { queryableOf } from './queryable.js'\nimport { selectCaseUntyped, updateCaseState } from './store.js'\nimport { withTransaction } from './transaction.js'\n\nconst CASES = `${FRAMEWORK_SCHEMA}.cases`\nconst CLAIMS = `${FRAMEWORK_SCHEMA}.claims`\nconst expiryExpression = (parameter: string): string =>\n `now() + (${parameter}::double precision * interval '1 millisecond')`\n\ntype ClaimRow = {\n execution_id: string\n step: string\n scope_key: string | null\n attempt: number\n expires_at: Date\n expired: boolean\n}\n\n/** The production adapter: each port verb implemented as SQL over the claims, journal and cases tables. */\nexport const pgLifecyclePort = <TCommit>(\n db: DatabaseAccess,\n commitContext: (tx: Transaction) => TCommit,\n): LifecyclePort<TCommit> => {\n // The lease verbs are single self-contained statements; only the\n // case-locked transactions care which arm of the access the caller brought.\n const q = queryableOf(db)\n return {\n withCase: (caseId, fn) =>\n withTransaction(db, async (tx) => {\n // Establish serialization before invoking any domain decisions or app writes.\n const { rows } = await tx.query<{ id: string }>(\n `select id from ${CASES} where id = $1 for update`,\n [caseId],\n )\n if (rows.length === 0) throw new CaseNotFoundError(caseId)\n return fn({\n loadCase: () => selectCaseUntyped(tx, caseId),\n currentClaim: async () => {\n const { rows } = await tx.query<ClaimRow>(\n `select execution_id, step, scope_key, attempt, expires_at, expires_at <= now() as expired\n from ${CLAIMS} where case_id = $1`,\n [caseId],\n )\n const row = rows[0]\n if (!row) return null\n return {\n executionId: row.execution_id,\n step: row.step,\n scopeKey: row.scope_key,\n attempt: row.attempt,\n expiresAt: row.expires_at.toISOString(),\n expired: row.expired,\n }\n },\n insertClaim: async (executionId, step, scopeKey, ttlMs) => {\n const { rows } = await tx.query<{ claimed_at: Date }>(\n `insert into ${CLAIMS} (case_id, execution_id, step, scope_key, expires_at)\n values ($1, $2, $3, $4, ${expiryExpression('$5')})\n returning claimed_at`,\n [caseId, executionId, step, scopeKey, ttlMs],\n )\n return {\n claimedAt:\n rows[0]?.claimed_at.toISOString() ?? new Date().toISOString(),\n }\n },\n deleteClaim: async (executionId) => {\n await tx.query(\n `delete from ${CLAIMS} where case_id = $1 and execution_id = $2`,\n [caseId, executionId],\n )\n },\n appendEntry: (input) => appendEntry(tx, input),\n updateCaseState: async (state, dormancy) => {\n const updated = await updateCaseState(tx, caseId, state, dormancy)\n return {\n seq: updated.seq,\n endedAt:\n updated.endedAt === null ? null : updated.endedAt.toISOString(),\n }\n },\n applyEffects: async (effects) => {\n const context = commitContext(tx)\n for (const effect of effects) {\n if (effect.kind === 'write') await effect.write(context)\n else await registerCorrelation(tx, effect.registration)\n }\n },\n })\n }),\n appendEntry: (input) => appendEntry(q, input),\n heartbeat: async (caseId, executionId, ttlMs) => {\n await q\n .query(\n `update ${CLAIMS}\n set heartbeat_at = now(), expires_at = ${expiryExpression('$3')}\n where case_id = $1 and execution_id = $2`,\n [caseId, executionId, ttlMs],\n )\n .catch(() => undefined)\n },\n bumpAttempt: async (caseId, executionId, attempt) => {\n await q\n .query(\n `update ${CLAIMS} set attempt = $3 where case_id = $1 and execution_id = $2`,\n [caseId, executionId, attempt],\n )\n .catch(() => undefined)\n },\n releaseClaim: async (caseId, executionId) => {\n await q\n .query(\n `delete from ${CLAIMS} where case_id = $1 and execution_id = $2`,\n [caseId, executionId],\n )\n .catch(() => undefined)\n },\n }\n}\n"]}
@@ -0,0 +1,8 @@
1
+ /** Postgres storage for the Affordance engine. */
2
+ export { deleteCase } from './admin.js';
3
+ export { bootstrap, CASE_TABLES, FRAMEWORK_SCHEMA } from './bootstrap.js';
4
+ export type { DatabaseAccess, PoolLike, Queryable, Transaction, } from './queryable.js';
5
+ export { queryableOf } from './queryable.js';
6
+ export type { PgStorageOptions } from './storage.js';
7
+ export { createPgStorage } from './storage.js';
8
+ export { withTransaction } from './transaction.js';
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ /** Postgres storage for the Affordance engine. */
2
+ export { deleteCase } from './admin.js';
3
+ export { bootstrap, CASE_TABLES, FRAMEWORK_SCHEMA } from './bootstrap.js';
4
+ export { queryableOf } from './queryable.js';
5
+ export { createPgStorage } from './storage.js';
6
+ export { withTransaction } from './transaction.js';
7
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,kDAAkD;AAElD,OAAO,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AACvC,OAAO,EAAE,SAAS,EAAE,WAAW,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAA;AAOzE,OAAO,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AAE5C,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAA;AAC9C,OAAO,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAA","sourcesContent":["/** Postgres storage for the Affordance engine. */\n\nexport { deleteCase } from './admin.js'\nexport { bootstrap, CASE_TABLES, FRAMEWORK_SCHEMA } from './bootstrap.js'\nexport type {\n DatabaseAccess,\n PoolLike,\n Queryable,\n Transaction,\n} from './queryable.js'\nexport { queryableOf } from './queryable.js'\nexport type { PgStorageOptions } from './storage.js'\nexport { createPgStorage } from './storage.js'\nexport { withTransaction } from './transaction.js'\n"]}
@@ -0,0 +1,9 @@
1
+ import type { JournalEntry, JournalEntryInput, JournalFilter } from '@affordance/core';
2
+ import type { Queryable } from './queryable.js';
3
+ /** Append one entry. Inserts only — journal rows are never updated or deleted. */
4
+ export declare const appendEntry: (db: Queryable, input: JournalEntryInput) => Promise<JournalEntry>;
5
+ /**
6
+ * Read a case's journal in insertion order, oldest first. With no filter this
7
+ * is the whole story of the case; with `scopeKey` it is one track's audit.
8
+ */
9
+ export declare const readJournal: (db: Queryable, caseId: string, filter?: JournalFilter) => Promise<readonly JournalEntry[]>;
@@ -0,0 +1,94 @@
1
+ import { mintId, projectEntry } from '@affordance/core/storage';
2
+ import { FRAMEWORK_SCHEMA } from './bootstrap.js';
3
+ import { sqlWhere } from './sql.js';
4
+ const JOURNAL = `${FRAMEWORK_SCHEMA}.journal`;
5
+ const JOURNAL_COLUMNS = 'ordinal, id, case_id, execution_id, entry, attempt, step, scope_key, actor, input, as_of, guard, state, delta, dormancy, error, recorded_at';
6
+ const toEntry = (row) => ({
7
+ ordinal: Number(row.ordinal),
8
+ id: row.id,
9
+ caseId: row.case_id,
10
+ executionId: row.execution_id,
11
+ entry: row.entry,
12
+ attempt: row.attempt,
13
+ step: row.step,
14
+ scopeKey: row.scope_key,
15
+ actor: row.actor,
16
+ input: row.input,
17
+ asOf: row.as_of === null ? null : row.as_of.toISOString(),
18
+ guard: row.guard,
19
+ state: row.state,
20
+ delta: row.delta,
21
+ dormancy: row.dormancy,
22
+ error: row.error,
23
+ recordedAt: row.recorded_at.toISOString(),
24
+ });
25
+ /**
26
+ * Serialize a value for a jsonb column. Actors and inputs are app-owned
27
+ * shapes, and a journal append must never be the thing that fails an
28
+ * otherwise-good Execution: a value that will not stringify (a cycle, a
29
+ * BigInt) is journaled as a marker string rather than thrown over.
30
+ */
31
+ const toJsonb = (value) => {
32
+ if (value === undefined || value === null)
33
+ return null;
34
+ try {
35
+ const json = JSON.stringify(value);
36
+ return json === undefined ? null : json;
37
+ }
38
+ catch {
39
+ return JSON.stringify({ '~unserializable': String(value) });
40
+ }
41
+ };
42
+ /** Append one entry. Inserts only — journal rows are never updated or deleted. */
43
+ export const appendEntry = async (db, input) => {
44
+ const entry = projectEntry(input);
45
+ const { rows } = await db.query(`insert into ${JOURNAL}
46
+ (id, case_id, execution_id, entry, attempt, step, scope_key, actor, input, as_of, guard, state, delta, dormancy, error)
47
+ values ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9::jsonb, $10::timestamptz, $11::jsonb, $12::jsonb, $13::jsonb, $14, $15::jsonb)
48
+ returning ${JOURNAL_COLUMNS}`, [
49
+ mintId('journal'),
50
+ entry.caseId,
51
+ entry.executionId,
52
+ entry.entry,
53
+ entry.attempt,
54
+ entry.step,
55
+ entry.scopeKey,
56
+ toJsonb(entry.actor),
57
+ toJsonb(entry.input),
58
+ entry.asOf,
59
+ toJsonb(entry.guard),
60
+ toJsonb(entry.state),
61
+ toJsonb(entry.delta),
62
+ entry.dormancy,
63
+ toJsonb(entry.error),
64
+ ]);
65
+ const row = rows[0];
66
+ if (!row)
67
+ throw new Error(`insert into ${JOURNAL} returned no row`);
68
+ return toEntry(row);
69
+ };
70
+ /**
71
+ * Read a case's journal in insertion order, oldest first. With no filter this
72
+ * is the whole story of the case; with `scopeKey` it is one track's audit.
73
+ */
74
+ export const readJournal = async (db, caseId, filter = {}) => {
75
+ const { conditions, values, bind, where } = sqlWhere(['case_id = $1'], [caseId]);
76
+ if (filter.scopeKey !== undefined)
77
+ conditions.push(`scope_key = ${bind(filter.scopeKey)}`);
78
+ if (filter.step !== undefined)
79
+ conditions.push(`step = ${bind(filter.step)}`);
80
+ if (filter.executionId !== undefined)
81
+ conditions.push(`execution_id = ${bind(filter.executionId)}`);
82
+ if (filter.entry !== undefined) {
83
+ const entries = Array.isArray(filter.entry) ? filter.entry : [filter.entry];
84
+ conditions.push(`entry = any(${bind(entries)}::text[])`);
85
+ }
86
+ if (filter.since !== undefined)
87
+ conditions.push(`ordinal > ${bind(filter.since)}`);
88
+ const limit = filter.limit === undefined ? '' : ` limit ${bind(filter.limit)}`;
89
+ const { rows } = await db.query(`select ${JOURNAL_COLUMNS} from ${JOURNAL}
90
+ where ${where()}
91
+ order by ordinal asc${limit}`, values);
92
+ return rows.map(toEntry);
93
+ };
94
+ //# sourceMappingURL=journal.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"journal.js","sourceRoot":"","sources":["../src/journal.ts"],"names":[],"mappings":"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","sourcesContent":["import type {\n GuardEvaluation,\n JournalEntry,\n JournalEntryInput,\n JournalEntryType,\n JournalError,\n JournalFilter,\n StateDelta,\n} from '@affordance/core'\nimport { mintId, projectEntry } from '@affordance/core/storage'\nimport { FRAMEWORK_SCHEMA } from './bootstrap.js'\nimport type { Queryable } from './queryable.js'\nimport { sqlWhere } from './sql.js'\n\nconst JOURNAL = `${FRAMEWORK_SCHEMA}.journal`\nconst JOURNAL_COLUMNS =\n 'ordinal, id, case_id, execution_id, entry, attempt, step, scope_key, actor, input, as_of, guard, state, delta, dormancy, error, recorded_at'\n\ntype JournalRow = {\n ordinal: string | number\n id: string\n case_id: string\n execution_id: string\n entry: string\n attempt: number\n step: string\n scope_key: string | null\n actor: unknown\n input: unknown\n as_of: Date | null\n guard: GuardEvaluation | null\n state: unknown\n delta: StateDelta | null\n dormancy: string | null\n error: JournalError | null\n recorded_at: Date\n}\n\nconst toEntry = (row: JournalRow): JournalEntry => ({\n ordinal: Number(row.ordinal),\n id: row.id,\n caseId: row.case_id,\n executionId: row.execution_id,\n entry: row.entry as JournalEntryType,\n attempt: row.attempt,\n step: row.step,\n scopeKey: row.scope_key,\n actor: row.actor,\n input: row.input,\n asOf: row.as_of === null ? null : row.as_of.toISOString(),\n guard: row.guard,\n state: row.state,\n delta: row.delta,\n dormancy: row.dormancy as 'ended' | 'reopened' | null,\n error: row.error,\n recordedAt: row.recorded_at.toISOString(),\n})\n\n/**\n * Serialize a value for a jsonb column. Actors and inputs are app-owned\n * shapes, and a journal append must never be the thing that fails an\n * otherwise-good Execution: a value that will not stringify (a cycle, a\n * BigInt) is journaled as a marker string rather than thrown over.\n */\nconst toJsonb = (value: unknown): string | null => {\n if (value === undefined || value === null) return null\n try {\n const json = JSON.stringify(value)\n return json === undefined ? null : json\n } catch {\n return JSON.stringify({ '~unserializable': String(value) })\n }\n}\n\n/** Append one entry. Inserts only — journal rows are never updated or deleted. */\nexport const appendEntry = async (\n db: Queryable,\n input: JournalEntryInput,\n): Promise<JournalEntry> => {\n const entry = projectEntry(input)\n const { rows } = await db.query<JournalRow>(\n `insert into ${JOURNAL}\n (id, case_id, execution_id, entry, attempt, step, scope_key, actor, input, as_of, guard, state, delta, dormancy, error)\n values ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9::jsonb, $10::timestamptz, $11::jsonb, $12::jsonb, $13::jsonb, $14, $15::jsonb)\n returning ${JOURNAL_COLUMNS}`,\n [\n mintId('journal'),\n entry.caseId,\n entry.executionId,\n entry.entry,\n entry.attempt,\n entry.step,\n entry.scopeKey,\n toJsonb(entry.actor),\n toJsonb(entry.input),\n entry.asOf,\n toJsonb(entry.guard),\n toJsonb(entry.state),\n toJsonb(entry.delta),\n entry.dormancy,\n toJsonb(entry.error),\n ],\n )\n const row = rows[0]\n if (!row) throw new Error(`insert into ${JOURNAL} returned no row`)\n return toEntry(row)\n}\n\n/**\n * Read a case's journal in insertion order, oldest first. With no filter this\n * is the whole story of the case; with `scopeKey` it is one track's audit.\n */\nexport const readJournal = async (\n db: Queryable,\n caseId: string,\n filter: JournalFilter = {},\n): Promise<readonly JournalEntry[]> => {\n const { conditions, values, bind, where } = sqlWhere(\n ['case_id = $1'],\n [caseId],\n )\n\n if (filter.scopeKey !== undefined)\n conditions.push(`scope_key = ${bind(filter.scopeKey)}`)\n if (filter.step !== undefined) conditions.push(`step = ${bind(filter.step)}`)\n if (filter.executionId !== undefined)\n conditions.push(`execution_id = ${bind(filter.executionId)}`)\n if (filter.entry !== undefined) {\n const entries = Array.isArray(filter.entry) ? filter.entry : [filter.entry]\n conditions.push(`entry = any(${bind(entries)}::text[])`)\n }\n if (filter.since !== undefined)\n conditions.push(`ordinal > ${bind(filter.since)}`)\n\n const limit = filter.limit === undefined ? '' : ` limit ${bind(filter.limit)}`\n const { rows } = await db.query<JournalRow>(\n `select ${JOURNAL_COLUMNS} from ${JOURNAL}\n where ${where()}\n order by ordinal asc${limit}`,\n values,\n )\n return rows.map(toEntry)\n}\n"]}
@@ -0,0 +1,7 @@
1
+ import type { CaseRepository } from '@affordance/core/storage';
2
+ import type { Queryable } from './queryable.js';
3
+ /** The cursor preserves Postgres timestamp precision, including sub-millisecond ties. */
4
+ export declare const listCases: (db: Queryable, options: Parameters<CaseRepository["list"]>[0]) => Promise<{
5
+ cases: import("@affordance/core").CaseHandle<unknown>[];
6
+ nextCursor: string | null;
7
+ }>;
@@ -0,0 +1,49 @@
1
+ import { FRAMEWORK_SCHEMA } from './bootstrap.js';
2
+ import { toHandle } from './store.js';
3
+ /** The cursor preserves Postgres timestamp precision, including sub-millisecond ties. */
4
+ export const listCases = async (db, options) => {
5
+ const types = [...options.caseTypeNames].sort();
6
+ const filter = JSON.stringify([types, options.includeEnded === true]);
7
+ let after = null;
8
+ if (options.cursor !== undefined) {
9
+ try {
10
+ const parsed = JSON.parse(Buffer.from(options.cursor, 'base64url').toString('utf8'));
11
+ if (parsed.version !== 1 ||
12
+ parsed.filter !== filter ||
13
+ typeof parsed.createdAt !== 'string' ||
14
+ !Number.isFinite(Date.parse(parsed.createdAt)) ||
15
+ typeof parsed.id !== 'string')
16
+ throw new Error('invalid');
17
+ after = parsed;
18
+ }
19
+ catch {
20
+ throw new TypeError('listCases: invalid cursor or changed filters');
21
+ }
22
+ }
23
+ const { rows } = await db.query(`select id, case_type, state, seq, ended_at, created_at, updated_at,
24
+ created_at::text as cursor_created_at
25
+ from ${FRAMEWORK_SCHEMA}.cases
26
+ where case_type = any($1::text[]) and ($2::boolean or ended_at is null)
27
+ and ($3::timestamptz is null or (created_at, id) < ($3::timestamptz, $4::text))
28
+ order by created_at desc, id desc limit $5`, [
29
+ types,
30
+ options.includeEnded === true,
31
+ after?.createdAt ?? null,
32
+ after?.id ?? null,
33
+ options.limit + 1,
34
+ ]);
35
+ const selected = rows.slice(0, options.limit);
36
+ const last = selected.at(-1);
37
+ return {
38
+ cases: selected.map((row) => toHandle(row, row.state)),
39
+ nextCursor: rows.length > options.limit && last !== undefined
40
+ ? Buffer.from(JSON.stringify({
41
+ version: 1,
42
+ filter,
43
+ createdAt: last.cursor_created_at,
44
+ id: last.id,
45
+ })).toString('base64url')
46
+ : null,
47
+ };
48
+ };
49
+ //# sourceMappingURL=listing.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"listing.js","sourceRoot":"","sources":["../src/listing.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAA;AAEjD,OAAO,EAAgB,QAAQ,EAAE,MAAM,YAAY,CAAA;AAEnD,yFAAyF;AACzF,MAAM,CAAC,MAAM,SAAS,GAAG,KAAK,EAC5B,EAAa,EACb,OAA8C,EAC9C,EAAE;IACF,MAAM,KAAK,GAAG,CAAC,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC,IAAI,EAAE,CAAA;IAC/C,MAAM,MAAM,GAAG,IAAI,CAAC,SAAS,CAAC,CAAC,KAAK,EAAE,OAAO,CAAC,YAAY,KAAK,IAAI,CAAC,CAAC,CAAA;IACrE,IAAI,KAAK,GAA6C,IAAI,CAAA;IAC1D,IAAI,OAAO,CAAC,MAAM,KAAK,SAAS,EAAE,CAAC;QACjC,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CACvB,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAC1D,CAAA;YACD,IACE,MAAM,CAAC,OAAO,KAAK,CAAC;gBACpB,MAAM,CAAC,MAAM,KAAK,MAAM;gBACxB,OAAO,MAAM,CAAC,SAAS,KAAK,QAAQ;gBACpC,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;gBAC9C,OAAO,MAAM,CAAC,EAAE,KAAK,QAAQ;gBAE7B,MAAM,IAAI,KAAK,CAAC,SAAS,CAAC,CAAA;YAC5B,KAAK,GAAG,MAAM,CAAA;QAChB,CAAC;QAAC,MAAM,CAAC;YACP,MAAM,IAAI,SAAS,CAAC,8CAA8C,CAAC,CAAA;QACrE,CAAC;IACH,CAAC;IACD,MAAM,EAAE,IAAI,EAAE,GAAG,MAAM,EAAE,CAAC,KAAK,CAC7B;;YAEQ,gBAAgB;;;gDAGoB,EAC5C;QACE,KAAK;QACL,OAAO,CAAC,YAAY,KAAK,IAAI;QAC7B,KAAK,EAAE,SAAS,IAAI,IAAI;QACxB,KAAK,EAAE,EAAE,IAAI,IAAI;QACjB,OAAO,CAAC,KAAK,GAAG,CAAC;KAClB,CACF,CAAA;IACD,MAAM,QAAQ,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,OAAO,CAAC,KAAK,CAAC,CAAA;IAC7C,MAAM,IAAI,GAAG,QAAQ,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAA;IAC5B,OAAO;QACL,KAAK,EAAE,QAAQ,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,QAAQ,CAAC,GAAG,EAAE,GAAG,CAAC,KAAK,CAAC,CAAC;QACtD,UAAU,EACR,IAAI,CAAC,MAAM,GAAG,OAAO,CAAC,KAAK,IAAI,IAAI,KAAK,SAAS;YAC/C,CAAC,CAAC,MAAM,CAAC,IAAI,CACT,IAAI,CAAC,SAAS,CAAC;gBACb,OAAO,EAAE,CAAC;gBACV,MAAM;gBACN,SAAS,EAAE,IAAI,CAAC,iBAAiB;gBACjC,EAAE,EAAE,IAAI,CAAC,EAAE;aACZ,CAAC,CACH,CAAC,QAAQ,CAAC,WAAW,CAAC;YACzB,CAAC,CAAC,IAAI;KACX,CAAA;AACH,CAAC,CAAA","sourcesContent":["import type { CaseRepository } from '@affordance/core/storage'\nimport { FRAMEWORK_SCHEMA } from './bootstrap.js'\nimport type { Queryable } from './queryable.js'\nimport { type CaseRow, toHandle } from './store.js'\n\n/** The cursor preserves Postgres timestamp precision, including sub-millisecond ties. */\nexport const listCases = async (\n db: Queryable,\n options: Parameters<CaseRepository['list']>[0],\n) => {\n const types = [...options.caseTypeNames].sort()\n const filter = JSON.stringify([types, options.includeEnded === true])\n let after: { createdAt: string; id: string } | null = null\n if (options.cursor !== undefined) {\n try {\n const parsed = JSON.parse(\n Buffer.from(options.cursor, 'base64url').toString('utf8'),\n )\n if (\n parsed.version !== 1 ||\n parsed.filter !== filter ||\n typeof parsed.createdAt !== 'string' ||\n !Number.isFinite(Date.parse(parsed.createdAt)) ||\n typeof parsed.id !== 'string'\n )\n throw new Error('invalid')\n after = parsed\n } catch {\n throw new TypeError('listCases: invalid cursor or changed filters')\n }\n }\n const { rows } = await db.query<CaseRow & { cursor_created_at: string }>(\n `select id, case_type, state, seq, ended_at, created_at, updated_at,\n created_at::text as cursor_created_at\n from ${FRAMEWORK_SCHEMA}.cases\n where case_type = any($1::text[]) and ($2::boolean or ended_at is null)\n and ($3::timestamptz is null or (created_at, id) < ($3::timestamptz, $4::text))\n order by created_at desc, id desc limit $5`,\n [\n types,\n options.includeEnded === true,\n after?.createdAt ?? null,\n after?.id ?? null,\n options.limit + 1,\n ],\n )\n const selected = rows.slice(0, options.limit)\n const last = selected.at(-1)\n return {\n cases: selected.map((row) => toHandle(row, row.state)),\n nextCursor:\n rows.length > options.limit && last !== undefined\n ? Buffer.from(\n JSON.stringify({\n version: 1,\n filter,\n createdAt: last.cursor_created_at,\n id: last.id,\n }),\n ).toString('base64url')\n : null,\n }\n}\n"]}
@@ -0,0 +1,7 @@
1
+ import type { MigrationOptions } from '@affordance/core';
2
+ import type { MigrationPage } from '@affordance/core/storage';
3
+ import type { Queryable } from './queryable.js';
4
+ /** Case ids of this type that do not yet carry the migration's marker, oldest first. */
5
+ export declare const findCandidates: (db: Queryable, caseTypeName: string, marker: string, options: MigrationOptions, afterId: string | null, batchSize: number) => Promise<MigrationPage>;
6
+ /** Whether one case already carries a migration's marker. */
7
+ export declare const hasCompleted: (db: Queryable, caseId: string, marker: string) => Promise<boolean>;
@@ -0,0 +1,38 @@
1
+ import { FRAMEWORK_SCHEMA } from './bootstrap.js';
2
+ import { sqlWhere } from './sql.js';
3
+ const CASES = `${FRAMEWORK_SCHEMA}.cases`;
4
+ const JOURNAL = `${FRAMEWORK_SCHEMA}.journal`;
5
+ /** Case ids of this type that do not yet carry the migration's marker, oldest first. */
6
+ export const findCandidates = async (db, caseTypeName, marker, options, afterId, batchSize) => {
7
+ const { conditions, values, bind, where } = sqlWhere([
8
+ `c.case_type = $1`,
9
+ // The marker: a completed Execution of this migration on this case. The
10
+ // journal is the record of what has happened, so it is also the record of
11
+ // what has already been migrated — no bookkeeping table, no state flag.
12
+ `not exists (
13
+ select 1 from ${JOURNAL} j
14
+ where j.case_id = c.id and j.step = $2 and j.entry = 'completed'
15
+ )`,
16
+ ], [caseTypeName, marker]);
17
+ if (options.includeEnded !== true)
18
+ conditions.push(`c.ended_at is null`);
19
+ if (options.caseIds !== undefined)
20
+ conditions.push(`c.id = any(${bind(options.caseIds)}::text[])`);
21
+ if (afterId !== null)
22
+ conditions.push(`c.id > ${bind(afterId)}`);
23
+ const { rows } = await db.query(`select c.id, c.state from ${CASES} c
24
+ where ${where()}
25
+ order by c.id asc
26
+ limit ${bind(batchSize)}`, values);
27
+ return {
28
+ cases: rows,
29
+ nextCursor: rows.length === batchSize ? (rows.at(-1)?.id ?? null) : null,
30
+ };
31
+ };
32
+ /** Whether one case already carries a migration's marker. */
33
+ export const hasCompleted = async (db, caseId, marker) => {
34
+ const { rows } = await db.query(`select 1 as one from ${JOURNAL}
35
+ where case_id = $1 and step = $2 and entry = 'completed' limit 1`, [caseId, marker]);
36
+ return rows.length > 0;
37
+ };
38
+ //# sourceMappingURL=migration.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"migration.js","sourceRoot":"","sources":["../src/migration.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAA;AAEjD,OAAO,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAA;AAEnC,MAAM,KAAK,GAAG,GAAG,gBAAgB,QAAQ,CAAA;AACzC,MAAM,OAAO,GAAG,GAAG,gBAAgB,UAAU,CAAA;AAC7C,wFAAwF;AACxF,MAAM,CAAC,MAAM,cAAc,GAAG,KAAK,EACjC,EAAa,EACb,YAAoB,EACpB,MAAc,EACd,OAAyB,EACzB,OAAsB,EACtB,SAAiB,EACO,EAAE;IAC1B,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,GAAG,QAAQ,CAClD;QACE,kBAAkB;QAClB,wEAAwE;QACxE,0EAA0E;QAC1E,wEAAwE;QACxE;uBACiB,OAAO;;OAEvB;KACF,EACD,CAAC,YAAY,EAAE,MAAM,CAAC,CACvB,CAAA;IACD,IAAI,OAAO,CAAC,YAAY,KAAK,IAAI;QAAE,UAAU,CAAC,IAAI,CAAC,oBAAoB,CAAC,CAAA;IACxE,IAAI,OAAO,CAAC,OAAO,KAAK,SAAS;QAC/B,UAAU,CAAC,IAAI,CAAC,cAAc,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,WAAW,CAAC,CAAA;IACjE,IAAI,OAAO,KAAK,IAAI;QAAE,UAAU,CAAC,IAAI,CAAC,UAAU,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,CAAA;IAEhE,MAAM,EAAE,IAAI,EAAE,GAAG,MAAM,EAAE,CAAC,KAAK,CAC7B,6BAA6B,KAAK;aACzB,KAAK,EAAE;;aAEP,IAAI,CAAC,SAAS,CAAC,EAAE,EAC1B,MAAM,CACP,CAAA;IACD,OAAO;QACL,KAAK,EAAE,IAAI;QACX,UAAU,EAAE,IAAI,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,EAAE,IAAI,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI;KACzE,CAAA;AACH,CAAC,CAAA;AAED,6DAA6D;AAC7D,MAAM,CAAC,MAAM,YAAY,GAAG,KAAK,EAC/B,EAAa,EACb,MAAc,EACd,MAAc,EACI,EAAE;IACpB,MAAM,EAAE,IAAI,EAAE,GAAG,MAAM,EAAE,CAAC,KAAK,CAC7B,wBAAwB,OAAO;sEACmC,EAClE,CAAC,MAAM,EAAE,MAAM,CAAC,CACjB,CAAA;IACD,OAAO,IAAI,CAAC,MAAM,GAAG,CAAC,CAAA;AACxB,CAAC,CAAA","sourcesContent":["import type { MigrationOptions } from '@affordance/core'\nimport type { MigrationPage } from '@affordance/core/storage'\nimport { FRAMEWORK_SCHEMA } from './bootstrap.js'\nimport type { Queryable } from './queryable.js'\nimport { sqlWhere } from './sql.js'\n\nconst CASES = `${FRAMEWORK_SCHEMA}.cases`\nconst JOURNAL = `${FRAMEWORK_SCHEMA}.journal`\n/** Case ids of this type that do not yet carry the migration's marker, oldest first. */\nexport const findCandidates = async (\n db: Queryable,\n caseTypeName: string,\n marker: string,\n options: MigrationOptions,\n afterId: string | null,\n batchSize: number,\n): Promise<MigrationPage> => {\n const { conditions, values, bind, where } = sqlWhere(\n [\n `c.case_type = $1`,\n // The marker: a completed Execution of this migration on this case. The\n // journal is the record of what has happened, so it is also the record of\n // what has already been migrated — no bookkeeping table, no state flag.\n `not exists (\n select 1 from ${JOURNAL} j\n where j.case_id = c.id and j.step = $2 and j.entry = 'completed'\n )`,\n ],\n [caseTypeName, marker],\n )\n if (options.includeEnded !== true) conditions.push(`c.ended_at is null`)\n if (options.caseIds !== undefined)\n conditions.push(`c.id = any(${bind(options.caseIds)}::text[])`)\n if (afterId !== null) conditions.push(`c.id > ${bind(afterId)}`)\n\n const { rows } = await db.query<{ id: string; state: unknown }>(\n `select c.id, c.state from ${CASES} c\n where ${where()}\n order by c.id asc\n limit ${bind(batchSize)}`,\n values,\n )\n return {\n cases: rows,\n nextCursor: rows.length === batchSize ? (rows.at(-1)?.id ?? null) : null,\n }\n}\n\n/** Whether one case already carries a migration's marker. */\nexport const hasCompleted = async (\n db: Queryable,\n caseId: string,\n marker: string,\n): Promise<boolean> => {\n const { rows } = await db.query<{ one: number }>(\n `select 1 as one from ${JOURNAL}\n where case_id = $1 and step = $2 and entry = 'completed' limit 1`,\n [caseId, marker],\n )\n return rows.length > 0\n}\n"]}
@@ -0,0 +1,61 @@
1
+ import type { QueryResult, QueryResultRow } from 'pg';
2
+ /**
3
+ * Minimal query surface satisfied by pg.Pool, pg.Client, and pg.PoolClient.
4
+ *
5
+ * Every store internal takes a Queryable rather than a Pool so a future
6
+ * caller can run case-store queries on an existing client/transaction —
7
+ * the shared-transaction seam (spec §Mechanics/Persistence: handlers may
8
+ * join the framework transaction so app-table writes commit atomically
9
+ * with case state).
10
+ */
11
+ export interface Queryable {
12
+ query<R extends QueryResultRow = QueryResultRow>(text: string, values?: unknown[]): Promise<QueryResult<R>>;
13
+ }
14
+ /**
15
+ * A pooled connection source: `connect()` checks out a client that must be
16
+ * released. `pg.Pool` satisfies this structurally, and so does any
17
+ * multiplexing wrapper (an instrumented pool, a proxy) that declares
18
+ * checkout the same way.
19
+ */
20
+ export interface PoolLike extends Queryable {
21
+ connect(): Promise<Queryable & {
22
+ release(): void;
23
+ }>;
24
+ }
25
+ /**
26
+ * The database as the engine's caller supplies it. The union states, in the
27
+ * type, the one fact a transaction must know: whether `begin` needs a
28
+ * checked-out connection first. A pool multiplexes — issuing `begin` on it
29
+ * would put each statement on a different connection — so `pool` promises
30
+ * checkout via `connect()`, and `client` asserts a connection dedicated to
31
+ * the engine, safe to run a transaction on directly. The caller declares
32
+ * which they have; nothing downstream sniffs the object to guess.
33
+ */
34
+ export type DatabaseAccess = {
35
+ readonly pool: PoolLike;
36
+ } | {
37
+ readonly client: Queryable;
38
+ };
39
+ /**
40
+ * The plain query surface of either arm — what a single self-contained
41
+ * statement (a journal read, a heartbeat) runs against, where pool vs.
42
+ * client makes no difference.
43
+ */
44
+ export declare const queryableOf: (db: DatabaseAccess) => Queryable;
45
+ declare const transactionBrand: unique symbol;
46
+ /**
47
+ * A {@link Queryable} known to be inside an open transaction — the handle
48
+ * `withTransaction` passes to its callback, and the only place the brand is
49
+ * ever applied.
50
+ *
51
+ * Some operations are meaningless (or silently wrong) against a pool: a
52
+ * `select … for update` whose lock vanishes with the statement, a write
53
+ * that must commit in the same transaction as the state it accompanies.
54
+ * Those take a `Transaction`, so "pass the transaction handle, not a pool"
55
+ * is a compile error rather than a sentence a caller has to have read.
56
+ */
57
+ export interface Transaction extends Queryable {
58
+ readonly [transactionBrand]: true;
59
+ }
60
+ export declare const withClient: <T>(client: Queryable, fn: () => Promise<T>) => Promise<T>;
61
+ export {};
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The plain query surface of either arm — what a single self-contained
3
+ * statement (a journal read, a heartbeat) runs against, where pool vs.
4
+ * client makes no difference.
5
+ */
6
+ export const queryableOf = (db) => 'pool' in db
7
+ ? db.pool
8
+ : {
9
+ query: (text, values) => withClient(db.client, () => db.client.query(text, values)),
10
+ };
11
+ // A dedicated connection cannot host overlapping transactions. Single-statement
12
+ // access participates in the same queue so it cannot join an unrelated commit.
13
+ const clientQueues = new WeakMap();
14
+ export const withClient = async (client, fn) => {
15
+ const previous = clientQueues.get(client) ?? Promise.resolve();
16
+ let release;
17
+ const current = new Promise((resolve) => {
18
+ release = resolve;
19
+ });
20
+ clientQueues.set(client, current);
21
+ await previous;
22
+ try {
23
+ return await fn();
24
+ }
25
+ finally {
26
+ if (clientQueues.get(client) === current)
27
+ clientQueues.delete(client);
28
+ release();
29
+ }
30
+ };
31
+ //# sourceMappingURL=queryable.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"queryable.js","sourceRoot":"","sources":["../src/queryable.ts"],"names":[],"mappings":"AAyCA;;;;GAIG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,CAAC,EAAkB,EAAa,EAAE,CAC3D,MAAM,IAAI,EAAE;IACV,CAAC,CAAC,EAAE,CAAC,IAAI;IACT,CAAC,CAAC;QACE,KAAK,EAAE,CACL,IAAY,EACZ,MAAkB,EAClB,EAAE,CAAC,UAAU,CAAC,EAAE,CAAC,MAAM,EAAE,GAAG,EAAE,CAAC,EAAE,CAAC,MAAM,CAAC,KAAK,CAAI,IAAI,EAAE,MAAM,CAAC,CAAC;KACnE,CAAA;AAmBP,gFAAgF;AAChF,+EAA+E;AAC/E,MAAM,YAAY,GAAG,IAAI,OAAO,EAA4B,CAAA;AAC5D,MAAM,CAAC,MAAM,UAAU,GAAG,KAAK,EAC7B,MAAiB,EACjB,EAAoB,EACR,EAAE;IACd,MAAM,QAAQ,GAAG,YAAY,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,OAAO,CAAC,OAAO,EAAE,CAAA;IAC9D,IAAI,OAAoB,CAAA;IACxB,MAAM,OAAO,GAAG,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,EAAE;QAC5C,OAAO,GAAG,OAAO,CAAA;IACnB,CAAC,CAAC,CAAA;IACF,YAAY,CAAC,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IACjC,MAAM,QAAQ,CAAA;IACd,IAAI,CAAC;QACH,OAAO,MAAM,EAAE,EAAE,CAAA;IACnB,CAAC;YAAS,CAAC;QACT,IAAI,YAAY,CAAC,GAAG,CAAC,MAAM,CAAC,KAAK,OAAO;YAAE,YAAY,CAAC,MAAM,CAAC,MAAM,CAAC,CAAA;QACrE,OAAO,EAAE,CAAA;IACX,CAAC;AACH,CAAC,CAAA","sourcesContent":["import type { QueryResult, QueryResultRow } from 'pg'\n\n/**\n * Minimal query surface satisfied by pg.Pool, pg.Client, and pg.PoolClient.\n *\n * Every store internal takes a Queryable rather than a Pool so a future\n * caller can run case-store queries on an existing client/transaction —\n * the shared-transaction seam (spec §Mechanics/Persistence: handlers may\n * join the framework transaction so app-table writes commit atomically\n * with case state).\n */\nexport interface Queryable {\n query<R extends QueryResultRow = QueryResultRow>(\n text: string,\n values?: unknown[],\n ): Promise<QueryResult<R>>\n}\n\n/**\n * A pooled connection source: `connect()` checks out a client that must be\n * released. `pg.Pool` satisfies this structurally, and so does any\n * multiplexing wrapper (an instrumented pool, a proxy) that declares\n * checkout the same way.\n */\nexport interface PoolLike extends Queryable {\n connect(): Promise<Queryable & { release(): void }>\n}\n\n/**\n * The database as the engine's caller supplies it. The union states, in the\n * type, the one fact a transaction must know: whether `begin` needs a\n * checked-out connection first. A pool multiplexes — issuing `begin` on it\n * would put each statement on a different connection — so `pool` promises\n * checkout via `connect()`, and `client` asserts a connection dedicated to\n * the engine, safe to run a transaction on directly. The caller declares\n * which they have; nothing downstream sniffs the object to guess.\n */\nexport type DatabaseAccess =\n | { readonly pool: PoolLike }\n | { readonly client: Queryable }\n\n/**\n * The plain query surface of either arm — what a single self-contained\n * statement (a journal read, a heartbeat) runs against, where pool vs.\n * client makes no difference.\n */\nexport const queryableOf = (db: DatabaseAccess): Queryable =>\n 'pool' in db\n ? db.pool\n : {\n query: <R extends QueryResultRow = QueryResultRow>(\n text: string,\n values?: unknown[],\n ) => withClient(db.client, () => db.client.query<R>(text, values)),\n }\n\ndeclare const transactionBrand: unique symbol\n\n/**\n * A {@link Queryable} known to be inside an open transaction — the handle\n * `withTransaction` passes to its callback, and the only place the brand is\n * ever applied.\n *\n * Some operations are meaningless (or silently wrong) against a pool: a\n * `select … for update` whose lock vanishes with the statement, a write\n * that must commit in the same transaction as the state it accompanies.\n * Those take a `Transaction`, so \"pass the transaction handle, not a pool\"\n * is a compile error rather than a sentence a caller has to have read.\n */\nexport interface Transaction extends Queryable {\n readonly [transactionBrand]: true\n}\n\n// A dedicated connection cannot host overlapping transactions. Single-statement\n// access participates in the same queue so it cannot join an unrelated commit.\nconst clientQueues = new WeakMap<Queryable, Promise<void>>()\nexport const withClient = async <T>(\n client: Queryable,\n fn: () => Promise<T>,\n): Promise<T> => {\n const previous = clientQueues.get(client) ?? Promise.resolve()\n let release!: () => void\n const current = new Promise<void>((resolve) => {\n release = resolve\n })\n clientQueues.set(client, current)\n await previous\n try {\n return await fn()\n } finally {\n if (clientQueues.get(client) === current) clientQueues.delete(client)\n release()\n }\n}\n"]}