@kyneta/postgres-store 1.5.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/LICENSE +21 -0
- package/README.md +99 -0
- package/dist/index.d.ts +44 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +203 -0
- package/dist/index.js.map +1 -0
- package/package.json +56 -0
- package/schema.sql +23 -0
- package/src/__tests__/postgres-store.test.ts +243 -0
- package/src/index.ts +351 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025-present Duane Johnson
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# @kyneta/postgres-store
|
|
2
|
+
|
|
3
|
+
Postgres storage backend for `@kyneta/exchange` — async-native, JSONB meta, BYTEA blobs.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pnpm add @kyneta/postgres-store pg
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Peer dependencies: `@kyneta/exchange`, `@kyneta/schema`, `@kyneta/sql-store-core`, `pg`.
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
### Recommended: `createPostgresStore` factory
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { Pool } from "pg"
|
|
19
|
+
import { Exchange } from "@kyneta/exchange"
|
|
20
|
+
import { createPostgresStore } from "@kyneta/postgres-store"
|
|
21
|
+
|
|
22
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
|
|
23
|
+
const store = await createPostgresStore(pool)
|
|
24
|
+
|
|
25
|
+
const exchange = new Exchange({
|
|
26
|
+
stores: [store],
|
|
27
|
+
// ...
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
// On shutdown:
|
|
31
|
+
// await exchange.shutdown()
|
|
32
|
+
// await pool.end()
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The factory queries `information_schema.columns` to validate that the canonical schema exists with compatible column types, then returns a ready Store. If validation fails, a curated error tells you which column is missing or has the wrong type.
|
|
36
|
+
|
|
37
|
+
### Sync constructor (advanced)
|
|
38
|
+
|
|
39
|
+
For callers that validate the schema separately at process start:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { PostgresStore } from "@kyneta/postgres-store"
|
|
43
|
+
|
|
44
|
+
const store = new PostgresStore(pool)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Schema
|
|
48
|
+
|
|
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
|
+
|
|
51
|
+
```sql
|
|
52
|
+
CREATE TABLE IF NOT EXISTS kyneta_meta (
|
|
53
|
+
doc_id TEXT PRIMARY KEY,
|
|
54
|
+
data JSONB NOT NULL
|
|
55
|
+
);
|
|
56
|
+
CREATE TABLE IF NOT EXISTS kyneta_records (
|
|
57
|
+
doc_id TEXT NOT NULL,
|
|
58
|
+
seq INTEGER NOT NULL,
|
|
59
|
+
kind TEXT NOT NULL,
|
|
60
|
+
payload TEXT,
|
|
61
|
+
blob BYTEA,
|
|
62
|
+
PRIMARY KEY (doc_id, seq)
|
|
63
|
+
);
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
JSONB on `meta.data` enables operator queryability for admin tooling (`data->>'syncProtocol'`, `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
|
+
|
|
68
|
+
## Options
|
|
69
|
+
|
|
70
|
+
### `tables`
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
const store = await createPostgresStore(pool, {
|
|
74
|
+
tables: { meta: "app_meta", records: "app_records" },
|
|
75
|
+
})
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Default: `{ meta: "kyneta_meta", records: "kyneta_records" }`. Use to run multiple isolated Exchange instances against the same database — each owns one `tables` pair.
|
|
79
|
+
|
|
80
|
+
`listDocIds(prefix)` uses a range scan (`doc_id >= prefix AND doc_id < successor(prefix)`), not `LIKE`. Doc IDs containing `%` and `_` are matched literally.
|
|
81
|
+
|
|
82
|
+
## Lifecycle
|
|
83
|
+
|
|
84
|
+
The caller owns the connection lifecycle:
|
|
85
|
+
|
|
86
|
+
- `Pool`: passed in by the caller. The Store calls `pool.connect()`/`release()` per transaction. The caller calls `pool.end()` on shutdown.
|
|
87
|
+
- `Client`: passed in by the caller; transactions run against the same connection. The caller calls `client.end()` on shutdown.
|
|
88
|
+
|
|
89
|
+
`PostgresStore.close()` is a no-op.
|
|
90
|
+
|
|
91
|
+
### Runtime schema drift
|
|
92
|
+
|
|
93
|
+
Schema validation runs once at `createPostgresStore` time. If a DBA alters the schema while the Exchange is running, the change is **not** detected — re-run `createPostgresStore` after migrations (which means restarting the Exchange). Build a `revalidate()` API only if your operational pattern actually requires it.
|
|
94
|
+
|
|
95
|
+
## See also
|
|
96
|
+
|
|
97
|
+
- [`@kyneta/sql-store-core`](../sql-core/) — pure helpers shared with `sqlite-store` and `prisma-store`.
|
|
98
|
+
- [`@kyneta/sqlite-store`](../sqlite/) — universal SQLite backend.
|
|
99
|
+
- [`@kyneta/prisma-store`](../prisma/) — backend that takes a caller-supplied `PrismaClient`.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { DocId, Store, StoreMeta, StoreRecord } from "@kyneta/exchange";
|
|
2
|
+
import { TableNames } from "@kyneta/sql-store-core";
|
|
3
|
+
import { Client, Pool } from "pg";
|
|
4
|
+
|
|
5
|
+
//#region src/index.d.ts
|
|
6
|
+
interface PostgresStoreOptions {
|
|
7
|
+
/**
|
|
8
|
+
* Override the default table names (`kyneta_meta` and `kyneta_records`).
|
|
9
|
+
*
|
|
10
|
+
* Use when running multiple isolated Exchange instances against the
|
|
11
|
+
* same database — each instance owns one `tables` pair.
|
|
12
|
+
*/
|
|
13
|
+
tables?: Partial<TableNames>;
|
|
14
|
+
}
|
|
15
|
+
type PgConnection = Client | Pool;
|
|
16
|
+
/**
|
|
17
|
+
* Caller owns the connection lifecycle — `close()` is a no-op,
|
|
18
|
+
* `pool.end()` is the caller's responsibility. Prefer
|
|
19
|
+
* `createPostgresStore` over the bare constructor: it validates the
|
|
20
|
+
* schema at construction time so misconfiguration fails loudly with a
|
|
21
|
+
* curated error rather than per-method `column does not exist` later.
|
|
22
|
+
*/
|
|
23
|
+
declare class PostgresStore implements Store {
|
|
24
|
+
#private;
|
|
25
|
+
constructor(client: PgConnection, options?: PostgresStoreOptions);
|
|
26
|
+
append(docId: DocId, record: StoreRecord): Promise<void>;
|
|
27
|
+
loadAll(docId: DocId): AsyncIterable<StoreRecord>;
|
|
28
|
+
replace(docId: DocId, records: StoreRecord[]): Promise<void>;
|
|
29
|
+
delete(docId: DocId): Promise<void>;
|
|
30
|
+
currentMeta(docId: DocId): Promise<StoreMeta | null>;
|
|
31
|
+
listDocIds(prefix?: string): AsyncIterable<DocId>;
|
|
32
|
+
close(): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Validation runs once at factory time, not on every method call. A
|
|
36
|
+
* schema change applied while the Exchange is running won't be
|
|
37
|
+
* detected — restart after migrations. Polling or a `revalidate()`
|
|
38
|
+
* API would be over-engineering for a failure mode that fails loudly
|
|
39
|
+
* on the next write anyway.
|
|
40
|
+
*/
|
|
41
|
+
declare function createPostgresStore(client: PgConnection, options?: PostgresStoreOptions): Promise<Store>;
|
|
42
|
+
//#endregion
|
|
43
|
+
export { PostgresStore, PostgresStoreOptions, createPostgresStore };
|
|
44
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","names":[],"sources":["../src/index.ts"],"mappings":";;;;;UAgCiB,oBAAA;;AAAjB;;;;;EAOE,MAAA,GAAS,OAAA,CAAQ,UAAA;AAAA;AAAA,KAGd,YAAA,GAAe,MAAA,GAAS,IAAA;;AAF5B;;;;;AAuBD;cAAa,aAAA,YAAyB,KAAA;EAAA;cAKxB,MAAA,EAAQ,YAAA,EAAc,OAAA,GAAS,oBAAA;EA0DrC,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;;;;;;;;iBAsCK,mBAAA,CACpB,MAAA,EAAQ,YAAA,EACR,OAAA,GAAS,oBAAA,GACR,OAAA,CAAQ,KAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { SeqNoTracker } from "@kyneta/exchange";
|
|
2
|
+
import { fromRow, planAppend, planReplace, resolveTables } from "@kyneta/sql-store-core";
|
|
3
|
+
//#region src/index.ts
|
|
4
|
+
/**
|
|
5
|
+
* Caller owns the connection lifecycle — `close()` is a no-op,
|
|
6
|
+
* `pool.end()` is the caller's responsibility. Prefer
|
|
7
|
+
* `createPostgresStore` over the bare constructor: it validates the
|
|
8
|
+
* schema at construction time so misconfiguration fails loudly with a
|
|
9
|
+
* curated error rather than per-method `column does not exist` later.
|
|
10
|
+
*/
|
|
11
|
+
var PostgresStore = class {
|
|
12
|
+
#client;
|
|
13
|
+
#seqNos = new SeqNoTracker();
|
|
14
|
+
#tables;
|
|
15
|
+
constructor(client, options = {}) {
|
|
16
|
+
this.#client = client;
|
|
17
|
+
this.#tables = resolveTables(options);
|
|
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();
|
|
32
|
+
try {
|
|
33
|
+
await poolClient.query("BEGIN");
|
|
34
|
+
try {
|
|
35
|
+
const result = await fn(poolClient);
|
|
36
|
+
await poolClient.query("COMMIT");
|
|
37
|
+
return result;
|
|
38
|
+
} catch (e) {
|
|
39
|
+
await poolClient.query("ROLLBACK");
|
|
40
|
+
throw e;
|
|
41
|
+
}
|
|
42
|
+
} finally {
|
|
43
|
+
poolClient.release();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const client = this.#client;
|
|
47
|
+
await client.query("BEGIN");
|
|
48
|
+
try {
|
|
49
|
+
const result = await fn(client);
|
|
50
|
+
await client.query("COMMIT");
|
|
51
|
+
return result;
|
|
52
|
+
} catch (e) {
|
|
53
|
+
await client.query("ROLLBACK");
|
|
54
|
+
throw e;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
get #q() {
|
|
58
|
+
return this.#client;
|
|
59
|
+
}
|
|
60
|
+
async append(docId, record) {
|
|
61
|
+
const plan = planAppend(docId, record, await this.currentMeta(docId), await this.#seqNos.next(docId, async () => {
|
|
62
|
+
return (await this.#q.query(`SELECT MAX(seq)::int AS max_seq FROM ${this.#tables.records} WHERE doc_id = $1`, [docId])).rows[0]?.max_seq ?? null;
|
|
63
|
+
}));
|
|
64
|
+
await this.#withTransaction(async (q) => {
|
|
65
|
+
if (plan.upsertMeta !== null) await q.query(`INSERT INTO ${this.#tables.meta} (doc_id, data)
|
|
66
|
+
VALUES ($1, $2::jsonb)
|
|
67
|
+
ON CONFLICT (doc_id) DO UPDATE SET data = EXCLUDED.data`, [docId, plan.upsertMeta.data]);
|
|
68
|
+
const { row } = plan.insertRecord;
|
|
69
|
+
await q.query(`INSERT INTO ${this.#tables.records}
|
|
70
|
+
(doc_id, seq, kind, payload, blob)
|
|
71
|
+
VALUES ($1, $2, $3, $4, $5)`, [
|
|
72
|
+
docId,
|
|
73
|
+
plan.insertRecord.seq,
|
|
74
|
+
row.kind,
|
|
75
|
+
row.payload,
|
|
76
|
+
row.blob
|
|
77
|
+
]);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
async *loadAll(docId) {
|
|
81
|
+
const result = await this.#q.query(`SELECT kind, payload, blob FROM ${this.#tables.records}
|
|
82
|
+
WHERE doc_id = $1 ORDER BY seq`, [docId]);
|
|
83
|
+
for (const row of result.rows) yield fromRow(row);
|
|
84
|
+
}
|
|
85
|
+
async replace(docId, records) {
|
|
86
|
+
const plan = planReplace(records, await this.currentMeta(docId));
|
|
87
|
+
await this.#withTransaction(async (q) => {
|
|
88
|
+
await q.query(`DELETE FROM ${this.#tables.records} WHERE doc_id = $1`, [docId]);
|
|
89
|
+
for (const { seq, row } of plan.records) await q.query(`INSERT INTO ${this.#tables.records}
|
|
90
|
+
(doc_id, seq, kind, payload, blob)
|
|
91
|
+
VALUES ($1, $2, $3, $4, $5)`, [
|
|
92
|
+
docId,
|
|
93
|
+
seq,
|
|
94
|
+
row.kind,
|
|
95
|
+
row.payload,
|
|
96
|
+
row.blob
|
|
97
|
+
]);
|
|
98
|
+
await q.query(`INSERT INTO ${this.#tables.meta} (doc_id, data)
|
|
99
|
+
VALUES ($1, $2::jsonb)
|
|
100
|
+
ON CONFLICT (doc_id) DO UPDATE SET data = EXCLUDED.data`, [docId, plan.upsertMeta.data]);
|
|
101
|
+
});
|
|
102
|
+
this.#seqNos.reset(docId, records.length - 1);
|
|
103
|
+
}
|
|
104
|
+
async delete(docId) {
|
|
105
|
+
await this.#withTransaction(async (q) => {
|
|
106
|
+
await q.query(`DELETE FROM ${this.#tables.records} WHERE doc_id = $1`, [docId]);
|
|
107
|
+
await q.query(`DELETE FROM ${this.#tables.meta} WHERE doc_id = $1`, [docId]);
|
|
108
|
+
});
|
|
109
|
+
this.#seqNos.remove(docId);
|
|
110
|
+
}
|
|
111
|
+
async currentMeta(docId) {
|
|
112
|
+
return (await this.#q.query(`SELECT data FROM ${this.#tables.meta} WHERE doc_id = $1`, [docId])).rows[0]?.data ?? null;
|
|
113
|
+
}
|
|
114
|
+
async *listDocIds(prefix) {
|
|
115
|
+
if (prefix === void 0) {
|
|
116
|
+
const result = await this.#q.query(`SELECT doc_id FROM ${this.#tables.meta}`);
|
|
117
|
+
for (const row of result.rows) yield row.doc_id;
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const upper = prefixUpperBound(prefix);
|
|
121
|
+
const result = upper === null ? await this.#q.query(`SELECT doc_id FROM ${this.#tables.meta} WHERE doc_id >= $1`, [prefix]) : await this.#q.query(`SELECT doc_id FROM ${this.#tables.meta}
|
|
122
|
+
WHERE doc_id >= $1 AND doc_id < $2`, [prefix, upper]);
|
|
123
|
+
for (const row of result.rows) yield row.doc_id;
|
|
124
|
+
}
|
|
125
|
+
async close() {}
|
|
126
|
+
};
|
|
127
|
+
/**
|
|
128
|
+
* Returns null when no successor exists (e.g. all code units at U+10FFFF),
|
|
129
|
+
* letting the caller fall back to an unbounded `>= prefix` scan.
|
|
130
|
+
*/
|
|
131
|
+
function prefixUpperBound(prefix) {
|
|
132
|
+
if (prefix.length === 0) return null;
|
|
133
|
+
const codes = Array.from(prefix);
|
|
134
|
+
for (let i = codes.length - 1; i >= 0; i--) {
|
|
135
|
+
const code = codes[i].codePointAt(0);
|
|
136
|
+
if (code < 1114111) {
|
|
137
|
+
const next = String.fromCodePoint(code + 1);
|
|
138
|
+
return codes.slice(0, i).join("") + next;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Validation runs once at factory time, not on every method call. A
|
|
145
|
+
* schema change applied while the Exchange is running won't be
|
|
146
|
+
* detected — restart after migrations. Polling or a `revalidate()`
|
|
147
|
+
* API would be over-engineering for a failure mode that fails loudly
|
|
148
|
+
* on the next write anyway.
|
|
149
|
+
*/
|
|
150
|
+
async function createPostgresStore(client, options = {}) {
|
|
151
|
+
await validateSchema(client, resolveTables(options));
|
|
152
|
+
return new PostgresStore(client, options);
|
|
153
|
+
}
|
|
154
|
+
const EXPECTED_COLUMNS = {
|
|
155
|
+
meta: [{
|
|
156
|
+
name: "doc_id",
|
|
157
|
+
types: ["text"]
|
|
158
|
+
}, {
|
|
159
|
+
name: "data",
|
|
160
|
+
types: ["jsonb"]
|
|
161
|
+
}],
|
|
162
|
+
records: [
|
|
163
|
+
{
|
|
164
|
+
name: "doc_id",
|
|
165
|
+
types: ["text"]
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
name: "seq",
|
|
169
|
+
types: ["integer"]
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
name: "kind",
|
|
173
|
+
types: ["text"]
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
name: "payload",
|
|
177
|
+
types: ["text"]
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
name: "blob",
|
|
181
|
+
types: ["bytea"]
|
|
182
|
+
}
|
|
183
|
+
]
|
|
184
|
+
};
|
|
185
|
+
async function validateSchema(q, tables) {
|
|
186
|
+
for (const [role, expected] of [["meta", EXPECTED_COLUMNS.meta], ["records", EXPECTED_COLUMNS.records]]) {
|
|
187
|
+
const tableName = tables[role];
|
|
188
|
+
const result = await q.query(`SELECT column_name, data_type, is_nullable
|
|
189
|
+
FROM information_schema.columns
|
|
190
|
+
WHERE table_name = $1`, [tableName]);
|
|
191
|
+
if (result.rows.length === 0) throw new Error(`@kyneta/postgres-store: table "${tableName}" not found. Run schema.sql or include the canonical DDL in your migrations.`);
|
|
192
|
+
const columnsByName = new Map(result.rows.map((r) => [r.column_name, r]));
|
|
193
|
+
for (const col of expected) {
|
|
194
|
+
const found = columnsByName.get(col.name);
|
|
195
|
+
if (found === void 0) throw new Error(`@kyneta/postgres-store: table "${tableName}" missing column "${col.name}". See schema.sql for the canonical definition.`);
|
|
196
|
+
if (!col.types.includes(found.data_type)) throw new Error(`@kyneta/postgres-store: table "${tableName}" column "${col.name}" has type "${found.data_type}", expected one of [${col.types.join(", ")}].`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
//#endregion
|
|
201
|
+
export { PostgresStore, createPostgresStore };
|
|
202
|
+
|
|
203
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +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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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kyneta/postgres-store",
|
|
3
|
+
"version": "1.5.0",
|
|
4
|
+
"description": "Postgres storage backend for @kyneta/exchange",
|
|
5
|
+
"author": "Duane Johnson",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/halecraft/kyneta",
|
|
10
|
+
"directory": "packages/exchange/stores/postgres"
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"main": "./dist/index.js",
|
|
17
|
+
"module": "./dist/index.js",
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"src",
|
|
22
|
+
"schema.sql"
|
|
23
|
+
],
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"import": "./dist/index.js",
|
|
28
|
+
"default": "./dist/index.js"
|
|
29
|
+
},
|
|
30
|
+
"./src": "./src/index.ts",
|
|
31
|
+
"./src/*": "./src/*",
|
|
32
|
+
"./schema.sql": "./schema.sql"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@kyneta/exchange": "^1.5.0",
|
|
36
|
+
"@kyneta/schema": "^1.5.0",
|
|
37
|
+
"@kyneta/sql-store-core": "^1.5.0",
|
|
38
|
+
"pg": "^8.11.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/node": "^22",
|
|
42
|
+
"@types/pg": "^8.11.0",
|
|
43
|
+
"pg": "^8.13.0",
|
|
44
|
+
"tsdown": "^0.21.9",
|
|
45
|
+
"typescript": "^5.9.2",
|
|
46
|
+
"vitest": "^4.0.17",
|
|
47
|
+
"@kyneta/exchange": "^1.5.0",
|
|
48
|
+
"@kyneta/sql-store-core": "^1.5.0",
|
|
49
|
+
"@kyneta/schema": "^1.5.0"
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"build": "tsdown",
|
|
53
|
+
"test": "verify logic",
|
|
54
|
+
"verify": "verify"
|
|
55
|
+
}
|
|
56
|
+
}
|
package/schema.sql
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
-- @kyneta/postgres-store — canonical schema.
|
|
2
|
+
--
|
|
3
|
+
-- Run this once before constructing a `PostgresStore`, or include it
|
|
4
|
+
-- as a migration step in your application's migration pipeline. The
|
|
5
|
+
-- `createPostgresStore` factory validates that these tables exist with
|
|
6
|
+
-- the expected columns; it does not auto-DDL.
|
|
7
|
+
--
|
|
8
|
+
-- Default table names. To use different names, override via the
|
|
9
|
+
-- `tables` option and replace the names below to match.
|
|
10
|
+
|
|
11
|
+
CREATE TABLE IF NOT EXISTS kyneta_meta (
|
|
12
|
+
doc_id TEXT PRIMARY KEY,
|
|
13
|
+
data JSONB NOT NULL
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
CREATE TABLE IF NOT EXISTS kyneta_records (
|
|
17
|
+
doc_id TEXT NOT NULL,
|
|
18
|
+
seq INTEGER NOT NULL,
|
|
19
|
+
kind TEXT NOT NULL,
|
|
20
|
+
payload TEXT,
|
|
21
|
+
blob BYTEA,
|
|
22
|
+
PRIMARY KEY (doc_id, seq)
|
|
23
|
+
);
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// postgres-store — conformance + Postgres-specific tests.
|
|
2
|
+
//
|
|
3
|
+
// Gated by the `KYNETA_PG_URL` env var. When unset, the entire suite
|
|
4
|
+
// is skipped (the package still builds and typechecks). Set
|
|
5
|
+
// `KYNETA_PG_URL=postgres://localhost:5432/kyneta_test` (or similar)
|
|
6
|
+
// to run.
|
|
7
|
+
|
|
8
|
+
import { describeStore, makeMetaRecord } from "@kyneta/exchange/testing"
|
|
9
|
+
import { Pool } from "pg"
|
|
10
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest"
|
|
11
|
+
import { createPostgresStore, PostgresStore } from "../index.js"
|
|
12
|
+
|
|
13
|
+
const PG_URL = process.env.KYNETA_PG_URL
|
|
14
|
+
const ENABLED = PG_URL !== undefined && PG_URL.length > 0
|
|
15
|
+
|
|
16
|
+
const pool: Pool | null = ENABLED
|
|
17
|
+
? new Pool({ connectionString: PG_URL })
|
|
18
|
+
: null
|
|
19
|
+
|
|
20
|
+
// Per-test schema namespace via per-test table names. Truncate between
|
|
21
|
+
// tests via DELETE on the canonical tables for the conformance run.
|
|
22
|
+
const SCHEMA_TABLES = {
|
|
23
|
+
meta: "kyneta_meta",
|
|
24
|
+
records: "kyneta_records",
|
|
25
|
+
} as const
|
|
26
|
+
|
|
27
|
+
const SCHEMA_DDL = `
|
|
28
|
+
CREATE TABLE IF NOT EXISTS ${SCHEMA_TABLES.meta} (
|
|
29
|
+
doc_id TEXT PRIMARY KEY,
|
|
30
|
+
data JSONB NOT NULL
|
|
31
|
+
);
|
|
32
|
+
CREATE TABLE IF NOT EXISTS ${SCHEMA_TABLES.records} (
|
|
33
|
+
doc_id TEXT NOT NULL,
|
|
34
|
+
seq INTEGER NOT NULL,
|
|
35
|
+
kind TEXT NOT NULL,
|
|
36
|
+
payload TEXT,
|
|
37
|
+
blob BYTEA,
|
|
38
|
+
PRIMARY KEY (doc_id, seq)
|
|
39
|
+
);
|
|
40
|
+
`
|
|
41
|
+
|
|
42
|
+
if (ENABLED && pool !== null) {
|
|
43
|
+
beforeAll(async () => {
|
|
44
|
+
await pool.query(SCHEMA_DDL)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
afterAll(async () => {
|
|
48
|
+
await pool.end()
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const describeIfEnabled = ENABLED ? describe : describe.skip
|
|
53
|
+
|
|
54
|
+
describeIfEnabled("PostgresStore", () => {
|
|
55
|
+
if (!ENABLED || pool === null) return
|
|
56
|
+
|
|
57
|
+
// -------------------------------------------------------------------------
|
|
58
|
+
// Conformance suite — uses canonical tables + per-test truncation
|
|
59
|
+
// -------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
describeStore(
|
|
62
|
+
"PostgresStore",
|
|
63
|
+
async () => {
|
|
64
|
+
// Truncate before each test for a clean slate.
|
|
65
|
+
await pool.query(
|
|
66
|
+
`TRUNCATE ${SCHEMA_TABLES.records}, ${SCHEMA_TABLES.meta}`,
|
|
67
|
+
)
|
|
68
|
+
return new PostgresStore(pool)
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
cleanup: async () => {
|
|
72
|
+
await pool.query(
|
|
73
|
+
`TRUNCATE ${SCHEMA_TABLES.records}, ${SCHEMA_TABLES.meta}`,
|
|
74
|
+
)
|
|
75
|
+
},
|
|
76
|
+
faultFactory: async () => {
|
|
77
|
+
await pool.query(
|
|
78
|
+
`TRUNCATE ${SCHEMA_TABLES.records}, ${SCHEMA_TABLES.meta}`,
|
|
79
|
+
)
|
|
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.
|
|
84
|
+
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)
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
store,
|
|
112
|
+
injectFault: n => {
|
|
113
|
+
armed = n
|
|
114
|
+
count = 0
|
|
115
|
+
},
|
|
116
|
+
freshStore: async () => new PostgresStore(pool),
|
|
117
|
+
cleanup: async () => {
|
|
118
|
+
client.release()
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
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" }
|
|
126
|
+
await pool.query(`
|
|
127
|
+
CREATE TABLE IF NOT EXISTS ${tablesA.meta} (
|
|
128
|
+
doc_id TEXT PRIMARY KEY, data JSONB NOT NULL
|
|
129
|
+
);
|
|
130
|
+
CREATE TABLE IF NOT EXISTS ${tablesA.records} (
|
|
131
|
+
doc_id TEXT, seq INTEGER, kind TEXT, payload TEXT, blob BYTEA,
|
|
132
|
+
PRIMARY KEY (doc_id, seq)
|
|
133
|
+
);
|
|
134
|
+
CREATE TABLE IF NOT EXISTS ${tablesB.meta} (
|
|
135
|
+
doc_id TEXT PRIMARY KEY, data JSONB NOT NULL
|
|
136
|
+
);
|
|
137
|
+
CREATE TABLE IF NOT EXISTS ${tablesB.records} (
|
|
138
|
+
doc_id TEXT, seq INTEGER, kind TEXT, payload TEXT, blob BYTEA,
|
|
139
|
+
PRIMARY KEY (doc_id, seq)
|
|
140
|
+
);
|
|
141
|
+
TRUNCATE ${tablesA.records}, ${tablesA.meta},
|
|
142
|
+
${tablesB.records}, ${tablesB.meta};
|
|
143
|
+
`)
|
|
144
|
+
return {
|
|
145
|
+
storeA: new PostgresStore(pool, { tables: tablesA }),
|
|
146
|
+
storeB: new PostgresStore(pool, { tables: tablesB }),
|
|
147
|
+
cleanup: async () => {
|
|
148
|
+
await pool.query(`
|
|
149
|
+
DROP TABLE IF EXISTS ${tablesA.records};
|
|
150
|
+
DROP TABLE IF EXISTS ${tablesA.meta};
|
|
151
|
+
DROP TABLE IF EXISTS ${tablesB.records};
|
|
152
|
+
DROP TABLE IF EXISTS ${tablesB.meta};
|
|
153
|
+
`)
|
|
154
|
+
},
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
// -------------------------------------------------------------------------
|
|
161
|
+
// Postgres-specific: createPostgresStore validation
|
|
162
|
+
// -------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
describe("createPostgresStore — schema validation", () => {
|
|
165
|
+
it("rejects when meta table is missing", async () => {
|
|
166
|
+
await expect(
|
|
167
|
+
createPostgresStore(pool, {
|
|
168
|
+
tables: { meta: "nonexistent_meta", records: "kyneta_records" },
|
|
169
|
+
}),
|
|
170
|
+
).rejects.toThrow(/nonexistent_meta/)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it("rejects when records table is missing", async () => {
|
|
174
|
+
await expect(
|
|
175
|
+
createPostgresStore(pool, {
|
|
176
|
+
tables: { meta: "kyneta_meta", records: "nonexistent_records" },
|
|
177
|
+
}),
|
|
178
|
+
).rejects.toThrow(/nonexistent_records/)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it("returns a ready Store when schema is valid", async () => {
|
|
182
|
+
const store = await createPostgresStore(pool)
|
|
183
|
+
expect(store).toBeDefined()
|
|
184
|
+
await store.close()
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it("rejects when a column has the wrong type", async () => {
|
|
188
|
+
const tables = {
|
|
189
|
+
meta: "wrongtype_meta",
|
|
190
|
+
records: "wrongtype_records",
|
|
191
|
+
}
|
|
192
|
+
await pool.query(`
|
|
193
|
+
DROP TABLE IF EXISTS ${tables.records};
|
|
194
|
+
DROP TABLE IF EXISTS ${tables.meta};
|
|
195
|
+
CREATE TABLE ${tables.meta} (
|
|
196
|
+
doc_id TEXT PRIMARY KEY, data TEXT NOT NULL
|
|
197
|
+
);
|
|
198
|
+
CREATE TABLE ${tables.records} (
|
|
199
|
+
doc_id TEXT, seq INTEGER, kind TEXT, payload TEXT, blob BYTEA,
|
|
200
|
+
PRIMARY KEY (doc_id, seq)
|
|
201
|
+
);
|
|
202
|
+
`)
|
|
203
|
+
try {
|
|
204
|
+
await expect(createPostgresStore(pool, { tables })).rejects.toThrow(
|
|
205
|
+
/data.*type "text"/,
|
|
206
|
+
)
|
|
207
|
+
} finally {
|
|
208
|
+
await pool.query(`
|
|
209
|
+
DROP TABLE IF EXISTS ${tables.records};
|
|
210
|
+
DROP TABLE IF EXISTS ${tables.meta};
|
|
211
|
+
`)
|
|
212
|
+
}
|
|
213
|
+
})
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
// -------------------------------------------------------------------------
|
|
217
|
+
// Postgres-specific: range-scan correctness
|
|
218
|
+
// -------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
describe("listDocIds — range scan vs LIKE-pattern hazards", () => {
|
|
221
|
+
it("prefix containing % and _ matches literally, not as wildcards", async () => {
|
|
222
|
+
await pool.query(
|
|
223
|
+
`TRUNCATE ${SCHEMA_TABLES.records}, ${SCHEMA_TABLES.meta}`,
|
|
224
|
+
)
|
|
225
|
+
const store = new PostgresStore(pool)
|
|
226
|
+
|
|
227
|
+
await store.append("100%_done", makeMetaRecord())
|
|
228
|
+
await store.append("100_other", makeMetaRecord())
|
|
229
|
+
await store.append("100xyz", makeMetaRecord())
|
|
230
|
+
await store.append("other", makeMetaRecord())
|
|
231
|
+
|
|
232
|
+
// "100%" must match only "100%_done" — NOT "100_other" / "100xyz"
|
|
233
|
+
const matched: string[] = []
|
|
234
|
+
for await (const id of store.listDocIds("100%")) matched.push(id)
|
|
235
|
+
expect(matched).toEqual(["100%_done"])
|
|
236
|
+
|
|
237
|
+
// "100_" must match only "100_other"
|
|
238
|
+
const matched2: string[] = []
|
|
239
|
+
for await (const id of store.listDocIds("100_")) matched2.push(id)
|
|
240
|
+
expect(matched2).toEqual(["100_other"])
|
|
241
|
+
})
|
|
242
|
+
})
|
|
243
|
+
})
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
// Postgres Store backend.
|
|
2
|
+
//
|
|
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.
|
|
6
|
+
// Cost: JSONB normalizes whitespace and key order at insert time, so
|
|
7
|
+
// `meta.data` bytes don't match SQLite's TEXT-stored meta — but
|
|
8
|
+
// round-trip through `loadAll` still yields a structurally equal
|
|
9
|
+
// `StoreRecord`, which is what cross-backend portability actually
|
|
10
|
+
// requires.
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
type DocId,
|
|
14
|
+
SeqNoTracker,
|
|
15
|
+
type Store,
|
|
16
|
+
type StoreMeta,
|
|
17
|
+
type StoreRecord,
|
|
18
|
+
} from "@kyneta/exchange"
|
|
19
|
+
import {
|
|
20
|
+
fromRow,
|
|
21
|
+
planAppend,
|
|
22
|
+
planReplace,
|
|
23
|
+
type RowShape,
|
|
24
|
+
resolveTables,
|
|
25
|
+
type TableNames,
|
|
26
|
+
} from "@kyneta/sql-store-core"
|
|
27
|
+
import type { Client, Pool, PoolClient } from "pg"
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Options
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
export interface PostgresStoreOptions {
|
|
34
|
+
/**
|
|
35
|
+
* Override the default table names (`kyneta_meta` and `kyneta_records`).
|
|
36
|
+
*
|
|
37
|
+
* Use when running multiple isolated Exchange instances against the
|
|
38
|
+
* same database — each instance owns one `tables` pair.
|
|
39
|
+
*/
|
|
40
|
+
tables?: Partial<TableNames>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type PgConnection = Client | Pool
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Narrow structural type for the methods we actually call. Keeps the
|
|
47
|
+
* package independent of `pg`'s top-level type changes across versions.
|
|
48
|
+
*/
|
|
49
|
+
interface PgQuerier {
|
|
50
|
+
query<R = unknown>(text: string, values?: unknown[]): Promise<{ rows: R[] }>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// PostgresStore
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Caller owns the connection lifecycle — `close()` is a no-op,
|
|
59
|
+
* `pool.end()` is the caller's responsibility. Prefer
|
|
60
|
+
* `createPostgresStore` over the bare constructor: it validates the
|
|
61
|
+
* schema at construction time so misconfiguration fails loudly with a
|
|
62
|
+
* curated error rather than per-method `column does not exist` later.
|
|
63
|
+
*/
|
|
64
|
+
export class PostgresStore implements Store {
|
|
65
|
+
readonly #client: PgConnection
|
|
66
|
+
readonly #seqNos = new SeqNoTracker()
|
|
67
|
+
readonly #tables: TableNames
|
|
68
|
+
|
|
69
|
+
constructor(client: PgConnection, options: PostgresStoreOptions = {}) {
|
|
70
|
+
this.#client = client
|
|
71
|
+
this.#tables = resolveTables(options)
|
|
72
|
+
}
|
|
73
|
+
|
|
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
|
+
// -------------------------------------------------------------------------
|
|
124
|
+
// Store interface
|
|
125
|
+
// -------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
async append(docId: DocId, record: StoreRecord): Promise<void> {
|
|
128
|
+
const existingMeta = await this.currentMeta(docId)
|
|
129
|
+
const seq = await this.#seqNos.next(docId, async () => {
|
|
130
|
+
const result = await this.#q.query<{ max_seq: number | null }>(
|
|
131
|
+
`SELECT MAX(seq)::int AS max_seq FROM ${this.#tables.records} WHERE doc_id = $1`,
|
|
132
|
+
[docId],
|
|
133
|
+
)
|
|
134
|
+
return result.rows[0]?.max_seq ?? null
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
const plan = planAppend(docId, record, existingMeta, seq)
|
|
138
|
+
|
|
139
|
+
await this.#withTransaction(async q => {
|
|
140
|
+
if (plan.upsertMeta !== null) {
|
|
141
|
+
await q.query(
|
|
142
|
+
`INSERT INTO ${this.#tables.meta} (doc_id, data)
|
|
143
|
+
VALUES ($1, $2::jsonb)
|
|
144
|
+
ON CONFLICT (doc_id) DO UPDATE SET data = EXCLUDED.data`,
|
|
145
|
+
[docId, plan.upsertMeta.data],
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
const { row } = plan.insertRecord
|
|
149
|
+
await q.query(
|
|
150
|
+
`INSERT INTO ${this.#tables.records}
|
|
151
|
+
(doc_id, seq, kind, payload, blob)
|
|
152
|
+
VALUES ($1, $2, $3, $4, $5)`,
|
|
153
|
+
[docId, plan.insertRecord.seq, row.kind, row.payload, row.blob],
|
|
154
|
+
)
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async *loadAll(docId: DocId): AsyncIterable<StoreRecord> {
|
|
159
|
+
const result = await this.#q.query<RowShape>(
|
|
160
|
+
`SELECT kind, payload, blob FROM ${this.#tables.records}
|
|
161
|
+
WHERE doc_id = $1 ORDER BY seq`,
|
|
162
|
+
[docId],
|
|
163
|
+
)
|
|
164
|
+
for (const row of result.rows) {
|
|
165
|
+
yield fromRow(row)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async replace(docId: DocId, records: StoreRecord[]): Promise<void> {
|
|
170
|
+
const existingMeta = await this.currentMeta(docId)
|
|
171
|
+
const plan = planReplace(records, existingMeta)
|
|
172
|
+
|
|
173
|
+
await this.#withTransaction(async q => {
|
|
174
|
+
await q.query(`DELETE FROM ${this.#tables.records} WHERE doc_id = $1`, [
|
|
175
|
+
docId,
|
|
176
|
+
])
|
|
177
|
+
|
|
178
|
+
for (const { seq, row } of plan.records) {
|
|
179
|
+
await q.query(
|
|
180
|
+
`INSERT INTO ${this.#tables.records}
|
|
181
|
+
(doc_id, seq, kind, payload, blob)
|
|
182
|
+
VALUES ($1, $2, $3, $4, $5)`,
|
|
183
|
+
[docId, seq, row.kind, row.payload, row.blob],
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
await q.query(
|
|
188
|
+
`INSERT INTO ${this.#tables.meta} (doc_id, data)
|
|
189
|
+
VALUES ($1, $2::jsonb)
|
|
190
|
+
ON CONFLICT (doc_id) DO UPDATE SET data = EXCLUDED.data`,
|
|
191
|
+
[docId, plan.upsertMeta.data],
|
|
192
|
+
)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
// Must run after commit. If `#withTransaction` rejects, the throw
|
|
196
|
+
// propagates past this line; the cache stays unmutated. Inside the
|
|
197
|
+
// callback would corrupt it on rollback — the next append would
|
|
198
|
+
// collide with restored rows on (doc_id, seq).
|
|
199
|
+
this.#seqNos.reset(docId, records.length - 1)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async delete(docId: DocId): Promise<void> {
|
|
203
|
+
await this.#withTransaction(async q => {
|
|
204
|
+
await q.query(`DELETE FROM ${this.#tables.records} WHERE doc_id = $1`, [
|
|
205
|
+
docId,
|
|
206
|
+
])
|
|
207
|
+
await q.query(`DELETE FROM ${this.#tables.meta} WHERE doc_id = $1`, [
|
|
208
|
+
docId,
|
|
209
|
+
])
|
|
210
|
+
})
|
|
211
|
+
this.#seqNos.remove(docId)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
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`,
|
|
217
|
+
[docId],
|
|
218
|
+
)
|
|
219
|
+
return result.rows[0]?.data ?? null
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async *listDocIds(prefix?: string): AsyncIterable<DocId> {
|
|
223
|
+
if (prefix === undefined) {
|
|
224
|
+
const result = await this.#q.query<{ doc_id: string }>(
|
|
225
|
+
`SELECT doc_id FROM ${this.#tables.meta}`,
|
|
226
|
+
)
|
|
227
|
+
for (const row of result.rows) yield row.doc_id
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Range scan instead of LIKE — `%` and `_` in doc IDs are literal,
|
|
232
|
+
// not wildcards.
|
|
233
|
+
const upper = prefixUpperBound(prefix)
|
|
234
|
+
const result =
|
|
235
|
+
upper === null
|
|
236
|
+
? await this.#q.query<{ doc_id: string }>(
|
|
237
|
+
`SELECT doc_id FROM ${this.#tables.meta} WHERE doc_id >= $1`,
|
|
238
|
+
[prefix],
|
|
239
|
+
)
|
|
240
|
+
: await this.#q.query<{ doc_id: string }>(
|
|
241
|
+
`SELECT doc_id FROM ${this.#tables.meta}
|
|
242
|
+
WHERE doc_id >= $1 AND doc_id < $2`,
|
|
243
|
+
[prefix, upper],
|
|
244
|
+
)
|
|
245
|
+
for (const row of result.rows) yield row.doc_id
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async close(): Promise<void> {
|
|
249
|
+
// Caller calls `pool.end()` / `client.end()`.
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
// Range-scan helper
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Returns null when no successor exists (e.g. all code units at U+10FFFF),
|
|
259
|
+
* letting the caller fall back to an unbounded `>= prefix` scan.
|
|
260
|
+
*/
|
|
261
|
+
function prefixUpperBound(prefix: string): string | null {
|
|
262
|
+
if (prefix.length === 0) return null
|
|
263
|
+
const codes = Array.from(prefix)
|
|
264
|
+
for (let i = codes.length - 1; i >= 0; i--) {
|
|
265
|
+
const ch = codes[i] as string
|
|
266
|
+
const code = ch.codePointAt(0) as number
|
|
267
|
+
if (code < 0x10ffff) {
|
|
268
|
+
const next = String.fromCodePoint(code + 1)
|
|
269
|
+
return codes.slice(0, i).join("") + next
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return null
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
// Factory: createPostgresStore (recommended entry point)
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Validation runs once at factory time, not on every method call. A
|
|
281
|
+
* schema change applied while the Exchange is running won't be
|
|
282
|
+
* detected — restart after migrations. Polling or a `revalidate()`
|
|
283
|
+
* API would be over-engineering for a failure mode that fails loudly
|
|
284
|
+
* on the next write anyway.
|
|
285
|
+
*/
|
|
286
|
+
export async function createPostgresStore(
|
|
287
|
+
client: PgConnection,
|
|
288
|
+
options: PostgresStoreOptions = {},
|
|
289
|
+
): Promise<Store> {
|
|
290
|
+
const tables = resolveTables(options)
|
|
291
|
+
await validateSchema(client as unknown as PgQuerier, tables)
|
|
292
|
+
return new PostgresStore(client, options)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
interface ColumnInfo {
|
|
296
|
+
column_name: string
|
|
297
|
+
data_type: string
|
|
298
|
+
is_nullable: string
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const EXPECTED_COLUMNS = {
|
|
302
|
+
meta: [
|
|
303
|
+
{ name: "doc_id", types: ["text"] },
|
|
304
|
+
{ name: "data", types: ["jsonb"] },
|
|
305
|
+
],
|
|
306
|
+
records: [
|
|
307
|
+
{ name: "doc_id", types: ["text"] },
|
|
308
|
+
{ name: "seq", types: ["integer"] },
|
|
309
|
+
{ name: "kind", types: ["text"] },
|
|
310
|
+
{ name: "payload", types: ["text"] },
|
|
311
|
+
{ name: "blob", types: ["bytea"] },
|
|
312
|
+
],
|
|
313
|
+
} as const
|
|
314
|
+
|
|
315
|
+
async function validateSchema(q: PgQuerier, tables: TableNames): Promise<void> {
|
|
316
|
+
for (const [role, expected] of [
|
|
317
|
+
["meta", EXPECTED_COLUMNS.meta] as const,
|
|
318
|
+
["records", EXPECTED_COLUMNS.records] as const,
|
|
319
|
+
]) {
|
|
320
|
+
const tableName = tables[role]
|
|
321
|
+
const result = await q.query<ColumnInfo>(
|
|
322
|
+
`SELECT column_name, data_type, is_nullable
|
|
323
|
+
FROM information_schema.columns
|
|
324
|
+
WHERE table_name = $1`,
|
|
325
|
+
[tableName],
|
|
326
|
+
)
|
|
327
|
+
if (result.rows.length === 0) {
|
|
328
|
+
throw new Error(
|
|
329
|
+
`@kyneta/postgres-store: table "${tableName}" not found. ` +
|
|
330
|
+
`Run schema.sql or include the canonical DDL in your migrations.`,
|
|
331
|
+
)
|
|
332
|
+
}
|
|
333
|
+
const columnsByName = new Map(result.rows.map(r => [r.column_name, r]))
|
|
334
|
+
for (const col of expected) {
|
|
335
|
+
const found = columnsByName.get(col.name)
|
|
336
|
+
if (found === undefined) {
|
|
337
|
+
throw new Error(
|
|
338
|
+
`@kyneta/postgres-store: table "${tableName}" missing column ` +
|
|
339
|
+
`"${col.name}". See schema.sql for the canonical definition.`,
|
|
340
|
+
)
|
|
341
|
+
}
|
|
342
|
+
if (!(col.types as readonly string[]).includes(found.data_type)) {
|
|
343
|
+
throw new Error(
|
|
344
|
+
`@kyneta/postgres-store: table "${tableName}" column ` +
|
|
345
|
+
`"${col.name}" has type "${found.data_type}", ` +
|
|
346
|
+
`expected one of [${col.types.join(", ")}].`,
|
|
347
|
+
)
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|