@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/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 `syncProtocol` or `replicaType` during incident
5
- // investigations, and JSONB makes `data->>'syncProtocol'` trivial.
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 (`kyneta_meta` and `kyneta_records`).
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` pair.
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 #client: PgConnection
147
+ readonly #adapter: PgAdapter
66
148
  readonly #seqNos = new SeqNoTracker()
67
149
  readonly #tables: TableNames
68
150
 
69
- constructor(client: PgConnection, options: PostgresStoreOptions = {}) {
70
- this.#client = client
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.#q.query<{ max_seq: number | null }>(
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.#withTransaction(async q => {
172
+ await this.#adapter.transaction(async q => {
140
173
  if (plan.upsertMeta !== null) {
141
174
  await q.query(
142
- `INSERT INTO ${this.#tables.meta} (doc_id, data)
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.#q.query<RowShape>(
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.#withTransaction(async q => {
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.meta} (doc_id, data)
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.#withTransaction(async q => {
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.meta} WHERE doc_id = $1`, [
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.#q.query<{ data: StoreMeta }>(
216
- `SELECT data FROM ${this.#tables.meta} WHERE doc_id = $1`,
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.#q.query<{ doc_id: string }>(
225
- `SELECT doc_id FROM ${this.#tables.meta}`,
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.#q.query<{ doc_id: string }>(
237
- `SELECT doc_id FROM ${this.#tables.meta} WHERE doc_id >= $1`,
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.#q.query<{ doc_id: string }>(
241
- `SELECT doc_id FROM ${this.#tables.meta}
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
- client: PgConnection,
320
+ adapter: PgAdapter,
288
321
  options: PostgresStoreOptions = {},
289
322
  ): Promise<Store> {
290
323
  const tables = resolveTables(options)
291
- await validateSchema(client as unknown as PgQuerier, tables)
292
- return new PostgresStore(client, options)
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
- meta: [
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
- ["meta", EXPECTED_COLUMNS.meta] as const,
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>(