@kyneta/postgres-store 1.7.0 → 2.0.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/README.md +19 -11
- package/dist/index.d.ts +43 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +119 -62
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/schema.sql +9 -1
- package/src/__tests__/pg-adapter.test.ts +112 -0
- package/src/__tests__/postgres-store.test.ts +118 -67
- package/src/index.ts +166 -80
package/README.md
CHANGED
|
@@ -17,10 +17,10 @@ Peer dependencies: `@kyneta/exchange`, `@kyneta/schema`, `@kyneta/sql-store-core
|
|
|
17
17
|
```ts
|
|
18
18
|
import { Pool } from "pg"
|
|
19
19
|
import { Exchange } from "@kyneta/exchange"
|
|
20
|
-
import { createPostgresStore } from "@kyneta/postgres-store"
|
|
20
|
+
import { createPostgresStore, fromPool } from "@kyneta/postgres-store"
|
|
21
21
|
|
|
22
22
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
|
|
23
|
-
const store = await createPostgresStore(pool)
|
|
23
|
+
const store = await createPostgresStore(fromPool(pool))
|
|
24
24
|
|
|
25
25
|
const exchange = new Exchange({
|
|
26
26
|
stores: [store],
|
|
@@ -32,16 +32,16 @@ const exchange = new Exchange({
|
|
|
32
32
|
// await pool.end()
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
The factory queries `information_schema.columns` to validate that the canonical schema exists with compatible column types, then returns a ready Store
|
|
35
|
+
`createPostgresStore` takes a `PgAdapter` — wrap your connection with `fromPool(pool)` (pooled; each transaction checks out and releases a connection) or `fromClient(client)` (a single dedicated connection). This injection mirrors `@kyneta/sqlite-store`'s `fromBetterSqlite3` and keeps the store free of any runtime `pg`-class coupling. The factory queries `information_schema.columns` to validate that the canonical schema exists with compatible column types, then returns a ready Store; a curated error tells you which column is missing or has the wrong type.
|
|
36
36
|
|
|
37
37
|
### Sync constructor (advanced)
|
|
38
38
|
|
|
39
39
|
For callers that validate the schema separately at process start:
|
|
40
40
|
|
|
41
41
|
```ts
|
|
42
|
-
import { PostgresStore } from "@kyneta/postgres-store"
|
|
42
|
+
import { PostgresStore, fromPool } from "@kyneta/postgres-store"
|
|
43
43
|
|
|
44
|
-
const store = new PostgresStore(pool)
|
|
44
|
+
const store = new PostgresStore(fromPool(pool))
|
|
45
45
|
```
|
|
46
46
|
|
|
47
47
|
## Schema
|
|
@@ -49,7 +49,7 @@ const store = new PostgresStore(pool)
|
|
|
49
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
50
|
|
|
51
51
|
```sql
|
|
52
|
-
CREATE TABLE IF NOT EXISTS
|
|
52
|
+
CREATE TABLE IF NOT EXISTS kyneta_doc_meta (
|
|
53
53
|
doc_id TEXT PRIMARY KEY,
|
|
54
54
|
data JSONB NOT NULL
|
|
55
55
|
);
|
|
@@ -61,9 +61,17 @@ CREATE TABLE IF NOT EXISTS kyneta_records (
|
|
|
61
61
|
blob BYTEA,
|
|
62
62
|
PRIMARY KEY (doc_id, seq)
|
|
63
63
|
);
|
|
64
|
+
-- Store-global metadata (e.g. the on-disk format version). Distinct from
|
|
65
|
+
-- the per-document kyneta_doc_meta.
|
|
66
|
+
CREATE TABLE IF NOT EXISTS kyneta_store_meta (
|
|
67
|
+
key TEXT PRIMARY KEY,
|
|
68
|
+
value JSONB NOT NULL
|
|
69
|
+
);
|
|
64
70
|
```
|
|
65
71
|
|
|
66
|
-
|
|
72
|
+
`createPostgresStore` validates all three tables exist and runs the **store-format gate** on open: it stamps a `{ major, minor }` version into `kyneta_store_meta` (a one-row idempotent write — not DDL) and, on a later open, throws `StoreFormatVersionError` for an incompatible major or an unversioned store that already holds documents. No automatic migration is performed. Adding `kyneta_store_meta` (and the `kyneta_meta` → `kyneta_doc_meta` rename) to an existing deployment is an explicit migration step.
|
|
73
|
+
|
|
74
|
+
JSONB on `doc_meta.data` enables operator queryability for admin tooling (`data->>'syncMode'`, `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
75
|
|
|
68
76
|
## Options
|
|
69
77
|
|
|
@@ -71,11 +79,11 @@ JSONB on `meta.data` enables operator queryability for admin tooling (`data->>'s
|
|
|
71
79
|
|
|
72
80
|
```ts
|
|
73
81
|
const store = await createPostgresStore(pool, {
|
|
74
|
-
tables: {
|
|
82
|
+
tables: { docMeta: "app_doc_meta", records: "app_records", storeMeta: "app_store_meta" },
|
|
75
83
|
})
|
|
76
84
|
```
|
|
77
85
|
|
|
78
|
-
Default: `{
|
|
86
|
+
Default: `{ docMeta: "kyneta_doc_meta", records: "kyneta_records", storeMeta: "kyneta_store_meta" }`. Use to run multiple isolated Exchange instances against the same database — each owns one `tables` set.
|
|
79
87
|
|
|
80
88
|
`listDocIds(prefix)` uses a range scan (`doc_id >= prefix AND doc_id < successor(prefix)`), not `LIKE`. Doc IDs containing `%` and `_` are matched literally.
|
|
81
89
|
|
|
@@ -83,8 +91,8 @@ Default: `{ meta: "kyneta_meta", records: "kyneta_records" }`. Use to run multip
|
|
|
83
91
|
|
|
84
92
|
The caller owns the connection lifecycle:
|
|
85
93
|
|
|
86
|
-
- `
|
|
87
|
-
- `
|
|
94
|
+
- `fromPool(pool)`: each transaction checks out a connection via `pool.connect()` and `release()`s it; the caller calls `pool.end()` on shutdown.
|
|
95
|
+
- `fromClient(client)`: transactions run against the one connection; the caller calls `client.end()` on shutdown.
|
|
88
96
|
|
|
89
97
|
`PostgresStore.close()` is a no-op.
|
|
90
98
|
|
package/dist/index.d.ts
CHANGED
|
@@ -1,18 +1,54 @@
|
|
|
1
1
|
import { DocId, Store, StoreMeta, StoreRecord } from "@kyneta/exchange";
|
|
2
2
|
import { TableNames } from "@kyneta/sql-store-core";
|
|
3
|
-
import { Client, Pool } from "pg";
|
|
3
|
+
import { Client, Pool, PoolClient } from "pg";
|
|
4
4
|
|
|
5
5
|
//#region src/index.d.ts
|
|
6
6
|
interface PostgresStoreOptions {
|
|
7
7
|
/**
|
|
8
|
-
* Override the default table names (`
|
|
8
|
+
* Override the default table names (`kyneta_doc_meta`, `kyneta_records`,
|
|
9
|
+
* `kyneta_store_meta`).
|
|
9
10
|
*
|
|
10
11
|
* Use when running multiple isolated Exchange instances against the
|
|
11
|
-
* same database — each instance owns one `tables`
|
|
12
|
+
* same database — each instance owns one `tables` set.
|
|
12
13
|
*/
|
|
13
14
|
tables?: Partial<TableNames>;
|
|
14
15
|
}
|
|
15
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Narrow structural type for the methods we actually call. Keeps the
|
|
18
|
+
* package independent of `pg`'s top-level type changes across versions.
|
|
19
|
+
*/
|
|
20
|
+
interface PgQuerier {
|
|
21
|
+
query<R = unknown>(text: string, values?: unknown[]): Promise<{
|
|
22
|
+
rows: R[];
|
|
23
|
+
}>;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The minimal Postgres capability `PostgresStore` needs, decoupled from any
|
|
27
|
+
* specific pg surface — mirrors `SqliteAdapter`. `fromPool` / `fromClient`
|
|
28
|
+
* supply it, so `PostgresStore` never discriminates connection types and `pg`
|
|
29
|
+
* stays a type-only import (no `instanceof`, no runtime class coupling).
|
|
30
|
+
*/
|
|
31
|
+
interface PgAdapter extends PgQuerier {
|
|
32
|
+
/**
|
|
33
|
+
* Run `fn` with a querier whose statements share one connection under
|
|
34
|
+
* BEGIN/COMMIT; ROLLBACK and rethrow on failure.
|
|
35
|
+
*/
|
|
36
|
+
transaction<R>(fn: (q: PgQuerier) => Promise<R>): Promise<R>;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Adapter over a `Pool`: each transaction checks out a `PoolClient` so
|
|
40
|
+
* BEGIN..COMMIT share one physical connection (Postgres transactions are
|
|
41
|
+
* connection-scoped — checking back out for COMMIT would target a different
|
|
42
|
+
* connection), then releases it. Non-transactional queries go to the pool
|
|
43
|
+
* directly (the pool checks out/in per query — fine for single reads).
|
|
44
|
+
*/
|
|
45
|
+
declare function fromPool(pool: Pool): PgAdapter;
|
|
46
|
+
/**
|
|
47
|
+
* Adapter over a single `Client` (or an already-checked-out `PoolClient`):
|
|
48
|
+
* transactions run inline on the one connection. Re-throws on rollback so a
|
|
49
|
+
* caller can place post-commit work lexically after the awaited call.
|
|
50
|
+
*/
|
|
51
|
+
declare function fromClient(client: Client | PoolClient): PgAdapter;
|
|
16
52
|
/**
|
|
17
53
|
* Caller owns the connection lifecycle — `close()` is a no-op,
|
|
18
54
|
* `pool.end()` is the caller's responsibility. Prefer
|
|
@@ -22,7 +58,7 @@ type PgConnection = Client | Pool;
|
|
|
22
58
|
*/
|
|
23
59
|
declare class PostgresStore implements Store {
|
|
24
60
|
#private;
|
|
25
|
-
constructor(
|
|
61
|
+
constructor(adapter: PgAdapter, options?: PostgresStoreOptions);
|
|
26
62
|
append(docId: DocId, record: StoreRecord): Promise<void>;
|
|
27
63
|
loadAll(docId: DocId): AsyncIterable<StoreRecord>;
|
|
28
64
|
replace(docId: DocId, records: StoreRecord[]): Promise<void>;
|
|
@@ -38,7 +74,7 @@ declare class PostgresStore implements Store {
|
|
|
38
74
|
* API would be over-engineering for a failure mode that fails loudly
|
|
39
75
|
* on the next write anyway.
|
|
40
76
|
*/
|
|
41
|
-
declare function createPostgresStore(
|
|
77
|
+
declare function createPostgresStore(adapter: PgAdapter, options?: PostgresStoreOptions): Promise<Store>;
|
|
42
78
|
//#endregion
|
|
43
|
-
export { PostgresStore, PostgresStoreOptions, createPostgresStore };
|
|
79
|
+
export { PgAdapter, PgQuerier, PostgresStore, PostgresStoreOptions, createPostgresStore, fromClient, fromPool };
|
|
44
80
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","names":[],"sources":["../src/index.ts"],"mappings":";;;;;
|
|
1
|
+
{"version":3,"file":"index.d.ts","names":[],"sources":["../src/index.ts"],"mappings":";;;;;UAqCiB,oBAAA;;AAAjB;;;;;;EAQE,MAAA,GAAS,OAAO,CAAC,UAAA;AAAA;AAAU;AAO7B;;;AAP6B,UAOZ,SAAA;EACf,KAAA,cAAmB,IAAA,UAAc,MAAA,eAAqB,OAAO;IAAG,IAAA,EAAM,CAAA;EAAA;AAAA;;;;;AAAC;AASzE;UAAiB,SAAA,SAAkB,SAAA;EAAR;;;;EAKzB,WAAA,IAAe,EAAA,GAAK,CAAA,EAAG,SAAA,KAAc,OAAA,CAAQ,CAAA,IAAK,OAAA,CAAQ,CAAA;AAAA;;;;;;;;iBAU5C,QAAA,CAAS,IAAA,EAAM,IAAA,GAAO,SAAS;;;;;;iBAkC/B,UAAA,CAAW,MAAA,EAAQ,MAAA,GAAS,UAAA,GAAa,SAAA;AA5CI;AAU7D;;;;;;AAV6D,cA8EhD,aAAA,YAAyB,KAAA;EAAA;cAKxB,OAAA,EAAS,SAAA,EAAW,OAAA,GAAS,oBAAA;EASnC,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;;;;;;AAzKiD;AAkClE;iBA6KsB,mBAAA,CACpB,OAAA,EAAS,SAAA,EACT,OAAA,GAAS,oBAAA,GACR,OAAA,CAAQ,KAAA"}
|
package/dist/index.js
CHANGED
|
@@ -1,68 +1,83 @@
|
|
|
1
|
-
import { SeqNoTracker } from "@kyneta/exchange";
|
|
2
|
-
import { fromRow, planAppend, planReplace, resolveTables } from "@kyneta/sql-store-core";
|
|
1
|
+
import { STORE_META_FORMAT_KEY, SeqNoTracker, StoreFormatVersionError, decideStoreFormat, parseStoreFormat } from "@kyneta/exchange";
|
|
2
|
+
import { STORE_FORMAT_VERSION, fromRow, planAppend, planReplace, resolveTables } from "@kyneta/sql-store-core";
|
|
3
3
|
//#region src/index.ts
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* Adapter over a `Pool`: each transaction checks out a `PoolClient` so
|
|
6
|
+
* BEGIN..COMMIT share one physical connection (Postgres transactions are
|
|
7
|
+
* connection-scoped — checking back out for COMMIT would target a different
|
|
8
|
+
* connection), then releases it. Non-transactional queries go to the pool
|
|
9
|
+
* directly (the pool checks out/in per query — fine for single reads).
|
|
10
10
|
*/
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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();
|
|
11
|
+
function fromPool(pool) {
|
|
12
|
+
const direct = pool;
|
|
13
|
+
return {
|
|
14
|
+
query(text, values) {
|
|
15
|
+
return direct.query(text, values);
|
|
16
|
+
},
|
|
17
|
+
async transaction(fn) {
|
|
18
|
+
const poolClient = await pool.connect();
|
|
19
|
+
const q = poolClient;
|
|
32
20
|
try {
|
|
33
|
-
await
|
|
21
|
+
await q.query("BEGIN");
|
|
34
22
|
try {
|
|
35
|
-
const result = await fn(
|
|
36
|
-
await
|
|
23
|
+
const result = await fn(q);
|
|
24
|
+
await q.query("COMMIT");
|
|
37
25
|
return result;
|
|
38
26
|
} catch (e) {
|
|
39
|
-
await
|
|
27
|
+
await q.query("ROLLBACK");
|
|
40
28
|
throw e;
|
|
41
29
|
}
|
|
42
30
|
} finally {
|
|
43
31
|
poolClient.release();
|
|
44
32
|
}
|
|
45
33
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Adapter over a single `Client` (or an already-checked-out `PoolClient`):
|
|
38
|
+
* transactions run inline on the one connection. Re-throws on rollback so a
|
|
39
|
+
* caller can place post-commit work lexically after the awaited call.
|
|
40
|
+
*/
|
|
41
|
+
function fromClient(client) {
|
|
42
|
+
const q = client;
|
|
43
|
+
return {
|
|
44
|
+
query(text, values) {
|
|
45
|
+
return q.query(text, values);
|
|
46
|
+
},
|
|
47
|
+
async transaction(fn) {
|
|
48
|
+
await q.query("BEGIN");
|
|
49
|
+
try {
|
|
50
|
+
const result = await fn(q);
|
|
51
|
+
await q.query("COMMIT");
|
|
52
|
+
return result;
|
|
53
|
+
} catch (e) {
|
|
54
|
+
await q.query("ROLLBACK");
|
|
55
|
+
throw e;
|
|
56
|
+
}
|
|
55
57
|
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Caller owns the connection lifecycle — `close()` is a no-op,
|
|
62
|
+
* `pool.end()` is the caller's responsibility. Prefer
|
|
63
|
+
* `createPostgresStore` over the bare constructor: it validates the
|
|
64
|
+
* schema at construction time so misconfiguration fails loudly with a
|
|
65
|
+
* curated error rather than per-method `column does not exist` later.
|
|
66
|
+
*/
|
|
67
|
+
var PostgresStore = class {
|
|
68
|
+
#adapter;
|
|
69
|
+
#seqNos = new SeqNoTracker();
|
|
70
|
+
#tables;
|
|
71
|
+
constructor(adapter, options = {}) {
|
|
72
|
+
this.#adapter = adapter;
|
|
73
|
+
this.#tables = resolveTables(options);
|
|
59
74
|
}
|
|
60
75
|
async append(docId, record) {
|
|
61
76
|
const plan = planAppend(docId, record, await this.currentMeta(docId), await this.#seqNos.next(docId, async () => {
|
|
62
|
-
return (await this.#
|
|
77
|
+
return (await this.#adapter.query(`SELECT MAX(seq)::int AS max_seq FROM ${this.#tables.records} WHERE doc_id = $1`, [docId])).rows[0]?.max_seq ?? null;
|
|
63
78
|
}));
|
|
64
|
-
await this.#
|
|
65
|
-
if (plan.upsertMeta !== null) await q.query(`INSERT INTO ${this.#tables.
|
|
79
|
+
await this.#adapter.transaction(async (q) => {
|
|
80
|
+
if (plan.upsertMeta !== null) await q.query(`INSERT INTO ${this.#tables.docMeta} (doc_id, data)
|
|
66
81
|
VALUES ($1, $2::jsonb)
|
|
67
82
|
ON CONFLICT (doc_id) DO UPDATE SET data = EXCLUDED.data`, [docId, plan.upsertMeta.data]);
|
|
68
83
|
const { row } = plan.insertRecord;
|
|
@@ -78,13 +93,13 @@ var PostgresStore = class {
|
|
|
78
93
|
});
|
|
79
94
|
}
|
|
80
95
|
async *loadAll(docId) {
|
|
81
|
-
const result = await this.#
|
|
96
|
+
const result = await this.#adapter.query(`SELECT kind, payload, blob FROM ${this.#tables.records}
|
|
82
97
|
WHERE doc_id = $1 ORDER BY seq`, [docId]);
|
|
83
98
|
for (const row of result.rows) yield fromRow(row);
|
|
84
99
|
}
|
|
85
100
|
async replace(docId, records) {
|
|
86
101
|
const plan = planReplace(records, await this.currentMeta(docId));
|
|
87
|
-
await this.#
|
|
102
|
+
await this.#adapter.transaction(async (q) => {
|
|
88
103
|
await q.query(`DELETE FROM ${this.#tables.records} WHERE doc_id = $1`, [docId]);
|
|
89
104
|
for (const { seq, row } of plan.records) await q.query(`INSERT INTO ${this.#tables.records}
|
|
90
105
|
(doc_id, seq, kind, payload, blob)
|
|
@@ -95,30 +110,30 @@ var PostgresStore = class {
|
|
|
95
110
|
row.payload,
|
|
96
111
|
row.blob
|
|
97
112
|
]);
|
|
98
|
-
await q.query(`INSERT INTO ${this.#tables.
|
|
113
|
+
await q.query(`INSERT INTO ${this.#tables.docMeta} (doc_id, data)
|
|
99
114
|
VALUES ($1, $2::jsonb)
|
|
100
115
|
ON CONFLICT (doc_id) DO UPDATE SET data = EXCLUDED.data`, [docId, plan.upsertMeta.data]);
|
|
101
116
|
});
|
|
102
117
|
this.#seqNos.reset(docId, records.length - 1);
|
|
103
118
|
}
|
|
104
119
|
async delete(docId) {
|
|
105
|
-
await this.#
|
|
120
|
+
await this.#adapter.transaction(async (q) => {
|
|
106
121
|
await q.query(`DELETE FROM ${this.#tables.records} WHERE doc_id = $1`, [docId]);
|
|
107
|
-
await q.query(`DELETE FROM ${this.#tables.
|
|
122
|
+
await q.query(`DELETE FROM ${this.#tables.docMeta} WHERE doc_id = $1`, [docId]);
|
|
108
123
|
});
|
|
109
124
|
this.#seqNos.remove(docId);
|
|
110
125
|
}
|
|
111
126
|
async currentMeta(docId) {
|
|
112
|
-
return (await this.#
|
|
127
|
+
return (await this.#adapter.query(`SELECT data FROM ${this.#tables.docMeta} WHERE doc_id = $1`, [docId])).rows[0]?.data ?? null;
|
|
113
128
|
}
|
|
114
129
|
async *listDocIds(prefix) {
|
|
115
130
|
if (prefix === void 0) {
|
|
116
|
-
const result = await this.#
|
|
131
|
+
const result = await this.#adapter.query(`SELECT doc_id FROM ${this.#tables.docMeta}`);
|
|
117
132
|
for (const row of result.rows) yield row.doc_id;
|
|
118
133
|
return;
|
|
119
134
|
}
|
|
120
135
|
const upper = prefixUpperBound(prefix);
|
|
121
|
-
const result = upper === null ? await this.#
|
|
136
|
+
const result = upper === null ? await this.#adapter.query(`SELECT doc_id FROM ${this.#tables.docMeta} WHERE doc_id >= $1`, [prefix]) : await this.#adapter.query(`SELECT doc_id FROM ${this.#tables.docMeta}
|
|
122
137
|
WHERE doc_id >= $1 AND doc_id < $2`, [prefix, upper]);
|
|
123
138
|
for (const row of result.rows) yield row.doc_id;
|
|
124
139
|
}
|
|
@@ -147,12 +162,43 @@ function prefixUpperBound(prefix) {
|
|
|
147
162
|
* API would be over-engineering for a failure mode that fails loudly
|
|
148
163
|
* on the next write anyway.
|
|
149
164
|
*/
|
|
150
|
-
async function createPostgresStore(
|
|
151
|
-
|
|
152
|
-
|
|
165
|
+
async function createPostgresStore(adapter, options = {}) {
|
|
166
|
+
const tables = resolveTables(options);
|
|
167
|
+
await validateSchema(adapter, tables);
|
|
168
|
+
await assertFormat(adapter, tables);
|
|
169
|
+
return new PostgresStore(adapter, options);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Bootstrap reader: stamp/accept/refuse the store-format marker on open.
|
|
173
|
+
* Writes at most one idempotent row (`ON CONFLICT DO NOTHING`) — not DDL,
|
|
174
|
+
* so the "no auto-DDL" invariant holds; the operator still owns the table.
|
|
175
|
+
*/
|
|
176
|
+
async function assertFormat(q, tables) {
|
|
177
|
+
const raw = (await q.query(`SELECT value FROM ${tables.storeMeta} WHERE key = $1`, [STORE_META_FORMAT_KEY])).rows[0]?.value;
|
|
178
|
+
const parsed = raw === void 0 ? null : parseStoreFormat(raw);
|
|
179
|
+
if (parsed === "malformed") throw new StoreFormatVersionError({
|
|
180
|
+
reason: "malformed-version",
|
|
181
|
+
backend: "postgres",
|
|
182
|
+
stored: null,
|
|
183
|
+
current: STORE_FORMAT_VERSION
|
|
184
|
+
});
|
|
185
|
+
const decision = decideStoreFormat({
|
|
186
|
+
current: STORE_FORMAT_VERSION,
|
|
187
|
+
stored: parsed,
|
|
188
|
+
storeHasData: (await q.query(`SELECT 1 FROM ${tables.docMeta} LIMIT 1`)).rows.length > 0
|
|
189
|
+
});
|
|
190
|
+
if (decision.action === "refuse") throw new StoreFormatVersionError({
|
|
191
|
+
reason: decision.reason,
|
|
192
|
+
backend: "postgres",
|
|
193
|
+
stored: parsed,
|
|
194
|
+
current: STORE_FORMAT_VERSION
|
|
195
|
+
});
|
|
196
|
+
if (decision.action === "stamp") await q.query(`INSERT INTO ${tables.storeMeta} (key, value)
|
|
197
|
+
VALUES ($1, $2::jsonb)
|
|
198
|
+
ON CONFLICT (key) DO NOTHING`, [STORE_META_FORMAT_KEY, JSON.stringify(decision.value)]);
|
|
153
199
|
}
|
|
154
200
|
const EXPECTED_COLUMNS = {
|
|
155
|
-
|
|
201
|
+
docMeta: [{
|
|
156
202
|
name: "doc_id",
|
|
157
203
|
types: ["text"]
|
|
158
204
|
}, {
|
|
@@ -180,10 +226,21 @@ const EXPECTED_COLUMNS = {
|
|
|
180
226
|
name: "blob",
|
|
181
227
|
types: ["bytea"]
|
|
182
228
|
}
|
|
183
|
-
]
|
|
229
|
+
],
|
|
230
|
+
storeMeta: [{
|
|
231
|
+
name: "key",
|
|
232
|
+
types: ["text"]
|
|
233
|
+
}, {
|
|
234
|
+
name: "value",
|
|
235
|
+
types: ["jsonb"]
|
|
236
|
+
}]
|
|
184
237
|
};
|
|
185
238
|
async function validateSchema(q, tables) {
|
|
186
|
-
for (const [role, expected] of [
|
|
239
|
+
for (const [role, expected] of [
|
|
240
|
+
["docMeta", EXPECTED_COLUMNS.docMeta],
|
|
241
|
+
["records", EXPECTED_COLUMNS.records],
|
|
242
|
+
["storeMeta", EXPECTED_COLUMNS.storeMeta]
|
|
243
|
+
]) {
|
|
187
244
|
const tableName = tables[role];
|
|
188
245
|
const result = await q.query(`SELECT column_name, data_type, is_nullable
|
|
189
246
|
FROM information_schema.columns
|
|
@@ -198,6 +255,6 @@ async function validateSchema(q, tables) {
|
|
|
198
255
|
}
|
|
199
256
|
}
|
|
200
257
|
//#endregion
|
|
201
|
-
export { PostgresStore, createPostgresStore };
|
|
258
|
+
export { PostgresStore, createPostgresStore, fromClient, fromPool };
|
|
202
259
|
|
|
203
260
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +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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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":["#adapter","#seqNos","#tables"],"sources":["../src/index.ts"],"sourcesContent":["// Postgres Store backend.\n//\n// Why JSONB on meta, not TEXT: operators occasionally need to filter\n// metas by `syncMode` or `replicaType` during incident\n// investigations, and JSONB makes `data->>'syncMode'` 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 decideStoreFormat,\n parseStoreFormat,\n SeqNoTracker,\n STORE_META_FORMAT_KEY,\n type Store,\n StoreFormatVersionError,\n type StoreMeta,\n type StoreRecord,\n} from \"@kyneta/exchange\"\nimport {\n fromRow,\n planAppend,\n planReplace,\n type RowShape,\n resolveTables,\n STORE_FORMAT_VERSION,\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_doc_meta`, `kyneta_records`,\n * `kyneta_store_meta`).\n *\n * Use when running multiple isolated Exchange instances against the\n * same database — each instance owns one `tables` set.\n */\n tables?: Partial<TableNames>\n}\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 */\nexport interface PgQuerier {\n query<R = unknown>(text: string, values?: unknown[]): Promise<{ rows: R[] }>\n}\n\n/**\n * The minimal Postgres capability `PostgresStore` needs, decoupled from any\n * specific pg surface — mirrors `SqliteAdapter`. `fromPool` / `fromClient`\n * supply it, so `PostgresStore` never discriminates connection types and `pg`\n * stays a type-only import (no `instanceof`, no runtime class coupling).\n */\nexport interface PgAdapter extends PgQuerier {\n /**\n * Run `fn` with a querier whose statements share one connection under\n * BEGIN/COMMIT; ROLLBACK and rethrow on failure.\n */\n transaction<R>(fn: (q: PgQuerier) => Promise<R>): Promise<R>\n}\n\n/**\n * Adapter over a `Pool`: each transaction checks out a `PoolClient` so\n * BEGIN..COMMIT share one physical connection (Postgres transactions are\n * connection-scoped — checking back out for COMMIT would target a different\n * connection), then releases it. Non-transactional queries go to the pool\n * directly (the pool checks out/in per query — fine for single reads).\n */\nexport function fromPool(pool: Pool): PgAdapter {\n const direct = pool as unknown as PgQuerier\n return {\n query<R = unknown>(\n text: string,\n values?: unknown[],\n ): Promise<{ rows: R[] }> {\n return direct.query<R>(text, values)\n },\n async transaction<R>(fn: (q: PgQuerier) => Promise<R>): Promise<R> {\n const poolClient: PoolClient = await pool.connect()\n const q = poolClient as unknown as PgQuerier\n try {\n await q.query(\"BEGIN\")\n try {\n const result = await fn(q)\n await q.query(\"COMMIT\")\n return result\n } catch (e) {\n await q.query(\"ROLLBACK\")\n throw e\n }\n } finally {\n poolClient.release()\n }\n },\n }\n}\n\n/**\n * Adapter over a single `Client` (or an already-checked-out `PoolClient`):\n * transactions run inline on the one connection. Re-throws on rollback so a\n * caller can place post-commit work lexically after the awaited call.\n */\nexport function fromClient(client: Client | PoolClient): PgAdapter {\n const q = client as unknown as PgQuerier\n return {\n query<R = unknown>(\n text: string,\n values?: unknown[],\n ): Promise<{ rows: R[] }> {\n return q.query<R>(text, values)\n },\n async transaction<R>(fn: (q: PgQuerier) => Promise<R>): Promise<R> {\n await q.query(\"BEGIN\")\n try {\n const result = await fn(q)\n await q.query(\"COMMIT\")\n return result\n } catch (e) {\n await q.query(\"ROLLBACK\")\n throw e\n }\n },\n }\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 #adapter: PgAdapter\n readonly #seqNos = new SeqNoTracker()\n readonly #tables: TableNames\n\n constructor(adapter: PgAdapter, options: PostgresStoreOptions = {}) {\n this.#adapter = adapter\n this.#tables = resolveTables(options)\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.#adapter.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.#adapter.transaction(async q => {\n if (plan.upsertMeta !== null) {\n await q.query(\n `INSERT INTO ${this.#tables.docMeta} (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.#adapter.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.#adapter.transaction(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.docMeta} (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.#adapter.transaction(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.docMeta} 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.#adapter.query<{ data: StoreMeta }>(\n `SELECT data FROM ${this.#tables.docMeta} 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.#adapter.query<{ doc_id: string }>(\n `SELECT doc_id FROM ${this.#tables.docMeta}`,\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.#adapter.query<{ doc_id: string }>(\n `SELECT doc_id FROM ${this.#tables.docMeta} WHERE doc_id >= $1`,\n [prefix],\n )\n : await this.#adapter.query<{ doc_id: string }>(\n `SELECT doc_id FROM ${this.#tables.docMeta}\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 adapter: PgAdapter,\n options: PostgresStoreOptions = {},\n): Promise<Store> {\n const tables = resolveTables(options)\n await validateSchema(adapter, tables)\n await assertFormat(adapter, tables)\n return new PostgresStore(adapter, options)\n}\n\n/**\n * Bootstrap reader: stamp/accept/refuse the store-format marker on open.\n * Writes at most one idempotent row (`ON CONFLICT DO NOTHING`) — not DDL,\n * so the \"no auto-DDL\" invariant holds; the operator still owns the table.\n */\nasync function assertFormat(q: PgQuerier, tables: TableNames): Promise<void> {\n const markerResult = await q.query<{ value: unknown }>(\n `SELECT value FROM ${tables.storeMeta} WHERE key = $1`,\n [STORE_META_FORMAT_KEY],\n )\n const raw = markerResult.rows[0]?.value\n const parsed = raw === undefined ? null : parseStoreFormat(raw)\n if (parsed === \"malformed\") {\n throw new StoreFormatVersionError({\n reason: \"malformed-version\",\n backend: \"postgres\",\n stored: null,\n current: STORE_FORMAT_VERSION,\n })\n }\n\n const dataResult = await q.query(`SELECT 1 FROM ${tables.docMeta} LIMIT 1`)\n\n const decision = decideStoreFormat({\n current: STORE_FORMAT_VERSION,\n stored: parsed,\n storeHasData: dataResult.rows.length > 0,\n })\n\n if (decision.action === \"refuse\") {\n throw new StoreFormatVersionError({\n reason: decision.reason,\n backend: \"postgres\",\n stored: parsed,\n current: STORE_FORMAT_VERSION,\n })\n }\n if (decision.action === \"stamp\") {\n await q.query(\n `INSERT INTO ${tables.storeMeta} (key, value)\n VALUES ($1, $2::jsonb)\n ON CONFLICT (key) DO NOTHING`,\n [STORE_META_FORMAT_KEY, JSON.stringify(decision.value)],\n )\n }\n}\n\ninterface ColumnInfo {\n column_name: string\n data_type: string\n is_nullable: string\n}\n\nconst EXPECTED_COLUMNS = {\n docMeta: [\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 storeMeta: [\n { name: \"key\", types: [\"text\"] },\n { name: \"value\", types: [\"jsonb\"] },\n ],\n} as const\n\nasync function validateSchema(q: PgQuerier, tables: TableNames): Promise<void> {\n for (const [role, expected] of [\n [\"docMeta\", EXPECTED_COLUMNS.docMeta] as const,\n [\"records\", EXPECTED_COLUMNS.records] as const,\n [\"storeMeta\", EXPECTED_COLUMNS.storeMeta] 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":";;;;;;;;;;AA6EA,SAAgB,SAAS,MAAuB;CAC9C,MAAM,SAAS;CACf,OAAO;EACL,MACE,MACA,QACwB;GACxB,OAAO,OAAO,MAAS,MAAM,MAAM;EACrC;EACA,MAAM,YAAe,IAA8C;GACjE,MAAM,aAAyB,MAAM,KAAK,QAAQ;GAClD,MAAM,IAAI;GACV,IAAI;IACF,MAAM,EAAE,MAAM,OAAO;IACrB,IAAI;KACF,MAAM,SAAS,MAAM,GAAG,CAAC;KACzB,MAAM,EAAE,MAAM,QAAQ;KACtB,OAAO;IACT,SAAS,GAAG;KACV,MAAM,EAAE,MAAM,UAAU;KACxB,MAAM;IACR;GACF,UAAU;IACR,WAAW,QAAQ;GACrB;EACF;CACF;AACF;;;;;;AAOA,SAAgB,WAAW,QAAwC;CACjE,MAAM,IAAI;CACV,OAAO;EACL,MACE,MACA,QACwB;GACxB,OAAO,EAAE,MAAS,MAAM,MAAM;EAChC;EACA,MAAM,YAAe,IAA8C;GACjE,MAAM,EAAE,MAAM,OAAO;GACrB,IAAI;IACF,MAAM,SAAS,MAAM,GAAG,CAAC;IACzB,MAAM,EAAE,MAAM,QAAQ;IACtB,OAAO;GACT,SAAS,GAAG;IACV,MAAM,EAAE,MAAM,UAAU;IACxB,MAAM;GACR;EACF;CACF;AACF;;;;;;;;AAaA,IAAa,gBAAb,MAA4C;CAC1C;CACA,UAAmB,IAAI,aAAa;CACpC;CAEA,YAAY,SAAoB,UAAgC,CAAC,GAAG;EAClE,KAAKA,WAAW;EAChB,KAAKE,UAAU,cAAc,OAAO;CACtC;CAMA,MAAM,OAAO,OAAc,QAAoC;EAU7D,MAAM,OAAO,WAAW,OAAO,QAAQ,MATZ,KAAK,YAAY,KAAK,GASI,MARnC,KAAKD,QAAQ,KAAK,OAAO,YAAY;GAKrD,QAAO,MAJc,KAAKD,SAAS,MACjC,wCAAwC,KAAKE,QAAQ,QAAQ,qBAC7D,CAAC,KAAK,CACR,GACc,KAAK,IAAI,WAAW;EACpC,CAAC,CAEuD;EAExD,MAAM,KAAKF,SAAS,YAAY,OAAM,MAAK;GACzC,IAAI,KAAK,eAAe,MACtB,MAAM,EAAE,MACN,eAAe,KAAKE,QAAQ,QAAQ;;qEAGpC,CAAC,OAAO,KAAK,WAAW,IAAI,CAC9B;GAEF,MAAM,EAAE,QAAQ,KAAK;GACrB,MAAM,EAAE,MACN,eAAe,KAAKA,QAAQ,QAAQ;;uCAGpC;IAAC;IAAO,KAAK,aAAa;IAAK,IAAI;IAAM,IAAI;IAAS,IAAI;GAAI,CAChE;EACF,CAAC;CACH;CAEA,OAAO,QAAQ,OAA0C;EACvD,MAAM,SAAS,MAAM,KAAKF,SAAS,MACjC,mCAAmC,KAAKE,QAAQ,QAAQ;wCAExD,CAAC,KAAK,CACR;EACA,KAAK,MAAM,OAAO,OAAO,MACvB,MAAM,QAAQ,GAAG;CAErB;CAEA,MAAM,QAAQ,OAAc,SAAuC;EAEjE,MAAM,OAAO,YAAY,SAAS,MADP,KAAK,YAAY,KAAK,CACH;EAE9C,MAAM,KAAKF,SAAS,YAAY,OAAM,MAAK;GACzC,MAAM,EAAE,MAAM,eAAe,KAAKE,QAAQ,QAAQ,qBAAqB,CACrE,KACF,CAAC;GAED,KAAK,MAAM,EAAE,KAAK,SAAS,KAAK,SAC9B,MAAM,EAAE,MACN,eAAe,KAAKA,QAAQ,QAAQ;;yCAGpC;IAAC;IAAO;IAAK,IAAI;IAAM,IAAI;IAAS,IAAI;GAAI,CAC9C;GAGF,MAAM,EAAE,MACN,eAAe,KAAKA,QAAQ,QAAQ;;mEAGpC,CAAC,OAAO,KAAK,WAAW,IAAI,CAC9B;EACF,CAAC;EAMD,KAAKD,QAAQ,MAAM,OAAO,QAAQ,SAAS,CAAC;CAC9C;CAEA,MAAM,OAAO,OAA6B;EACxC,MAAM,KAAKD,SAAS,YAAY,OAAM,MAAK;GACzC,MAAM,EAAE,MAAM,eAAe,KAAKE,QAAQ,QAAQ,qBAAqB,CACrE,KACF,CAAC;GACD,MAAM,EAAE,MAAM,eAAe,KAAKA,QAAQ,QAAQ,qBAAqB,CACrE,KACF,CAAC;EACH,CAAC;EACD,KAAKD,QAAQ,OAAO,KAAK;CAC3B;CAEA,MAAM,YAAY,OAAyC;EAKzD,QAAO,MAJc,KAAKD,SAAS,MACjC,oBAAoB,KAAKE,QAAQ,QAAQ,qBACzC,CAAC,KAAK,CACR,GACc,KAAK,IAAI,QAAQ;CACjC;CAEA,OAAO,WAAW,QAAuC;EACvD,IAAI,WAAW,KAAA,GAAW;GACxB,MAAM,SAAS,MAAM,KAAKF,SAAS,MACjC,sBAAsB,KAAKE,QAAQ,SACrC;GACA,KAAK,MAAM,OAAO,OAAO,MAAM,MAAM,IAAI;GACzC;EACF;EAIA,MAAM,QAAQ,iBAAiB,MAAM;EACrC,MAAM,SACJ,UAAU,OACN,MAAM,KAAKF,SAAS,MAClB,sBAAsB,KAAKE,QAAQ,QAAQ,sBAC3C,CAAC,MAAM,CACT,IACA,MAAM,KAAKF,SAAS,MAClB,sBAAsB,KAAKE,QAAQ,QAAQ;kDAE3C,CAAC,QAAQ,KAAK,CAChB;EACN,KAAK,MAAM,OAAO,OAAO,MAAM,MAAM,IAAI;CAC3C;CAEA,MAAM,QAAuB,CAE7B;AACF;;;;;AAUA,SAAS,iBAAiB,QAA+B;CACvD,IAAI,OAAO,WAAW,GAAG,OAAO;CAChC,MAAM,QAAQ,MAAM,KAAK,MAAM;CAC/B,KAAK,IAAI,IAAI,MAAM,SAAS,GAAG,KAAK,GAAG,KAAK;EAE1C,MAAM,OADK,MAAM,GACD,YAAY,CAAC;EAC7B,IAAI,OAAO,SAAU;GACnB,MAAM,OAAO,OAAO,cAAc,OAAO,CAAC;GAC1C,OAAO,MAAM,MAAM,GAAG,CAAC,EAAE,KAAK,EAAE,IAAI;EACtC;CACF;CACA,OAAO;AACT;;;;;;;;AAaA,eAAsB,oBACpB,SACA,UAAgC,CAAC,GACjB;CAChB,MAAM,SAAS,cAAc,OAAO;CACpC,MAAM,eAAe,SAAS,MAAM;CACpC,MAAM,aAAa,SAAS,MAAM;CAClC,OAAO,IAAI,cAAc,SAAS,OAAO;AAC3C;;;;;;AAOA,eAAe,aAAa,GAAc,QAAmC;CAK3E,MAAM,OAAM,MAJe,EAAE,MAC3B,qBAAqB,OAAO,UAAU,kBACtC,CAAC,qBAAqB,CACxB,GACyB,KAAK,IAAI;CAClC,MAAM,SAAS,QAAQ,KAAA,IAAY,OAAO,iBAAiB,GAAG;CAC9D,IAAI,WAAW,aACb,MAAM,IAAI,wBAAwB;EAChC,QAAQ;EACR,SAAS;EACT,QAAQ;EACR,SAAS;CACX,CAAC;CAKH,MAAM,WAAW,kBAAkB;EACjC,SAAS;EACT,QAAQ;EACR,eAAc,MALS,EAAE,MAAM,iBAAiB,OAAO,QAAQ,SAAS,GAK/C,KAAK,SAAS;CACzC,CAAC;CAED,IAAI,SAAS,WAAW,UACtB,MAAM,IAAI,wBAAwB;EAChC,QAAQ,SAAS;EACjB,SAAS;EACT,QAAQ;EACR,SAAS;CACX,CAAC;CAEH,IAAI,SAAS,WAAW,SACtB,MAAM,EAAE,MACN,eAAe,OAAO,UAAU;;sCAGhC,CAAC,uBAAuB,KAAK,UAAU,SAAS,KAAK,CAAC,CACxD;AAEJ;AAQA,MAAM,mBAAmB;CACvB,SAAS,CACP;EAAE,MAAM;EAAU,OAAO,CAAC,MAAM;CAAE,GAClC;EAAE,MAAM;EAAQ,OAAO,CAAC,OAAO;CAAE,CACnC;CACA,SAAS;EACP;GAAE,MAAM;GAAU,OAAO,CAAC,MAAM;EAAE;EAClC;GAAE,MAAM;GAAO,OAAO,CAAC,SAAS;EAAE;EAClC;GAAE,MAAM;GAAQ,OAAO,CAAC,MAAM;EAAE;EAChC;GAAE,MAAM;GAAW,OAAO,CAAC,MAAM;EAAE;EACnC;GAAE,MAAM;GAAQ,OAAO,CAAC,OAAO;EAAE;CACnC;CACA,WAAW,CACT;EAAE,MAAM;EAAO,OAAO,CAAC,MAAM;CAAE,GAC/B;EAAE,MAAM;EAAS,OAAO,CAAC,OAAO;CAAE,CACpC;AACF;AAEA,eAAe,eAAe,GAAc,QAAmC;CAC7E,KAAK,MAAM,CAAC,MAAM,aAAa;EAC7B,CAAC,WAAW,iBAAiB,OAAO;EACpC,CAAC,WAAW,iBAAiB,OAAO;EACpC,CAAC,aAAa,iBAAiB,SAAS;CAC1C,GAAG;EACD,MAAM,YAAY,OAAO;EACzB,MAAM,SAAS,MAAM,EAAE,MACrB;;+BAGA,CAAC,SAAS,CACZ;EACA,IAAI,OAAO,KAAK,WAAW,GACzB,MAAM,IAAI,MACR,kCAAkC,UAAU,6EAE9C;EAEF,MAAM,gBAAgB,IAAI,IAAI,OAAO,KAAK,KAAI,MAAK,CAAC,EAAE,aAAa,CAAC,CAAC,CAAC;EACtE,KAAK,MAAM,OAAO,UAAU;GAC1B,MAAM,QAAQ,cAAc,IAAI,IAAI,IAAI;GACxC,IAAI,UAAU,KAAA,GACZ,MAAM,IAAI,MACR,kCAAkC,UAAU,oBACtC,IAAI,KAAK,gDACjB;GAEF,IAAI,CAAE,IAAI,MAA4B,SAAS,MAAM,SAAS,GAC5D,MAAM,IAAI,MACR,kCAAkC,UAAU,YACtC,IAAI,KAAK,cAAc,MAAM,UAAU,sBACvB,IAAI,MAAM,KAAK,IAAI,EAAE,GAC7C;EAEJ;CACF;AACF"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kyneta/postgres-store",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Postgres storage backend for @kyneta/exchange",
|
|
5
5
|
"author": "Duane Johnson",
|
|
6
6
|
"license": "MIT",
|
|
@@ -31,9 +31,9 @@
|
|
|
31
31
|
},
|
|
32
32
|
"peerDependencies": {
|
|
33
33
|
"pg": "^8.11.0",
|
|
34
|
-
"@kyneta/
|
|
35
|
-
"@kyneta/
|
|
36
|
-
"@kyneta/
|
|
34
|
+
"@kyneta/exchange": "^2.0.0",
|
|
35
|
+
"@kyneta/schema": "^2.0.0",
|
|
36
|
+
"@kyneta/sql-store-core": "^2.0.0"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"@types/node": "^22",
|
|
@@ -42,9 +42,9 @@
|
|
|
42
42
|
"tsdown": "^0.22.0",
|
|
43
43
|
"typescript": "^5.9.2",
|
|
44
44
|
"vitest": "^4.0.17",
|
|
45
|
-
"@kyneta/exchange": "^
|
|
46
|
-
"@kyneta/
|
|
47
|
-
"@kyneta/
|
|
45
|
+
"@kyneta/exchange": "^2.0.0",
|
|
46
|
+
"@kyneta/schema": "^2.0.0",
|
|
47
|
+
"@kyneta/sql-store-core": "^2.0.0"
|
|
48
48
|
},
|
|
49
49
|
"scripts": {
|
|
50
50
|
"build": "tsdown",
|
package/schema.sql
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
-- Default table names. To use different names, override via the
|
|
9
9
|
-- `tables` option and replace the names below to match.
|
|
10
10
|
|
|
11
|
-
CREATE TABLE IF NOT EXISTS
|
|
11
|
+
CREATE TABLE IF NOT EXISTS kyneta_doc_meta (
|
|
12
12
|
doc_id TEXT PRIMARY KEY,
|
|
13
13
|
data JSONB NOT NULL
|
|
14
14
|
);
|
|
@@ -21,3 +21,11 @@ CREATE TABLE IF NOT EXISTS kyneta_records (
|
|
|
21
21
|
blob BYTEA,
|
|
22
22
|
PRIMARY KEY (doc_id, seq)
|
|
23
23
|
);
|
|
24
|
+
|
|
25
|
+
-- Store-global metadata (keyed by an opaque `key`, not a doc_id). Holds the
|
|
26
|
+
-- on-disk format version under key 'format'; the store factory stamps and
|
|
27
|
+
-- gates it on open. Distinct from kyneta_doc_meta (per-document metadata).
|
|
28
|
+
CREATE TABLE IF NOT EXISTS kyneta_store_meta (
|
|
29
|
+
key TEXT PRIMARY KEY,
|
|
30
|
+
value JSONB NOT NULL
|
|
31
|
+
);
|