@kyneta/postgres-store 1.8.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/src/index.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Postgres Store backend.
|
|
2
2
|
//
|
|
3
3
|
// Why JSONB on meta, not TEXT: operators occasionally need to filter
|
|
4
|
-
// metas by `
|
|
5
|
-
// investigations, and JSONB makes `data->>'
|
|
4
|
+
// metas by `syncMode` or `replicaType` during incident
|
|
5
|
+
// investigations, and JSONB makes `data->>'syncMode'` trivial.
|
|
6
6
|
// Cost: JSONB normalizes whitespace and key order at insert time, so
|
|
7
7
|
// `meta.data` bytes don't match SQLite's TEXT-stored meta — but
|
|
8
8
|
// round-trip through `loadAll` still yields a structurally equal
|
|
@@ -11,8 +11,12 @@
|
|
|
11
11
|
|
|
12
12
|
import {
|
|
13
13
|
type DocId,
|
|
14
|
+
decideStoreFormat,
|
|
15
|
+
parseStoreFormat,
|
|
14
16
|
SeqNoTracker,
|
|
17
|
+
STORE_META_FORMAT_KEY,
|
|
15
18
|
type Store,
|
|
19
|
+
StoreFormatVersionError,
|
|
16
20
|
type StoreMeta,
|
|
17
21
|
type StoreRecord,
|
|
18
22
|
} from "@kyneta/exchange"
|
|
@@ -22,6 +26,7 @@ import {
|
|
|
22
26
|
planReplace,
|
|
23
27
|
type RowShape,
|
|
24
28
|
resolveTables,
|
|
29
|
+
STORE_FORMAT_VERSION,
|
|
25
30
|
type TableNames,
|
|
26
31
|
} from "@kyneta/sql-store-core"
|
|
27
32
|
import type { Client, Pool, PoolClient } from "pg"
|
|
@@ -32,24 +37,101 @@ import type { Client, Pool, PoolClient } from "pg"
|
|
|
32
37
|
|
|
33
38
|
export interface PostgresStoreOptions {
|
|
34
39
|
/**
|
|
35
|
-
* Override the default table names (`
|
|
40
|
+
* Override the default table names (`kyneta_doc_meta`, `kyneta_records`,
|
|
41
|
+
* `kyneta_store_meta`).
|
|
36
42
|
*
|
|
37
43
|
* Use when running multiple isolated Exchange instances against the
|
|
38
|
-
* same database — each instance owns one `tables`
|
|
44
|
+
* same database — each instance owns one `tables` set.
|
|
39
45
|
*/
|
|
40
46
|
tables?: Partial<TableNames>
|
|
41
47
|
}
|
|
42
48
|
|
|
43
|
-
type PgConnection = Client | Pool
|
|
44
|
-
|
|
45
49
|
/**
|
|
46
50
|
* Narrow structural type for the methods we actually call. Keeps the
|
|
47
51
|
* package independent of `pg`'s top-level type changes across versions.
|
|
48
52
|
*/
|
|
49
|
-
interface PgQuerier {
|
|
53
|
+
export interface PgQuerier {
|
|
50
54
|
query<R = unknown>(text: string, values?: unknown[]): Promise<{ rows: R[] }>
|
|
51
55
|
}
|
|
52
56
|
|
|
57
|
+
/**
|
|
58
|
+
* The minimal Postgres capability `PostgresStore` needs, decoupled from any
|
|
59
|
+
* specific pg surface — mirrors `SqliteAdapter`. `fromPool` / `fromClient`
|
|
60
|
+
* supply it, so `PostgresStore` never discriminates connection types and `pg`
|
|
61
|
+
* stays a type-only import (no `instanceof`, no runtime class coupling).
|
|
62
|
+
*/
|
|
63
|
+
export interface PgAdapter extends PgQuerier {
|
|
64
|
+
/**
|
|
65
|
+
* Run `fn` with a querier whose statements share one connection under
|
|
66
|
+
* BEGIN/COMMIT; ROLLBACK and rethrow on failure.
|
|
67
|
+
*/
|
|
68
|
+
transaction<R>(fn: (q: PgQuerier) => Promise<R>): Promise<R>
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Adapter over a `Pool`: each transaction checks out a `PoolClient` so
|
|
73
|
+
* BEGIN..COMMIT share one physical connection (Postgres transactions are
|
|
74
|
+
* connection-scoped — checking back out for COMMIT would target a different
|
|
75
|
+
* connection), then releases it. Non-transactional queries go to the pool
|
|
76
|
+
* directly (the pool checks out/in per query — fine for single reads).
|
|
77
|
+
*/
|
|
78
|
+
export function fromPool(pool: Pool): PgAdapter {
|
|
79
|
+
const direct = pool as unknown as PgQuerier
|
|
80
|
+
return {
|
|
81
|
+
query<R = unknown>(
|
|
82
|
+
text: string,
|
|
83
|
+
values?: unknown[],
|
|
84
|
+
): Promise<{ rows: R[] }> {
|
|
85
|
+
return direct.query<R>(text, values)
|
|
86
|
+
},
|
|
87
|
+
async transaction<R>(fn: (q: PgQuerier) => Promise<R>): Promise<R> {
|
|
88
|
+
const poolClient: PoolClient = await pool.connect()
|
|
89
|
+
const q = poolClient as unknown as PgQuerier
|
|
90
|
+
try {
|
|
91
|
+
await q.query("BEGIN")
|
|
92
|
+
try {
|
|
93
|
+
const result = await fn(q)
|
|
94
|
+
await q.query("COMMIT")
|
|
95
|
+
return result
|
|
96
|
+
} catch (e) {
|
|
97
|
+
await q.query("ROLLBACK")
|
|
98
|
+
throw e
|
|
99
|
+
}
|
|
100
|
+
} finally {
|
|
101
|
+
poolClient.release()
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Adapter over a single `Client` (or an already-checked-out `PoolClient`):
|
|
109
|
+
* transactions run inline on the one connection. Re-throws on rollback so a
|
|
110
|
+
* caller can place post-commit work lexically after the awaited call.
|
|
111
|
+
*/
|
|
112
|
+
export function fromClient(client: Client | PoolClient): PgAdapter {
|
|
113
|
+
const q = client as unknown as PgQuerier
|
|
114
|
+
return {
|
|
115
|
+
query<R = unknown>(
|
|
116
|
+
text: string,
|
|
117
|
+
values?: unknown[],
|
|
118
|
+
): Promise<{ rows: R[] }> {
|
|
119
|
+
return q.query<R>(text, values)
|
|
120
|
+
},
|
|
121
|
+
async transaction<R>(fn: (q: PgQuerier) => Promise<R>): Promise<R> {
|
|
122
|
+
await q.query("BEGIN")
|
|
123
|
+
try {
|
|
124
|
+
const result = await fn(q)
|
|
125
|
+
await q.query("COMMIT")
|
|
126
|
+
return result
|
|
127
|
+
} catch (e) {
|
|
128
|
+
await q.query("ROLLBACK")
|
|
129
|
+
throw e
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
53
135
|
// ---------------------------------------------------------------------------
|
|
54
136
|
// PostgresStore
|
|
55
137
|
// ---------------------------------------------------------------------------
|
|
@@ -62,64 +144,15 @@ interface PgQuerier {
|
|
|
62
144
|
* curated error rather than per-method `column does not exist` later.
|
|
63
145
|
*/
|
|
64
146
|
export class PostgresStore implements Store {
|
|
65
|
-
readonly #
|
|
147
|
+
readonly #adapter: PgAdapter
|
|
66
148
|
readonly #seqNos = new SeqNoTracker()
|
|
67
149
|
readonly #tables: TableNames
|
|
68
150
|
|
|
69
|
-
constructor(
|
|
70
|
-
this.#
|
|
151
|
+
constructor(adapter: PgAdapter, options: PostgresStoreOptions = {}) {
|
|
152
|
+
this.#adapter = adapter
|
|
71
153
|
this.#tables = resolveTables(options)
|
|
72
154
|
}
|
|
73
155
|
|
|
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
156
|
// -------------------------------------------------------------------------
|
|
124
157
|
// Store interface
|
|
125
158
|
// -------------------------------------------------------------------------
|
|
@@ -127,7 +160,7 @@ export class PostgresStore implements Store {
|
|
|
127
160
|
async append(docId: DocId, record: StoreRecord): Promise<void> {
|
|
128
161
|
const existingMeta = await this.currentMeta(docId)
|
|
129
162
|
const seq = await this.#seqNos.next(docId, async () => {
|
|
130
|
-
const result = await this.#
|
|
163
|
+
const result = await this.#adapter.query<{ max_seq: number | null }>(
|
|
131
164
|
`SELECT MAX(seq)::int AS max_seq FROM ${this.#tables.records} WHERE doc_id = $1`,
|
|
132
165
|
[docId],
|
|
133
166
|
)
|
|
@@ -136,10 +169,10 @@ export class PostgresStore implements Store {
|
|
|
136
169
|
|
|
137
170
|
const plan = planAppend(docId, record, existingMeta, seq)
|
|
138
171
|
|
|
139
|
-
await this.#
|
|
172
|
+
await this.#adapter.transaction(async q => {
|
|
140
173
|
if (plan.upsertMeta !== null) {
|
|
141
174
|
await q.query(
|
|
142
|
-
`INSERT INTO ${this.#tables.
|
|
175
|
+
`INSERT INTO ${this.#tables.docMeta} (doc_id, data)
|
|
143
176
|
VALUES ($1, $2::jsonb)
|
|
144
177
|
ON CONFLICT (doc_id) DO UPDATE SET data = EXCLUDED.data`,
|
|
145
178
|
[docId, plan.upsertMeta.data],
|
|
@@ -156,7 +189,7 @@ export class PostgresStore implements Store {
|
|
|
156
189
|
}
|
|
157
190
|
|
|
158
191
|
async *loadAll(docId: DocId): AsyncIterable<StoreRecord> {
|
|
159
|
-
const result = await this.#
|
|
192
|
+
const result = await this.#adapter.query<RowShape>(
|
|
160
193
|
`SELECT kind, payload, blob FROM ${this.#tables.records}
|
|
161
194
|
WHERE doc_id = $1 ORDER BY seq`,
|
|
162
195
|
[docId],
|
|
@@ -170,7 +203,7 @@ export class PostgresStore implements Store {
|
|
|
170
203
|
const existingMeta = await this.currentMeta(docId)
|
|
171
204
|
const plan = planReplace(records, existingMeta)
|
|
172
205
|
|
|
173
|
-
await this.#
|
|
206
|
+
await this.#adapter.transaction(async q => {
|
|
174
207
|
await q.query(`DELETE FROM ${this.#tables.records} WHERE doc_id = $1`, [
|
|
175
208
|
docId,
|
|
176
209
|
])
|
|
@@ -185,7 +218,7 @@ export class PostgresStore implements Store {
|
|
|
185
218
|
}
|
|
186
219
|
|
|
187
220
|
await q.query(
|
|
188
|
-
`INSERT INTO ${this.#tables.
|
|
221
|
+
`INSERT INTO ${this.#tables.docMeta} (doc_id, data)
|
|
189
222
|
VALUES ($1, $2::jsonb)
|
|
190
223
|
ON CONFLICT (doc_id) DO UPDATE SET data = EXCLUDED.data`,
|
|
191
224
|
[docId, plan.upsertMeta.data],
|
|
@@ -200,11 +233,11 @@ export class PostgresStore implements Store {
|
|
|
200
233
|
}
|
|
201
234
|
|
|
202
235
|
async delete(docId: DocId): Promise<void> {
|
|
203
|
-
await this.#
|
|
236
|
+
await this.#adapter.transaction(async q => {
|
|
204
237
|
await q.query(`DELETE FROM ${this.#tables.records} WHERE doc_id = $1`, [
|
|
205
238
|
docId,
|
|
206
239
|
])
|
|
207
|
-
await q.query(`DELETE FROM ${this.#tables.
|
|
240
|
+
await q.query(`DELETE FROM ${this.#tables.docMeta} WHERE doc_id = $1`, [
|
|
208
241
|
docId,
|
|
209
242
|
])
|
|
210
243
|
})
|
|
@@ -212,8 +245,8 @@ export class PostgresStore implements Store {
|
|
|
212
245
|
}
|
|
213
246
|
|
|
214
247
|
async currentMeta(docId: DocId): Promise<StoreMeta | null> {
|
|
215
|
-
const result = await this.#
|
|
216
|
-
`SELECT data FROM ${this.#tables.
|
|
248
|
+
const result = await this.#adapter.query<{ data: StoreMeta }>(
|
|
249
|
+
`SELECT data FROM ${this.#tables.docMeta} WHERE doc_id = $1`,
|
|
217
250
|
[docId],
|
|
218
251
|
)
|
|
219
252
|
return result.rows[0]?.data ?? null
|
|
@@ -221,8 +254,8 @@ export class PostgresStore implements Store {
|
|
|
221
254
|
|
|
222
255
|
async *listDocIds(prefix?: string): AsyncIterable<DocId> {
|
|
223
256
|
if (prefix === undefined) {
|
|
224
|
-
const result = await this.#
|
|
225
|
-
`SELECT doc_id FROM ${this.#tables.
|
|
257
|
+
const result = await this.#adapter.query<{ doc_id: string }>(
|
|
258
|
+
`SELECT doc_id FROM ${this.#tables.docMeta}`,
|
|
226
259
|
)
|
|
227
260
|
for (const row of result.rows) yield row.doc_id
|
|
228
261
|
return
|
|
@@ -233,12 +266,12 @@ export class PostgresStore implements Store {
|
|
|
233
266
|
const upper = prefixUpperBound(prefix)
|
|
234
267
|
const result =
|
|
235
268
|
upper === null
|
|
236
|
-
? await this.#
|
|
237
|
-
`SELECT doc_id FROM ${this.#tables.
|
|
269
|
+
? await this.#adapter.query<{ doc_id: string }>(
|
|
270
|
+
`SELECT doc_id FROM ${this.#tables.docMeta} WHERE doc_id >= $1`,
|
|
238
271
|
[prefix],
|
|
239
272
|
)
|
|
240
|
-
: await this.#
|
|
241
|
-
`SELECT doc_id FROM ${this.#tables.
|
|
273
|
+
: await this.#adapter.query<{ doc_id: string }>(
|
|
274
|
+
`SELECT doc_id FROM ${this.#tables.docMeta}
|
|
242
275
|
WHERE doc_id >= $1 AND doc_id < $2`,
|
|
243
276
|
[prefix, upper],
|
|
244
277
|
)
|
|
@@ -284,12 +317,60 @@ function prefixUpperBound(prefix: string): string | null {
|
|
|
284
317
|
* on the next write anyway.
|
|
285
318
|
*/
|
|
286
319
|
export async function createPostgresStore(
|
|
287
|
-
|
|
320
|
+
adapter: PgAdapter,
|
|
288
321
|
options: PostgresStoreOptions = {},
|
|
289
322
|
): Promise<Store> {
|
|
290
323
|
const tables = resolveTables(options)
|
|
291
|
-
await validateSchema(
|
|
292
|
-
|
|
324
|
+
await validateSchema(adapter, tables)
|
|
325
|
+
await assertFormat(adapter, tables)
|
|
326
|
+
return new PostgresStore(adapter, options)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Bootstrap reader: stamp/accept/refuse the store-format marker on open.
|
|
331
|
+
* Writes at most one idempotent row (`ON CONFLICT DO NOTHING`) — not DDL,
|
|
332
|
+
* so the "no auto-DDL" invariant holds; the operator still owns the table.
|
|
333
|
+
*/
|
|
334
|
+
async function assertFormat(q: PgQuerier, tables: TableNames): Promise<void> {
|
|
335
|
+
const markerResult = await q.query<{ value: unknown }>(
|
|
336
|
+
`SELECT value FROM ${tables.storeMeta} WHERE key = $1`,
|
|
337
|
+
[STORE_META_FORMAT_KEY],
|
|
338
|
+
)
|
|
339
|
+
const raw = markerResult.rows[0]?.value
|
|
340
|
+
const parsed = raw === undefined ? null : parseStoreFormat(raw)
|
|
341
|
+
if (parsed === "malformed") {
|
|
342
|
+
throw new StoreFormatVersionError({
|
|
343
|
+
reason: "malformed-version",
|
|
344
|
+
backend: "postgres",
|
|
345
|
+
stored: null,
|
|
346
|
+
current: STORE_FORMAT_VERSION,
|
|
347
|
+
})
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const dataResult = await q.query(`SELECT 1 FROM ${tables.docMeta} LIMIT 1`)
|
|
351
|
+
|
|
352
|
+
const decision = decideStoreFormat({
|
|
353
|
+
current: STORE_FORMAT_VERSION,
|
|
354
|
+
stored: parsed,
|
|
355
|
+
storeHasData: dataResult.rows.length > 0,
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
if (decision.action === "refuse") {
|
|
359
|
+
throw new StoreFormatVersionError({
|
|
360
|
+
reason: decision.reason,
|
|
361
|
+
backend: "postgres",
|
|
362
|
+
stored: parsed,
|
|
363
|
+
current: STORE_FORMAT_VERSION,
|
|
364
|
+
})
|
|
365
|
+
}
|
|
366
|
+
if (decision.action === "stamp") {
|
|
367
|
+
await q.query(
|
|
368
|
+
`INSERT INTO ${tables.storeMeta} (key, value)
|
|
369
|
+
VALUES ($1, $2::jsonb)
|
|
370
|
+
ON CONFLICT (key) DO NOTHING`,
|
|
371
|
+
[STORE_META_FORMAT_KEY, JSON.stringify(decision.value)],
|
|
372
|
+
)
|
|
373
|
+
}
|
|
293
374
|
}
|
|
294
375
|
|
|
295
376
|
interface ColumnInfo {
|
|
@@ -299,7 +380,7 @@ interface ColumnInfo {
|
|
|
299
380
|
}
|
|
300
381
|
|
|
301
382
|
const EXPECTED_COLUMNS = {
|
|
302
|
-
|
|
383
|
+
docMeta: [
|
|
303
384
|
{ name: "doc_id", types: ["text"] },
|
|
304
385
|
{ name: "data", types: ["jsonb"] },
|
|
305
386
|
],
|
|
@@ -310,12 +391,17 @@ const EXPECTED_COLUMNS = {
|
|
|
310
391
|
{ name: "payload", types: ["text"] },
|
|
311
392
|
{ name: "blob", types: ["bytea"] },
|
|
312
393
|
],
|
|
394
|
+
storeMeta: [
|
|
395
|
+
{ name: "key", types: ["text"] },
|
|
396
|
+
{ name: "value", types: ["jsonb"] },
|
|
397
|
+
],
|
|
313
398
|
} as const
|
|
314
399
|
|
|
315
400
|
async function validateSchema(q: PgQuerier, tables: TableNames): Promise<void> {
|
|
316
401
|
for (const [role, expected] of [
|
|
317
|
-
["
|
|
402
|
+
["docMeta", EXPECTED_COLUMNS.docMeta] as const,
|
|
318
403
|
["records", EXPECTED_COLUMNS.records] as const,
|
|
404
|
+
["storeMeta", EXPECTED_COLUMNS.storeMeta] as const,
|
|
319
405
|
]) {
|
|
320
406
|
const tableName = tables[role]
|
|
321
407
|
const result = await q.query<ColumnInfo>(
|