@kyneta/postgres-store 1.5.2 → 1.6.1

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.
@@ -1 +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"}
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../src/index.ts"],"mappings":";;;;;UAgCiB,oBAAA;;AAAjB;;;;;EAOE,MAAA,GAAS,OAAO,CAAC,UAAA;AAAA;AAAA,KAGd,YAAA,GAAe,MAAA,GAAS,IAAI;AAHJ;AAC5B;;;;AAEgC;AAqBjC;AAxB6B,cAwBhB,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.map CHANGED
@@ -1 +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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"}
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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kyneta/postgres-store",
3
- "version": "1.5.2",
3
+ "version": "1.6.1",
4
4
  "description": "Postgres storage backend for @kyneta/exchange",
5
5
  "author": "Duane Johnson",
6
6
  "license": "MIT",
@@ -32,21 +32,21 @@
32
32
  "./schema.sql": "./schema.sql"
33
33
  },
34
34
  "peerDependencies": {
35
- "@kyneta/exchange": "^1.5.2",
36
- "@kyneta/schema": "^1.5.2",
37
- "@kyneta/sql-store-core": "^1.5.2",
38
- "pg": "^8.11.0"
35
+ "pg": "^8.11.0",
36
+ "@kyneta/exchange": "^1.6.1",
37
+ "@kyneta/schema": "^1.6.1",
38
+ "@kyneta/sql-store-core": "^1.6.1"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@types/node": "^22",
42
42
  "@types/pg": "^8.11.0",
43
43
  "pg": "^8.13.0",
44
- "tsdown": "^0.21.9",
44
+ "tsdown": "^0.22.0",
45
45
  "typescript": "^5.9.2",
46
46
  "vitest": "^4.0.17",
47
- "@kyneta/sql-store-core": "^1.5.2",
48
- "@kyneta/exchange": "^1.5.2",
49
- "@kyneta/schema": "^1.5.2"
47
+ "@kyneta/sql-store-core": "^1.6.1",
48
+ "@kyneta/exchange": "^1.6.1",
49
+ "@kyneta/schema": "^1.6.1"
50
50
  },
51
51
  "scripts": {
52
52
  "build": "tsdown",