@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.
@@ -0,0 +1,112 @@
1
+ // pg-adapter — non-gated unit tests for the fromPool / fromClient adapters.
2
+ //
3
+ // These run without KYNETA_PG_URL: they assert the transaction control flow
4
+ // (checkout / BEGIN / COMMIT / ROLLBACK / release) against lightweight fakes,
5
+ // locking the behaviour that used to live in PostgresStore's connection-type
6
+ // discriminant — now resolved at the call site by which factory is used.
7
+
8
+ import type { Client, Pool } from "pg"
9
+ import { describe, expect, it, vi } from "vitest"
10
+ import { fromClient, fromPool } from "../index.js"
11
+
12
+ describe("fromClient", () => {
13
+ it("runs a transaction inline: BEGIN → fn → COMMIT", async () => {
14
+ const calls: string[] = []
15
+ const client = {
16
+ query: vi.fn(async (text: string) => {
17
+ calls.push(text)
18
+ return { rows: [] }
19
+ }),
20
+ } as unknown as Client
21
+
22
+ const result = await fromClient(client).transaction(async q => {
23
+ await q.query("INSERT 1")
24
+ return "done"
25
+ })
26
+
27
+ expect(result).toBe("done")
28
+ expect(calls).toEqual(["BEGIN", "INSERT 1", "COMMIT"])
29
+ })
30
+
31
+ it("ROLLBACK + rethrow when fn throws", async () => {
32
+ const calls: string[] = []
33
+ const client = {
34
+ query: vi.fn(async (text: string) => {
35
+ calls.push(text)
36
+ return { rows: [] }
37
+ }),
38
+ } as unknown as Client
39
+
40
+ await expect(
41
+ fromClient(client).transaction(async q => {
42
+ await q.query("INSERT 1")
43
+ throw new Error("boom")
44
+ }),
45
+ ).rejects.toThrow("boom")
46
+
47
+ expect(calls).toEqual(["BEGIN", "INSERT 1", "ROLLBACK"])
48
+ })
49
+ })
50
+
51
+ describe("fromPool", () => {
52
+ it("checks out a client, BEGIN/COMMIT on it, releases in finally", async () => {
53
+ const calls: string[] = []
54
+ const release = vi.fn()
55
+ const poolClient = {
56
+ query: vi.fn(async (text: string) => {
57
+ calls.push(text)
58
+ return { rows: [] }
59
+ }),
60
+ release,
61
+ }
62
+ const connect = vi.fn(async () => poolClient)
63
+ const pool = { connect, query: vi.fn() } as unknown as Pool
64
+
65
+ const result = await fromPool(pool).transaction(async q => {
66
+ await q.query("INSERT 1")
67
+ return 42
68
+ })
69
+
70
+ expect(result).toBe(42)
71
+ expect(connect).toHaveBeenCalledTimes(1)
72
+ expect(calls).toEqual(["BEGIN", "INSERT 1", "COMMIT"])
73
+ expect(release).toHaveBeenCalledTimes(1)
74
+ })
75
+
76
+ it("ROLLBACK + release + rethrow when fn throws", async () => {
77
+ const calls: string[] = []
78
+ const release = vi.fn()
79
+ const poolClient = {
80
+ query: vi.fn(async (text: string) => {
81
+ calls.push(text)
82
+ return { rows: [] }
83
+ }),
84
+ release,
85
+ }
86
+ const pool = {
87
+ connect: vi.fn(async () => poolClient),
88
+ query: vi.fn(),
89
+ } as unknown as Pool
90
+
91
+ await expect(
92
+ fromPool(pool).transaction(async () => {
93
+ throw new Error("boom")
94
+ }),
95
+ ).rejects.toThrow("boom")
96
+
97
+ expect(calls).toEqual(["BEGIN", "ROLLBACK"])
98
+ expect(release).toHaveBeenCalledTimes(1)
99
+ })
100
+
101
+ it("non-transactional query goes to the pool directly (no checkout)", async () => {
102
+ const connect = vi.fn()
103
+ const poolQuery = vi.fn(async () => ({ rows: [{ n: 1 }] }))
104
+ const pool = { connect, query: poolQuery } as unknown as Pool
105
+
106
+ const res = await fromPool(pool).query<{ n: number }>("SELECT 1")
107
+
108
+ expect(res.rows).toEqual([{ n: 1 }])
109
+ expect(poolQuery).toHaveBeenCalledTimes(1)
110
+ expect(connect).not.toHaveBeenCalled()
111
+ })
112
+ })
@@ -5,10 +5,19 @@
5
5
  // `KYNETA_PG_URL=postgres://localhost:5432/kyneta_test` (or similar)
6
6
  // to run.
7
7
 
8
- import { describeStore, makeMetaRecord } from "@kyneta/exchange/testing"
8
+ import {
9
+ describeStore,
10
+ makeArmedFault,
11
+ makeMetaRecord,
12
+ } from "@kyneta/exchange/testing"
9
13
  import { Pool } from "pg"
10
14
  import { afterAll, beforeAll, describe, expect, it } from "vitest"
11
- import { createPostgresStore, PostgresStore } from "../index.js"
15
+ import {
16
+ createPostgresStore,
17
+ fromClient,
18
+ fromPool,
19
+ PostgresStore,
20
+ } from "../index.js"
12
21
 
13
22
  const PG_URL = process.env.KYNETA_PG_URL
14
23
  const ENABLED = PG_URL !== undefined && PG_URL.length > 0
@@ -20,12 +29,13 @@ const pool: Pool | null = ENABLED
20
29
  // Per-test schema namespace via per-test table names. Truncate between
21
30
  // tests via DELETE on the canonical tables for the conformance run.
22
31
  const SCHEMA_TABLES = {
23
- meta: "kyneta_meta",
32
+ docMeta: "kyneta_doc_meta",
24
33
  records: "kyneta_records",
34
+ storeMeta: "kyneta_store_meta",
25
35
  } as const
26
36
 
27
37
  const SCHEMA_DDL = `
28
- CREATE TABLE IF NOT EXISTS ${SCHEMA_TABLES.meta} (
38
+ CREATE TABLE IF NOT EXISTS ${SCHEMA_TABLES.docMeta} (
29
39
  doc_id TEXT PRIMARY KEY,
30
40
  data JSONB NOT NULL
31
41
  );
@@ -37,6 +47,10 @@ const SCHEMA_DDL = `
37
47
  blob BYTEA,
38
48
  PRIMARY KEY (doc_id, seq)
39
49
  );
50
+ CREATE TABLE IF NOT EXISTS ${SCHEMA_TABLES.storeMeta} (
51
+ key TEXT PRIMARY KEY,
52
+ value JSONB NOT NULL
53
+ );
40
54
  `
41
55
 
42
56
  if (ENABLED && pool !== null) {
@@ -63,93 +77,70 @@ describeIfEnabled("PostgresStore", () => {
63
77
  async () => {
64
78
  // Truncate before each test for a clean slate.
65
79
  await pool.query(
66
- `TRUNCATE ${SCHEMA_TABLES.records}, ${SCHEMA_TABLES.meta}`,
80
+ `TRUNCATE ${SCHEMA_TABLES.records}, ${SCHEMA_TABLES.docMeta}`,
67
81
  )
68
- return new PostgresStore(pool)
82
+ return new PostgresStore(fromPool(pool))
69
83
  },
70
84
  {
71
85
  cleanup: async () => {
72
86
  await pool.query(
73
- `TRUNCATE ${SCHEMA_TABLES.records}, ${SCHEMA_TABLES.meta}`,
87
+ `TRUNCATE ${SCHEMA_TABLES.records}, ${SCHEMA_TABLES.docMeta}`,
74
88
  )
75
89
  },
76
90
  faultFactory: async () => {
77
91
  await pool.query(
78
- `TRUNCATE ${SCHEMA_TABLES.records}, ${SCHEMA_TABLES.meta}`,
92
+ `TRUNCATE ${SCHEMA_TABLES.records}, ${SCHEMA_TABLES.docMeta}`,
79
93
  )
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.
94
+ // Check out a single connection and wrap its `query` with the shared
95
+ // op-weighted fault primitive. `fromClient` runs transactions inline
96
+ // on that one connection, so every query (BEGIN/COMMIT/inserts/reads)
97
+ // is counted injectFault(2) fires mid-transaction → rollback.
84
98
  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)
99
+ const { proxy, arm } = makeArmedFault(client, { query: 1 })
100
+ const store = new PostgresStore(fromClient(proxy))
109
101
 
110
102
  return {
111
103
  store,
112
- injectFault: n => {
113
- armed = n
114
- count = 0
115
- },
116
- freshStore: async () => new PostgresStore(pool),
104
+ injectFault: arm,
105
+ freshStore: async () => new PostgresStore(fromPool(pool)),
117
106
  cleanup: async () => {
118
107
  client.release()
119
108
  },
120
109
  }
121
110
  },
122
111
  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" }
112
+ // Two distinct table-name sets sharing the same Pool. These use the
113
+ // bare `PostgresStore` constructor (no store-format gate), so the
114
+ // store-metadata table is unused here and not created.
115
+ const tablesA = { docMeta: "iso_a_meta", records: "iso_a_records" }
116
+ const tablesB = { docMeta: "iso_b_meta", records: "iso_b_records" }
126
117
  await pool.query(`
127
- CREATE TABLE IF NOT EXISTS ${tablesA.meta} (
118
+ CREATE TABLE IF NOT EXISTS ${tablesA.docMeta} (
128
119
  doc_id TEXT PRIMARY KEY, data JSONB NOT NULL
129
120
  );
130
121
  CREATE TABLE IF NOT EXISTS ${tablesA.records} (
131
122
  doc_id TEXT, seq INTEGER, kind TEXT, payload TEXT, blob BYTEA,
132
123
  PRIMARY KEY (doc_id, seq)
133
124
  );
134
- CREATE TABLE IF NOT EXISTS ${tablesB.meta} (
125
+ CREATE TABLE IF NOT EXISTS ${tablesB.docMeta} (
135
126
  doc_id TEXT PRIMARY KEY, data JSONB NOT NULL
136
127
  );
137
128
  CREATE TABLE IF NOT EXISTS ${tablesB.records} (
138
129
  doc_id TEXT, seq INTEGER, kind TEXT, payload TEXT, blob BYTEA,
139
130
  PRIMARY KEY (doc_id, seq)
140
131
  );
141
- TRUNCATE ${tablesA.records}, ${tablesA.meta},
142
- ${tablesB.records}, ${tablesB.meta};
132
+ TRUNCATE ${tablesA.records}, ${tablesA.docMeta},
133
+ ${tablesB.records}, ${tablesB.docMeta};
143
134
  `)
144
135
  return {
145
- storeA: new PostgresStore(pool, { tables: tablesA }),
146
- storeB: new PostgresStore(pool, { tables: tablesB }),
136
+ storeA: new PostgresStore(fromPool(pool), { tables: tablesA }),
137
+ storeB: new PostgresStore(fromPool(pool), { tables: tablesB }),
147
138
  cleanup: async () => {
148
139
  await pool.query(`
149
140
  DROP TABLE IF EXISTS ${tablesA.records};
150
- DROP TABLE IF EXISTS ${tablesA.meta};
141
+ DROP TABLE IF EXISTS ${tablesA.docMeta};
151
142
  DROP TABLE IF EXISTS ${tablesB.records};
152
- DROP TABLE IF EXISTS ${tablesB.meta};
143
+ DROP TABLE IF EXISTS ${tablesB.docMeta};
153
144
  `)
154
145
  },
155
146
  }
@@ -162,37 +153,40 @@ describeIfEnabled("PostgresStore", () => {
162
153
  // -------------------------------------------------------------------------
163
154
 
164
155
  describe("createPostgresStore — schema validation", () => {
165
- it("rejects when meta table is missing", async () => {
156
+ it("rejects when doc-meta table is missing", async () => {
166
157
  await expect(
167
- createPostgresStore(pool, {
168
- tables: { meta: "nonexistent_meta", records: "kyneta_records" },
158
+ createPostgresStore(fromPool(pool), {
159
+ tables: { docMeta: "nonexistent_meta", records: "kyneta_records" },
169
160
  }),
170
161
  ).rejects.toThrow(/nonexistent_meta/)
171
162
  })
172
163
 
173
164
  it("rejects when records table is missing", async () => {
174
165
  await expect(
175
- createPostgresStore(pool, {
176
- tables: { meta: "kyneta_meta", records: "nonexistent_records" },
166
+ createPostgresStore(fromPool(pool), {
167
+ tables: {
168
+ docMeta: "kyneta_doc_meta",
169
+ records: "nonexistent_records",
170
+ },
177
171
  }),
178
172
  ).rejects.toThrow(/nonexistent_records/)
179
173
  })
180
174
 
181
175
  it("returns a ready Store when schema is valid", async () => {
182
- const store = await createPostgresStore(pool)
176
+ const store = await createPostgresStore(fromPool(pool))
183
177
  expect(store).toBeDefined()
184
178
  await store.close()
185
179
  })
186
180
 
187
181
  it("rejects when a column has the wrong type", async () => {
188
182
  const tables = {
189
- meta: "wrongtype_meta",
183
+ docMeta: "wrongtype_meta",
190
184
  records: "wrongtype_records",
191
185
  }
192
186
  await pool.query(`
193
187
  DROP TABLE IF EXISTS ${tables.records};
194
- DROP TABLE IF EXISTS ${tables.meta};
195
- CREATE TABLE ${tables.meta} (
188
+ DROP TABLE IF EXISTS ${tables.docMeta};
189
+ CREATE TABLE ${tables.docMeta} (
196
190
  doc_id TEXT PRIMARY KEY, data TEXT NOT NULL
197
191
  );
198
192
  CREATE TABLE ${tables.records} (
@@ -201,13 +195,13 @@ describeIfEnabled("PostgresStore", () => {
201
195
  );
202
196
  `)
203
197
  try {
204
- await expect(createPostgresStore(pool, { tables })).rejects.toThrow(
205
- /data.*type "text"/,
206
- )
198
+ await expect(
199
+ createPostgresStore(fromPool(pool), { tables }),
200
+ ).rejects.toThrow(/data.*type "text"/)
207
201
  } finally {
208
202
  await pool.query(`
209
203
  DROP TABLE IF EXISTS ${tables.records};
210
- DROP TABLE IF EXISTS ${tables.meta};
204
+ DROP TABLE IF EXISTS ${tables.docMeta};
211
205
  `)
212
206
  }
213
207
  })
@@ -220,9 +214,9 @@ describeIfEnabled("PostgresStore", () => {
220
214
  describe("listDocIds — range scan vs LIKE-pattern hazards", () => {
221
215
  it("prefix containing % and _ matches literally, not as wildcards", async () => {
222
216
  await pool.query(
223
- `TRUNCATE ${SCHEMA_TABLES.records}, ${SCHEMA_TABLES.meta}`,
217
+ `TRUNCATE ${SCHEMA_TABLES.records}, ${SCHEMA_TABLES.docMeta}`,
224
218
  )
225
- const store = new PostgresStore(pool)
219
+ const store = new PostgresStore(fromPool(pool))
226
220
 
227
221
  await store.append("100%_done", makeMetaRecord())
228
222
  await store.append("100_other", makeMetaRecord())
@@ -240,4 +234,61 @@ describeIfEnabled("PostgresStore", () => {
240
234
  expect(matched2).toEqual(["100_other"])
241
235
  })
242
236
  })
237
+
238
+ // -------------------------------------------------------------------------
239
+ // Postgres-specific: store-format gate
240
+ // -------------------------------------------------------------------------
241
+
242
+ describe("createPostgresStore — store-format gate", () => {
243
+ const tables = {
244
+ docMeta: "fmt_doc_meta",
245
+ records: "fmt_records",
246
+ storeMeta: "fmt_store_meta",
247
+ }
248
+ const ddl = `
249
+ CREATE TABLE IF NOT EXISTS ${tables.docMeta} (
250
+ doc_id TEXT PRIMARY KEY, data JSONB NOT NULL
251
+ );
252
+ CREATE TABLE IF NOT EXISTS ${tables.records} (
253
+ doc_id TEXT, seq INTEGER, kind TEXT, payload TEXT, blob BYTEA,
254
+ PRIMARY KEY (doc_id, seq)
255
+ );
256
+ CREATE TABLE IF NOT EXISTS ${tables.storeMeta} (
257
+ key TEXT PRIMARY KEY, value JSONB NOT NULL
258
+ );
259
+ `
260
+ const drop = `
261
+ DROP TABLE IF EXISTS ${tables.records};
262
+ DROP TABLE IF EXISTS ${tables.docMeta};
263
+ DROP TABLE IF EXISTS ${tables.storeMeta};
264
+ `
265
+
266
+ it("stamps a fresh store and refuses an incompatible major", async () => {
267
+ await pool.query(drop)
268
+ await pool.query(ddl)
269
+ try {
270
+ // First open stamps {major:1,minor:0}.
271
+ const store = await createPostgresStore(fromPool(pool), { tables })
272
+ await store.close()
273
+ const stamped = await pool.query<{ value: { major: number } }>(
274
+ `SELECT value FROM ${tables.storeMeta} WHERE key = 'format'`,
275
+ )
276
+ expect(stamped.rows[0]?.value.major).toBe(1)
277
+
278
+ // Tamper to a future major → reopen refuses.
279
+ await pool.query(
280
+ `UPDATE ${tables.storeMeta} SET value = $1::jsonb WHERE key = 'format'`,
281
+ [JSON.stringify({ major: 99, minor: 0 })],
282
+ )
283
+ await expect(
284
+ createPostgresStore(fromPool(pool), { tables }),
285
+ ).rejects.toMatchObject({
286
+ name: "StoreFormatVersionError",
287
+ reason: "incompatible-major",
288
+ })
289
+ } finally {
290
+ await pool.query(drop)
291
+ }
292
+ })
293
+ })
243
294
  })