@kyneta/postgres-store 1.5.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-present Duane Johnson
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,99 @@
1
+ # @kyneta/postgres-store
2
+
3
+ Postgres storage backend for `@kyneta/exchange` — async-native, JSONB meta, BYTEA blobs.
4
+
5
+ ## Installation
6
+
7
+ ```sh
8
+ pnpm add @kyneta/postgres-store pg
9
+ ```
10
+
11
+ Peer dependencies: `@kyneta/exchange`, `@kyneta/schema`, `@kyneta/sql-store-core`, `pg`.
12
+
13
+ ## Usage
14
+
15
+ ### Recommended: `createPostgresStore` factory
16
+
17
+ ```ts
18
+ import { Pool } from "pg"
19
+ import { Exchange } from "@kyneta/exchange"
20
+ import { createPostgresStore } from "@kyneta/postgres-store"
21
+
22
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL })
23
+ const store = await createPostgresStore(pool)
24
+
25
+ const exchange = new Exchange({
26
+ stores: [store],
27
+ // ...
28
+ })
29
+
30
+ // On shutdown:
31
+ // await exchange.shutdown()
32
+ // await pool.end()
33
+ ```
34
+
35
+ The factory queries `information_schema.columns` to validate that the canonical schema exists with compatible column types, then returns a ready Store. If validation fails, a curated error tells you which column is missing or has the wrong type.
36
+
37
+ ### Sync constructor (advanced)
38
+
39
+ For callers that validate the schema separately at process start:
40
+
41
+ ```ts
42
+ import { PostgresStore } from "@kyneta/postgres-store"
43
+
44
+ const store = new PostgresStore(pool)
45
+ ```
46
+
47
+ ## Schema
48
+
49
+ Run [`schema.sql`](./schema.sql) once before constructing the store, or include the canonical DDL as a step in your migration pipeline. The store does not auto-DDL — Postgres convention is migrations-as-deployment-step.
50
+
51
+ ```sql
52
+ CREATE TABLE IF NOT EXISTS kyneta_meta (
53
+ doc_id TEXT PRIMARY KEY,
54
+ data JSONB NOT NULL
55
+ );
56
+ CREATE TABLE IF NOT EXISTS kyneta_records (
57
+ doc_id TEXT NOT NULL,
58
+ seq INTEGER NOT NULL,
59
+ kind TEXT NOT NULL,
60
+ payload TEXT,
61
+ blob BYTEA,
62
+ PRIMARY KEY (doc_id, seq)
63
+ );
64
+ ```
65
+
66
+ JSONB on `meta.data` enables operator queryability for admin tooling (`data->>'syncProtocol'`, `data->>'replicaType'`). Round-trip through `loadAll` is structurally portable with `@kyneta/sqlite-store` (both consume `toRow`/`fromRow` from `@kyneta/sql-store-core`); JSONB normalizes whitespace and key order, so a byte-level dump comparison would diverge.
67
+
68
+ ## Options
69
+
70
+ ### `tables`
71
+
72
+ ```ts
73
+ const store = await createPostgresStore(pool, {
74
+ tables: { meta: "app_meta", records: "app_records" },
75
+ })
76
+ ```
77
+
78
+ Default: `{ meta: "kyneta_meta", records: "kyneta_records" }`. Use to run multiple isolated Exchange instances against the same database — each owns one `tables` pair.
79
+
80
+ `listDocIds(prefix)` uses a range scan (`doc_id >= prefix AND doc_id < successor(prefix)`), not `LIKE`. Doc IDs containing `%` and `_` are matched literally.
81
+
82
+ ## Lifecycle
83
+
84
+ The caller owns the connection lifecycle:
85
+
86
+ - `Pool`: passed in by the caller. The Store calls `pool.connect()`/`release()` per transaction. The caller calls `pool.end()` on shutdown.
87
+ - `Client`: passed in by the caller; transactions run against the same connection. The caller calls `client.end()` on shutdown.
88
+
89
+ `PostgresStore.close()` is a no-op.
90
+
91
+ ### Runtime schema drift
92
+
93
+ Schema validation runs once at `createPostgresStore` time. If a DBA alters the schema while the Exchange is running, the change is **not** detected — re-run `createPostgresStore` after migrations (which means restarting the Exchange). Build a `revalidate()` API only if your operational pattern actually requires it.
94
+
95
+ ## See also
96
+
97
+ - [`@kyneta/sql-store-core`](../sql-core/) — pure helpers shared with `sqlite-store` and `prisma-store`.
98
+ - [`@kyneta/sqlite-store`](../sqlite/) — universal SQLite backend.
99
+ - [`@kyneta/prisma-store`](../prisma/) — backend that takes a caller-supplied `PrismaClient`.
@@ -0,0 +1,44 @@
1
+ import { DocId, Store, StoreMeta, StoreRecord } from "@kyneta/exchange";
2
+ import { TableNames } from "@kyneta/sql-store-core";
3
+ import { Client, Pool } from "pg";
4
+
5
+ //#region src/index.d.ts
6
+ interface PostgresStoreOptions {
7
+ /**
8
+ * Override the default table names (`kyneta_meta` and `kyneta_records`).
9
+ *
10
+ * Use when running multiple isolated Exchange instances against the
11
+ * same database — each instance owns one `tables` pair.
12
+ */
13
+ tables?: Partial<TableNames>;
14
+ }
15
+ type PgConnection = Client | Pool;
16
+ /**
17
+ * Caller owns the connection lifecycle — `close()` is a no-op,
18
+ * `pool.end()` is the caller's responsibility. Prefer
19
+ * `createPostgresStore` over the bare constructor: it validates the
20
+ * schema at construction time so misconfiguration fails loudly with a
21
+ * curated error rather than per-method `column does not exist` later.
22
+ */
23
+ declare class PostgresStore implements Store {
24
+ #private;
25
+ constructor(client: PgConnection, options?: PostgresStoreOptions);
26
+ append(docId: DocId, record: StoreRecord): Promise<void>;
27
+ loadAll(docId: DocId): AsyncIterable<StoreRecord>;
28
+ replace(docId: DocId, records: StoreRecord[]): Promise<void>;
29
+ delete(docId: DocId): Promise<void>;
30
+ currentMeta(docId: DocId): Promise<StoreMeta | null>;
31
+ listDocIds(prefix?: string): AsyncIterable<DocId>;
32
+ close(): Promise<void>;
33
+ }
34
+ /**
35
+ * Validation runs once at factory time, not on every method call. A
36
+ * schema change applied while the Exchange is running won't be
37
+ * detected — restart after migrations. Polling or a `revalidate()`
38
+ * API would be over-engineering for a failure mode that fails loudly
39
+ * on the next write anyway.
40
+ */
41
+ declare function createPostgresStore(client: PgConnection, options?: PostgresStoreOptions): Promise<Store>;
42
+ //#endregion
43
+ export { PostgresStore, PostgresStoreOptions, createPostgresStore };
44
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../src/index.ts"],"mappings":";;;;;UAgCiB,oBAAA;;AAAjB;;;;;EAOE,MAAA,GAAS,OAAA,CAAQ,UAAA;AAAA;AAAA,KAGd,YAAA,GAAe,MAAA,GAAS,IAAA;;AAF5B;;;;;AAuBD;cAAa,aAAA,YAAyB,KAAA;EAAA;cAKxB,MAAA,EAAQ,YAAA,EAAc,OAAA,GAAS,oBAAA;EA0DrC,MAAA,CAAO,KAAA,EAAO,KAAA,EAAO,MAAA,EAAQ,WAAA,GAAc,OAAA;EA+B1C,OAAA,CAAQ,KAAA,EAAO,KAAA,GAAQ,aAAA,CAAc,WAAA;EAWtC,OAAA,CAAQ,KAAA,EAAO,KAAA,EAAO,OAAA,EAAS,WAAA,KAAgB,OAAA;EAiC/C,MAAA,CAAO,KAAA,EAAO,KAAA,GAAQ,OAAA;EAYtB,WAAA,CAAY,KAAA,EAAO,KAAA,GAAQ,OAAA,CAAQ,SAAA;EAQlC,UAAA,CAAW,MAAA,YAAkB,aAAA,CAAc,KAAA;EA0B5C,KAAA,CAAA,GAAS,OAAA;AAAA;;;;;;;;iBAsCK,mBAAA,CACpB,MAAA,EAAQ,YAAA,EACR,OAAA,GAAS,oBAAA,GACR,OAAA,CAAQ,KAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,203 @@
1
+ import { SeqNoTracker } from "@kyneta/exchange";
2
+ import { fromRow, planAppend, planReplace, resolveTables } from "@kyneta/sql-store-core";
3
+ //#region src/index.ts
4
+ /**
5
+ * Caller owns the connection lifecycle — `close()` is a no-op,
6
+ * `pool.end()` is the caller's responsibility. Prefer
7
+ * `createPostgresStore` over the bare constructor: it validates the
8
+ * schema at construction time so misconfiguration fails loudly with a
9
+ * curated error rather than per-method `column does not exist` later.
10
+ */
11
+ var PostgresStore = class {
12
+ #client;
13
+ #seqNos = new SeqNoTracker();
14
+ #tables;
15
+ constructor(client, options = {}) {
16
+ this.#client = client;
17
+ this.#tables = resolveTables(options);
18
+ }
19
+ /**
20
+ * For `Pool`: check out a `PoolClient` so BEGIN..COMMIT all run on
21
+ * the same physical connection (Postgres transactions are
22
+ * connection-scoped; checking back out for COMMIT would target a
23
+ * different connection). For `Client`: run inline.
24
+ *
25
+ * Re-throws on rollback so callers can put post-commit work
26
+ * lexically after the awaited call — a rejection skips the next
27
+ * statement, mirroring sync `transaction()` + throw.
28
+ */
29
+ async #withTransaction(fn) {
30
+ if (typeof this.#client.connect === "function") {
31
+ const poolClient = await this.#client.connect();
32
+ try {
33
+ await poolClient.query("BEGIN");
34
+ try {
35
+ const result = await fn(poolClient);
36
+ await poolClient.query("COMMIT");
37
+ return result;
38
+ } catch (e) {
39
+ await poolClient.query("ROLLBACK");
40
+ throw e;
41
+ }
42
+ } finally {
43
+ poolClient.release();
44
+ }
45
+ }
46
+ const client = this.#client;
47
+ await client.query("BEGIN");
48
+ try {
49
+ const result = await fn(client);
50
+ await client.query("COMMIT");
51
+ return result;
52
+ } catch (e) {
53
+ await client.query("ROLLBACK");
54
+ throw e;
55
+ }
56
+ }
57
+ get #q() {
58
+ return this.#client;
59
+ }
60
+ async append(docId, record) {
61
+ const plan = planAppend(docId, record, await this.currentMeta(docId), await this.#seqNos.next(docId, async () => {
62
+ return (await this.#q.query(`SELECT MAX(seq)::int AS max_seq FROM ${this.#tables.records} WHERE doc_id = $1`, [docId])).rows[0]?.max_seq ?? null;
63
+ }));
64
+ await this.#withTransaction(async (q) => {
65
+ if (plan.upsertMeta !== null) await q.query(`INSERT INTO ${this.#tables.meta} (doc_id, data)
66
+ VALUES ($1, $2::jsonb)
67
+ ON CONFLICT (doc_id) DO UPDATE SET data = EXCLUDED.data`, [docId, plan.upsertMeta.data]);
68
+ const { row } = plan.insertRecord;
69
+ await q.query(`INSERT INTO ${this.#tables.records}
70
+ (doc_id, seq, kind, payload, blob)
71
+ VALUES ($1, $2, $3, $4, $5)`, [
72
+ docId,
73
+ plan.insertRecord.seq,
74
+ row.kind,
75
+ row.payload,
76
+ row.blob
77
+ ]);
78
+ });
79
+ }
80
+ async *loadAll(docId) {
81
+ const result = await this.#q.query(`SELECT kind, payload, blob FROM ${this.#tables.records}
82
+ WHERE doc_id = $1 ORDER BY seq`, [docId]);
83
+ for (const row of result.rows) yield fromRow(row);
84
+ }
85
+ async replace(docId, records) {
86
+ const plan = planReplace(records, await this.currentMeta(docId));
87
+ await this.#withTransaction(async (q) => {
88
+ await q.query(`DELETE FROM ${this.#tables.records} WHERE doc_id = $1`, [docId]);
89
+ for (const { seq, row } of plan.records) await q.query(`INSERT INTO ${this.#tables.records}
90
+ (doc_id, seq, kind, payload, blob)
91
+ VALUES ($1, $2, $3, $4, $5)`, [
92
+ docId,
93
+ seq,
94
+ row.kind,
95
+ row.payload,
96
+ row.blob
97
+ ]);
98
+ await q.query(`INSERT INTO ${this.#tables.meta} (doc_id, data)
99
+ VALUES ($1, $2::jsonb)
100
+ ON CONFLICT (doc_id) DO UPDATE SET data = EXCLUDED.data`, [docId, plan.upsertMeta.data]);
101
+ });
102
+ this.#seqNos.reset(docId, records.length - 1);
103
+ }
104
+ async delete(docId) {
105
+ await this.#withTransaction(async (q) => {
106
+ await q.query(`DELETE FROM ${this.#tables.records} WHERE doc_id = $1`, [docId]);
107
+ await q.query(`DELETE FROM ${this.#tables.meta} WHERE doc_id = $1`, [docId]);
108
+ });
109
+ this.#seqNos.remove(docId);
110
+ }
111
+ async currentMeta(docId) {
112
+ return (await this.#q.query(`SELECT data FROM ${this.#tables.meta} WHERE doc_id = $1`, [docId])).rows[0]?.data ?? null;
113
+ }
114
+ async *listDocIds(prefix) {
115
+ if (prefix === void 0) {
116
+ const result = await this.#q.query(`SELECT doc_id FROM ${this.#tables.meta}`);
117
+ for (const row of result.rows) yield row.doc_id;
118
+ return;
119
+ }
120
+ const upper = prefixUpperBound(prefix);
121
+ const result = upper === null ? await this.#q.query(`SELECT doc_id FROM ${this.#tables.meta} WHERE doc_id >= $1`, [prefix]) : await this.#q.query(`SELECT doc_id FROM ${this.#tables.meta}
122
+ WHERE doc_id >= $1 AND doc_id < $2`, [prefix, upper]);
123
+ for (const row of result.rows) yield row.doc_id;
124
+ }
125
+ async close() {}
126
+ };
127
+ /**
128
+ * Returns null when no successor exists (e.g. all code units at U+10FFFF),
129
+ * letting the caller fall back to an unbounded `>= prefix` scan.
130
+ */
131
+ function prefixUpperBound(prefix) {
132
+ if (prefix.length === 0) return null;
133
+ const codes = Array.from(prefix);
134
+ for (let i = codes.length - 1; i >= 0; i--) {
135
+ const code = codes[i].codePointAt(0);
136
+ if (code < 1114111) {
137
+ const next = String.fromCodePoint(code + 1);
138
+ return codes.slice(0, i).join("") + next;
139
+ }
140
+ }
141
+ return null;
142
+ }
143
+ /**
144
+ * Validation runs once at factory time, not on every method call. A
145
+ * schema change applied while the Exchange is running won't be
146
+ * detected — restart after migrations. Polling or a `revalidate()`
147
+ * API would be over-engineering for a failure mode that fails loudly
148
+ * on the next write anyway.
149
+ */
150
+ async function createPostgresStore(client, options = {}) {
151
+ await validateSchema(client, resolveTables(options));
152
+ return new PostgresStore(client, options);
153
+ }
154
+ const EXPECTED_COLUMNS = {
155
+ meta: [{
156
+ name: "doc_id",
157
+ types: ["text"]
158
+ }, {
159
+ name: "data",
160
+ types: ["jsonb"]
161
+ }],
162
+ records: [
163
+ {
164
+ name: "doc_id",
165
+ types: ["text"]
166
+ },
167
+ {
168
+ name: "seq",
169
+ types: ["integer"]
170
+ },
171
+ {
172
+ name: "kind",
173
+ types: ["text"]
174
+ },
175
+ {
176
+ name: "payload",
177
+ types: ["text"]
178
+ },
179
+ {
180
+ name: "blob",
181
+ types: ["bytea"]
182
+ }
183
+ ]
184
+ };
185
+ async function validateSchema(q, tables) {
186
+ for (const [role, expected] of [["meta", EXPECTED_COLUMNS.meta], ["records", EXPECTED_COLUMNS.records]]) {
187
+ const tableName = tables[role];
188
+ const result = await q.query(`SELECT column_name, data_type, is_nullable
189
+ FROM information_schema.columns
190
+ WHERE table_name = $1`, [tableName]);
191
+ if (result.rows.length === 0) throw new Error(`@kyneta/postgres-store: table "${tableName}" not found. Run schema.sql or include the canonical DDL in your migrations.`);
192
+ const columnsByName = new Map(result.rows.map((r) => [r.column_name, r]));
193
+ for (const col of expected) {
194
+ const found = columnsByName.get(col.name);
195
+ if (found === void 0) throw new Error(`@kyneta/postgres-store: table "${tableName}" missing column "${col.name}". See schema.sql for the canonical definition.`);
196
+ if (!col.types.includes(found.data_type)) throw new Error(`@kyneta/postgres-store: table "${tableName}" column "${col.name}" has type "${found.data_type}", expected one of [${col.types.join(", ")}].`);
197
+ }
198
+ }
199
+ }
200
+ //#endregion
201
+ export { PostgresStore, createPostgresStore };
202
+
203
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":["#client","#seqNos","#tables","#withTransaction","#q"],"sources":["../src/index.ts"],"sourcesContent":["// Postgres Store backend.\n//\n// Why JSONB on meta, not TEXT: operators occasionally need to filter\n// metas by `syncProtocol` or `replicaType` during incident\n// investigations, and JSONB makes `data->>'syncProtocol'` trivial.\n// Cost: JSONB normalizes whitespace and key order at insert time, so\n// `meta.data` bytes don't match SQLite's TEXT-stored meta — but\n// round-trip through `loadAll` still yields a structurally equal\n// `StoreRecord`, which is what cross-backend portability actually\n// requires.\n\nimport {\n type DocId,\n SeqNoTracker,\n type Store,\n type StoreMeta,\n type StoreRecord,\n} from \"@kyneta/exchange\"\nimport {\n fromRow,\n planAppend,\n planReplace,\n type RowShape,\n resolveTables,\n type TableNames,\n} from \"@kyneta/sql-store-core\"\nimport type { Client, Pool, PoolClient } from \"pg\"\n\n// ---------------------------------------------------------------------------\n// Options\n// ---------------------------------------------------------------------------\n\nexport interface PostgresStoreOptions {\n /**\n * Override the default table names (`kyneta_meta` and `kyneta_records`).\n *\n * Use when running multiple isolated Exchange instances against the\n * same database — each instance owns one `tables` pair.\n */\n tables?: Partial<TableNames>\n}\n\ntype PgConnection = Client | Pool\n\n/**\n * Narrow structural type for the methods we actually call. Keeps the\n * package independent of `pg`'s top-level type changes across versions.\n */\ninterface PgQuerier {\n query<R = unknown>(text: string, values?: unknown[]): Promise<{ rows: R[] }>\n}\n\n// ---------------------------------------------------------------------------\n// PostgresStore\n// ---------------------------------------------------------------------------\n\n/**\n * Caller owns the connection lifecycle — `close()` is a no-op,\n * `pool.end()` is the caller's responsibility. Prefer\n * `createPostgresStore` over the bare constructor: it validates the\n * schema at construction time so misconfiguration fails loudly with a\n * curated error rather than per-method `column does not exist` later.\n */\nexport class PostgresStore implements Store {\n readonly #client: PgConnection\n readonly #seqNos = new SeqNoTracker()\n readonly #tables: TableNames\n\n constructor(client: PgConnection, options: PostgresStoreOptions = {}) {\n this.#client = client\n this.#tables = resolveTables(options)\n }\n\n /**\n * For `Pool`: check out a `PoolClient` so BEGIN..COMMIT all run on\n * the same physical connection (Postgres transactions are\n * connection-scoped; checking back out for COMMIT would target a\n * different connection). For `Client`: run inline.\n *\n * Re-throws on rollback so callers can put post-commit work\n * lexically after the awaited call — a rejection skips the next\n * statement, mirroring sync `transaction()` + throw.\n */\n async #withTransaction<R>(\n fn: (querier: PgQuerier) => Promise<R>,\n ): Promise<R> {\n const isPool = typeof (this.#client as Pool).connect === \"function\"\n if (isPool) {\n const poolClient: PoolClient = await (this.#client as Pool).connect()\n try {\n await poolClient.query(\"BEGIN\")\n try {\n const result = await fn(poolClient as unknown as PgQuerier)\n await poolClient.query(\"COMMIT\")\n return result\n } catch (e) {\n await poolClient.query(\"ROLLBACK\")\n throw e\n }\n } finally {\n poolClient.release()\n }\n }\n const client = this.#client as Client\n await client.query(\"BEGIN\")\n try {\n const result = await fn(client as unknown as PgQuerier)\n await client.query(\"COMMIT\")\n return result\n } catch (e) {\n await client.query(\"ROLLBACK\")\n throw e\n }\n }\n\n // Non-transactional reads (currentMeta, loadAll, listDocIds, the\n // cold-start MAX(seq)) don't need a held connection — issuing them\n // against the pool/client directly avoids unnecessary checkouts.\n get #q(): PgQuerier {\n return this.#client as unknown as PgQuerier\n }\n\n // -------------------------------------------------------------------------\n // Store interface\n // -------------------------------------------------------------------------\n\n async append(docId: DocId, record: StoreRecord): Promise<void> {\n const existingMeta = await this.currentMeta(docId)\n const seq = await this.#seqNos.next(docId, async () => {\n const result = await this.#q.query<{ max_seq: number | null }>(\n `SELECT MAX(seq)::int AS max_seq FROM ${this.#tables.records} WHERE doc_id = $1`,\n [docId],\n )\n return result.rows[0]?.max_seq ?? null\n })\n\n const plan = planAppend(docId, record, existingMeta, seq)\n\n await this.#withTransaction(async q => {\n if (plan.upsertMeta !== null) {\n await q.query(\n `INSERT INTO ${this.#tables.meta} (doc_id, data)\n VALUES ($1, $2::jsonb)\n ON CONFLICT (doc_id) DO UPDATE SET data = EXCLUDED.data`,\n [docId, plan.upsertMeta.data],\n )\n }\n const { row } = plan.insertRecord\n await q.query(\n `INSERT INTO ${this.#tables.records}\n (doc_id, seq, kind, payload, blob)\n VALUES ($1, $2, $3, $4, $5)`,\n [docId, plan.insertRecord.seq, row.kind, row.payload, row.blob],\n )\n })\n }\n\n async *loadAll(docId: DocId): AsyncIterable<StoreRecord> {\n const result = await this.#q.query<RowShape>(\n `SELECT kind, payload, blob FROM ${this.#tables.records}\n WHERE doc_id = $1 ORDER BY seq`,\n [docId],\n )\n for (const row of result.rows) {\n yield fromRow(row)\n }\n }\n\n async replace(docId: DocId, records: StoreRecord[]): Promise<void> {\n const existingMeta = await this.currentMeta(docId)\n const plan = planReplace(records, existingMeta)\n\n await this.#withTransaction(async q => {\n await q.query(`DELETE FROM ${this.#tables.records} WHERE doc_id = $1`, [\n docId,\n ])\n\n for (const { seq, row } of plan.records) {\n await q.query(\n `INSERT INTO ${this.#tables.records}\n (doc_id, seq, kind, payload, blob)\n VALUES ($1, $2, $3, $4, $5)`,\n [docId, seq, row.kind, row.payload, row.blob],\n )\n }\n\n await q.query(\n `INSERT INTO ${this.#tables.meta} (doc_id, data)\n VALUES ($1, $2::jsonb)\n ON CONFLICT (doc_id) DO UPDATE SET data = EXCLUDED.data`,\n [docId, plan.upsertMeta.data],\n )\n })\n\n // Must run after commit. If `#withTransaction` rejects, the throw\n // propagates past this line; the cache stays unmutated. Inside the\n // callback would corrupt it on rollback — the next append would\n // collide with restored rows on (doc_id, seq).\n this.#seqNos.reset(docId, records.length - 1)\n }\n\n async delete(docId: DocId): Promise<void> {\n await this.#withTransaction(async q => {\n await q.query(`DELETE FROM ${this.#tables.records} WHERE doc_id = $1`, [\n docId,\n ])\n await q.query(`DELETE FROM ${this.#tables.meta} WHERE doc_id = $1`, [\n docId,\n ])\n })\n this.#seqNos.remove(docId)\n }\n\n async currentMeta(docId: DocId): Promise<StoreMeta | null> {\n const result = await this.#q.query<{ data: StoreMeta }>(\n `SELECT data FROM ${this.#tables.meta} WHERE doc_id = $1`,\n [docId],\n )\n return result.rows[0]?.data ?? null\n }\n\n async *listDocIds(prefix?: string): AsyncIterable<DocId> {\n if (prefix === undefined) {\n const result = await this.#q.query<{ doc_id: string }>(\n `SELECT doc_id FROM ${this.#tables.meta}`,\n )\n for (const row of result.rows) yield row.doc_id\n return\n }\n\n // Range scan instead of LIKE — `%` and `_` in doc IDs are literal,\n // not wildcards.\n const upper = prefixUpperBound(prefix)\n const result =\n upper === null\n ? await this.#q.query<{ doc_id: string }>(\n `SELECT doc_id FROM ${this.#tables.meta} WHERE doc_id >= $1`,\n [prefix],\n )\n : await this.#q.query<{ doc_id: string }>(\n `SELECT doc_id FROM ${this.#tables.meta}\n WHERE doc_id >= $1 AND doc_id < $2`,\n [prefix, upper],\n )\n for (const row of result.rows) yield row.doc_id\n }\n\n async close(): Promise<void> {\n // Caller calls `pool.end()` / `client.end()`.\n }\n}\n\n// ---------------------------------------------------------------------------\n// Range-scan helper\n// ---------------------------------------------------------------------------\n\n/**\n * Returns null when no successor exists (e.g. all code units at U+10FFFF),\n * letting the caller fall back to an unbounded `>= prefix` scan.\n */\nfunction prefixUpperBound(prefix: string): string | null {\n if (prefix.length === 0) return null\n const codes = Array.from(prefix)\n for (let i = codes.length - 1; i >= 0; i--) {\n const ch = codes[i] as string\n const code = ch.codePointAt(0) as number\n if (code < 0x10ffff) {\n const next = String.fromCodePoint(code + 1)\n return codes.slice(0, i).join(\"\") + next\n }\n }\n return null\n}\n\n// ---------------------------------------------------------------------------\n// Factory: createPostgresStore (recommended entry point)\n// ---------------------------------------------------------------------------\n\n/**\n * Validation runs once at factory time, not on every method call. A\n * schema change applied while the Exchange is running won't be\n * detected — restart after migrations. Polling or a `revalidate()`\n * API would be over-engineering for a failure mode that fails loudly\n * on the next write anyway.\n */\nexport async function createPostgresStore(\n client: PgConnection,\n options: PostgresStoreOptions = {},\n): Promise<Store> {\n const tables = resolveTables(options)\n await validateSchema(client as unknown as PgQuerier, tables)\n return new PostgresStore(client, options)\n}\n\ninterface ColumnInfo {\n column_name: string\n data_type: string\n is_nullable: string\n}\n\nconst EXPECTED_COLUMNS = {\n meta: [\n { name: \"doc_id\", types: [\"text\"] },\n { name: \"data\", types: [\"jsonb\"] },\n ],\n records: [\n { name: \"doc_id\", types: [\"text\"] },\n { name: \"seq\", types: [\"integer\"] },\n { name: \"kind\", types: [\"text\"] },\n { name: \"payload\", types: [\"text\"] },\n { name: \"blob\", types: [\"bytea\"] },\n ],\n} as const\n\nasync function validateSchema(q: PgQuerier, tables: TableNames): Promise<void> {\n for (const [role, expected] of [\n [\"meta\", EXPECTED_COLUMNS.meta] as const,\n [\"records\", EXPECTED_COLUMNS.records] as const,\n ]) {\n const tableName = tables[role]\n const result = await q.query<ColumnInfo>(\n `SELECT column_name, data_type, is_nullable\n FROM information_schema.columns\n WHERE table_name = $1`,\n [tableName],\n )\n if (result.rows.length === 0) {\n throw new Error(\n `@kyneta/postgres-store: table \"${tableName}\" not found. ` +\n `Run schema.sql or include the canonical DDL in your migrations.`,\n )\n }\n const columnsByName = new Map(result.rows.map(r => [r.column_name, r]))\n for (const col of expected) {\n const found = columnsByName.get(col.name)\n if (found === undefined) {\n throw new Error(\n `@kyneta/postgres-store: table \"${tableName}\" missing column ` +\n `\"${col.name}\". See schema.sql for the canonical definition.`,\n )\n }\n if (!(col.types as readonly string[]).includes(found.data_type)) {\n throw new Error(\n `@kyneta/postgres-store: table \"${tableName}\" column ` +\n `\"${col.name}\" has type \"${found.data_type}\", ` +\n `expected one of [${col.types.join(\", \")}].`,\n )\n }\n }\n }\n}\n"],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@kyneta/postgres-store",
3
+ "version": "1.5.0",
4
+ "description": "Postgres storage backend for @kyneta/exchange",
5
+ "author": "Duane Johnson",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/halecraft/kyneta",
10
+ "directory": "packages/exchange/stores/postgres"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "type": "module",
16
+ "main": "./dist/index.js",
17
+ "module": "./dist/index.js",
18
+ "types": "./dist/index.d.ts",
19
+ "files": [
20
+ "dist",
21
+ "src",
22
+ "schema.sql"
23
+ ],
24
+ "exports": {
25
+ ".": {
26
+ "types": "./dist/index.d.ts",
27
+ "import": "./dist/index.js",
28
+ "default": "./dist/index.js"
29
+ },
30
+ "./src": "./src/index.ts",
31
+ "./src/*": "./src/*",
32
+ "./schema.sql": "./schema.sql"
33
+ },
34
+ "peerDependencies": {
35
+ "@kyneta/exchange": "^1.5.0",
36
+ "@kyneta/schema": "^1.5.0",
37
+ "@kyneta/sql-store-core": "^1.5.0",
38
+ "pg": "^8.11.0"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "^22",
42
+ "@types/pg": "^8.11.0",
43
+ "pg": "^8.13.0",
44
+ "tsdown": "^0.21.9",
45
+ "typescript": "^5.9.2",
46
+ "vitest": "^4.0.17",
47
+ "@kyneta/exchange": "^1.5.0",
48
+ "@kyneta/sql-store-core": "^1.5.0",
49
+ "@kyneta/schema": "^1.5.0"
50
+ },
51
+ "scripts": {
52
+ "build": "tsdown",
53
+ "test": "verify logic",
54
+ "verify": "verify"
55
+ }
56
+ }
package/schema.sql ADDED
@@ -0,0 +1,23 @@
1
+ -- @kyneta/postgres-store — canonical schema.
2
+ --
3
+ -- Run this once before constructing a `PostgresStore`, or include it
4
+ -- as a migration step in your application's migration pipeline. The
5
+ -- `createPostgresStore` factory validates that these tables exist with
6
+ -- the expected columns; it does not auto-DDL.
7
+ --
8
+ -- Default table names. To use different names, override via the
9
+ -- `tables` option and replace the names below to match.
10
+
11
+ CREATE TABLE IF NOT EXISTS kyneta_meta (
12
+ doc_id TEXT PRIMARY KEY,
13
+ data JSONB NOT NULL
14
+ );
15
+
16
+ CREATE TABLE IF NOT EXISTS kyneta_records (
17
+ doc_id TEXT NOT NULL,
18
+ seq INTEGER NOT NULL,
19
+ kind TEXT NOT NULL,
20
+ payload TEXT,
21
+ blob BYTEA,
22
+ PRIMARY KEY (doc_id, seq)
23
+ );
@@ -0,0 +1,243 @@
1
+ // postgres-store — conformance + Postgres-specific tests.
2
+ //
3
+ // Gated by the `KYNETA_PG_URL` env var. When unset, the entire suite
4
+ // is skipped (the package still builds and typechecks). Set
5
+ // `KYNETA_PG_URL=postgres://localhost:5432/kyneta_test` (or similar)
6
+ // to run.
7
+
8
+ import { describeStore, makeMetaRecord } from "@kyneta/exchange/testing"
9
+ import { Pool } from "pg"
10
+ import { afterAll, beforeAll, describe, expect, it } from "vitest"
11
+ import { createPostgresStore, PostgresStore } from "../index.js"
12
+
13
+ const PG_URL = process.env.KYNETA_PG_URL
14
+ const ENABLED = PG_URL !== undefined && PG_URL.length > 0
15
+
16
+ const pool: Pool | null = ENABLED
17
+ ? new Pool({ connectionString: PG_URL })
18
+ : null
19
+
20
+ // Per-test schema namespace via per-test table names. Truncate between
21
+ // tests via DELETE on the canonical tables for the conformance run.
22
+ const SCHEMA_TABLES = {
23
+ meta: "kyneta_meta",
24
+ records: "kyneta_records",
25
+ } as const
26
+
27
+ const SCHEMA_DDL = `
28
+ CREATE TABLE IF NOT EXISTS ${SCHEMA_TABLES.meta} (
29
+ doc_id TEXT PRIMARY KEY,
30
+ data JSONB NOT NULL
31
+ );
32
+ CREATE TABLE IF NOT EXISTS ${SCHEMA_TABLES.records} (
33
+ doc_id TEXT NOT NULL,
34
+ seq INTEGER NOT NULL,
35
+ kind TEXT NOT NULL,
36
+ payload TEXT,
37
+ blob BYTEA,
38
+ PRIMARY KEY (doc_id, seq)
39
+ );
40
+ `
41
+
42
+ if (ENABLED && pool !== null) {
43
+ beforeAll(async () => {
44
+ await pool.query(SCHEMA_DDL)
45
+ })
46
+
47
+ afterAll(async () => {
48
+ await pool.end()
49
+ })
50
+ }
51
+
52
+ const describeIfEnabled = ENABLED ? describe : describe.skip
53
+
54
+ describeIfEnabled("PostgresStore", () => {
55
+ if (!ENABLED || pool === null) return
56
+
57
+ // -------------------------------------------------------------------------
58
+ // Conformance suite — uses canonical tables + per-test truncation
59
+ // -------------------------------------------------------------------------
60
+
61
+ describeStore(
62
+ "PostgresStore",
63
+ async () => {
64
+ // Truncate before each test for a clean slate.
65
+ await pool.query(
66
+ `TRUNCATE ${SCHEMA_TABLES.records}, ${SCHEMA_TABLES.meta}`,
67
+ )
68
+ return new PostgresStore(pool)
69
+ },
70
+ {
71
+ cleanup: async () => {
72
+ await pool.query(
73
+ `TRUNCATE ${SCHEMA_TABLES.records}, ${SCHEMA_TABLES.meta}`,
74
+ )
75
+ },
76
+ faultFactory: async () => {
77
+ await pool.query(
78
+ `TRUNCATE ${SCHEMA_TABLES.records}, ${SCHEMA_TABLES.meta}`,
79
+ )
80
+ // Wrap a single connection (not the pool) so we can intercept
81
+ // its `query` method to inject failures. The faulty store uses
82
+ // a Client-shaped wrapper; the fresh store uses a fresh client
83
+ // checked out from the pool.
84
+ const client = await pool.connect()
85
+ // Forward all queries except after arming, when the Nth post-arm
86
+ // call throws. Schema DDL ran in beforeAll, so we don't need to
87
+ // protect those calls.
88
+ let armed: number | null = null
89
+ let count = 0
90
+ const realQuery = client.query.bind(client) as (
91
+ ...args: unknown[]
92
+ ) => Promise<unknown>
93
+ const wrappedClient = {
94
+ query: ((...args: unknown[]) => {
95
+ if (armed !== null) {
96
+ count += 1
97
+ if (count === armed) {
98
+ return Promise.reject(
99
+ new Error(`fault-injected: query call #${count}`),
100
+ )
101
+ }
102
+ }
103
+ return realQuery(...args)
104
+ }) as typeof client.query,
105
+ // Pretend to be a Client (no .connect method).
106
+ } as unknown as ConstructorParameters<typeof PostgresStore>[0]
107
+
108
+ const store = new PostgresStore(wrappedClient)
109
+
110
+ return {
111
+ store,
112
+ injectFault: n => {
113
+ armed = n
114
+ count = 0
115
+ },
116
+ freshStore: async () => new PostgresStore(pool),
117
+ cleanup: async () => {
118
+ client.release()
119
+ },
120
+ }
121
+ },
122
+ isolationFactory: async () => {
123
+ // Two distinct table-name pairs sharing the same Pool.
124
+ const tablesA = { meta: "iso_a_meta", records: "iso_a_records" }
125
+ const tablesB = { meta: "iso_b_meta", records: "iso_b_records" }
126
+ await pool.query(`
127
+ CREATE TABLE IF NOT EXISTS ${tablesA.meta} (
128
+ doc_id TEXT PRIMARY KEY, data JSONB NOT NULL
129
+ );
130
+ CREATE TABLE IF NOT EXISTS ${tablesA.records} (
131
+ doc_id TEXT, seq INTEGER, kind TEXT, payload TEXT, blob BYTEA,
132
+ PRIMARY KEY (doc_id, seq)
133
+ );
134
+ CREATE TABLE IF NOT EXISTS ${tablesB.meta} (
135
+ doc_id TEXT PRIMARY KEY, data JSONB NOT NULL
136
+ );
137
+ CREATE TABLE IF NOT EXISTS ${tablesB.records} (
138
+ doc_id TEXT, seq INTEGER, kind TEXT, payload TEXT, blob BYTEA,
139
+ PRIMARY KEY (doc_id, seq)
140
+ );
141
+ TRUNCATE ${tablesA.records}, ${tablesA.meta},
142
+ ${tablesB.records}, ${tablesB.meta};
143
+ `)
144
+ return {
145
+ storeA: new PostgresStore(pool, { tables: tablesA }),
146
+ storeB: new PostgresStore(pool, { tables: tablesB }),
147
+ cleanup: async () => {
148
+ await pool.query(`
149
+ DROP TABLE IF EXISTS ${tablesA.records};
150
+ DROP TABLE IF EXISTS ${tablesA.meta};
151
+ DROP TABLE IF EXISTS ${tablesB.records};
152
+ DROP TABLE IF EXISTS ${tablesB.meta};
153
+ `)
154
+ },
155
+ }
156
+ },
157
+ },
158
+ )
159
+
160
+ // -------------------------------------------------------------------------
161
+ // Postgres-specific: createPostgresStore validation
162
+ // -------------------------------------------------------------------------
163
+
164
+ describe("createPostgresStore — schema validation", () => {
165
+ it("rejects when meta table is missing", async () => {
166
+ await expect(
167
+ createPostgresStore(pool, {
168
+ tables: { meta: "nonexistent_meta", records: "kyneta_records" },
169
+ }),
170
+ ).rejects.toThrow(/nonexistent_meta/)
171
+ })
172
+
173
+ it("rejects when records table is missing", async () => {
174
+ await expect(
175
+ createPostgresStore(pool, {
176
+ tables: { meta: "kyneta_meta", records: "nonexistent_records" },
177
+ }),
178
+ ).rejects.toThrow(/nonexistent_records/)
179
+ })
180
+
181
+ it("returns a ready Store when schema is valid", async () => {
182
+ const store = await createPostgresStore(pool)
183
+ expect(store).toBeDefined()
184
+ await store.close()
185
+ })
186
+
187
+ it("rejects when a column has the wrong type", async () => {
188
+ const tables = {
189
+ meta: "wrongtype_meta",
190
+ records: "wrongtype_records",
191
+ }
192
+ await pool.query(`
193
+ DROP TABLE IF EXISTS ${tables.records};
194
+ DROP TABLE IF EXISTS ${tables.meta};
195
+ CREATE TABLE ${tables.meta} (
196
+ doc_id TEXT PRIMARY KEY, data TEXT NOT NULL
197
+ );
198
+ CREATE TABLE ${tables.records} (
199
+ doc_id TEXT, seq INTEGER, kind TEXT, payload TEXT, blob BYTEA,
200
+ PRIMARY KEY (doc_id, seq)
201
+ );
202
+ `)
203
+ try {
204
+ await expect(createPostgresStore(pool, { tables })).rejects.toThrow(
205
+ /data.*type "text"/,
206
+ )
207
+ } finally {
208
+ await pool.query(`
209
+ DROP TABLE IF EXISTS ${tables.records};
210
+ DROP TABLE IF EXISTS ${tables.meta};
211
+ `)
212
+ }
213
+ })
214
+ })
215
+
216
+ // -------------------------------------------------------------------------
217
+ // Postgres-specific: range-scan correctness
218
+ // -------------------------------------------------------------------------
219
+
220
+ describe("listDocIds — range scan vs LIKE-pattern hazards", () => {
221
+ it("prefix containing % and _ matches literally, not as wildcards", async () => {
222
+ await pool.query(
223
+ `TRUNCATE ${SCHEMA_TABLES.records}, ${SCHEMA_TABLES.meta}`,
224
+ )
225
+ const store = new PostgresStore(pool)
226
+
227
+ await store.append("100%_done", makeMetaRecord())
228
+ await store.append("100_other", makeMetaRecord())
229
+ await store.append("100xyz", makeMetaRecord())
230
+ await store.append("other", makeMetaRecord())
231
+
232
+ // "100%" must match only "100%_done" — NOT "100_other" / "100xyz"
233
+ const matched: string[] = []
234
+ for await (const id of store.listDocIds("100%")) matched.push(id)
235
+ expect(matched).toEqual(["100%_done"])
236
+
237
+ // "100_" must match only "100_other"
238
+ const matched2: string[] = []
239
+ for await (const id of store.listDocIds("100_")) matched2.push(id)
240
+ expect(matched2).toEqual(["100_other"])
241
+ })
242
+ })
243
+ })
package/src/index.ts ADDED
@@ -0,0 +1,351 @@
1
+ // Postgres Store backend.
2
+ //
3
+ // Why JSONB on meta, not TEXT: operators occasionally need to filter
4
+ // metas by `syncProtocol` or `replicaType` during incident
5
+ // investigations, and JSONB makes `data->>'syncProtocol'` trivial.
6
+ // Cost: JSONB normalizes whitespace and key order at insert time, so
7
+ // `meta.data` bytes don't match SQLite's TEXT-stored meta — but
8
+ // round-trip through `loadAll` still yields a structurally equal
9
+ // `StoreRecord`, which is what cross-backend portability actually
10
+ // requires.
11
+
12
+ import {
13
+ type DocId,
14
+ SeqNoTracker,
15
+ type Store,
16
+ type StoreMeta,
17
+ type StoreRecord,
18
+ } from "@kyneta/exchange"
19
+ import {
20
+ fromRow,
21
+ planAppend,
22
+ planReplace,
23
+ type RowShape,
24
+ resolveTables,
25
+ type TableNames,
26
+ } from "@kyneta/sql-store-core"
27
+ import type { Client, Pool, PoolClient } from "pg"
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Options
31
+ // ---------------------------------------------------------------------------
32
+
33
+ export interface PostgresStoreOptions {
34
+ /**
35
+ * Override the default table names (`kyneta_meta` and `kyneta_records`).
36
+ *
37
+ * Use when running multiple isolated Exchange instances against the
38
+ * same database — each instance owns one `tables` pair.
39
+ */
40
+ tables?: Partial<TableNames>
41
+ }
42
+
43
+ type PgConnection = Client | Pool
44
+
45
+ /**
46
+ * Narrow structural type for the methods we actually call. Keeps the
47
+ * package independent of `pg`'s top-level type changes across versions.
48
+ */
49
+ interface PgQuerier {
50
+ query<R = unknown>(text: string, values?: unknown[]): Promise<{ rows: R[] }>
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // PostgresStore
55
+ // ---------------------------------------------------------------------------
56
+
57
+ /**
58
+ * Caller owns the connection lifecycle — `close()` is a no-op,
59
+ * `pool.end()` is the caller's responsibility. Prefer
60
+ * `createPostgresStore` over the bare constructor: it validates the
61
+ * schema at construction time so misconfiguration fails loudly with a
62
+ * curated error rather than per-method `column does not exist` later.
63
+ */
64
+ export class PostgresStore implements Store {
65
+ readonly #client: PgConnection
66
+ readonly #seqNos = new SeqNoTracker()
67
+ readonly #tables: TableNames
68
+
69
+ constructor(client: PgConnection, options: PostgresStoreOptions = {}) {
70
+ this.#client = client
71
+ this.#tables = resolveTables(options)
72
+ }
73
+
74
+ /**
75
+ * For `Pool`: check out a `PoolClient` so BEGIN..COMMIT all run on
76
+ * the same physical connection (Postgres transactions are
77
+ * connection-scoped; checking back out for COMMIT would target a
78
+ * different connection). For `Client`: run inline.
79
+ *
80
+ * Re-throws on rollback so callers can put post-commit work
81
+ * lexically after the awaited call — a rejection skips the next
82
+ * statement, mirroring sync `transaction()` + throw.
83
+ */
84
+ async #withTransaction<R>(
85
+ fn: (querier: PgQuerier) => Promise<R>,
86
+ ): Promise<R> {
87
+ const isPool = typeof (this.#client as Pool).connect === "function"
88
+ if (isPool) {
89
+ const poolClient: PoolClient = await (this.#client as Pool).connect()
90
+ try {
91
+ await poolClient.query("BEGIN")
92
+ try {
93
+ const result = await fn(poolClient as unknown as PgQuerier)
94
+ await poolClient.query("COMMIT")
95
+ return result
96
+ } catch (e) {
97
+ await poolClient.query("ROLLBACK")
98
+ throw e
99
+ }
100
+ } finally {
101
+ poolClient.release()
102
+ }
103
+ }
104
+ const client = this.#client as Client
105
+ await client.query("BEGIN")
106
+ try {
107
+ const result = await fn(client as unknown as PgQuerier)
108
+ await client.query("COMMIT")
109
+ return result
110
+ } catch (e) {
111
+ await client.query("ROLLBACK")
112
+ throw e
113
+ }
114
+ }
115
+
116
+ // Non-transactional reads (currentMeta, loadAll, listDocIds, the
117
+ // cold-start MAX(seq)) don't need a held connection — issuing them
118
+ // against the pool/client directly avoids unnecessary checkouts.
119
+ get #q(): PgQuerier {
120
+ return this.#client as unknown as PgQuerier
121
+ }
122
+
123
+ // -------------------------------------------------------------------------
124
+ // Store interface
125
+ // -------------------------------------------------------------------------
126
+
127
+ async append(docId: DocId, record: StoreRecord): Promise<void> {
128
+ const existingMeta = await this.currentMeta(docId)
129
+ const seq = await this.#seqNos.next(docId, async () => {
130
+ const result = await this.#q.query<{ max_seq: number | null }>(
131
+ `SELECT MAX(seq)::int AS max_seq FROM ${this.#tables.records} WHERE doc_id = $1`,
132
+ [docId],
133
+ )
134
+ return result.rows[0]?.max_seq ?? null
135
+ })
136
+
137
+ const plan = planAppend(docId, record, existingMeta, seq)
138
+
139
+ await this.#withTransaction(async q => {
140
+ if (plan.upsertMeta !== null) {
141
+ await q.query(
142
+ `INSERT INTO ${this.#tables.meta} (doc_id, data)
143
+ VALUES ($1, $2::jsonb)
144
+ ON CONFLICT (doc_id) DO UPDATE SET data = EXCLUDED.data`,
145
+ [docId, plan.upsertMeta.data],
146
+ )
147
+ }
148
+ const { row } = plan.insertRecord
149
+ await q.query(
150
+ `INSERT INTO ${this.#tables.records}
151
+ (doc_id, seq, kind, payload, blob)
152
+ VALUES ($1, $2, $3, $4, $5)`,
153
+ [docId, plan.insertRecord.seq, row.kind, row.payload, row.blob],
154
+ )
155
+ })
156
+ }
157
+
158
+ async *loadAll(docId: DocId): AsyncIterable<StoreRecord> {
159
+ const result = await this.#q.query<RowShape>(
160
+ `SELECT kind, payload, blob FROM ${this.#tables.records}
161
+ WHERE doc_id = $1 ORDER BY seq`,
162
+ [docId],
163
+ )
164
+ for (const row of result.rows) {
165
+ yield fromRow(row)
166
+ }
167
+ }
168
+
169
+ async replace(docId: DocId, records: StoreRecord[]): Promise<void> {
170
+ const existingMeta = await this.currentMeta(docId)
171
+ const plan = planReplace(records, existingMeta)
172
+
173
+ await this.#withTransaction(async q => {
174
+ await q.query(`DELETE FROM ${this.#tables.records} WHERE doc_id = $1`, [
175
+ docId,
176
+ ])
177
+
178
+ for (const { seq, row } of plan.records) {
179
+ await q.query(
180
+ `INSERT INTO ${this.#tables.records}
181
+ (doc_id, seq, kind, payload, blob)
182
+ VALUES ($1, $2, $3, $4, $5)`,
183
+ [docId, seq, row.kind, row.payload, row.blob],
184
+ )
185
+ }
186
+
187
+ await q.query(
188
+ `INSERT INTO ${this.#tables.meta} (doc_id, data)
189
+ VALUES ($1, $2::jsonb)
190
+ ON CONFLICT (doc_id) DO UPDATE SET data = EXCLUDED.data`,
191
+ [docId, plan.upsertMeta.data],
192
+ )
193
+ })
194
+
195
+ // Must run after commit. If `#withTransaction` rejects, the throw
196
+ // propagates past this line; the cache stays unmutated. Inside the
197
+ // callback would corrupt it on rollback — the next append would
198
+ // collide with restored rows on (doc_id, seq).
199
+ this.#seqNos.reset(docId, records.length - 1)
200
+ }
201
+
202
+ async delete(docId: DocId): Promise<void> {
203
+ await this.#withTransaction(async q => {
204
+ await q.query(`DELETE FROM ${this.#tables.records} WHERE doc_id = $1`, [
205
+ docId,
206
+ ])
207
+ await q.query(`DELETE FROM ${this.#tables.meta} WHERE doc_id = $1`, [
208
+ docId,
209
+ ])
210
+ })
211
+ this.#seqNos.remove(docId)
212
+ }
213
+
214
+ async currentMeta(docId: DocId): Promise<StoreMeta | null> {
215
+ const result = await this.#q.query<{ data: StoreMeta }>(
216
+ `SELECT data FROM ${this.#tables.meta} WHERE doc_id = $1`,
217
+ [docId],
218
+ )
219
+ return result.rows[0]?.data ?? null
220
+ }
221
+
222
+ async *listDocIds(prefix?: string): AsyncIterable<DocId> {
223
+ if (prefix === undefined) {
224
+ const result = await this.#q.query<{ doc_id: string }>(
225
+ `SELECT doc_id FROM ${this.#tables.meta}`,
226
+ )
227
+ for (const row of result.rows) yield row.doc_id
228
+ return
229
+ }
230
+
231
+ // Range scan instead of LIKE — `%` and `_` in doc IDs are literal,
232
+ // not wildcards.
233
+ const upper = prefixUpperBound(prefix)
234
+ const result =
235
+ upper === null
236
+ ? await this.#q.query<{ doc_id: string }>(
237
+ `SELECT doc_id FROM ${this.#tables.meta} WHERE doc_id >= $1`,
238
+ [prefix],
239
+ )
240
+ : await this.#q.query<{ doc_id: string }>(
241
+ `SELECT doc_id FROM ${this.#tables.meta}
242
+ WHERE doc_id >= $1 AND doc_id < $2`,
243
+ [prefix, upper],
244
+ )
245
+ for (const row of result.rows) yield row.doc_id
246
+ }
247
+
248
+ async close(): Promise<void> {
249
+ // Caller calls `pool.end()` / `client.end()`.
250
+ }
251
+ }
252
+
253
+ // ---------------------------------------------------------------------------
254
+ // Range-scan helper
255
+ // ---------------------------------------------------------------------------
256
+
257
+ /**
258
+ * Returns null when no successor exists (e.g. all code units at U+10FFFF),
259
+ * letting the caller fall back to an unbounded `>= prefix` scan.
260
+ */
261
+ function prefixUpperBound(prefix: string): string | null {
262
+ if (prefix.length === 0) return null
263
+ const codes = Array.from(prefix)
264
+ for (let i = codes.length - 1; i >= 0; i--) {
265
+ const ch = codes[i] as string
266
+ const code = ch.codePointAt(0) as number
267
+ if (code < 0x10ffff) {
268
+ const next = String.fromCodePoint(code + 1)
269
+ return codes.slice(0, i).join("") + next
270
+ }
271
+ }
272
+ return null
273
+ }
274
+
275
+ // ---------------------------------------------------------------------------
276
+ // Factory: createPostgresStore (recommended entry point)
277
+ // ---------------------------------------------------------------------------
278
+
279
+ /**
280
+ * Validation runs once at factory time, not on every method call. A
281
+ * schema change applied while the Exchange is running won't be
282
+ * detected — restart after migrations. Polling or a `revalidate()`
283
+ * API would be over-engineering for a failure mode that fails loudly
284
+ * on the next write anyway.
285
+ */
286
+ export async function createPostgresStore(
287
+ client: PgConnection,
288
+ options: PostgresStoreOptions = {},
289
+ ): Promise<Store> {
290
+ const tables = resolveTables(options)
291
+ await validateSchema(client as unknown as PgQuerier, tables)
292
+ return new PostgresStore(client, options)
293
+ }
294
+
295
+ interface ColumnInfo {
296
+ column_name: string
297
+ data_type: string
298
+ is_nullable: string
299
+ }
300
+
301
+ const EXPECTED_COLUMNS = {
302
+ meta: [
303
+ { name: "doc_id", types: ["text"] },
304
+ { name: "data", types: ["jsonb"] },
305
+ ],
306
+ records: [
307
+ { name: "doc_id", types: ["text"] },
308
+ { name: "seq", types: ["integer"] },
309
+ { name: "kind", types: ["text"] },
310
+ { name: "payload", types: ["text"] },
311
+ { name: "blob", types: ["bytea"] },
312
+ ],
313
+ } as const
314
+
315
+ async function validateSchema(q: PgQuerier, tables: TableNames): Promise<void> {
316
+ for (const [role, expected] of [
317
+ ["meta", EXPECTED_COLUMNS.meta] as const,
318
+ ["records", EXPECTED_COLUMNS.records] as const,
319
+ ]) {
320
+ const tableName = tables[role]
321
+ const result = await q.query<ColumnInfo>(
322
+ `SELECT column_name, data_type, is_nullable
323
+ FROM information_schema.columns
324
+ WHERE table_name = $1`,
325
+ [tableName],
326
+ )
327
+ if (result.rows.length === 0) {
328
+ throw new Error(
329
+ `@kyneta/postgres-store: table "${tableName}" not found. ` +
330
+ `Run schema.sql or include the canonical DDL in your migrations.`,
331
+ )
332
+ }
333
+ const columnsByName = new Map(result.rows.map(r => [r.column_name, r]))
334
+ for (const col of expected) {
335
+ const found = columnsByName.get(col.name)
336
+ if (found === undefined) {
337
+ throw new Error(
338
+ `@kyneta/postgres-store: table "${tableName}" missing column ` +
339
+ `"${col.name}". See schema.sql for the canonical definition.`,
340
+ )
341
+ }
342
+ if (!(col.types as readonly string[]).includes(found.data_type)) {
343
+ throw new Error(
344
+ `@kyneta/postgres-store: table "${tableName}" column ` +
345
+ `"${col.name}" has type "${found.data_type}", ` +
346
+ `expected one of [${col.types.join(", ")}].`,
347
+ )
348
+ }
349
+ }
350
+ }
351
+ }