@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
|
@@ -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 {
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
92
|
+
`TRUNCATE ${SCHEMA_TABLES.records}, ${SCHEMA_TABLES.docMeta}`,
|
|
79
93
|
)
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
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
|
-
|
|
86
|
-
|
|
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:
|
|
113
|
-
|
|
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
|
|
124
|
-
|
|
125
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
142
|
-
${tablesB.records}, ${tablesB.
|
|
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.
|
|
141
|
+
DROP TABLE IF EXISTS ${tablesA.docMeta};
|
|
151
142
|
DROP TABLE IF EXISTS ${tablesB.records};
|
|
152
|
-
DROP TABLE IF EXISTS ${tablesB.
|
|
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: {
|
|
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: {
|
|
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
|
-
|
|
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.
|
|
195
|
-
CREATE TABLE ${tables.
|
|
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(
|
|
205
|
-
|
|
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.
|
|
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.
|
|
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
|
})
|