@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.
- package/LICENSE +21 -0
- package/README.md +35 -0
- package/dist/admin.d.ts +5 -0
- package/dist/admin.js +12 -0
- package/dist/admin.js.map +1 -0
- package/dist/bootstrap.d.ts +57 -0
- package/dist/bootstrap.js +268 -0
- package/dist/bootstrap.js.map +1 -0
- package/dist/correlation.d.ts +16 -0
- package/dist/correlation.js +62 -0
- package/dist/correlation.js.map +1 -0
- package/dist/delivery.d.ts +30 -0
- package/dist/delivery.js +97 -0
- package/dist/delivery.js.map +1 -0
- package/dist/execution.d.ts +4 -0
- package/dist/execution.js +89 -0
- package/dist/execution.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/journal.d.ts +9 -0
- package/dist/journal.js +94 -0
- package/dist/journal.js.map +1 -0
- package/dist/listing.d.ts +7 -0
- package/dist/listing.js +49 -0
- package/dist/listing.js.map +1 -0
- package/dist/migration.d.ts +7 -0
- package/dist/migration.js +38 -0
- package/dist/migration.js.map +1 -0
- package/dist/queryable.d.ts +61 -0
- package/dist/queryable.js +31 -0
- package/dist/queryable.js.map +1 -0
- package/dist/sql.d.ts +26 -0
- package/dist/sql.js +21 -0
- package/dist/sql.js.map +1 -0
- package/dist/storage.d.ts +12 -0
- package/dist/storage.js +40 -0
- package/dist/storage.js.map +1 -0
- package/dist/store.d.ts +53 -0
- package/dist/store.js +100 -0
- package/dist/store.js.map +1 -0
- package/dist/transaction.d.ts +24 -0
- package/dist/transaction.js +50 -0
- package/dist/transaction.js.map +1 -0
- package/package.json +61 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mochicode LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# @affordance/pg
|
|
2
|
+
|
|
3
|
+
Postgres storage for `@affordance/core`: cases, execution claims, journals,
|
|
4
|
+
correlations, delivery deduplication, migrations, and atomic commit effects.
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
npm install @affordance/core @affordance/pg pg
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { createEngine } from '@affordance/core'
|
|
12
|
+
import { bootstrap, createPgStorage } from '@affordance/pg'
|
|
13
|
+
import { Pool } from 'pg'
|
|
14
|
+
|
|
15
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
|
|
16
|
+
await bootstrap(pool)
|
|
17
|
+
const storage = createPgStorage({ db: { pool } })
|
|
18
|
+
const engine = createEngine({ storage, caseTypes: [purchase] })
|
|
19
|
+
const page = await engine.listCases({ limit: 100 })
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The app owns connection lifetime. Supply `{ pool }` for a connection source or
|
|
23
|
+
`{ client }` for a dedicated connection. Schema bootstrap is explicit and
|
|
24
|
+
idempotent; the engine does not run DDL when constructed.
|
|
25
|
+
|
|
26
|
+
`ctx.onCommit` receives the commit transaction by default. Supply
|
|
27
|
+
`commitContext: tx => ({ payments: createPaymentRepository(tx) })` to expose
|
|
28
|
+
application repositories bound to that transaction instead. Declare the context
|
|
29
|
+
with core's `stepsOf(schema, actor<Actor>(), commitContext<Repositories>())`.
|
|
30
|
+
|
|
31
|
+
`deleteCase(db, caseId)` is destructive administrative cleanup for disposable
|
|
32
|
+
cases. It deletes the case and its framework records in one transaction.
|
|
33
|
+
|
|
34
|
+
See [storage adapters](https://github.com/mochicodecom/affordance/blob/main/docs/storage.md)
|
|
35
|
+
for the interface and migration from `createEngine({ db })`.
|
package/dist/admin.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { DatabaseAccess } from './queryable.js';
|
|
2
|
+
/** Destructive administrative cleanup, intended for disposable demo/test cases.
|
|
3
|
+
* Locks the case before removing related records; all deletions commit together.
|
|
4
|
+
*/
|
|
5
|
+
export declare const deleteCase: (db: DatabaseAccess, caseId: string) => Promise<void>;
|
package/dist/admin.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { CASE_TABLES, FRAMEWORK_SCHEMA } from './bootstrap.js';
|
|
2
|
+
import { withTransaction } from './transaction.js';
|
|
3
|
+
/** Destructive administrative cleanup, intended for disposable demo/test cases.
|
|
4
|
+
* Locks the case before removing related records; all deletions commit together.
|
|
5
|
+
*/
|
|
6
|
+
export const deleteCase = (db, caseId) => withTransaction(db, async (tx) => {
|
|
7
|
+
await tx.query(`select id from ${FRAMEWORK_SCHEMA}.cases where id = $1 for update`, [caseId]);
|
|
8
|
+
for (const { table, caseColumn } of CASE_TABLES) {
|
|
9
|
+
await tx.query(`delete from ${FRAMEWORK_SCHEMA}.${table} where ${caseColumn} = $1`, [caseId]);
|
|
10
|
+
}
|
|
11
|
+
});
|
|
12
|
+
//# sourceMappingURL=admin.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"admin.js","sourceRoot":"","sources":["../src/admin.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAA;AAE9D,OAAO,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAA;AAElD;;GAEG;AACH,MAAM,CAAC,MAAM,UAAU,GAAG,CAAC,EAAkB,EAAE,MAAc,EAAiB,EAAE,CAC9E,eAAe,CAAC,EAAE,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE;IAC/B,MAAM,EAAE,CAAC,KAAK,CACZ,kBAAkB,gBAAgB,iCAAiC,EACnE,CAAC,MAAM,CAAC,CACT,CAAA;IACD,KAAK,MAAM,EAAE,KAAK,EAAE,UAAU,EAAE,IAAI,WAAW,EAAE,CAAC;QAChD,MAAM,EAAE,CAAC,KAAK,CACZ,eAAe,gBAAgB,IAAI,KAAK,UAAU,UAAU,OAAO,EACnE,CAAC,MAAM,CAAC,CACT,CAAA;IACH,CAAC;AACH,CAAC,CAAC,CAAA","sourcesContent":["import { CASE_TABLES, FRAMEWORK_SCHEMA } from './bootstrap.js'\nimport type { DatabaseAccess } from './queryable.js'\nimport { withTransaction } from './transaction.js'\n\n/** Destructive administrative cleanup, intended for disposable demo/test cases.\n * Locks the case before removing related records; all deletions commit together.\n */\nexport const deleteCase = (db: DatabaseAccess, caseId: string): Promise<void> =>\n withTransaction(db, async (tx) => {\n await tx.query(\n `select id from ${FRAMEWORK_SCHEMA}.cases where id = $1 for update`,\n [caseId],\n )\n for (const { table, caseColumn } of CASE_TABLES) {\n await tx.query(\n `delete from ${FRAMEWORK_SCHEMA}.${table} where ${caseColumn} = $1`,\n [caseId],\n )\n }\n })\n"]}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { Queryable } from './queryable.js';
|
|
2
|
+
/**
|
|
3
|
+
* Dedicated Postgres schema owning all framework tables.
|
|
4
|
+
* Named `affordance` because `case` itself is a SQL reserved word.
|
|
5
|
+
*/
|
|
6
|
+
export declare const FRAMEWORK_SCHEMA = "affordance";
|
|
7
|
+
/**
|
|
8
|
+
* The DDL revision below. Bump it whenever the DDL changes: a database
|
|
9
|
+
* already carrying this version skips the DDL entirely, which is what keeps
|
|
10
|
+
* a start-up from touching a busy database at all.
|
|
11
|
+
*/
|
|
12
|
+
export declare const SCHEMA_VERSION = 4;
|
|
13
|
+
/**
|
|
14
|
+
* Every framework table that holds rows belonging to one case, with the
|
|
15
|
+
* column that names the case — listed in an order safe to delete from
|
|
16
|
+
* (children first; everything references `cases`). **The one answer to
|
|
17
|
+
* "which tables does the framework own"** outside the DDL above: a consumer
|
|
18
|
+
* that sweeps per-case rows (a dev console's case purge, a test harness's
|
|
19
|
+
* cleanup) iterates this instead of keeping a private copy that goes stale
|
|
20
|
+
* the release a table is added.
|
|
21
|
+
*
|
|
22
|
+
* `ingested_events.case_id` is nullable — an unrouted event belongs to no
|
|
23
|
+
* case and survives a per-case sweep, which is correct: it was never about
|
|
24
|
+
* the deleted case.
|
|
25
|
+
*/
|
|
26
|
+
export declare const CASE_TABLES: readonly [{
|
|
27
|
+
readonly table: "journal";
|
|
28
|
+
readonly caseColumn: "case_id";
|
|
29
|
+
}, {
|
|
30
|
+
readonly table: "claims";
|
|
31
|
+
readonly caseColumn: "case_id";
|
|
32
|
+
}, {
|
|
33
|
+
readonly table: "correlations";
|
|
34
|
+
readonly caseColumn: "case_id";
|
|
35
|
+
}, {
|
|
36
|
+
readonly table: "ingested_events";
|
|
37
|
+
readonly caseColumn: "case_id";
|
|
38
|
+
}, {
|
|
39
|
+
readonly table: "cases";
|
|
40
|
+
readonly caseColumn: "id";
|
|
41
|
+
}];
|
|
42
|
+
/**
|
|
43
|
+
* Idempotent DDL bootstrap for the framework schema. Safe to call on every
|
|
44
|
+
* app start and from concurrent processes: the statements are sent as one
|
|
45
|
+
* multi-statement simple query, which Postgres runs on one connection inside
|
|
46
|
+
* a single implicit transaction, and the leading `pg_advisory_xact_lock`
|
|
47
|
+
* serializes racing bootstraps (concurrent `CREATE ... IF NOT EXISTS` can
|
|
48
|
+
* otherwise fail on catalog uniqueness).
|
|
49
|
+
*
|
|
50
|
+
* Also safe to call against a *busy* database, which is the harder promise,
|
|
51
|
+
* and is answered twice over. First, a bootstrap with nothing to do does
|
|
52
|
+
* nothing at all: {@link isCurrent} checks the version marker and returns
|
|
53
|
+
* before any DDL runs, so the common case takes no table locks whatsoever.
|
|
54
|
+
* Second, when there *is* work, the transaction bounds its own lock wait and
|
|
55
|
+
* this retries it — schema management yields to live work, never the reverse.
|
|
56
|
+
*/
|
|
57
|
+
export declare const bootstrap: (db: Queryable, attempts?: number) => Promise<void>;
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dedicated Postgres schema owning all framework tables.
|
|
3
|
+
* Named `affordance` because `case` itself is a SQL reserved word.
|
|
4
|
+
*/
|
|
5
|
+
export const FRAMEWORK_SCHEMA = 'affordance';
|
|
6
|
+
/**
|
|
7
|
+
* The DDL revision below. Bump it whenever the DDL changes: a database
|
|
8
|
+
* already carrying this version skips the DDL entirely, which is what keeps
|
|
9
|
+
* a start-up from touching a busy database at all.
|
|
10
|
+
*/
|
|
11
|
+
export const SCHEMA_VERSION = 4;
|
|
12
|
+
/**
|
|
13
|
+
* Framework DDL, `IF NOT EXISTS` throughout — no migration framework.
|
|
14
|
+
*
|
|
15
|
+
* `cases` columns:
|
|
16
|
+
* - `id` text — a typed id (`case:<uuid>`), minted by the store on
|
|
17
|
+
* creation; every framework id carries its kind (see `ids.ts`)
|
|
18
|
+
* - `case_type` the Case Type name (the code definition floats; only the
|
|
19
|
+
* name is persisted)
|
|
20
|
+
* - `state` the materialized Case State document
|
|
21
|
+
* - `seq` per-case monotonic sequence counter, starts at 0; bumped by
|
|
22
|
+
* every committed Execution
|
|
23
|
+
* - `ended_at` dormancy marker written by `end()` — null while active;
|
|
24
|
+
* dormancy, never a freeze (spec §Core model)
|
|
25
|
+
*
|
|
26
|
+
* `journal` is the immutable per-Execution record. The framework
|
|
27
|
+
* only ever **inserts** into it — no update or delete path exists anywhere in
|
|
28
|
+
* the library. One Execution contributes several entries (`claimed`, then any
|
|
29
|
+
* `attempt-failed`, then a terminal `completed` / `failed` / `expired`), each
|
|
30
|
+
* self-contained so a per-track audit is a filter, never a join:
|
|
31
|
+
* - `ordinal` bigserial — total insertion order; per-case order is
|
|
32
|
+
* `(case_id, ordinal)`
|
|
33
|
+
* - `entry` which lifecycle moment this row records
|
|
34
|
+
* - `step`/`scope_key`/`actor`/`input` — the Execution's identity, repeated on
|
|
35
|
+
* every entry so `where scope_key = …` is the per-track audit
|
|
36
|
+
* - `as_of`/`guard`/`state` — on `claimed`: the transactional guard
|
|
37
|
+
* re-evaluation, the instant it was evaluated as of, and the
|
|
38
|
+
* Case State it was evaluated against. Together they make
|
|
39
|
+
* audit reconstruction exact rather than approximate.
|
|
40
|
+
* - `delta` on `completed`: the JSON-Patch delta (previous → next)
|
|
41
|
+
* - `dormancy` on `completed`: `end()` / `reopen()` called by the handler
|
|
42
|
+
* - `error` on `attempt-failed` / `failed` / `expired`
|
|
43
|
+
*
|
|
44
|
+
* `correlations` and `ingested_events` are the two integration primitives.
|
|
45
|
+
* A correlation maps an external identifier to (case, scope
|
|
46
|
+
* element); it is written by the handler that starts the external
|
|
47
|
+
* interaction, and `unique (system, external_id)` makes re-registering the
|
|
48
|
+
* same envelope idempotent — a repeat changes nothing. `ingested_events` is
|
|
49
|
+
* both the dedup gate and the dead-letter surface: `unique (idempotency_key)`
|
|
50
|
+
* is what makes "three deliveries, one Execution" a database fact rather than
|
|
51
|
+
* a hope, and the `status` / `reason` columns are why an event that changed
|
|
52
|
+
* nothing is still visible.
|
|
53
|
+
*
|
|
54
|
+
* `claims` is the opposite kind of table: mutable, transient lease
|
|
55
|
+
* bookkeeping, one row per **in-flight** Execution, deleted the moment the
|
|
56
|
+
* Execution settles. `case_id` is its primary key — that single constraint is
|
|
57
|
+
* "one in-flight execution per case". `expires_at` is what keeps a crash
|
|
58
|
+
* from stranding a case: a crashed handler stops heartbeating and the next
|
|
59
|
+
* claimant takes the case over (journaling an `expired` entry for the
|
|
60
|
+
* abandoned Execution).
|
|
61
|
+
*/
|
|
62
|
+
const DDL = `
|
|
63
|
+
select pg_advisory_xact_lock(hashtextextended('${FRAMEWORK_SCHEMA}.bootstrap', 0));
|
|
64
|
+
|
|
65
|
+
-- DDL must never be what blocks live work. "create index if not exists" and
|
|
66
|
+
-- friends take table locks whether or not they have anything to do, so a
|
|
67
|
+
-- bootstrap running against a busy database can queue behind -- or deadlock
|
|
68
|
+
-- with -- Executions committing. Bounding the wait makes this transaction
|
|
69
|
+
-- the one that yields, and bootstrap() retries it.
|
|
70
|
+
set local lock_timeout = '2s';
|
|
71
|
+
|
|
72
|
+
create schema if not exists ${FRAMEWORK_SCHEMA};
|
|
73
|
+
|
|
74
|
+
-- Schema v2 stores typed text ids ('case:<uuid>', 'execution:<uuid>', …); v1
|
|
75
|
+
-- stored bare uuids in uuid columns, which cannot hold them. There is no DDL
|
|
76
|
+
-- migration framework, so a v1 database fails loudly here rather than
|
|
77
|
+
-- corrupting silently on the first insert.
|
|
78
|
+
do $$
|
|
79
|
+
begin
|
|
80
|
+
if exists (
|
|
81
|
+
select 1 from information_schema.columns
|
|
82
|
+
where table_schema = '${FRAMEWORK_SCHEMA}' and table_name = 'cases'
|
|
83
|
+
and column_name = 'id' and data_type = 'uuid'
|
|
84
|
+
) then
|
|
85
|
+
raise exception 'affordance schema v1 detected (uuid ids); v2 ids are text of the form kind:uuid. No automatic conversion exists — export anything you need, then: drop schema ${FRAMEWORK_SCHEMA} cascade; and re-bootstrap.';
|
|
86
|
+
end if;
|
|
87
|
+
end $$;
|
|
88
|
+
|
|
89
|
+
create table if not exists ${FRAMEWORK_SCHEMA}.cases (
|
|
90
|
+
id text primary key,
|
|
91
|
+
case_type text not null,
|
|
92
|
+
state jsonb not null,
|
|
93
|
+
seq bigint not null default 0,
|
|
94
|
+
ended_at timestamptz,
|
|
95
|
+
created_at timestamptz not null default now(),
|
|
96
|
+
updated_at timestamptz not null default now()
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
create table if not exists ${FRAMEWORK_SCHEMA}.journal (
|
|
100
|
+
ordinal bigserial primary key,
|
|
101
|
+
id text not null unique,
|
|
102
|
+
case_id text not null references ${FRAMEWORK_SCHEMA}.cases (id),
|
|
103
|
+
execution_id text not null,
|
|
104
|
+
entry text not null,
|
|
105
|
+
attempt integer not null default 1,
|
|
106
|
+
step text not null,
|
|
107
|
+
scope_key text,
|
|
108
|
+
actor jsonb,
|
|
109
|
+
input jsonb,
|
|
110
|
+
as_of timestamptz,
|
|
111
|
+
guard jsonb,
|
|
112
|
+
state jsonb,
|
|
113
|
+
delta jsonb,
|
|
114
|
+
dormancy text,
|
|
115
|
+
error jsonb,
|
|
116
|
+
recorded_at timestamptz not null default now()
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
-- Schema v4 removed rule automation, and with it the \`cause\` column (the
|
|
120
|
+
-- causality record an automatic Execution carried). The column is left in
|
|
121
|
+
-- place on a database that has it: journal rows are immutable history, and
|
|
122
|
+
-- old automatic Executions keep the cause they were recorded with. New
|
|
123
|
+
-- entries simply never write it.
|
|
124
|
+
|
|
125
|
+
create index if not exists journal_case_idx
|
|
126
|
+
on ${FRAMEWORK_SCHEMA}.journal (case_id, ordinal);
|
|
127
|
+
create index if not exists journal_scope_idx
|
|
128
|
+
on ${FRAMEWORK_SCHEMA}.journal (case_id, scope_key, ordinal);
|
|
129
|
+
create index if not exists journal_execution_idx
|
|
130
|
+
on ${FRAMEWORK_SCHEMA}.journal (execution_id, ordinal);
|
|
131
|
+
|
|
132
|
+
create table if not exists ${FRAMEWORK_SCHEMA}.claims (
|
|
133
|
+
case_id text primary key references ${FRAMEWORK_SCHEMA}.cases (id),
|
|
134
|
+
execution_id text not null,
|
|
135
|
+
step text not null,
|
|
136
|
+
scope_key text,
|
|
137
|
+
attempt integer not null default 1,
|
|
138
|
+
claimed_at timestamptz not null default now(),
|
|
139
|
+
heartbeat_at timestamptz not null default now(),
|
|
140
|
+
expires_at timestamptz not null
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
-- Schema v3 removed timer scheduling. Timer rows were derived state (a
|
|
144
|
+
-- case's future time-flips, recomputable from nothing but Case State), so
|
|
145
|
+
-- dropping the table on a v2 database loses no facts.
|
|
146
|
+
drop table if exists ${FRAMEWORK_SCHEMA}.timers;
|
|
147
|
+
|
|
148
|
+
create table if not exists ${FRAMEWORK_SCHEMA}.correlations (
|
|
149
|
+
id text primary key,
|
|
150
|
+
system text not null,
|
|
151
|
+
external_id text not null,
|
|
152
|
+
case_id text not null references ${FRAMEWORK_SCHEMA}.cases (id),
|
|
153
|
+
scope_key text,
|
|
154
|
+
step text,
|
|
155
|
+
metadata jsonb,
|
|
156
|
+
created_at timestamptz not null default now(),
|
|
157
|
+
unique (system, external_id)
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
create index if not exists correlations_case_idx
|
|
161
|
+
on ${FRAMEWORK_SCHEMA}.correlations (case_id, scope_key);
|
|
162
|
+
|
|
163
|
+
create table if not exists ${FRAMEWORK_SCHEMA}.ingested_events (
|
|
164
|
+
id text primary key,
|
|
165
|
+
system text not null,
|
|
166
|
+
external_id text not null,
|
|
167
|
+
type text not null,
|
|
168
|
+
idempotency_key text not null unique,
|
|
169
|
+
case_id text,
|
|
170
|
+
scope_key text,
|
|
171
|
+
step text,
|
|
172
|
+
status text not null,
|
|
173
|
+
reason text,
|
|
174
|
+
detail text,
|
|
175
|
+
execution_id text,
|
|
176
|
+
event jsonb not null,
|
|
177
|
+
received_at timestamptz not null default now()
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
create index if not exists ingested_events_dead_letter_idx
|
|
181
|
+
on ${FRAMEWORK_SCHEMA}.ingested_events (status, received_at desc);
|
|
182
|
+
|
|
183
|
+
create table if not exists ${FRAMEWORK_SCHEMA}.schema_version (
|
|
184
|
+
version integer primary key,
|
|
185
|
+
applied_at timestamptz not null default now()
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
insert into ${FRAMEWORK_SCHEMA}.schema_version (version)
|
|
189
|
+
values (${SCHEMA_VERSION}) on conflict do nothing;
|
|
190
|
+
`;
|
|
191
|
+
/**
|
|
192
|
+
* Every framework table that holds rows belonging to one case, with the
|
|
193
|
+
* column that names the case — listed in an order safe to delete from
|
|
194
|
+
* (children first; everything references `cases`). **The one answer to
|
|
195
|
+
* "which tables does the framework own"** outside the DDL above: a consumer
|
|
196
|
+
* that sweeps per-case rows (a dev console's case purge, a test harness's
|
|
197
|
+
* cleanup) iterates this instead of keeping a private copy that goes stale
|
|
198
|
+
* the release a table is added.
|
|
199
|
+
*
|
|
200
|
+
* `ingested_events.case_id` is nullable — an unrouted event belongs to no
|
|
201
|
+
* case and survives a per-case sweep, which is correct: it was never about
|
|
202
|
+
* the deleted case.
|
|
203
|
+
*/
|
|
204
|
+
export const CASE_TABLES = [
|
|
205
|
+
{ table: 'journal', caseColumn: 'case_id' },
|
|
206
|
+
{ table: 'claims', caseColumn: 'case_id' },
|
|
207
|
+
{ table: 'correlations', caseColumn: 'case_id' },
|
|
208
|
+
{ table: 'ingested_events', caseColumn: 'case_id' },
|
|
209
|
+
{ table: 'cases', caseColumn: 'id' },
|
|
210
|
+
];
|
|
211
|
+
/**
|
|
212
|
+
* Whether the schema is already at {@link SCHEMA_VERSION} — two catalog reads
|
|
213
|
+
* that take no lock any Execution could ever be waiting on.
|
|
214
|
+
*
|
|
215
|
+
* This is what makes `bootstrap` free on every start after the first. The DDL
|
|
216
|
+
* below is idempotent, but idempotent is not the same as *harmless*: `create
|
|
217
|
+
* index if not exists` takes a table lock whether or not it has work to do,
|
|
218
|
+
* and a bootstrap holding one while Executions commit can deadlock with them
|
|
219
|
+
* — not as a rare race, but predictably, on every start against a busy
|
|
220
|
+
* database. Asking first means the locks are only ever taken when there is
|
|
221
|
+
* genuinely something to create.
|
|
222
|
+
*/
|
|
223
|
+
const isCurrent = async (db) => {
|
|
224
|
+
const marker = await db.query(`select to_regclass('${FRAMEWORK_SCHEMA}.schema_version') is not null as present`);
|
|
225
|
+
if (marker.rows[0]?.present !== true)
|
|
226
|
+
return false;
|
|
227
|
+
const applied = await db.query(`select max(version) as version from ${FRAMEWORK_SCHEMA}.schema_version`);
|
|
228
|
+
return (applied.rows[0]?.version ?? 0) >= SCHEMA_VERSION;
|
|
229
|
+
};
|
|
230
|
+
/** Postgres says the transaction lost a race it can retry: deadlock, or the bounded lock wait. */
|
|
231
|
+
const isContention = (error) => {
|
|
232
|
+
const code = error?.code;
|
|
233
|
+
return code === '40P01' || code === '55P03' || code === '40001';
|
|
234
|
+
};
|
|
235
|
+
const wait = (ms) => new Promise((resolve) => {
|
|
236
|
+
setTimeout(resolve, ms);
|
|
237
|
+
});
|
|
238
|
+
/**
|
|
239
|
+
* Idempotent DDL bootstrap for the framework schema. Safe to call on every
|
|
240
|
+
* app start and from concurrent processes: the statements are sent as one
|
|
241
|
+
* multi-statement simple query, which Postgres runs on one connection inside
|
|
242
|
+
* a single implicit transaction, and the leading `pg_advisory_xact_lock`
|
|
243
|
+
* serializes racing bootstraps (concurrent `CREATE ... IF NOT EXISTS` can
|
|
244
|
+
* otherwise fail on catalog uniqueness).
|
|
245
|
+
*
|
|
246
|
+
* Also safe to call against a *busy* database, which is the harder promise,
|
|
247
|
+
* and is answered twice over. First, a bootstrap with nothing to do does
|
|
248
|
+
* nothing at all: {@link isCurrent} checks the version marker and returns
|
|
249
|
+
* before any DDL runs, so the common case takes no table locks whatsoever.
|
|
250
|
+
* Second, when there *is* work, the transaction bounds its own lock wait and
|
|
251
|
+
* this retries it — schema management yields to live work, never the reverse.
|
|
252
|
+
*/
|
|
253
|
+
export const bootstrap = async (db, attempts = 5) => {
|
|
254
|
+
if (await isCurrent(db))
|
|
255
|
+
return;
|
|
256
|
+
for (let attempt = 1;; attempt += 1) {
|
|
257
|
+
try {
|
|
258
|
+
await db.query(DDL);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
if (attempt >= attempts || !isContention(error))
|
|
263
|
+
throw error;
|
|
264
|
+
await wait(50 * attempt);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
//# sourceMappingURL=bootstrap.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bootstrap.js","sourceRoot":"","sources":["../src/bootstrap.ts"],"names":[],"mappings":"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","sourcesContent":["import type { Queryable } from './queryable.js'\n\n/**\n * Dedicated Postgres schema owning all framework tables.\n * Named `affordance` because `case` itself is a SQL reserved word.\n */\nexport const FRAMEWORK_SCHEMA = 'affordance'\n\n/**\n * The DDL revision below. Bump it whenever the DDL changes: a database\n * already carrying this version skips the DDL entirely, which is what keeps\n * a start-up from touching a busy database at all.\n */\nexport const SCHEMA_VERSION = 4\n\n/**\n * Framework DDL, `IF NOT EXISTS` throughout — no migration framework.\n *\n * `cases` columns:\n * - `id` text — a typed id (`case:<uuid>`), minted by the store on\n * creation; every framework id carries its kind (see `ids.ts`)\n * - `case_type` the Case Type name (the code definition floats; only the\n * name is persisted)\n * - `state` the materialized Case State document\n * - `seq` per-case monotonic sequence counter, starts at 0; bumped by\n * every committed Execution\n * - `ended_at` dormancy marker written by `end()` — null while active;\n * dormancy, never a freeze (spec §Core model)\n *\n * `journal` is the immutable per-Execution record. The framework\n * only ever **inserts** into it — no update or delete path exists anywhere in\n * the library. One Execution contributes several entries (`claimed`, then any\n * `attempt-failed`, then a terminal `completed` / `failed` / `expired`), each\n * self-contained so a per-track audit is a filter, never a join:\n * - `ordinal` bigserial — total insertion order; per-case order is\n * `(case_id, ordinal)`\n * - `entry` which lifecycle moment this row records\n * - `step`/`scope_key`/`actor`/`input` — the Execution's identity, repeated on\n * every entry so `where scope_key = …` is the per-track audit\n * - `as_of`/`guard`/`state` — on `claimed`: the transactional guard\n * re-evaluation, the instant it was evaluated as of, and the\n * Case State it was evaluated against. Together they make\n * audit reconstruction exact rather than approximate.\n * - `delta` on `completed`: the JSON-Patch delta (previous → next)\n * - `dormancy` on `completed`: `end()` / `reopen()` called by the handler\n * - `error` on `attempt-failed` / `failed` / `expired`\n *\n * `correlations` and `ingested_events` are the two integration primitives.\n * A correlation maps an external identifier to (case, scope\n * element); it is written by the handler that starts the external\n * interaction, and `unique (system, external_id)` makes re-registering the\n * same envelope idempotent — a repeat changes nothing. `ingested_events` is\n * both the dedup gate and the dead-letter surface: `unique (idempotency_key)`\n * is what makes \"three deliveries, one Execution\" a database fact rather than\n * a hope, and the `status` / `reason` columns are why an event that changed\n * nothing is still visible.\n *\n * `claims` is the opposite kind of table: mutable, transient lease\n * bookkeeping, one row per **in-flight** Execution, deleted the moment the\n * Execution settles. `case_id` is its primary key — that single constraint is\n * \"one in-flight execution per case\". `expires_at` is what keeps a crash\n * from stranding a case: a crashed handler stops heartbeating and the next\n * claimant takes the case over (journaling an `expired` entry for the\n * abandoned Execution).\n */\nconst DDL = `\nselect pg_advisory_xact_lock(hashtextextended('${FRAMEWORK_SCHEMA}.bootstrap', 0));\n\n-- DDL must never be what blocks live work. \"create index if not exists\" and\n-- friends take table locks whether or not they have anything to do, so a\n-- bootstrap running against a busy database can queue behind -- or deadlock\n-- with -- Executions committing. Bounding the wait makes this transaction\n-- the one that yields, and bootstrap() retries it.\nset local lock_timeout = '2s';\n\ncreate schema if not exists ${FRAMEWORK_SCHEMA};\n\n-- Schema v2 stores typed text ids ('case:<uuid>', 'execution:<uuid>', …); v1\n-- stored bare uuids in uuid columns, which cannot hold them. There is no DDL\n-- migration framework, so a v1 database fails loudly here rather than\n-- corrupting silently on the first insert.\ndo $$\nbegin\n if exists (\n select 1 from information_schema.columns\n where table_schema = '${FRAMEWORK_SCHEMA}' and table_name = 'cases'\n and column_name = 'id' and data_type = 'uuid'\n ) then\n raise exception 'affordance schema v1 detected (uuid ids); v2 ids are text of the form kind:uuid. No automatic conversion exists — export anything you need, then: drop schema ${FRAMEWORK_SCHEMA} cascade; and re-bootstrap.';\n end if;\nend $$;\n\ncreate table if not exists ${FRAMEWORK_SCHEMA}.cases (\n id text primary key,\n case_type text not null,\n state jsonb not null,\n seq bigint not null default 0,\n ended_at timestamptz,\n created_at timestamptz not null default now(),\n updated_at timestamptz not null default now()\n);\n\ncreate table if not exists ${FRAMEWORK_SCHEMA}.journal (\n ordinal bigserial primary key,\n id text not null unique,\n case_id text not null references ${FRAMEWORK_SCHEMA}.cases (id),\n execution_id text not null,\n entry text not null,\n attempt integer not null default 1,\n step text not null,\n scope_key text,\n actor jsonb,\n input jsonb,\n as_of timestamptz,\n guard jsonb,\n state jsonb,\n delta jsonb,\n dormancy text,\n error jsonb,\n recorded_at timestamptz not null default now()\n);\n\n-- Schema v4 removed rule automation, and with it the \\`cause\\` column (the\n-- causality record an automatic Execution carried). The column is left in\n-- place on a database that has it: journal rows are immutable history, and\n-- old automatic Executions keep the cause they were recorded with. New\n-- entries simply never write it.\n\ncreate index if not exists journal_case_idx\n on ${FRAMEWORK_SCHEMA}.journal (case_id, ordinal);\ncreate index if not exists journal_scope_idx\n on ${FRAMEWORK_SCHEMA}.journal (case_id, scope_key, ordinal);\ncreate index if not exists journal_execution_idx\n on ${FRAMEWORK_SCHEMA}.journal (execution_id, ordinal);\n\ncreate table if not exists ${FRAMEWORK_SCHEMA}.claims (\n case_id text primary key references ${FRAMEWORK_SCHEMA}.cases (id),\n execution_id text not null,\n step text not null,\n scope_key text,\n attempt integer not null default 1,\n claimed_at timestamptz not null default now(),\n heartbeat_at timestamptz not null default now(),\n expires_at timestamptz not null\n);\n\n-- Schema v3 removed timer scheduling. Timer rows were derived state (a\n-- case's future time-flips, recomputable from nothing but Case State), so\n-- dropping the table on a v2 database loses no facts.\ndrop table if exists ${FRAMEWORK_SCHEMA}.timers;\n\ncreate table if not exists ${FRAMEWORK_SCHEMA}.correlations (\n id text primary key,\n system text not null,\n external_id text not null,\n case_id text not null references ${FRAMEWORK_SCHEMA}.cases (id),\n scope_key text,\n step text,\n metadata jsonb,\n created_at timestamptz not null default now(),\n unique (system, external_id)\n);\n\ncreate index if not exists correlations_case_idx\n on ${FRAMEWORK_SCHEMA}.correlations (case_id, scope_key);\n\ncreate table if not exists ${FRAMEWORK_SCHEMA}.ingested_events (\n id text primary key,\n system text not null,\n external_id text not null,\n type text not null,\n idempotency_key text not null unique,\n case_id text,\n scope_key text,\n step text,\n status text not null,\n reason text,\n detail text,\n execution_id text,\n event jsonb not null,\n received_at timestamptz not null default now()\n);\n\ncreate index if not exists ingested_events_dead_letter_idx\n on ${FRAMEWORK_SCHEMA}.ingested_events (status, received_at desc);\n\ncreate table if not exists ${FRAMEWORK_SCHEMA}.schema_version (\n version integer primary key,\n applied_at timestamptz not null default now()\n);\n\ninsert into ${FRAMEWORK_SCHEMA}.schema_version (version)\nvalues (${SCHEMA_VERSION}) on conflict do nothing;\n`\n\n/**\n * Every framework table that holds rows belonging to one case, with the\n * column that names the case — listed in an order safe to delete from\n * (children first; everything references `cases`). **The one answer to\n * \"which tables does the framework own\"** outside the DDL above: a consumer\n * that sweeps per-case rows (a dev console's case purge, a test harness's\n * cleanup) iterates this instead of keeping a private copy that goes stale\n * the release a table is added.\n *\n * `ingested_events.case_id` is nullable — an unrouted event belongs to no\n * case and survives a per-case sweep, which is correct: it was never about\n * the deleted case.\n */\nexport const CASE_TABLES = [\n { table: 'journal', caseColumn: 'case_id' },\n { table: 'claims', caseColumn: 'case_id' },\n { table: 'correlations', caseColumn: 'case_id' },\n { table: 'ingested_events', caseColumn: 'case_id' },\n { table: 'cases', caseColumn: 'id' },\n] as const\n\n/**\n * Whether the schema is already at {@link SCHEMA_VERSION} — two catalog reads\n * that take no lock any Execution could ever be waiting on.\n *\n * This is what makes `bootstrap` free on every start after the first. The DDL\n * below is idempotent, but idempotent is not the same as *harmless*: `create\n * index if not exists` takes a table lock whether or not it has work to do,\n * and a bootstrap holding one while Executions commit can deadlock with them\n * — not as a rare race, but predictably, on every start against a busy\n * database. Asking first means the locks are only ever taken when there is\n * genuinely something to create.\n */\nconst isCurrent = async (db: Queryable): Promise<boolean> => {\n const marker = await db.query<{ present: boolean }>(\n `select to_regclass('${FRAMEWORK_SCHEMA}.schema_version') is not null as present`,\n )\n if (marker.rows[0]?.present !== true) return false\n const applied = await db.query<{ version: number }>(\n `select max(version) as version from ${FRAMEWORK_SCHEMA}.schema_version`,\n )\n return (applied.rows[0]?.version ?? 0) >= SCHEMA_VERSION\n}\n\n/** Postgres says the transaction lost a race it can retry: deadlock, or the bounded lock wait. */\nconst isContention = (error: unknown): boolean => {\n const code = (error as { code?: unknown } | null)?.code\n return code === '40P01' || code === '55P03' || code === '40001'\n}\n\nconst wait = (ms: number): Promise<void> =>\n new Promise((resolve) => {\n setTimeout(resolve, ms)\n })\n\n/**\n * Idempotent DDL bootstrap for the framework schema. Safe to call on every\n * app start and from concurrent processes: the statements are sent as one\n * multi-statement simple query, which Postgres runs on one connection inside\n * a single implicit transaction, and the leading `pg_advisory_xact_lock`\n * serializes racing bootstraps (concurrent `CREATE ... IF NOT EXISTS` can\n * otherwise fail on catalog uniqueness).\n *\n * Also safe to call against a *busy* database, which is the harder promise,\n * and is answered twice over. First, a bootstrap with nothing to do does\n * nothing at all: {@link isCurrent} checks the version marker and returns\n * before any DDL runs, so the common case takes no table locks whatsoever.\n * Second, when there *is* work, the transaction bounds its own lock wait and\n * this retries it — schema management yields to live work, never the reverse.\n */\nexport const bootstrap = async (db: Queryable, attempts = 5): Promise<void> => {\n if (await isCurrent(db)) return\n for (let attempt = 1; ; attempt += 1) {\n try {\n await db.query(DDL)\n return\n } catch (error) {\n if (attempt >= attempts || !isContention(error)) throw error\n await wait(50 * attempt)\n }\n }\n}\n"]}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Correlation, CorrelationRegistration } from '@affordance/core';
|
|
2
|
+
import type { Queryable } from './queryable.js';
|
|
3
|
+
/**
|
|
4
|
+
* Register (or re-register) an external identifier against a case.
|
|
5
|
+
*
|
|
6
|
+
* Upserts on `(system, externalId)` — insert, or update the row already
|
|
7
|
+
* there: a retried handler attempt registering the same envelope again is
|
|
8
|
+
* not an error, it is the same fact. Pass any
|
|
9
|
+
* {@link Queryable} — from a handler this is the commit transaction, via
|
|
10
|
+
* `ctx.correlate` or `ctx.onCommit`.
|
|
11
|
+
*/
|
|
12
|
+
export declare const registerCorrelation: (db: Queryable, registration: CorrelationRegistration) => Promise<Correlation>;
|
|
13
|
+
/** Look up where an external identifier routes; `null` when nothing has claimed it. */
|
|
14
|
+
export declare const lookupCorrelation: (db: Queryable, system: string, externalId: string) => Promise<Correlation | null>;
|
|
15
|
+
/** Every identifier registered against a case — the "what is this case waiting on" view. */
|
|
16
|
+
export declare const correlationsFor: (db: Queryable, caseId: string, scopeKey?: string) => Promise<readonly Correlation[]>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { mintId } from '@affordance/core/storage';
|
|
2
|
+
import { FRAMEWORK_SCHEMA } from './bootstrap.js';
|
|
3
|
+
const CORRELATIONS = `${FRAMEWORK_SCHEMA}.correlations`;
|
|
4
|
+
const toCorrelation = (row) => ({
|
|
5
|
+
id: row.id,
|
|
6
|
+
system: row.system,
|
|
7
|
+
externalId: row.external_id,
|
|
8
|
+
caseId: row.case_id,
|
|
9
|
+
scopeKey: row.scope_key,
|
|
10
|
+
step: row.step,
|
|
11
|
+
metadata: row.metadata,
|
|
12
|
+
createdAt: row.created_at.toISOString(),
|
|
13
|
+
});
|
|
14
|
+
/**
|
|
15
|
+
* Register (or re-register) an external identifier against a case.
|
|
16
|
+
*
|
|
17
|
+
* Upserts on `(system, externalId)` — insert, or update the row already
|
|
18
|
+
* there: a retried handler attempt registering the same envelope again is
|
|
19
|
+
* not an error, it is the same fact. Pass any
|
|
20
|
+
* {@link Queryable} — from a handler this is the commit transaction, via
|
|
21
|
+
* `ctx.correlate` or `ctx.onCommit`.
|
|
22
|
+
*/
|
|
23
|
+
export const registerCorrelation = async (db, registration) => {
|
|
24
|
+
const { rows } = await db.query(`insert into ${CORRELATIONS} (id, system, external_id, case_id, scope_key, step, metadata)
|
|
25
|
+
values ($1, $2, $3, $4, $5, $6, $7::jsonb)
|
|
26
|
+
on conflict (system, external_id) do update
|
|
27
|
+
set case_id = excluded.case_id,
|
|
28
|
+
scope_key = excluded.scope_key,
|
|
29
|
+
step = excluded.step,
|
|
30
|
+
metadata = excluded.metadata
|
|
31
|
+
returning id, system, external_id, case_id, scope_key, step, metadata, created_at`, [
|
|
32
|
+
mintId('correlation'),
|
|
33
|
+
registration.system,
|
|
34
|
+
registration.externalId,
|
|
35
|
+
registration.caseId,
|
|
36
|
+
registration.scopeKey ?? null,
|
|
37
|
+
registration.step ?? null,
|
|
38
|
+
registration.metadata === undefined
|
|
39
|
+
? null
|
|
40
|
+
: JSON.stringify(registration.metadata),
|
|
41
|
+
]);
|
|
42
|
+
const row = rows[0];
|
|
43
|
+
if (!row)
|
|
44
|
+
throw new Error(`insert into ${CORRELATIONS} returned no row`);
|
|
45
|
+
return toCorrelation(row);
|
|
46
|
+
};
|
|
47
|
+
/** Look up where an external identifier routes; `null` when nothing has claimed it. */
|
|
48
|
+
export const lookupCorrelation = async (db, system, externalId) => {
|
|
49
|
+
const { rows } = await db.query(`select id, system, external_id, case_id, scope_key, step, metadata, created_at
|
|
50
|
+
from ${CORRELATIONS} where system = $1 and external_id = $2`, [system, externalId]);
|
|
51
|
+
const row = rows[0];
|
|
52
|
+
return row === undefined ? null : toCorrelation(row);
|
|
53
|
+
};
|
|
54
|
+
/** Every identifier registered against a case — the "what is this case waiting on" view. */
|
|
55
|
+
export const correlationsFor = async (db, caseId, scopeKey) => {
|
|
56
|
+
const { rows } = await db.query(`select id, system, external_id, case_id, scope_key, step, metadata, created_at
|
|
57
|
+
from ${CORRELATIONS}
|
|
58
|
+
where case_id = $1 ${scopeKey === undefined ? '' : 'and scope_key = $2'}
|
|
59
|
+
order by created_at asc`, scopeKey === undefined ? [caseId] : [caseId, scopeKey]);
|
|
60
|
+
return rows.map(toCorrelation);
|
|
61
|
+
};
|
|
62
|
+
//# sourceMappingURL=correlation.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"correlation.js","sourceRoot":"","sources":["../src/correlation.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,MAAM,EAAE,MAAM,0BAA0B,CAAA;AACjD,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAA;AAGjD,MAAM,YAAY,GAAG,GAAG,gBAAgB,eAAe,CAAA;AAavD,MAAM,aAAa,GAAG,CAAC,GAAmB,EAAe,EAAE,CAAC,CAAC;IAC3D,EAAE,EAAE,GAAG,CAAC,EAAE;IACV,MAAM,EAAE,GAAG,CAAC,MAAM;IAClB,UAAU,EAAE,GAAG,CAAC,WAAW;IAC3B,MAAM,EAAE,GAAG,CAAC,OAAO;IACnB,QAAQ,EAAE,GAAG,CAAC,SAAS;IACvB,IAAI,EAAE,GAAG,CAAC,IAAI;IACd,QAAQ,EAAE,GAAG,CAAC,QAAQ;IACtB,SAAS,EAAE,GAAG,CAAC,UAAU,CAAC,WAAW,EAAE;CACxC,CAAC,CAAA;AAEF;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG,KAAK,EACtC,EAAa,EACb,YAAqC,EACf,EAAE;IACxB,MAAM,EAAE,IAAI,EAAE,GAAG,MAAM,EAAE,CAAC,KAAK,CAC7B,eAAe,YAAY;;;;;;;uFAOwD,EACnF;QACE,MAAM,CAAC,aAAa,CAAC;QACrB,YAAY,CAAC,MAAM;QACnB,YAAY,CAAC,UAAU;QACvB,YAAY,CAAC,MAAM;QACnB,YAAY,CAAC,QAAQ,IAAI,IAAI;QAC7B,YAAY,CAAC,IAAI,IAAI,IAAI;QACzB,YAAY,CAAC,QAAQ,KAAK,SAAS;YACjC,CAAC,CAAC,IAAI;YACN,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,YAAY,CAAC,QAAQ,CAAC;KAC1C,CACF,CAAA;IACD,MAAM,GAAG,GAAG,IAAI,CAAC,CAAC,CAAC,CAAA;IACnB,IAAI,CAAC,GAAG;QAAE,MAAM,IAAI,KAAK,CAAC,eAAe,YAAY,kBAAkB,CAAC,CAAA;IACxE,OAAO,aAAa,CAAC,GAAG,CAAC,CAAA;AAC3B,CAAC,CAAA;AAED,uFAAuF;AACvF,MAAM,CAAC,MAAM,iBAAiB,GAAG,KAAK,EACpC,EAAa,EACb,MAAc,EACd,UAAkB,EACW,EAAE;IAC/B,MAAM,EAAE,IAAI,EAAE,GAAG,MAAM,EAAE,CAAC,KAAK,CAC7B;YACQ,YAAY,yCAAyC,EAC7D,CAAC,MAAM,EAAE,UAAU,CAAC,CACrB,CAAA;IACD,MAAM,GAAG,GAAG,IAAI,CAAC,CAAC,CAAC,CAAA;IACnB,OAAO,GAAG,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,aAAa,CAAC,GAAG,CAAC,CAAA;AACtD,CAAC,CAAA;AAED,4FAA4F;AAC5F,MAAM,CAAC,MAAM,eAAe,GAAG,KAAK,EAClC,EAAa,EACb,MAAc,EACd,QAAiB,EACgB,EAAE;IACnC,MAAM,EAAE,IAAI,EAAE,GAAG,MAAM,EAAE,CAAC,KAAK,CAC7B;YACQ,YAAY;0BACE,QAAQ,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,oBAAoB;6BAC/C,EACzB,QAAQ,KAAK,SAAS,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,EAAE,QAAQ,CAAC,CACvD,CAAA;IACD,OAAO,IAAI,CAAC,GAAG,CAAC,aAAa,CAAC,CAAA;AAChC,CAAC,CAAA","sourcesContent":["import type { Correlation, CorrelationRegistration } from '@affordance/core'\nimport { mintId } from '@affordance/core/storage'\nimport { FRAMEWORK_SCHEMA } from './bootstrap.js'\nimport type { Queryable } from './queryable.js'\n\nconst CORRELATIONS = `${FRAMEWORK_SCHEMA}.correlations`\n\ntype CorrelationRow = {\n id: string\n system: string\n external_id: string\n case_id: string\n scope_key: string | null\n step: string | null\n metadata: unknown\n created_at: Date\n}\n\nconst toCorrelation = (row: CorrelationRow): Correlation => ({\n id: row.id,\n system: row.system,\n externalId: row.external_id,\n caseId: row.case_id,\n scopeKey: row.scope_key,\n step: row.step,\n metadata: row.metadata,\n createdAt: row.created_at.toISOString(),\n})\n\n/**\n * Register (or re-register) an external identifier against a case.\n *\n * Upserts on `(system, externalId)` — insert, or update the row already\n * there: a retried handler attempt registering the same envelope again is\n * not an error, it is the same fact. Pass any\n * {@link Queryable} — from a handler this is the commit transaction, via\n * `ctx.correlate` or `ctx.onCommit`.\n */\nexport const registerCorrelation = async (\n db: Queryable,\n registration: CorrelationRegistration,\n): Promise<Correlation> => {\n const { rows } = await db.query<CorrelationRow>(\n `insert into ${CORRELATIONS} (id, system, external_id, case_id, scope_key, step, metadata)\n values ($1, $2, $3, $4, $5, $6, $7::jsonb)\n on conflict (system, external_id) do update\n set case_id = excluded.case_id,\n scope_key = excluded.scope_key,\n step = excluded.step,\n metadata = excluded.metadata\n returning id, system, external_id, case_id, scope_key, step, metadata, created_at`,\n [\n mintId('correlation'),\n registration.system,\n registration.externalId,\n registration.caseId,\n registration.scopeKey ?? null,\n registration.step ?? null,\n registration.metadata === undefined\n ? null\n : JSON.stringify(registration.metadata),\n ],\n )\n const row = rows[0]\n if (!row) throw new Error(`insert into ${CORRELATIONS} returned no row`)\n return toCorrelation(row)\n}\n\n/** Look up where an external identifier routes; `null` when nothing has claimed it. */\nexport const lookupCorrelation = async (\n db: Queryable,\n system: string,\n externalId: string,\n): Promise<Correlation | null> => {\n const { rows } = await db.query<CorrelationRow>(\n `select id, system, external_id, case_id, scope_key, step, metadata, created_at\n from ${CORRELATIONS} where system = $1 and external_id = $2`,\n [system, externalId],\n )\n const row = rows[0]\n return row === undefined ? null : toCorrelation(row)\n}\n\n/** Every identifier registered against a case — the \"what is this case waiting on\" view. */\nexport const correlationsFor = async (\n db: Queryable,\n caseId: string,\n scopeKey?: string,\n): Promise<readonly Correlation[]> => {\n const { rows } = await db.query<CorrelationRow>(\n `select id, system, external_id, case_id, scope_key, step, metadata, created_at\n from ${CORRELATIONS}\n where case_id = $1 ${scopeKey === undefined ? '' : 'and scope_key = $2'}\n order by created_at asc`,\n scopeKey === undefined ? [caseId] : [caseId, scopeKey],\n )\n return rows.map(toCorrelation)\n}\n"]}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { DeadLetter, DeadLetterFilter, DeadLetterReason, ExternalEvent } from '@affordance/core';
|
|
2
|
+
import type { DeliveryRecord } from '@affordance/core/storage';
|
|
3
|
+
import type { Queryable } from './queryable.js';
|
|
4
|
+
/**
|
|
5
|
+
* The dedup gate. Inserts the event's row and reports whether this delivery
|
|
6
|
+
* is the one that got it.
|
|
7
|
+
*
|
|
8
|
+
* `on conflict do nothing` is the whole mechanism: exactly one of N
|
|
9
|
+
* concurrent deliveries inserts, and the losers read what the winner wrote.
|
|
10
|
+
* A previous delivery that ended `dead-lettered` for a *transient* reason is
|
|
11
|
+
* reopened rather than deduplicated — a provider retry after "the case was
|
|
12
|
+
* busy" should get its chance, which is precisely what provider retries are
|
|
13
|
+
* for.
|
|
14
|
+
*/
|
|
15
|
+
export declare const claimDelivery: (db: Queryable, event: ExternalEvent, idempotencyKey: string, reopenable: readonly DeadLetterReason[]) => Promise<{
|
|
16
|
+
row: DeliveryRecord;
|
|
17
|
+
fresh: boolean;
|
|
18
|
+
}>;
|
|
19
|
+
/** Record how a delivery ended. The row is the dead-letter surface, so this is the only settle path. */
|
|
20
|
+
export declare const settle: (db: Queryable, id: string, fields: {
|
|
21
|
+
status: "executed" | "dead-lettered";
|
|
22
|
+
caseId?: string | null;
|
|
23
|
+
scopeKey?: string | null;
|
|
24
|
+
step?: string | null;
|
|
25
|
+
reason?: DeadLetterReason | null;
|
|
26
|
+
detail?: string | null;
|
|
27
|
+
executionId?: string | null;
|
|
28
|
+
}) => Promise<void>;
|
|
29
|
+
/** Read the dead-letter surface, newest first — the ops view of "arrived, did nothing". */
|
|
30
|
+
export declare const readDeadLetters: (db: Queryable, filter?: DeadLetterFilter) => Promise<readonly DeadLetter[]>;
|