@fonderie/store 0.2.1 → 0.3.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 +2 -2
- package/brain/signatures.md +2 -0
- package/dist/index.cjs +33 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +32 -0
- package/dist/index.js.map +1 -1
- package/dist/migrations/index.js +6 -0
- package/dist/migrations/index.js.map +1 -1
- package/package.json +9 -9
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ package.
|
|
|
28
28
|
You've shipped this plumbing before — auth, teams, billing, messaging —
|
|
29
29
|
and the next project will ask for it again. Fonderie packages it once:
|
|
30
30
|
plain TypeScript modules for
|
|
31
|
-
[`@fonderie/core`](https://github.com/
|
|
31
|
+
[`@fonderie/core`](https://github.com/fonderiejs/sdk/tree/main/packages/core),
|
|
32
32
|
PostgreSQL-backed, self-hosted, MIT. No external control plane, no
|
|
33
33
|
per-seat anything. Register the modules you need; skip the ones you don't.
|
|
34
34
|
|
|
@@ -36,7 +36,7 @@ per-seat anything. Register the modules you need; skip the ones you don't.
|
|
|
36
36
|
and the migration runner through which every brick installs its schema.
|
|
37
37
|
|
|
38
38
|
Browse the whole set at
|
|
39
|
-
[
|
|
39
|
+
[fonderiejs/sdk](https://github.com/fonderiejs/sdk) · follow
|
|
40
40
|
[@fonderiejs](https://x.com/fonderiejs)
|
|
41
41
|
|
|
42
42
|
## License
|
package/brain/signatures.md
CHANGED
|
@@ -48,6 +48,8 @@ new PGAdapter(config: string | IPoolConfig): PGAdapter
|
|
|
48
48
|
.transaction<T>(fn: (tx: IStoreAdapter) => Promise<T>): Promise<T>
|
|
49
49
|
.end(): Promise<void>
|
|
50
50
|
|
|
51
|
+
function assertProductionDbConfig(options: IPoolConfig): void
|
|
52
|
+
|
|
51
53
|
function versionedWrite<T>(r: IVersionedResource, store: IStoreAdapter, opts: { key: string; scope: string | null; data: Record<string, unknown>; ifVersion?: number; actor: string | null; }): Promise<...>
|
|
52
54
|
|
|
53
55
|
function versionedRollback<T>(r: IVersionedResource, store: IStoreAdapter, opts: { key: string; scope: string | null; toVersion: number; actor: string | null; }): Promise<T>
|
package/dist/index.cjs
CHANGED
|
@@ -34,6 +34,7 @@ __export(index_exports, {
|
|
|
34
34
|
MigrationRunner: () => MigrationRunner,
|
|
35
35
|
PGAdapter: () => PGAdapter,
|
|
36
36
|
VersionConflictError: () => VersionConflictError,
|
|
37
|
+
assertProductionDbConfig: () => assertProductionDbConfig,
|
|
37
38
|
createMigrationsPath: () => createMigrationsPath,
|
|
38
39
|
sql: () => sql,
|
|
39
40
|
versionedRollback: () => versionedRollback,
|
|
@@ -79,6 +80,12 @@ var MigrationRunner = class {
|
|
|
79
80
|
const sql2 = await (0, import_promises.readFile)((0, import_node_path.join)(this.migrationsDir, file), "utf8");
|
|
80
81
|
this.assertNoReservedPrefix(file, sql2);
|
|
81
82
|
await this.store.transaction(async (tx) => {
|
|
83
|
+
await tx.query(`SELECT pg_advisory_xact_lock(hashtext('${MIGRATIONS_TABLE}'))`);
|
|
84
|
+
const already = await tx.query(
|
|
85
|
+
`SELECT name FROM ${MIGRATIONS_TABLE} WHERE name = $1`,
|
|
86
|
+
[file]
|
|
87
|
+
);
|
|
88
|
+
if (already.length > 0) return;
|
|
82
89
|
await tx.query(sql2);
|
|
83
90
|
await tx.query(`INSERT INTO ${MIGRATIONS_TABLE} (name, applied_at) VALUES ($1, now())`, [
|
|
84
91
|
file
|
|
@@ -127,10 +134,35 @@ function createMigrationsPath(importMetaUrl) {
|
|
|
127
134
|
|
|
128
135
|
// src/adapters/pg.ts
|
|
129
136
|
var import_pg = __toESM(require("pg"), 1);
|
|
137
|
+
function assertProductionDbConfig(options) {
|
|
138
|
+
if (process.env["NODE_ENV"] !== "production") return;
|
|
139
|
+
const url = options.connectionString;
|
|
140
|
+
if (url !== void 0 && url.trim() === "") {
|
|
141
|
+
throw new Error(
|
|
142
|
+
"[store] connectionString is empty in production \u2014 refusing to boot. Set a real DATABASE_URL, or omit it entirely to use PG* environment variables."
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
if (url) {
|
|
146
|
+
if (/[?&]sslmode=disable\b/i.test(url) || /[?&]ssl=false\b/i.test(url)) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
"[store] database TLS is disabled (sslmode=disable) in production \u2014 traffic to Postgres would be unencrypted. Use sslmode=require (or stronger), or set NODE_ENV appropriately for non-production."
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
if (/:\/\/postgres:postgres@/i.test(url) || /:\/\/postgres:password@/i.test(url)) {
|
|
152
|
+
console.warn("[store] database is using well-known default credentials in production");
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (options.ssl === false) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
"[store] database TLS is disabled (ssl: false) in production \u2014 traffic to Postgres would be unencrypted. Enable ssl, or set NODE_ENV appropriately for non-production."
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
130
161
|
var PGAdapter = class {
|
|
131
162
|
pool;
|
|
132
163
|
constructor(config) {
|
|
133
164
|
const options = typeof config === "string" ? { connectionString: config } : config;
|
|
165
|
+
assertProductionDbConfig(options);
|
|
134
166
|
this.pool = new import_pg.default.Pool(options);
|
|
135
167
|
this.pool.on("error", (err) => {
|
|
136
168
|
console.error("[store] idle client error", err.message);
|
|
@@ -293,6 +325,7 @@ async function versionedRollback(r, store, opts) {
|
|
|
293
325
|
MigrationRunner,
|
|
294
326
|
PGAdapter,
|
|
295
327
|
VersionConflictError,
|
|
328
|
+
assertProductionDbConfig,
|
|
296
329
|
createMigrationsPath,
|
|
297
330
|
sql,
|
|
298
331
|
versionedRollback,
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/sql.ts","../src/migrations/runner.ts","../src/migrations/path.ts","../src/adapters/pg.ts","../src/versioned.ts"],"sourcesContent":["// ── Public API ───────────────────────────────────────────────────\nexport { sql } from './sql';\nexport type { ISqlQuery } from './sql';\nexport type { IStoreAdapter, IPoolConfig } from './types';\nexport { MigrationRunner, InternalMigrationRunner, createMigrationsPath } from './migrations';\nexport { PGAdapter } from './adapters/pg';\n\n// Versioned-resource control-plane primitive — version index + optimistic\n// concurrency + advisory-locked writes + revisions + rollback + push-notify.\nexport { versionedWrite, versionedRollback, VersionConflictError } from './versioned';\nexport type { IVersionedResource } from './versioned';\n","export interface ISqlQuery {\n\ttext: string;\n\tparams: unknown[];\n}\n\n// Tagged template literal for safe parameterized queries.\n// Never concatenates user input — always uses $N placeholders.\n//\n// Usage:\n// const { text, params } = sql`SELECT * FROM users WHERE id = ${userId}`\n// const rows = await store.query<User>(text, params)\n\nexport function sql(strings: TemplateStringsArray, ...values: unknown[]): ISqlQuery {\n\tlet text = '';\n\tconst params: unknown[] = [];\n\n\tstrings.forEach((str, i) => {\n\t\ttext += str;\n\t\tif (i < values.length) {\n\t\t\tparams.push(values[i]);\n\t\t\ttext += `$${params.length}`;\n\t\t}\n\t});\n\n\treturn { text, params };\n}\n","import { join } from 'node:path';\nimport { readdir, readFile } from 'node:fs/promises';\n\nimport type { IStoreAdapter } from '../types';\n\nconst MIGRATIONS_TABLE = 'fonderie_migrations';\nconst RESERVED_PREFIX_RE = /\\bfonderie_/i;\n\nexport class MigrationRunner {\n\tconstructor(\n\t\tprivate store: IStoreAdapter,\n\t\tprivate migrationsDir: string,\n\t) {}\n\n\tasync run(): Promise<void> {\n\t\tawait this.ensureTable();\n\n\t\tconst [applied, files] = await Promise.all([this.getApplied(), this.getFiles()]);\n\n\t\tconst pending = files.filter((f) => !applied.has(f));\n\n\t\tif (pending.length === 0) {\n\t\t\tconsole.log('[store] migrations: up to date');\n\t\t\treturn;\n\t\t}\n\n\t\tfor (const file of pending) {\n\t\t\tconst sql = await readFile(join(this.migrationsDir, file), 'utf8');\n\n\t\t\tthis.assertNoReservedPrefix(file, sql);\n\n\t\t\tawait this.store.transaction(async (tx) => {\n\t\t\t\tawait tx.query(sql);\n\t\t\t\tawait tx.query(`INSERT INTO ${MIGRATIONS_TABLE} (name, applied_at) VALUES ($1, now())`, [\n\t\t\t\t\tfile,\n\t\t\t\t]);\n\t\t\t});\n\n\t\t\tconsole.log(`[store] migrations: applied ${file}`);\n\t\t}\n\t}\n\n\tprotected assertNoReservedPrefix(file: string, sql: string): void {\n\t\tif (RESERVED_PREFIX_RE.test(sql)) {\n\t\t\tthrow new Error(\n\t\t\t\t`[store] migration \"${file}\" uses the reserved \"fonderie_\" prefix. ` +\n\t\t\t\t`Use InternalMigrationRunner for fonderie-owned migrations.`,\n\t\t\t);\n\t\t}\n\t}\n\n\tprivate async ensureTable(): Promise<void> {\n\t\tawait this.store.query(`\n\t\t\tCREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (\n\t\t\t name TEXT PRIMARY KEY,\n\t\t\t applied_at TIMESTAMPTZ NOT NULL DEFAULT now()\n\t\t\t)\n\t\t`);\n\t}\n\n\tprivate async getApplied(): Promise<Set<string>> {\n\t\tconst rows = await this.store.query<{ name: string }>(\n\t\t\t`SELECT name FROM ${MIGRATIONS_TABLE} ORDER BY name`,\n\t\t);\n\t\treturn new Set(rows.map((r) => r.name));\n\t}\n\n\tprivate async getFiles(): Promise<string[]> {\n\t\tconst all = await readdir(this.migrationsDir);\n\t\treturn all.filter((f) => f.endsWith('.sql')).sort(); // lexicographic — timestamp prefix keeps order correct\n\t}\n}\n\n// For fonderie-internal use only. Skips the reserved-prefix guard.\nexport class InternalMigrationRunner extends MigrationRunner {\n\tprotected override assertNoReservedPrefix(_file: string, _sql: string): void {}\n}\n","import { fileURLToPath } from 'node:url';\nimport { dirname, join } from 'node:path';\n\n// Each package calls this with its own import.meta.url to resolve\n// the absolute path to its compiled migrations/sql/ directory.\n// Called from dist/migrations/index.js, so dirname is already dist/migrations/.\n//\n// Usage in any package's migrations/index.ts:\n// export const getMigrationsPath = () => createMigrationsPath(import.meta.url)\nexport function createMigrationsPath(importMetaUrl: string): string {\n\treturn join(dirname(fileURLToPath(importMetaUrl)), 'sql');\n}\n","import pg from 'pg';\nimport type { IStoreAdapter, IPoolConfig } from '../types';\n\nexport class PGAdapter implements IStoreAdapter {\n\tprivate pool: pg.Pool;\n\n\tconstructor(config: IPoolConfig | string) {\n\t\tconst options = typeof config === 'string' ? { connectionString: config } : config;\n\t\tthis.pool = new pg.Pool(options);\n\n\t\tthis.pool.on('error', (err) => {\n\t\t\tconsole.error('[store] idle client error', err.message);\n\t\t});\n\t}\n\n\tasync testConnection(): Promise<boolean> {\n\t\ttry {\n\t\t\tawait this.pool.query('SELECT 1');\n\t\t\treturn true;\n\t\t} catch {\n\t\t\treturn false;\n\t\t}\n\t}\n\n\tasync query<T = unknown>(sql: string, params?: unknown[]): Promise<T[]> {\n\t\tconst result = await this.pool.query(sql, params);\n\t\treturn result.rows as T[];\n\t}\n\n\tasync transaction<T>(fn: (tx: IStoreAdapter) => Promise<T>): Promise<T> {\n\t\tconst client = await this.pool.connect();\n\n\t\ttry {\n\t\t\tawait client.query('BEGIN');\n\n\t\t\tconst tx: IStoreAdapter = {\n\t\t\t\tquery: async <U = unknown>(sql: string, params?: unknown[]) => {\n\t\t\t\t\tconst result = await client.query(sql, params);\n\t\t\t\t\treturn result.rows as U[];\n\t\t\t\t},\n\t\t\t\ttransaction: <V>(nested: (tx: IStoreAdapter) => Promise<V>) => nested(tx),\n\t\t\t};\n\n\t\t\tconst result = await fn(tx);\n\t\t\tawait client.query('COMMIT');\n\t\t\treturn result;\n\t\t} catch (err) {\n\t\t\tawait client.query('ROLLBACK');\n\t\t\tthrow err;\n\t\t} finally {\n\t\t\tclient.release();\n\t\t}\n\t}\n\n\tasync end(): Promise<void> {\n\t\tawait this.pool.end();\n\t}\n}\n","import type { IStoreAdapter } from './types';\n\n// The control-plane primitive: a version index + optimistic concurrency +\n// advisory-locked writes + append-only revisions + push-notify, reusable by any\n// resource identified by a (primary, scope) key pair. `@fonderie/config` (config\n// + secrets) and `@fonderie/courier` (email templates) run on it; a new resource\n// is a descriptor away. Lives in `store` because it's pure Postgres machinery\n// and every package already depends on store (no cycles).\n\n// Thrown when an optimistic-concurrency write loses the compare-and-swap: the\n// row's current version isn't the one the caller wrote against. Reject-and-retry.\nexport class VersionConflictError extends Error {\n\tconstructor(\n\t\tpublic readonly key: string,\n\t\tpublic readonly scope: string | null,\n\t\tpublic readonly currentVersion: number | null,\n\t\tpublic readonly expectedVersion: number,\n\t) {\n\t\tsuper(\n\t\t\t`\"${key}\" (${scope ?? 'base'}) is at version ${currentVersion ?? 'none'}, ` +\n\t\t\t\t`not ${expectedVersion} — reload and retry`,\n\t\t);\n\t\tthis.name = 'VersionConflictError';\n\t}\n}\n\n// A versioned resource's Postgres surface.\nexport interface IVersionedResource {\n\ttable: string; // main table, e.g. 'fonderie_config'\n\trevisions: string; // history table, e.g. 'fonderie_config_revisions'\n\tchannel: string; // LISTEN/NOTIFY channel, e.g. 'fonderie_config_changed'\n\t// The (primary, scope) key pair — config: ['key','environment'], courier:\n\t// ['type','locale']. `scope` is null-safe (a NULL locale is the base).\n\t// Intentionally a fixed 2-tuple: every resource is addressed by exactly one\n\t// primary key plus one optional scope. Composite 3+-part keys are out of\n\t// scope by design — model the extra dimension inside the primary key (e.g.\n\t// a compound string) or the scope rather than widening this contract.\n\tkeyColumns: readonly [string, string];\n\t// Content columns — written AND snapshotted into revisions (config: ['value'];\n\t// courier: ['subject','html','text']).\n\tcontentColumns: readonly string[];\n\t// Extra main-table columns, set only when supplied, never revisioned (config:\n\t// ['description','active']; courier: ['active']).\n\tmetaColumns?: readonly string[];\n\t// SELECT/RETURNING column list shaping the caller's row type.\n\treturning: string;\n}\n\nconst lockSql = `SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))`;\n\nfunction keyMatch(r: IVersionedResource): string {\n\tconst [id, scope] = r.keyColumns;\n\treturn `${id} IS NOT DISTINCT FROM $1 AND ${scope} IS NOT DISTINCT FROM $2`;\n}\n\n// Write one versioned entry: advisory-lock the (key, scope) pair (serializes even\n// a create), enforce optimistic concurrency when `ifVersion` is given, bump the\n// version, append a revision (content columns only), and broadcast invalidation\n// on commit. `data` supplies every content column and any meta columns to set.\nexport async function versionedWrite<T>(\n\tr: IVersionedResource,\n\tstore: IStoreAdapter,\n\topts: {\n\t\tkey: string;\n\t\tscope: string | null;\n\t\tdata: Record<string, unknown>;\n\t\tifVersion?: number;\n\t\tactor: string | null;\n\t},\n): Promise<T> {\n\tconst [idCol, scopeCol] = r.keyColumns;\n\tconst content = r.contentColumns;\n\tconst meta = (r.metaColumns ?? []).filter((c) => c in opts.data);\n\tconst contentVals = content.map((c) => opts.data[c] ?? null);\n\tconst metaVals = meta.map((c) => opts.data[c] ?? null);\n\n\treturn store.transaction(async (tx) => {\n\t\tawait tx.query(lockSql, [opts.key, opts.scope ?? '']);\n\n\t\tconst [cur] = await tx.query<{ version: number }>(\n\t\t\t`SELECT version FROM ${r.table} WHERE ${keyMatch(r)} FOR UPDATE`,\n\t\t\t[opts.key, opts.scope],\n\t\t);\n\t\tconst currentVersion = cur?.version ?? null;\n\t\tif (opts.ifVersion !== undefined && currentVersion !== opts.ifVersion) {\n\t\t\tthrow new VersionConflictError(opts.key, opts.scope, currentVersion, opts.ifVersion);\n\t\t}\n\t\tconst version = (currentVersion ?? 0) + 1;\n\t\tconst writeVals = [opts.key, opts.scope, ...contentVals, ...metaVals, version, opts.actor];\n\n\t\tlet row: T | undefined;\n\t\tif (cur) {\n\t\t\tconst sets = [\n\t\t\t\t...content.map((c, i) => `${c} = $${3 + i}`),\n\t\t\t\t...meta.map((c, j) => `${c} = $${3 + content.length + j}`),\n\t\t\t\t`version = $${3 + content.length + meta.length}`,\n\t\t\t\t`updated_by = $${4 + content.length + meta.length}`,\n\t\t\t\t`updated_at = now()`,\n\t\t\t];\n\t\t\t[row] = await tx.query<T>(\n\t\t\t\t`UPDATE ${r.table} SET ${sets.join(', ')} WHERE ${keyMatch(r)} RETURNING ${r.returning}`,\n\t\t\t\twriteVals,\n\t\t\t);\n\t\t} else {\n\t\t\tconst cols = [idCol, scopeCol, ...content, ...meta, 'version', 'updated_by'];\n\t\t\tconst ph = cols.map((_, i) => `$${i + 1}`);\n\t\t\t[row] = await tx.query<T>(\n\t\t\t\t`INSERT INTO ${r.table} (${cols.join(', ')}) VALUES (${ph.join(', ')}) RETURNING ${r.returning}`,\n\t\t\t\twriteVals,\n\t\t\t);\n\t\t}\n\n\t\tconst revCols = [idCol, scopeCol, ...content, 'version', 'actor'];\n\t\tconst revPh = revCols.map((_, i) => `$${i + 1}`);\n\t\tawait tx.query(\n\t\t\t`INSERT INTO ${r.revisions} (${revCols.join(', ')}) VALUES (${revPh.join(', ')})`,\n\t\t\t[opts.key, opts.scope, ...contentVals, version, opts.actor],\n\t\t);\n\t\tawait tx.query(`SELECT pg_notify('${r.channel}', $1)`, [opts.scope ?? '']);\n\n\t\tif (!row) throw new Error(`Failed to write ${r.table} entry`);\n\t\treturn row;\n\t});\n}\n\n// Roll *forward* to a past revision's content as a new version (rollout undo —\n// history is never rewritten).\nexport async function versionedRollback<T>(\n\tr: IVersionedResource,\n\tstore: IStoreAdapter,\n\topts: { key: string; scope: string | null; toVersion: number; actor: string | null },\n): Promise<T> {\n\tconst [idCol, scopeCol] = r.keyColumns;\n\tconst content = r.contentColumns;\n\n\treturn store.transaction(async (tx) => {\n\t\tawait tx.query(lockSql, [opts.key, opts.scope ?? '']);\n\n\t\tconst [target] = await tx.query<Record<string, unknown>>(\n\t\t\t`SELECT ${content.join(', ')} FROM ${r.revisions} WHERE ${keyMatch(r)} AND version = $3`,\n\t\t\t[opts.key, opts.scope, opts.toVersion],\n\t\t);\n\t\tif (!target) {\n\t\t\tthrow new Error(`\"${opts.key}\" (${opts.scope ?? 'base'}) has no revision ${opts.toVersion}`);\n\t\t}\n\t\tconst [cur] = await tx.query<{ version: number }>(\n\t\t\t`SELECT version FROM ${r.table} WHERE ${keyMatch(r)} FOR UPDATE`,\n\t\t\t[opts.key, opts.scope],\n\t\t);\n\t\tif (!cur) throw new Error(`\"${opts.key}\" (${opts.scope ?? 'base'}) does not exist`);\n\t\tconst version = cur.version + 1;\n\t\tconst contentVals = content.map((c) => target[c] ?? null);\n\t\tconst writeVals = [opts.key, opts.scope, ...contentVals, version, opts.actor];\n\n\t\tconst sets = [\n\t\t\t...content.map((c, i) => `${c} = $${3 + i}`),\n\t\t\t`version = $${3 + content.length}`,\n\t\t\t`updated_by = $${4 + content.length}`,\n\t\t\t`updated_at = now()`,\n\t\t];\n\t\tconst [row] = await tx.query<T>(\n\t\t\t`UPDATE ${r.table} SET ${sets.join(', ')} WHERE ${keyMatch(r)} RETURNING ${r.returning}`,\n\t\t\twriteVals,\n\t\t);\n\n\t\tconst revCols = [idCol, scopeCol, ...content, 'version', 'actor'];\n\t\tconst revPh = revCols.map((_, i) => `$${i + 1}`);\n\t\tawait tx.query(\n\t\t\t`INSERT INTO ${r.revisions} (${revCols.join(', ')}) VALUES (${revPh.join(', ')})`,\n\t\t\twriteVals.slice(0, 2 + content.length).concat(version, opts.actor),\n\t\t);\n\t\tawait tx.query(`SELECT pg_notify('${r.channel}', $1)`, [opts.scope ?? '']);\n\n\t\tif (!row) throw new Error('rollback failed');\n\t\treturn row;\n\t});\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACYO,SAAS,IAAI,YAAkC,QAA8B;AACnF,MAAI,OAAO;AACX,QAAM,SAAoB,CAAC;AAE3B,UAAQ,QAAQ,CAAC,KAAK,MAAM;AAC3B,YAAQ;AACR,QAAI,IAAI,OAAO,QAAQ;AACtB,aAAO,KAAK,OAAO,CAAC,CAAC;AACrB,cAAQ,IAAI,OAAO,MAAM;AAAA,IAC1B;AAAA,EACD,CAAC;AAED,SAAO,EAAE,MAAM,OAAO;AACvB;;;ACzBA,uBAAqB;AACrB,sBAAkC;AAIlC,IAAM,mBAAmB;AACzB,IAAM,qBAAqB;AAEpB,IAAM,kBAAN,MAAsB;AAAA,EAC5B,YACS,OACA,eACP;AAFO;AACA;AAAA,EACN;AAAA,EAFM;AAAA,EACA;AAAA,EAGT,MAAM,MAAqB;AAC1B,UAAM,KAAK,YAAY;AAEvB,UAAM,CAAC,SAAS,KAAK,IAAI,MAAM,QAAQ,IAAI,CAAC,KAAK,WAAW,GAAG,KAAK,SAAS,CAAC,CAAC;AAE/E,UAAM,UAAU,MAAM,OAAO,CAAC,MAAM,CAAC,QAAQ,IAAI,CAAC,CAAC;AAEnD,QAAI,QAAQ,WAAW,GAAG;AACzB,cAAQ,IAAI,gCAAgC;AAC5C;AAAA,IACD;AAEA,eAAW,QAAQ,SAAS;AAC3B,YAAMA,OAAM,UAAM,8BAAS,uBAAK,KAAK,eAAe,IAAI,GAAG,MAAM;AAEjE,WAAK,uBAAuB,MAAMA,IAAG;AAErC,YAAM,KAAK,MAAM,YAAY,OAAO,OAAO;AAC1C,cAAM,GAAG,MAAMA,IAAG;AAClB,cAAM,GAAG,MAAM,eAAe,gBAAgB,0CAA0C;AAAA,UACvF;AAAA,QACD,CAAC;AAAA,MACF,CAAC;AAED,cAAQ,IAAI,+BAA+B,IAAI,EAAE;AAAA,IAClD;AAAA,EACD;AAAA,EAEU,uBAAuB,MAAcA,MAAmB;AACjE,QAAI,mBAAmB,KAAKA,IAAG,GAAG;AACjC,YAAM,IAAI;AAAA,QACT,sBAAsB,IAAI;AAAA,MAE3B;AAAA,IACD;AAAA,EACD;AAAA,EAEA,MAAc,cAA6B;AAC1C,UAAM,KAAK,MAAM,MAAM;AAAA,gCACO,gBAAgB;AAAA;AAAA;AAAA;AAAA,GAI7C;AAAA,EACF;AAAA,EAEA,MAAc,aAAmC;AAChD,UAAM,OAAO,MAAM,KAAK,MAAM;AAAA,MAC7B,oBAAoB,gBAAgB;AAAA,IACrC;AACA,WAAO,IAAI,IAAI,KAAK,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC;AAAA,EACvC;AAAA,EAEA,MAAc,WAA8B;AAC3C,UAAM,MAAM,UAAM,yBAAQ,KAAK,aAAa;AAC5C,WAAO,IAAI,OAAO,CAAC,MAAM,EAAE,SAAS,MAAM,CAAC,EAAE,KAAK;AAAA,EACnD;AACD;AAGO,IAAM,0BAAN,cAAsC,gBAAgB;AAAA,EACzC,uBAAuB,OAAe,MAAoB;AAAA,EAAC;AAC/E;;;AC5EA,sBAA8B;AAC9B,IAAAC,oBAA8B;AAQvB,SAAS,qBAAqB,eAA+B;AACnE,aAAO,4BAAK,+BAAQ,+BAAc,aAAa,CAAC,GAAG,KAAK;AACzD;;;ACXA,gBAAe;AAGR,IAAM,YAAN,MAAyC;AAAA,EACvC;AAAA,EAER,YAAY,QAA8B;AACzC,UAAM,UAAU,OAAO,WAAW,WAAW,EAAE,kBAAkB,OAAO,IAAI;AAC5E,SAAK,OAAO,IAAI,UAAAC,QAAG,KAAK,OAAO;AAE/B,SAAK,KAAK,GAAG,SAAS,CAAC,QAAQ;AAC9B,cAAQ,MAAM,6BAA6B,IAAI,OAAO;AAAA,IACvD,CAAC;AAAA,EACF;AAAA,EAEA,MAAM,iBAAmC;AACxC,QAAI;AACH,YAAM,KAAK,KAAK,MAAM,UAAU;AAChC,aAAO;AAAA,IACR,QAAQ;AACP,aAAO;AAAA,IACR;AAAA,EACD;AAAA,EAEA,MAAM,MAAmBC,MAAa,QAAkC;AACvE,UAAM,SAAS,MAAM,KAAK,KAAK,MAAMA,MAAK,MAAM;AAChD,WAAO,OAAO;AAAA,EACf;AAAA,EAEA,MAAM,YAAe,IAAmD;AACvE,UAAM,SAAS,MAAM,KAAK,KAAK,QAAQ;AAEvC,QAAI;AACH,YAAM,OAAO,MAAM,OAAO;AAE1B,YAAM,KAAoB;AAAA,QACzB,OAAO,OAAoBA,MAAa,WAAuB;AAC9D,gBAAMC,UAAS,MAAM,OAAO,MAAMD,MAAK,MAAM;AAC7C,iBAAOC,QAAO;AAAA,QACf;AAAA,QACA,aAAa,CAAI,WAA8C,OAAO,EAAE;AAAA,MACzE;AAEA,YAAM,SAAS,MAAM,GAAG,EAAE;AAC1B,YAAM,OAAO,MAAM,QAAQ;AAC3B,aAAO;AAAA,IACR,SAAS,KAAK;AACb,YAAM,OAAO,MAAM,UAAU;AAC7B,YAAM;AAAA,IACP,UAAE;AACD,aAAO,QAAQ;AAAA,IAChB;AAAA,EACD;AAAA,EAEA,MAAM,MAAqB;AAC1B,UAAM,KAAK,KAAK,IAAI;AAAA,EACrB;AACD;;;AC9CO,IAAM,uBAAN,cAAmC,MAAM;AAAA,EAC/C,YACiB,KACA,OACA,gBACA,iBACf;AACD;AAAA,MACC,IAAI,GAAG,MAAM,SAAS,MAAM,mBAAmB,kBAAkB,MAAM,SAC/D,eAAe;AAAA,IACxB;AARgB;AACA;AACA;AACA;AAMhB,SAAK,OAAO;AAAA,EACb;AAAA,EAViB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAQlB;AAwBA,IAAM,UAAU;AAEhB,SAAS,SAAS,GAA+B;AAChD,QAAM,CAAC,IAAI,KAAK,IAAI,EAAE;AACtB,SAAO,GAAG,EAAE,gCAAgC,KAAK;AAClD;AAMA,eAAsB,eACrB,GACA,OACA,MAOa;AACb,QAAM,CAAC,OAAO,QAAQ,IAAI,EAAE;AAC5B,QAAM,UAAU,EAAE;AAClB,QAAM,QAAQ,EAAE,eAAe,CAAC,GAAG,OAAO,CAAC,MAAM,KAAK,KAAK,IAAI;AAC/D,QAAM,cAAc,QAAQ,IAAI,CAAC,MAAM,KAAK,KAAK,CAAC,KAAK,IAAI;AAC3D,QAAM,WAAW,KAAK,IAAI,CAAC,MAAM,KAAK,KAAK,CAAC,KAAK,IAAI;AAErD,SAAO,MAAM,YAAY,OAAO,OAAO;AACtC,UAAM,GAAG,MAAM,SAAS,CAAC,KAAK,KAAK,KAAK,SAAS,EAAE,CAAC;AAEpD,UAAM,CAAC,GAAG,IAAI,MAAM,GAAG;AAAA,MACtB,uBAAuB,EAAE,KAAK,UAAU,SAAS,CAAC,CAAC;AAAA,MACnD,CAAC,KAAK,KAAK,KAAK,KAAK;AAAA,IACtB;AACA,UAAM,iBAAiB,KAAK,WAAW;AACvC,QAAI,KAAK,cAAc,UAAa,mBAAmB,KAAK,WAAW;AACtE,YAAM,IAAI,qBAAqB,KAAK,KAAK,KAAK,OAAO,gBAAgB,KAAK,SAAS;AAAA,IACpF;AACA,UAAM,WAAW,kBAAkB,KAAK;AACxC,UAAM,YAAY,CAAC,KAAK,KAAK,KAAK,OAAO,GAAG,aAAa,GAAG,UAAU,SAAS,KAAK,KAAK;AAEzF,QAAI;AACJ,QAAI,KAAK;AACR,YAAM,OAAO;AAAA,QACZ,GAAG,QAAQ,IAAI,CAAC,GAAG,MAAM,GAAG,CAAC,OAAO,IAAI,CAAC,EAAE;AAAA,QAC3C,GAAG,KAAK,IAAI,CAAC,GAAG,MAAM,GAAG,CAAC,OAAO,IAAI,QAAQ,SAAS,CAAC,EAAE;AAAA,QACzD,cAAc,IAAI,QAAQ,SAAS,KAAK,MAAM;AAAA,QAC9C,iBAAiB,IAAI,QAAQ,SAAS,KAAK,MAAM;AAAA,QACjD;AAAA,MACD;AACA,OAAC,GAAG,IAAI,MAAM,GAAG;AAAA,QAChB,UAAU,EAAE,KAAK,QAAQ,KAAK,KAAK,IAAI,CAAC,UAAU,SAAS,CAAC,CAAC,cAAc,EAAE,SAAS;AAAA,QACtF;AAAA,MACD;AAAA,IACD,OAAO;AACN,YAAM,OAAO,CAAC,OAAO,UAAU,GAAG,SAAS,GAAG,MAAM,WAAW,YAAY;AAC3E,YAAM,KAAK,KAAK,IAAI,CAAC,GAAG,MAAM,IAAI,IAAI,CAAC,EAAE;AACzC,OAAC,GAAG,IAAI,MAAM,GAAG;AAAA,QAChB,eAAe,EAAE,KAAK,KAAK,KAAK,KAAK,IAAI,CAAC,aAAa,GAAG,KAAK,IAAI,CAAC,eAAe,EAAE,SAAS;AAAA,QAC9F;AAAA,MACD;AAAA,IACD;AAEA,UAAM,UAAU,CAAC,OAAO,UAAU,GAAG,SAAS,WAAW,OAAO;AAChE,UAAM,QAAQ,QAAQ,IAAI,CAAC,GAAG,MAAM,IAAI,IAAI,CAAC,EAAE;AAC/C,UAAM,GAAG;AAAA,MACR,eAAe,EAAE,SAAS,KAAK,QAAQ,KAAK,IAAI,CAAC,aAAa,MAAM,KAAK,IAAI,CAAC;AAAA,MAC9E,CAAC,KAAK,KAAK,KAAK,OAAO,GAAG,aAAa,SAAS,KAAK,KAAK;AAAA,IAC3D;AACA,UAAM,GAAG,MAAM,qBAAqB,EAAE,OAAO,UAAU,CAAC,KAAK,SAAS,EAAE,CAAC;AAEzE,QAAI,CAAC,IAAK,OAAM,IAAI,MAAM,mBAAmB,EAAE,KAAK,QAAQ;AAC5D,WAAO;AAAA,EACR,CAAC;AACF;AAIA,eAAsB,kBACrB,GACA,OACA,MACa;AACb,QAAM,CAAC,OAAO,QAAQ,IAAI,EAAE;AAC5B,QAAM,UAAU,EAAE;AAElB,SAAO,MAAM,YAAY,OAAO,OAAO;AACtC,UAAM,GAAG,MAAM,SAAS,CAAC,KAAK,KAAK,KAAK,SAAS,EAAE,CAAC;AAEpD,UAAM,CAAC,MAAM,IAAI,MAAM,GAAG;AAAA,MACzB,UAAU,QAAQ,KAAK,IAAI,CAAC,SAAS,EAAE,SAAS,UAAU,SAAS,CAAC,CAAC;AAAA,MACrE,CAAC,KAAK,KAAK,KAAK,OAAO,KAAK,SAAS;AAAA,IACtC;AACA,QAAI,CAAC,QAAQ;AACZ,YAAM,IAAI,MAAM,IAAI,KAAK,GAAG,MAAM,KAAK,SAAS,MAAM,qBAAqB,KAAK,SAAS,EAAE;AAAA,IAC5F;AACA,UAAM,CAAC,GAAG,IAAI,MAAM,GAAG;AAAA,MACtB,uBAAuB,EAAE,KAAK,UAAU,SAAS,CAAC,CAAC;AAAA,MACnD,CAAC,KAAK,KAAK,KAAK,KAAK;AAAA,IACtB;AACA,QAAI,CAAC,IAAK,OAAM,IAAI,MAAM,IAAI,KAAK,GAAG,MAAM,KAAK,SAAS,MAAM,kBAAkB;AAClF,UAAM,UAAU,IAAI,UAAU;AAC9B,UAAM,cAAc,QAAQ,IAAI,CAAC,MAAM,OAAO,CAAC,KAAK,IAAI;AACxD,UAAM,YAAY,CAAC,KAAK,KAAK,KAAK,OAAO,GAAG,aAAa,SAAS,KAAK,KAAK;AAE5E,UAAM,OAAO;AAAA,MACZ,GAAG,QAAQ,IAAI,CAAC,GAAG,MAAM,GAAG,CAAC,OAAO,IAAI,CAAC,EAAE;AAAA,MAC3C,cAAc,IAAI,QAAQ,MAAM;AAAA,MAChC,iBAAiB,IAAI,QAAQ,MAAM;AAAA,MACnC;AAAA,IACD;AACA,UAAM,CAAC,GAAG,IAAI,MAAM,GAAG;AAAA,MACtB,UAAU,EAAE,KAAK,QAAQ,KAAK,KAAK,IAAI,CAAC,UAAU,SAAS,CAAC,CAAC,cAAc,EAAE,SAAS;AAAA,MACtF;AAAA,IACD;AAEA,UAAM,UAAU,CAAC,OAAO,UAAU,GAAG,SAAS,WAAW,OAAO;AAChE,UAAM,QAAQ,QAAQ,IAAI,CAAC,GAAG,MAAM,IAAI,IAAI,CAAC,EAAE;AAC/C,UAAM,GAAG;AAAA,MACR,eAAe,EAAE,SAAS,KAAK,QAAQ,KAAK,IAAI,CAAC,aAAa,MAAM,KAAK,IAAI,CAAC;AAAA,MAC9E,UAAU,MAAM,GAAG,IAAI,QAAQ,MAAM,EAAE,OAAO,SAAS,KAAK,KAAK;AAAA,IAClE;AACA,UAAM,GAAG,MAAM,qBAAqB,EAAE,OAAO,UAAU,CAAC,KAAK,SAAS,EAAE,CAAC;AAEzE,QAAI,CAAC,IAAK,OAAM,IAAI,MAAM,iBAAiB;AAC3C,WAAO;AAAA,EACR,CAAC;AACF;","names":["sql","import_node_path","pg","sql","result"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/sql.ts","../src/migrations/runner.ts","../src/migrations/path.ts","../src/adapters/pg.ts","../src/versioned.ts"],"sourcesContent":["// ── Public API ───────────────────────────────────────────────────\nexport { sql } from './sql';\nexport type { ISqlQuery } from './sql';\nexport type { IStoreAdapter, IPoolConfig } from './types';\nexport { MigrationRunner, InternalMigrationRunner, createMigrationsPath } from './migrations';\nexport { PGAdapter, assertProductionDbConfig } from './adapters/pg';\n\n// Versioned-resource control-plane primitive — version index + optimistic\n// concurrency + advisory-locked writes + revisions + rollback + push-notify.\nexport { versionedWrite, versionedRollback, VersionConflictError } from './versioned';\nexport type { IVersionedResource } from './versioned';\n","export interface ISqlQuery {\n\ttext: string;\n\tparams: unknown[];\n}\n\n// Tagged template literal for safe parameterized queries.\n// Never concatenates user input — always uses $N placeholders.\n//\n// Usage:\n// const { text, params } = sql`SELECT * FROM users WHERE id = ${userId}`\n// const rows = await store.query<User>(text, params)\n\nexport function sql(strings: TemplateStringsArray, ...values: unknown[]): ISqlQuery {\n\tlet text = '';\n\tconst params: unknown[] = [];\n\n\tstrings.forEach((str, i) => {\n\t\ttext += str;\n\t\tif (i < values.length) {\n\t\t\tparams.push(values[i]);\n\t\t\ttext += `$${params.length}`;\n\t\t}\n\t});\n\n\treturn { text, params };\n}\n","import { join } from 'node:path';\nimport { readdir, readFile } from 'node:fs/promises';\n\nimport type { IStoreAdapter } from '../types';\n\nconst MIGRATIONS_TABLE = 'fonderie_migrations';\nconst RESERVED_PREFIX_RE = /\\bfonderie_/i;\n\nexport class MigrationRunner {\n\tconstructor(\n\t\tprivate store: IStoreAdapter,\n\t\tprivate migrationsDir: string,\n\t) {}\n\n\tasync run(): Promise<void> {\n\t\tawait this.ensureTable();\n\n\t\tconst [applied, files] = await Promise.all([this.getApplied(), this.getFiles()]);\n\n\t\tconst pending = files.filter((f) => !applied.has(f));\n\n\t\tif (pending.length === 0) {\n\t\t\tconsole.log('[store] migrations: up to date');\n\t\t\treturn;\n\t\t}\n\n\t\tfor (const file of pending) {\n\t\t\tconst sql = await readFile(join(this.migrationsDir, file), 'utf8');\n\n\t\t\tthis.assertNoReservedPrefix(file, sql);\n\n\t\t\tawait this.store.transaction(async (tx) => {\n\t\t\t\t// Cross-process serialization: several instances booting at once all\n\t\t\t\t// see the same pending list. The advisory xact-lock makes appliers\n\t\t\t\t// queue, and the in-lock recheck turns the loser's attempt into a\n\t\t\t\t// no-op instead of a duplicate DDL failure (or worse, a partial\n\t\t\t\t// double-application on non-idempotent SQL).\n\t\t\t\tawait tx.query(`SELECT pg_advisory_xact_lock(hashtext('${MIGRATIONS_TABLE}'))`);\n\t\t\t\tconst already = await tx.query<{ name: string }>(\n\t\t\t\t\t`SELECT name FROM ${MIGRATIONS_TABLE} WHERE name = $1`,\n\t\t\t\t\t[file],\n\t\t\t\t);\n\t\t\t\tif (already.length > 0) return;\n\n\t\t\t\tawait tx.query(sql);\n\t\t\t\tawait tx.query(`INSERT INTO ${MIGRATIONS_TABLE} (name, applied_at) VALUES ($1, now())`, [\n\t\t\t\t\tfile,\n\t\t\t\t]);\n\t\t\t});\n\n\t\t\tconsole.log(`[store] migrations: applied ${file}`);\n\t\t}\n\t}\n\n\tprotected assertNoReservedPrefix(file: string, sql: string): void {\n\t\tif (RESERVED_PREFIX_RE.test(sql)) {\n\t\t\tthrow new Error(\n\t\t\t\t`[store] migration \"${file}\" uses the reserved \"fonderie_\" prefix. ` +\n\t\t\t\t`Use InternalMigrationRunner for fonderie-owned migrations.`,\n\t\t\t);\n\t\t}\n\t}\n\n\tprivate async ensureTable(): Promise<void> {\n\t\tawait this.store.query(`\n\t\t\tCREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (\n\t\t\t name TEXT PRIMARY KEY,\n\t\t\t applied_at TIMESTAMPTZ NOT NULL DEFAULT now()\n\t\t\t)\n\t\t`);\n\t}\n\n\tprivate async getApplied(): Promise<Set<string>> {\n\t\tconst rows = await this.store.query<{ name: string }>(\n\t\t\t`SELECT name FROM ${MIGRATIONS_TABLE} ORDER BY name`,\n\t\t);\n\t\treturn new Set(rows.map((r) => r.name));\n\t}\n\n\tprivate async getFiles(): Promise<string[]> {\n\t\tconst all = await readdir(this.migrationsDir);\n\t\treturn all.filter((f) => f.endsWith('.sql')).sort(); // lexicographic — timestamp prefix keeps order correct\n\t}\n}\n\n// For fonderie-internal use only. Skips the reserved-prefix guard.\nexport class InternalMigrationRunner extends MigrationRunner {\n\tprotected override assertNoReservedPrefix(_file: string, _sql: string): void {}\n}\n","import { fileURLToPath } from 'node:url';\nimport { dirname, join } from 'node:path';\n\n// Each package calls this with its own import.meta.url to resolve\n// the absolute path to its compiled migrations/sql/ directory.\n// Called from dist/migrations/index.js, so dirname is already dist/migrations/.\n//\n// Usage in any package's migrations/index.ts:\n// export const getMigrationsPath = () => createMigrationsPath(import.meta.url)\nexport function createMigrationsPath(importMetaUrl: string): string {\n\treturn join(dirname(fileURLToPath(importMetaUrl)), 'sql');\n}\n","import pg from 'pg';\nimport type { IStoreAdapter, IPoolConfig } from '../types';\n\n// Fail-closed check on the database connection, run at pool construction. In\n// production a misconfigured DB is a security problem, not just an availability\n// one (plaintext transport, shared default credentials). The only fatal case is\n// an explicitly-empty connection string — a blank env var silently falling back\n// to pg's localhost defaults is almost never intended in production. The rest\n// are loud warnings so we don't break legitimate setups (unix sockets, PG* env\n// vars, trusted local networks). Outside production this is a no-op.\nexport function assertProductionDbConfig(options: IPoolConfig): void {\n\tif (process.env['NODE_ENV'] !== 'production') return;\n\n\tconst url = options.connectionString;\n\n\tif (url !== undefined && url.trim() === '') {\n\t\tthrow new Error(\n\t\t\t'[store] connectionString is empty in production — refusing to boot. ' +\n\t\t\t\t'Set a real DATABASE_URL, or omit it entirely to use PG* environment variables.',\n\t\t);\n\t}\n\n\tif (url) {\n\t\tif (/[?&]sslmode=disable\\b/i.test(url) || /[?&]ssl=false\\b/i.test(url)) {\n\t\t\tthrow new Error(\n\t\t\t\t'[store] database TLS is disabled (sslmode=disable) in production — traffic to ' +\n\t\t\t\t\t'Postgres would be unencrypted. Use sslmode=require (or stronger), or set ' +\n\t\t\t\t\t'NODE_ENV appropriately for non-production.',\n\t\t\t);\n\t\t}\n\t\tif (/:\\/\\/postgres:postgres@/i.test(url) || /:\\/\\/postgres:password@/i.test(url)) {\n\t\t\tconsole.warn('[store] database is using well-known default credentials in production');\n\t\t}\n\t}\n\n\t// The same explicit-disable rule for the CONFIG-OBJECT form — previously\n\t// only the connection-string form was checked, so `{ host, ssl: false }`\n\t// sailed through the production TLS gate.\n\tif (options.ssl === false) {\n\t\tthrow new Error(\n\t\t\t'[store] database TLS is disabled (ssl: false) in production — traffic to ' +\n\t\t\t\t'Postgres would be unencrypted. Enable ssl, or set NODE_ENV appropriately ' +\n\t\t\t\t'for non-production.',\n\t\t);\n\t}\n}\n\nexport class PGAdapter implements IStoreAdapter {\n\tprivate pool: pg.Pool;\n\n\tconstructor(config: IPoolConfig | string) {\n\t\tconst options = typeof config === 'string' ? { connectionString: config } : config;\n\t\tassertProductionDbConfig(options);\n\t\tthis.pool = new pg.Pool(options);\n\n\t\tthis.pool.on('error', (err) => {\n\t\t\tconsole.error('[store] idle client error', err.message);\n\t\t});\n\t}\n\n\tasync testConnection(): Promise<boolean> {\n\t\ttry {\n\t\t\tawait this.pool.query('SELECT 1');\n\t\t\treturn true;\n\t\t} catch {\n\t\t\treturn false;\n\t\t}\n\t}\n\n\tasync query<T = unknown>(sql: string, params?: unknown[]): Promise<T[]> {\n\t\tconst result = await this.pool.query(sql, params);\n\t\treturn result.rows as T[];\n\t}\n\n\tasync transaction<T>(fn: (tx: IStoreAdapter) => Promise<T>): Promise<T> {\n\t\tconst client = await this.pool.connect();\n\n\t\ttry {\n\t\t\tawait client.query('BEGIN');\n\n\t\t\tconst tx: IStoreAdapter = {\n\t\t\t\tquery: async <U = unknown>(sql: string, params?: unknown[]) => {\n\t\t\t\t\tconst result = await client.query(sql, params);\n\t\t\t\t\treturn result.rows as U[];\n\t\t\t\t},\n\t\t\t\ttransaction: <V>(nested: (tx: IStoreAdapter) => Promise<V>) => nested(tx),\n\t\t\t};\n\n\t\t\tconst result = await fn(tx);\n\t\t\tawait client.query('COMMIT');\n\t\t\treturn result;\n\t\t} catch (err) {\n\t\t\tawait client.query('ROLLBACK');\n\t\t\tthrow err;\n\t\t} finally {\n\t\t\tclient.release();\n\t\t}\n\t}\n\n\tasync end(): Promise<void> {\n\t\tawait this.pool.end();\n\t}\n}\n","import type { IStoreAdapter } from './types';\n\n// The control-plane primitive: a version index + optimistic concurrency +\n// advisory-locked writes + append-only revisions + push-notify, reusable by any\n// resource identified by a (primary, scope) key pair. `@fonderie/config` (config\n// + secrets) and `@fonderie/courier` (email templates) run on it; a new resource\n// is a descriptor away. Lives in `store` because it's pure Postgres machinery\n// and every package already depends on store (no cycles).\n\n// Thrown when an optimistic-concurrency write loses the compare-and-swap: the\n// row's current version isn't the one the caller wrote against. Reject-and-retry.\nexport class VersionConflictError extends Error {\n\tconstructor(\n\t\tpublic readonly key: string,\n\t\tpublic readonly scope: string | null,\n\t\tpublic readonly currentVersion: number | null,\n\t\tpublic readonly expectedVersion: number,\n\t) {\n\t\tsuper(\n\t\t\t`\"${key}\" (${scope ?? 'base'}) is at version ${currentVersion ?? 'none'}, ` +\n\t\t\t\t`not ${expectedVersion} — reload and retry`,\n\t\t);\n\t\tthis.name = 'VersionConflictError';\n\t}\n}\n\n// A versioned resource's Postgres surface.\nexport interface IVersionedResource {\n\ttable: string; // main table, e.g. 'fonderie_config'\n\trevisions: string; // history table, e.g. 'fonderie_config_revisions'\n\tchannel: string; // LISTEN/NOTIFY channel, e.g. 'fonderie_config_changed'\n\t// The (primary, scope) key pair — config: ['key','environment'], courier:\n\t// ['type','locale']. `scope` is null-safe (a NULL locale is the base).\n\t// Intentionally a fixed 2-tuple: every resource is addressed by exactly one\n\t// primary key plus one optional scope. Composite 3+-part keys are out of\n\t// scope by design — model the extra dimension inside the primary key (e.g.\n\t// a compound string) or the scope rather than widening this contract.\n\tkeyColumns: readonly [string, string];\n\t// Content columns — written AND snapshotted into revisions (config: ['value'];\n\t// courier: ['subject','html','text']).\n\tcontentColumns: readonly string[];\n\t// Extra main-table columns, set only when supplied, never revisioned (config:\n\t// ['description','active']; courier: ['active']).\n\tmetaColumns?: readonly string[];\n\t// SELECT/RETURNING column list shaping the caller's row type.\n\treturning: string;\n}\n\nconst lockSql = `SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))`;\n\nfunction keyMatch(r: IVersionedResource): string {\n\tconst [id, scope] = r.keyColumns;\n\treturn `${id} IS NOT DISTINCT FROM $1 AND ${scope} IS NOT DISTINCT FROM $2`;\n}\n\n// Write one versioned entry: advisory-lock the (key, scope) pair (serializes even\n// a create), enforce optimistic concurrency when `ifVersion` is given, bump the\n// version, append a revision (content columns only), and broadcast invalidation\n// on commit. `data` supplies every content column and any meta columns to set.\nexport async function versionedWrite<T>(\n\tr: IVersionedResource,\n\tstore: IStoreAdapter,\n\topts: {\n\t\tkey: string;\n\t\tscope: string | null;\n\t\tdata: Record<string, unknown>;\n\t\tifVersion?: number;\n\t\tactor: string | null;\n\t},\n): Promise<T> {\n\tconst [idCol, scopeCol] = r.keyColumns;\n\tconst content = r.contentColumns;\n\tconst meta = (r.metaColumns ?? []).filter((c) => c in opts.data);\n\tconst contentVals = content.map((c) => opts.data[c] ?? null);\n\tconst metaVals = meta.map((c) => opts.data[c] ?? null);\n\n\treturn store.transaction(async (tx) => {\n\t\tawait tx.query(lockSql, [opts.key, opts.scope ?? '']);\n\n\t\tconst [cur] = await tx.query<{ version: number }>(\n\t\t\t`SELECT version FROM ${r.table} WHERE ${keyMatch(r)} FOR UPDATE`,\n\t\t\t[opts.key, opts.scope],\n\t\t);\n\t\tconst currentVersion = cur?.version ?? null;\n\t\tif (opts.ifVersion !== undefined && currentVersion !== opts.ifVersion) {\n\t\t\tthrow new VersionConflictError(opts.key, opts.scope, currentVersion, opts.ifVersion);\n\t\t}\n\t\tconst version = (currentVersion ?? 0) + 1;\n\t\tconst writeVals = [opts.key, opts.scope, ...contentVals, ...metaVals, version, opts.actor];\n\n\t\tlet row: T | undefined;\n\t\tif (cur) {\n\t\t\tconst sets = [\n\t\t\t\t...content.map((c, i) => `${c} = $${3 + i}`),\n\t\t\t\t...meta.map((c, j) => `${c} = $${3 + content.length + j}`),\n\t\t\t\t`version = $${3 + content.length + meta.length}`,\n\t\t\t\t`updated_by = $${4 + content.length + meta.length}`,\n\t\t\t\t`updated_at = now()`,\n\t\t\t];\n\t\t\t[row] = await tx.query<T>(\n\t\t\t\t`UPDATE ${r.table} SET ${sets.join(', ')} WHERE ${keyMatch(r)} RETURNING ${r.returning}`,\n\t\t\t\twriteVals,\n\t\t\t);\n\t\t} else {\n\t\t\tconst cols = [idCol, scopeCol, ...content, ...meta, 'version', 'updated_by'];\n\t\t\tconst ph = cols.map((_, i) => `$${i + 1}`);\n\t\t\t[row] = await tx.query<T>(\n\t\t\t\t`INSERT INTO ${r.table} (${cols.join(', ')}) VALUES (${ph.join(', ')}) RETURNING ${r.returning}`,\n\t\t\t\twriteVals,\n\t\t\t);\n\t\t}\n\n\t\tconst revCols = [idCol, scopeCol, ...content, 'version', 'actor'];\n\t\tconst revPh = revCols.map((_, i) => `$${i + 1}`);\n\t\tawait tx.query(\n\t\t\t`INSERT INTO ${r.revisions} (${revCols.join(', ')}) VALUES (${revPh.join(', ')})`,\n\t\t\t[opts.key, opts.scope, ...contentVals, version, opts.actor],\n\t\t);\n\t\tawait tx.query(`SELECT pg_notify('${r.channel}', $1)`, [opts.scope ?? '']);\n\n\t\tif (!row) throw new Error(`Failed to write ${r.table} entry`);\n\t\treturn row;\n\t});\n}\n\n// Roll *forward* to a past revision's content as a new version (rollout undo —\n// history is never rewritten).\nexport async function versionedRollback<T>(\n\tr: IVersionedResource,\n\tstore: IStoreAdapter,\n\topts: { key: string; scope: string | null; toVersion: number; actor: string | null },\n): Promise<T> {\n\tconst [idCol, scopeCol] = r.keyColumns;\n\tconst content = r.contentColumns;\n\n\treturn store.transaction(async (tx) => {\n\t\tawait tx.query(lockSql, [opts.key, opts.scope ?? '']);\n\n\t\tconst [target] = await tx.query<Record<string, unknown>>(\n\t\t\t`SELECT ${content.join(', ')} FROM ${r.revisions} WHERE ${keyMatch(r)} AND version = $3`,\n\t\t\t[opts.key, opts.scope, opts.toVersion],\n\t\t);\n\t\tif (!target) {\n\t\t\tthrow new Error(`\"${opts.key}\" (${opts.scope ?? 'base'}) has no revision ${opts.toVersion}`);\n\t\t}\n\t\tconst [cur] = await tx.query<{ version: number }>(\n\t\t\t`SELECT version FROM ${r.table} WHERE ${keyMatch(r)} FOR UPDATE`,\n\t\t\t[opts.key, opts.scope],\n\t\t);\n\t\tif (!cur) throw new Error(`\"${opts.key}\" (${opts.scope ?? 'base'}) does not exist`);\n\t\tconst version = cur.version + 1;\n\t\tconst contentVals = content.map((c) => target[c] ?? null);\n\t\tconst writeVals = [opts.key, opts.scope, ...contentVals, version, opts.actor];\n\n\t\tconst sets = [\n\t\t\t...content.map((c, i) => `${c} = $${3 + i}`),\n\t\t\t`version = $${3 + content.length}`,\n\t\t\t`updated_by = $${4 + content.length}`,\n\t\t\t`updated_at = now()`,\n\t\t];\n\t\tconst [row] = await tx.query<T>(\n\t\t\t`UPDATE ${r.table} SET ${sets.join(', ')} WHERE ${keyMatch(r)} RETURNING ${r.returning}`,\n\t\t\twriteVals,\n\t\t);\n\n\t\tconst revCols = [idCol, scopeCol, ...content, 'version', 'actor'];\n\t\tconst revPh = revCols.map((_, i) => `$${i + 1}`);\n\t\tawait tx.query(\n\t\t\t`INSERT INTO ${r.revisions} (${revCols.join(', ')}) VALUES (${revPh.join(', ')})`,\n\t\t\twriteVals.slice(0, 2 + content.length).concat(version, opts.actor),\n\t\t);\n\t\tawait tx.query(`SELECT pg_notify('${r.channel}', $1)`, [opts.scope ?? '']);\n\n\t\tif (!row) throw new Error('rollback failed');\n\t\treturn row;\n\t});\n}\n"],"mappings":"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","names":["sql","import_node_path","pg","sql","result"]}
|
package/dist/index.d.cts
CHANGED
|
@@ -17,6 +17,7 @@ declare class InternalMigrationRunner extends MigrationRunner {
|
|
|
17
17
|
|
|
18
18
|
declare function createMigrationsPath(importMetaUrl: string): string;
|
|
19
19
|
|
|
20
|
+
declare function assertProductionDbConfig(options: IPoolConfig): void;
|
|
20
21
|
declare class PGAdapter implements IStoreAdapter {
|
|
21
22
|
private pool;
|
|
22
23
|
constructor(config: IPoolConfig | string);
|
|
@@ -56,4 +57,4 @@ declare function versionedRollback<T>(r: IVersionedResource, store: IStoreAdapte
|
|
|
56
57
|
actor: string | null;
|
|
57
58
|
}): Promise<T>;
|
|
58
59
|
|
|
59
|
-
export { IPoolConfig, IStoreAdapter, type IVersionedResource, InternalMigrationRunner, MigrationRunner, PGAdapter, VersionConflictError, createMigrationsPath, versionedRollback, versionedWrite };
|
|
60
|
+
export { IPoolConfig, IStoreAdapter, type IVersionedResource, InternalMigrationRunner, MigrationRunner, PGAdapter, VersionConflictError, assertProductionDbConfig, createMigrationsPath, versionedRollback, versionedWrite };
|
package/dist/index.d.ts
CHANGED
|
@@ -17,6 +17,7 @@ declare class InternalMigrationRunner extends MigrationRunner {
|
|
|
17
17
|
|
|
18
18
|
declare function createMigrationsPath(importMetaUrl: string): string;
|
|
19
19
|
|
|
20
|
+
declare function assertProductionDbConfig(options: IPoolConfig): void;
|
|
20
21
|
declare class PGAdapter implements IStoreAdapter {
|
|
21
22
|
private pool;
|
|
22
23
|
constructor(config: IPoolConfig | string);
|
|
@@ -56,4 +57,4 @@ declare function versionedRollback<T>(r: IVersionedResource, store: IStoreAdapte
|
|
|
56
57
|
actor: string | null;
|
|
57
58
|
}): Promise<T>;
|
|
58
59
|
|
|
59
|
-
export { IPoolConfig, IStoreAdapter, type IVersionedResource, InternalMigrationRunner, MigrationRunner, PGAdapter, VersionConflictError, createMigrationsPath, versionedRollback, versionedWrite };
|
|
60
|
+
export { IPoolConfig, IStoreAdapter, type IVersionedResource, InternalMigrationRunner, MigrationRunner, PGAdapter, VersionConflictError, assertProductionDbConfig, createMigrationsPath, versionedRollback, versionedWrite };
|
package/dist/index.js
CHANGED
|
@@ -36,6 +36,12 @@ var MigrationRunner = class {
|
|
|
36
36
|
const sql2 = await readFile(join(this.migrationsDir, file), "utf8");
|
|
37
37
|
this.assertNoReservedPrefix(file, sql2);
|
|
38
38
|
await this.store.transaction(async (tx) => {
|
|
39
|
+
await tx.query(`SELECT pg_advisory_xact_lock(hashtext('${MIGRATIONS_TABLE}'))`);
|
|
40
|
+
const already = await tx.query(
|
|
41
|
+
`SELECT name FROM ${MIGRATIONS_TABLE} WHERE name = $1`,
|
|
42
|
+
[file]
|
|
43
|
+
);
|
|
44
|
+
if (already.length > 0) return;
|
|
39
45
|
await tx.query(sql2);
|
|
40
46
|
await tx.query(`INSERT INTO ${MIGRATIONS_TABLE} (name, applied_at) VALUES ($1, now())`, [
|
|
41
47
|
file
|
|
@@ -84,10 +90,35 @@ function createMigrationsPath(importMetaUrl) {
|
|
|
84
90
|
|
|
85
91
|
// src/adapters/pg.ts
|
|
86
92
|
import pg from "pg";
|
|
93
|
+
function assertProductionDbConfig(options) {
|
|
94
|
+
if (process.env["NODE_ENV"] !== "production") return;
|
|
95
|
+
const url = options.connectionString;
|
|
96
|
+
if (url !== void 0 && url.trim() === "") {
|
|
97
|
+
throw new Error(
|
|
98
|
+
"[store] connectionString is empty in production \u2014 refusing to boot. Set a real DATABASE_URL, or omit it entirely to use PG* environment variables."
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
if (url) {
|
|
102
|
+
if (/[?&]sslmode=disable\b/i.test(url) || /[?&]ssl=false\b/i.test(url)) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
"[store] database TLS is disabled (sslmode=disable) in production \u2014 traffic to Postgres would be unencrypted. Use sslmode=require (or stronger), or set NODE_ENV appropriately for non-production."
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
if (/:\/\/postgres:postgres@/i.test(url) || /:\/\/postgres:password@/i.test(url)) {
|
|
108
|
+
console.warn("[store] database is using well-known default credentials in production");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (options.ssl === false) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
"[store] database TLS is disabled (ssl: false) in production \u2014 traffic to Postgres would be unencrypted. Enable ssl, or set NODE_ENV appropriately for non-production."
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
87
117
|
var PGAdapter = class {
|
|
88
118
|
pool;
|
|
89
119
|
constructor(config) {
|
|
90
120
|
const options = typeof config === "string" ? { connectionString: config } : config;
|
|
121
|
+
assertProductionDbConfig(options);
|
|
91
122
|
this.pool = new pg.Pool(options);
|
|
92
123
|
this.pool.on("error", (err) => {
|
|
93
124
|
console.error("[store] idle client error", err.message);
|
|
@@ -249,6 +280,7 @@ export {
|
|
|
249
280
|
MigrationRunner,
|
|
250
281
|
PGAdapter,
|
|
251
282
|
VersionConflictError,
|
|
283
|
+
assertProductionDbConfig,
|
|
252
284
|
createMigrationsPath,
|
|
253
285
|
sql,
|
|
254
286
|
versionedRollback,
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/sql.ts","../src/migrations/runner.ts","../src/migrations/path.ts","../src/adapters/pg.ts","../src/versioned.ts"],"sourcesContent":["export interface ISqlQuery {\n\ttext: string;\n\tparams: unknown[];\n}\n\n// Tagged template literal for safe parameterized queries.\n// Never concatenates user input — always uses $N placeholders.\n//\n// Usage:\n// const { text, params } = sql`SELECT * FROM users WHERE id = ${userId}`\n// const rows = await store.query<User>(text, params)\n\nexport function sql(strings: TemplateStringsArray, ...values: unknown[]): ISqlQuery {\n\tlet text = '';\n\tconst params: unknown[] = [];\n\n\tstrings.forEach((str, i) => {\n\t\ttext += str;\n\t\tif (i < values.length) {\n\t\t\tparams.push(values[i]);\n\t\t\ttext += `$${params.length}`;\n\t\t}\n\t});\n\n\treturn { text, params };\n}\n","import { join } from 'node:path';\nimport { readdir, readFile } from 'node:fs/promises';\n\nimport type { IStoreAdapter } from '../types';\n\nconst MIGRATIONS_TABLE = 'fonderie_migrations';\nconst RESERVED_PREFIX_RE = /\\bfonderie_/i;\n\nexport class MigrationRunner {\n\tconstructor(\n\t\tprivate store: IStoreAdapter,\n\t\tprivate migrationsDir: string,\n\t) {}\n\n\tasync run(): Promise<void> {\n\t\tawait this.ensureTable();\n\n\t\tconst [applied, files] = await Promise.all([this.getApplied(), this.getFiles()]);\n\n\t\tconst pending = files.filter((f) => !applied.has(f));\n\n\t\tif (pending.length === 0) {\n\t\t\tconsole.log('[store] migrations: up to date');\n\t\t\treturn;\n\t\t}\n\n\t\tfor (const file of pending) {\n\t\t\tconst sql = await readFile(join(this.migrationsDir, file), 'utf8');\n\n\t\t\tthis.assertNoReservedPrefix(file, sql);\n\n\t\t\tawait this.store.transaction(async (tx) => {\n\t\t\t\tawait tx.query(sql);\n\t\t\t\tawait tx.query(`INSERT INTO ${MIGRATIONS_TABLE} (name, applied_at) VALUES ($1, now())`, [\n\t\t\t\t\tfile,\n\t\t\t\t]);\n\t\t\t});\n\n\t\t\tconsole.log(`[store] migrations: applied ${file}`);\n\t\t}\n\t}\n\n\tprotected assertNoReservedPrefix(file: string, sql: string): void {\n\t\tif (RESERVED_PREFIX_RE.test(sql)) {\n\t\t\tthrow new Error(\n\t\t\t\t`[store] migration \"${file}\" uses the reserved \"fonderie_\" prefix. ` +\n\t\t\t\t`Use InternalMigrationRunner for fonderie-owned migrations.`,\n\t\t\t);\n\t\t}\n\t}\n\n\tprivate async ensureTable(): Promise<void> {\n\t\tawait this.store.query(`\n\t\t\tCREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (\n\t\t\t name TEXT PRIMARY KEY,\n\t\t\t applied_at TIMESTAMPTZ NOT NULL DEFAULT now()\n\t\t\t)\n\t\t`);\n\t}\n\n\tprivate async getApplied(): Promise<Set<string>> {\n\t\tconst rows = await this.store.query<{ name: string }>(\n\t\t\t`SELECT name FROM ${MIGRATIONS_TABLE} ORDER BY name`,\n\t\t);\n\t\treturn new Set(rows.map((r) => r.name));\n\t}\n\n\tprivate async getFiles(): Promise<string[]> {\n\t\tconst all = await readdir(this.migrationsDir);\n\t\treturn all.filter((f) => f.endsWith('.sql')).sort(); // lexicographic — timestamp prefix keeps order correct\n\t}\n}\n\n// For fonderie-internal use only. Skips the reserved-prefix guard.\nexport class InternalMigrationRunner extends MigrationRunner {\n\tprotected override assertNoReservedPrefix(_file: string, _sql: string): void {}\n}\n","import { fileURLToPath } from 'node:url';\nimport { dirname, join } from 'node:path';\n\n// Each package calls this with its own import.meta.url to resolve\n// the absolute path to its compiled migrations/sql/ directory.\n// Called from dist/migrations/index.js, so dirname is already dist/migrations/.\n//\n// Usage in any package's migrations/index.ts:\n// export const getMigrationsPath = () => createMigrationsPath(import.meta.url)\nexport function createMigrationsPath(importMetaUrl: string): string {\n\treturn join(dirname(fileURLToPath(importMetaUrl)), 'sql');\n}\n","import pg from 'pg';\nimport type { IStoreAdapter, IPoolConfig } from '../types';\n\nexport class PGAdapter implements IStoreAdapter {\n\tprivate pool: pg.Pool;\n\n\tconstructor(config: IPoolConfig | string) {\n\t\tconst options = typeof config === 'string' ? { connectionString: config } : config;\n\t\tthis.pool = new pg.Pool(options);\n\n\t\tthis.pool.on('error', (err) => {\n\t\t\tconsole.error('[store] idle client error', err.message);\n\t\t});\n\t}\n\n\tasync testConnection(): Promise<boolean> {\n\t\ttry {\n\t\t\tawait this.pool.query('SELECT 1');\n\t\t\treturn true;\n\t\t} catch {\n\t\t\treturn false;\n\t\t}\n\t}\n\n\tasync query<T = unknown>(sql: string, params?: unknown[]): Promise<T[]> {\n\t\tconst result = await this.pool.query(sql, params);\n\t\treturn result.rows as T[];\n\t}\n\n\tasync transaction<T>(fn: (tx: IStoreAdapter) => Promise<T>): Promise<T> {\n\t\tconst client = await this.pool.connect();\n\n\t\ttry {\n\t\t\tawait client.query('BEGIN');\n\n\t\t\tconst tx: IStoreAdapter = {\n\t\t\t\tquery: async <U = unknown>(sql: string, params?: unknown[]) => {\n\t\t\t\t\tconst result = await client.query(sql, params);\n\t\t\t\t\treturn result.rows as U[];\n\t\t\t\t},\n\t\t\t\ttransaction: <V>(nested: (tx: IStoreAdapter) => Promise<V>) => nested(tx),\n\t\t\t};\n\n\t\t\tconst result = await fn(tx);\n\t\t\tawait client.query('COMMIT');\n\t\t\treturn result;\n\t\t} catch (err) {\n\t\t\tawait client.query('ROLLBACK');\n\t\t\tthrow err;\n\t\t} finally {\n\t\t\tclient.release();\n\t\t}\n\t}\n\n\tasync end(): Promise<void> {\n\t\tawait this.pool.end();\n\t}\n}\n","import type { IStoreAdapter } from './types';\n\n// The control-plane primitive: a version index + optimistic concurrency +\n// advisory-locked writes + append-only revisions + push-notify, reusable by any\n// resource identified by a (primary, scope) key pair. `@fonderie/config` (config\n// + secrets) and `@fonderie/courier` (email templates) run on it; a new resource\n// is a descriptor away. Lives in `store` because it's pure Postgres machinery\n// and every package already depends on store (no cycles).\n\n// Thrown when an optimistic-concurrency write loses the compare-and-swap: the\n// row's current version isn't the one the caller wrote against. Reject-and-retry.\nexport class VersionConflictError extends Error {\n\tconstructor(\n\t\tpublic readonly key: string,\n\t\tpublic readonly scope: string | null,\n\t\tpublic readonly currentVersion: number | null,\n\t\tpublic readonly expectedVersion: number,\n\t) {\n\t\tsuper(\n\t\t\t`\"${key}\" (${scope ?? 'base'}) is at version ${currentVersion ?? 'none'}, ` +\n\t\t\t\t`not ${expectedVersion} — reload and retry`,\n\t\t);\n\t\tthis.name = 'VersionConflictError';\n\t}\n}\n\n// A versioned resource's Postgres surface.\nexport interface IVersionedResource {\n\ttable: string; // main table, e.g. 'fonderie_config'\n\trevisions: string; // history table, e.g. 'fonderie_config_revisions'\n\tchannel: string; // LISTEN/NOTIFY channel, e.g. 'fonderie_config_changed'\n\t// The (primary, scope) key pair — config: ['key','environment'], courier:\n\t// ['type','locale']. `scope` is null-safe (a NULL locale is the base).\n\t// Intentionally a fixed 2-tuple: every resource is addressed by exactly one\n\t// primary key plus one optional scope. Composite 3+-part keys are out of\n\t// scope by design — model the extra dimension inside the primary key (e.g.\n\t// a compound string) or the scope rather than widening this contract.\n\tkeyColumns: readonly [string, string];\n\t// Content columns — written AND snapshotted into revisions (config: ['value'];\n\t// courier: ['subject','html','text']).\n\tcontentColumns: readonly string[];\n\t// Extra main-table columns, set only when supplied, never revisioned (config:\n\t// ['description','active']; courier: ['active']).\n\tmetaColumns?: readonly string[];\n\t// SELECT/RETURNING column list shaping the caller's row type.\n\treturning: string;\n}\n\nconst lockSql = `SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))`;\n\nfunction keyMatch(r: IVersionedResource): string {\n\tconst [id, scope] = r.keyColumns;\n\treturn `${id} IS NOT DISTINCT FROM $1 AND ${scope} IS NOT DISTINCT FROM $2`;\n}\n\n// Write one versioned entry: advisory-lock the (key, scope) pair (serializes even\n// a create), enforce optimistic concurrency when `ifVersion` is given, bump the\n// version, append a revision (content columns only), and broadcast invalidation\n// on commit. `data` supplies every content column and any meta columns to set.\nexport async function versionedWrite<T>(\n\tr: IVersionedResource,\n\tstore: IStoreAdapter,\n\topts: {\n\t\tkey: string;\n\t\tscope: string | null;\n\t\tdata: Record<string, unknown>;\n\t\tifVersion?: number;\n\t\tactor: string | null;\n\t},\n): Promise<T> {\n\tconst [idCol, scopeCol] = r.keyColumns;\n\tconst content = r.contentColumns;\n\tconst meta = (r.metaColumns ?? []).filter((c) => c in opts.data);\n\tconst contentVals = content.map((c) => opts.data[c] ?? null);\n\tconst metaVals = meta.map((c) => opts.data[c] ?? null);\n\n\treturn store.transaction(async (tx) => {\n\t\tawait tx.query(lockSql, [opts.key, opts.scope ?? '']);\n\n\t\tconst [cur] = await tx.query<{ version: number }>(\n\t\t\t`SELECT version FROM ${r.table} WHERE ${keyMatch(r)} FOR UPDATE`,\n\t\t\t[opts.key, opts.scope],\n\t\t);\n\t\tconst currentVersion = cur?.version ?? null;\n\t\tif (opts.ifVersion !== undefined && currentVersion !== opts.ifVersion) {\n\t\t\tthrow new VersionConflictError(opts.key, opts.scope, currentVersion, opts.ifVersion);\n\t\t}\n\t\tconst version = (currentVersion ?? 0) + 1;\n\t\tconst writeVals = [opts.key, opts.scope, ...contentVals, ...metaVals, version, opts.actor];\n\n\t\tlet row: T | undefined;\n\t\tif (cur) {\n\t\t\tconst sets = [\n\t\t\t\t...content.map((c, i) => `${c} = $${3 + i}`),\n\t\t\t\t...meta.map((c, j) => `${c} = $${3 + content.length + j}`),\n\t\t\t\t`version = $${3 + content.length + meta.length}`,\n\t\t\t\t`updated_by = $${4 + content.length + meta.length}`,\n\t\t\t\t`updated_at = now()`,\n\t\t\t];\n\t\t\t[row] = await tx.query<T>(\n\t\t\t\t`UPDATE ${r.table} SET ${sets.join(', ')} WHERE ${keyMatch(r)} RETURNING ${r.returning}`,\n\t\t\t\twriteVals,\n\t\t\t);\n\t\t} else {\n\t\t\tconst cols = [idCol, scopeCol, ...content, ...meta, 'version', 'updated_by'];\n\t\t\tconst ph = cols.map((_, i) => `$${i + 1}`);\n\t\t\t[row] = await tx.query<T>(\n\t\t\t\t`INSERT INTO ${r.table} (${cols.join(', ')}) VALUES (${ph.join(', ')}) RETURNING ${r.returning}`,\n\t\t\t\twriteVals,\n\t\t\t);\n\t\t}\n\n\t\tconst revCols = [idCol, scopeCol, ...content, 'version', 'actor'];\n\t\tconst revPh = revCols.map((_, i) => `$${i + 1}`);\n\t\tawait tx.query(\n\t\t\t`INSERT INTO ${r.revisions} (${revCols.join(', ')}) VALUES (${revPh.join(', ')})`,\n\t\t\t[opts.key, opts.scope, ...contentVals, version, opts.actor],\n\t\t);\n\t\tawait tx.query(`SELECT pg_notify('${r.channel}', $1)`, [opts.scope ?? '']);\n\n\t\tif (!row) throw new Error(`Failed to write ${r.table} entry`);\n\t\treturn row;\n\t});\n}\n\n// Roll *forward* to a past revision's content as a new version (rollout undo —\n// history is never rewritten).\nexport async function versionedRollback<T>(\n\tr: IVersionedResource,\n\tstore: IStoreAdapter,\n\topts: { key: string; scope: string | null; toVersion: number; actor: string | null },\n): Promise<T> {\n\tconst [idCol, scopeCol] = r.keyColumns;\n\tconst content = r.contentColumns;\n\n\treturn store.transaction(async (tx) => {\n\t\tawait tx.query(lockSql, [opts.key, opts.scope ?? '']);\n\n\t\tconst [target] = await tx.query<Record<string, unknown>>(\n\t\t\t`SELECT ${content.join(', ')} FROM ${r.revisions} WHERE ${keyMatch(r)} AND version = $3`,\n\t\t\t[opts.key, opts.scope, opts.toVersion],\n\t\t);\n\t\tif (!target) {\n\t\t\tthrow new Error(`\"${opts.key}\" (${opts.scope ?? 'base'}) has no revision ${opts.toVersion}`);\n\t\t}\n\t\tconst [cur] = await tx.query<{ version: number }>(\n\t\t\t`SELECT version FROM ${r.table} WHERE ${keyMatch(r)} FOR UPDATE`,\n\t\t\t[opts.key, opts.scope],\n\t\t);\n\t\tif (!cur) throw new Error(`\"${opts.key}\" (${opts.scope ?? 'base'}) does not exist`);\n\t\tconst version = cur.version + 1;\n\t\tconst contentVals = content.map((c) => target[c] ?? null);\n\t\tconst writeVals = [opts.key, opts.scope, ...contentVals, version, opts.actor];\n\n\t\tconst sets = [\n\t\t\t...content.map((c, i) => `${c} = $${3 + i}`),\n\t\t\t`version = $${3 + content.length}`,\n\t\t\t`updated_by = $${4 + content.length}`,\n\t\t\t`updated_at = now()`,\n\t\t];\n\t\tconst [row] = await tx.query<T>(\n\t\t\t`UPDATE ${r.table} SET ${sets.join(', ')} WHERE ${keyMatch(r)} RETURNING ${r.returning}`,\n\t\t\twriteVals,\n\t\t);\n\n\t\tconst revCols = [idCol, scopeCol, ...content, 'version', 'actor'];\n\t\tconst revPh = revCols.map((_, i) => `$${i + 1}`);\n\t\tawait tx.query(\n\t\t\t`INSERT INTO ${r.revisions} (${revCols.join(', ')}) VALUES (${revPh.join(', ')})`,\n\t\t\twriteVals.slice(0, 2 + content.length).concat(version, opts.actor),\n\t\t);\n\t\tawait tx.query(`SELECT pg_notify('${r.channel}', $1)`, [opts.scope ?? '']);\n\n\t\tif (!row) throw new Error('rollback failed');\n\t\treturn row;\n\t});\n}\n"],"mappings":"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","names":["sql","join","sql","result"]}
|
|
1
|
+
{"version":3,"sources":["../src/sql.ts","../src/migrations/runner.ts","../src/migrations/path.ts","../src/adapters/pg.ts","../src/versioned.ts"],"sourcesContent":["export interface ISqlQuery {\n\ttext: string;\n\tparams: unknown[];\n}\n\n// Tagged template literal for safe parameterized queries.\n// Never concatenates user input — always uses $N placeholders.\n//\n// Usage:\n// const { text, params } = sql`SELECT * FROM users WHERE id = ${userId}`\n// const rows = await store.query<User>(text, params)\n\nexport function sql(strings: TemplateStringsArray, ...values: unknown[]): ISqlQuery {\n\tlet text = '';\n\tconst params: unknown[] = [];\n\n\tstrings.forEach((str, i) => {\n\t\ttext += str;\n\t\tif (i < values.length) {\n\t\t\tparams.push(values[i]);\n\t\t\ttext += `$${params.length}`;\n\t\t}\n\t});\n\n\treturn { text, params };\n}\n","import { join } from 'node:path';\nimport { readdir, readFile } from 'node:fs/promises';\n\nimport type { IStoreAdapter } from '../types';\n\nconst MIGRATIONS_TABLE = 'fonderie_migrations';\nconst RESERVED_PREFIX_RE = /\\bfonderie_/i;\n\nexport class MigrationRunner {\n\tconstructor(\n\t\tprivate store: IStoreAdapter,\n\t\tprivate migrationsDir: string,\n\t) {}\n\n\tasync run(): Promise<void> {\n\t\tawait this.ensureTable();\n\n\t\tconst [applied, files] = await Promise.all([this.getApplied(), this.getFiles()]);\n\n\t\tconst pending = files.filter((f) => !applied.has(f));\n\n\t\tif (pending.length === 0) {\n\t\t\tconsole.log('[store] migrations: up to date');\n\t\t\treturn;\n\t\t}\n\n\t\tfor (const file of pending) {\n\t\t\tconst sql = await readFile(join(this.migrationsDir, file), 'utf8');\n\n\t\t\tthis.assertNoReservedPrefix(file, sql);\n\n\t\t\tawait this.store.transaction(async (tx) => {\n\t\t\t\t// Cross-process serialization: several instances booting at once all\n\t\t\t\t// see the same pending list. The advisory xact-lock makes appliers\n\t\t\t\t// queue, and the in-lock recheck turns the loser's attempt into a\n\t\t\t\t// no-op instead of a duplicate DDL failure (or worse, a partial\n\t\t\t\t// double-application on non-idempotent SQL).\n\t\t\t\tawait tx.query(`SELECT pg_advisory_xact_lock(hashtext('${MIGRATIONS_TABLE}'))`);\n\t\t\t\tconst already = await tx.query<{ name: string }>(\n\t\t\t\t\t`SELECT name FROM ${MIGRATIONS_TABLE} WHERE name = $1`,\n\t\t\t\t\t[file],\n\t\t\t\t);\n\t\t\t\tif (already.length > 0) return;\n\n\t\t\t\tawait tx.query(sql);\n\t\t\t\tawait tx.query(`INSERT INTO ${MIGRATIONS_TABLE} (name, applied_at) VALUES ($1, now())`, [\n\t\t\t\t\tfile,\n\t\t\t\t]);\n\t\t\t});\n\n\t\t\tconsole.log(`[store] migrations: applied ${file}`);\n\t\t}\n\t}\n\n\tprotected assertNoReservedPrefix(file: string, sql: string): void {\n\t\tif (RESERVED_PREFIX_RE.test(sql)) {\n\t\t\tthrow new Error(\n\t\t\t\t`[store] migration \"${file}\" uses the reserved \"fonderie_\" prefix. ` +\n\t\t\t\t`Use InternalMigrationRunner for fonderie-owned migrations.`,\n\t\t\t);\n\t\t}\n\t}\n\n\tprivate async ensureTable(): Promise<void> {\n\t\tawait this.store.query(`\n\t\t\tCREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (\n\t\t\t name TEXT PRIMARY KEY,\n\t\t\t applied_at TIMESTAMPTZ NOT NULL DEFAULT now()\n\t\t\t)\n\t\t`);\n\t}\n\n\tprivate async getApplied(): Promise<Set<string>> {\n\t\tconst rows = await this.store.query<{ name: string }>(\n\t\t\t`SELECT name FROM ${MIGRATIONS_TABLE} ORDER BY name`,\n\t\t);\n\t\treturn new Set(rows.map((r) => r.name));\n\t}\n\n\tprivate async getFiles(): Promise<string[]> {\n\t\tconst all = await readdir(this.migrationsDir);\n\t\treturn all.filter((f) => f.endsWith('.sql')).sort(); // lexicographic — timestamp prefix keeps order correct\n\t}\n}\n\n// For fonderie-internal use only. Skips the reserved-prefix guard.\nexport class InternalMigrationRunner extends MigrationRunner {\n\tprotected override assertNoReservedPrefix(_file: string, _sql: string): void {}\n}\n","import { fileURLToPath } from 'node:url';\nimport { dirname, join } from 'node:path';\n\n// Each package calls this with its own import.meta.url to resolve\n// the absolute path to its compiled migrations/sql/ directory.\n// Called from dist/migrations/index.js, so dirname is already dist/migrations/.\n//\n// Usage in any package's migrations/index.ts:\n// export const getMigrationsPath = () => createMigrationsPath(import.meta.url)\nexport function createMigrationsPath(importMetaUrl: string): string {\n\treturn join(dirname(fileURLToPath(importMetaUrl)), 'sql');\n}\n","import pg from 'pg';\nimport type { IStoreAdapter, IPoolConfig } from '../types';\n\n// Fail-closed check on the database connection, run at pool construction. In\n// production a misconfigured DB is a security problem, not just an availability\n// one (plaintext transport, shared default credentials). The only fatal case is\n// an explicitly-empty connection string — a blank env var silently falling back\n// to pg's localhost defaults is almost never intended in production. The rest\n// are loud warnings so we don't break legitimate setups (unix sockets, PG* env\n// vars, trusted local networks). Outside production this is a no-op.\nexport function assertProductionDbConfig(options: IPoolConfig): void {\n\tif (process.env['NODE_ENV'] !== 'production') return;\n\n\tconst url = options.connectionString;\n\n\tif (url !== undefined && url.trim() === '') {\n\t\tthrow new Error(\n\t\t\t'[store] connectionString is empty in production — refusing to boot. ' +\n\t\t\t\t'Set a real DATABASE_URL, or omit it entirely to use PG* environment variables.',\n\t\t);\n\t}\n\n\tif (url) {\n\t\tif (/[?&]sslmode=disable\\b/i.test(url) || /[?&]ssl=false\\b/i.test(url)) {\n\t\t\tthrow new Error(\n\t\t\t\t'[store] database TLS is disabled (sslmode=disable) in production — traffic to ' +\n\t\t\t\t\t'Postgres would be unencrypted. Use sslmode=require (or stronger), or set ' +\n\t\t\t\t\t'NODE_ENV appropriately for non-production.',\n\t\t\t);\n\t\t}\n\t\tif (/:\\/\\/postgres:postgres@/i.test(url) || /:\\/\\/postgres:password@/i.test(url)) {\n\t\t\tconsole.warn('[store] database is using well-known default credentials in production');\n\t\t}\n\t}\n\n\t// The same explicit-disable rule for the CONFIG-OBJECT form — previously\n\t// only the connection-string form was checked, so `{ host, ssl: false }`\n\t// sailed through the production TLS gate.\n\tif (options.ssl === false) {\n\t\tthrow new Error(\n\t\t\t'[store] database TLS is disabled (ssl: false) in production — traffic to ' +\n\t\t\t\t'Postgres would be unencrypted. Enable ssl, or set NODE_ENV appropriately ' +\n\t\t\t\t'for non-production.',\n\t\t);\n\t}\n}\n\nexport class PGAdapter implements IStoreAdapter {\n\tprivate pool: pg.Pool;\n\n\tconstructor(config: IPoolConfig | string) {\n\t\tconst options = typeof config === 'string' ? { connectionString: config } : config;\n\t\tassertProductionDbConfig(options);\n\t\tthis.pool = new pg.Pool(options);\n\n\t\tthis.pool.on('error', (err) => {\n\t\t\tconsole.error('[store] idle client error', err.message);\n\t\t});\n\t}\n\n\tasync testConnection(): Promise<boolean> {\n\t\ttry {\n\t\t\tawait this.pool.query('SELECT 1');\n\t\t\treturn true;\n\t\t} catch {\n\t\t\treturn false;\n\t\t}\n\t}\n\n\tasync query<T = unknown>(sql: string, params?: unknown[]): Promise<T[]> {\n\t\tconst result = await this.pool.query(sql, params);\n\t\treturn result.rows as T[];\n\t}\n\n\tasync transaction<T>(fn: (tx: IStoreAdapter) => Promise<T>): Promise<T> {\n\t\tconst client = await this.pool.connect();\n\n\t\ttry {\n\t\t\tawait client.query('BEGIN');\n\n\t\t\tconst tx: IStoreAdapter = {\n\t\t\t\tquery: async <U = unknown>(sql: string, params?: unknown[]) => {\n\t\t\t\t\tconst result = await client.query(sql, params);\n\t\t\t\t\treturn result.rows as U[];\n\t\t\t\t},\n\t\t\t\ttransaction: <V>(nested: (tx: IStoreAdapter) => Promise<V>) => nested(tx),\n\t\t\t};\n\n\t\t\tconst result = await fn(tx);\n\t\t\tawait client.query('COMMIT');\n\t\t\treturn result;\n\t\t} catch (err) {\n\t\t\tawait client.query('ROLLBACK');\n\t\t\tthrow err;\n\t\t} finally {\n\t\t\tclient.release();\n\t\t}\n\t}\n\n\tasync end(): Promise<void> {\n\t\tawait this.pool.end();\n\t}\n}\n","import type { IStoreAdapter } from './types';\n\n// The control-plane primitive: a version index + optimistic concurrency +\n// advisory-locked writes + append-only revisions + push-notify, reusable by any\n// resource identified by a (primary, scope) key pair. `@fonderie/config` (config\n// + secrets) and `@fonderie/courier` (email templates) run on it; a new resource\n// is a descriptor away. Lives in `store` because it's pure Postgres machinery\n// and every package already depends on store (no cycles).\n\n// Thrown when an optimistic-concurrency write loses the compare-and-swap: the\n// row's current version isn't the one the caller wrote against. Reject-and-retry.\nexport class VersionConflictError extends Error {\n\tconstructor(\n\t\tpublic readonly key: string,\n\t\tpublic readonly scope: string | null,\n\t\tpublic readonly currentVersion: number | null,\n\t\tpublic readonly expectedVersion: number,\n\t) {\n\t\tsuper(\n\t\t\t`\"${key}\" (${scope ?? 'base'}) is at version ${currentVersion ?? 'none'}, ` +\n\t\t\t\t`not ${expectedVersion} — reload and retry`,\n\t\t);\n\t\tthis.name = 'VersionConflictError';\n\t}\n}\n\n// A versioned resource's Postgres surface.\nexport interface IVersionedResource {\n\ttable: string; // main table, e.g. 'fonderie_config'\n\trevisions: string; // history table, e.g. 'fonderie_config_revisions'\n\tchannel: string; // LISTEN/NOTIFY channel, e.g. 'fonderie_config_changed'\n\t// The (primary, scope) key pair — config: ['key','environment'], courier:\n\t// ['type','locale']. `scope` is null-safe (a NULL locale is the base).\n\t// Intentionally a fixed 2-tuple: every resource is addressed by exactly one\n\t// primary key plus one optional scope. Composite 3+-part keys are out of\n\t// scope by design — model the extra dimension inside the primary key (e.g.\n\t// a compound string) or the scope rather than widening this contract.\n\tkeyColumns: readonly [string, string];\n\t// Content columns — written AND snapshotted into revisions (config: ['value'];\n\t// courier: ['subject','html','text']).\n\tcontentColumns: readonly string[];\n\t// Extra main-table columns, set only when supplied, never revisioned (config:\n\t// ['description','active']; courier: ['active']).\n\tmetaColumns?: readonly string[];\n\t// SELECT/RETURNING column list shaping the caller's row type.\n\treturning: string;\n}\n\nconst lockSql = `SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))`;\n\nfunction keyMatch(r: IVersionedResource): string {\n\tconst [id, scope] = r.keyColumns;\n\treturn `${id} IS NOT DISTINCT FROM $1 AND ${scope} IS NOT DISTINCT FROM $2`;\n}\n\n// Write one versioned entry: advisory-lock the (key, scope) pair (serializes even\n// a create), enforce optimistic concurrency when `ifVersion` is given, bump the\n// version, append a revision (content columns only), and broadcast invalidation\n// on commit. `data` supplies every content column and any meta columns to set.\nexport async function versionedWrite<T>(\n\tr: IVersionedResource,\n\tstore: IStoreAdapter,\n\topts: {\n\t\tkey: string;\n\t\tscope: string | null;\n\t\tdata: Record<string, unknown>;\n\t\tifVersion?: number;\n\t\tactor: string | null;\n\t},\n): Promise<T> {\n\tconst [idCol, scopeCol] = r.keyColumns;\n\tconst content = r.contentColumns;\n\tconst meta = (r.metaColumns ?? []).filter((c) => c in opts.data);\n\tconst contentVals = content.map((c) => opts.data[c] ?? null);\n\tconst metaVals = meta.map((c) => opts.data[c] ?? null);\n\n\treturn store.transaction(async (tx) => {\n\t\tawait tx.query(lockSql, [opts.key, opts.scope ?? '']);\n\n\t\tconst [cur] = await tx.query<{ version: number }>(\n\t\t\t`SELECT version FROM ${r.table} WHERE ${keyMatch(r)} FOR UPDATE`,\n\t\t\t[opts.key, opts.scope],\n\t\t);\n\t\tconst currentVersion = cur?.version ?? null;\n\t\tif (opts.ifVersion !== undefined && currentVersion !== opts.ifVersion) {\n\t\t\tthrow new VersionConflictError(opts.key, opts.scope, currentVersion, opts.ifVersion);\n\t\t}\n\t\tconst version = (currentVersion ?? 0) + 1;\n\t\tconst writeVals = [opts.key, opts.scope, ...contentVals, ...metaVals, version, opts.actor];\n\n\t\tlet row: T | undefined;\n\t\tif (cur) {\n\t\t\tconst sets = [\n\t\t\t\t...content.map((c, i) => `${c} = $${3 + i}`),\n\t\t\t\t...meta.map((c, j) => `${c} = $${3 + content.length + j}`),\n\t\t\t\t`version = $${3 + content.length + meta.length}`,\n\t\t\t\t`updated_by = $${4 + content.length + meta.length}`,\n\t\t\t\t`updated_at = now()`,\n\t\t\t];\n\t\t\t[row] = await tx.query<T>(\n\t\t\t\t`UPDATE ${r.table} SET ${sets.join(', ')} WHERE ${keyMatch(r)} RETURNING ${r.returning}`,\n\t\t\t\twriteVals,\n\t\t\t);\n\t\t} else {\n\t\t\tconst cols = [idCol, scopeCol, ...content, ...meta, 'version', 'updated_by'];\n\t\t\tconst ph = cols.map((_, i) => `$${i + 1}`);\n\t\t\t[row] = await tx.query<T>(\n\t\t\t\t`INSERT INTO ${r.table} (${cols.join(', ')}) VALUES (${ph.join(', ')}) RETURNING ${r.returning}`,\n\t\t\t\twriteVals,\n\t\t\t);\n\t\t}\n\n\t\tconst revCols = [idCol, scopeCol, ...content, 'version', 'actor'];\n\t\tconst revPh = revCols.map((_, i) => `$${i + 1}`);\n\t\tawait tx.query(\n\t\t\t`INSERT INTO ${r.revisions} (${revCols.join(', ')}) VALUES (${revPh.join(', ')})`,\n\t\t\t[opts.key, opts.scope, ...contentVals, version, opts.actor],\n\t\t);\n\t\tawait tx.query(`SELECT pg_notify('${r.channel}', $1)`, [opts.scope ?? '']);\n\n\t\tif (!row) throw new Error(`Failed to write ${r.table} entry`);\n\t\treturn row;\n\t});\n}\n\n// Roll *forward* to a past revision's content as a new version (rollout undo —\n// history is never rewritten).\nexport async function versionedRollback<T>(\n\tr: IVersionedResource,\n\tstore: IStoreAdapter,\n\topts: { key: string; scope: string | null; toVersion: number; actor: string | null },\n): Promise<T> {\n\tconst [idCol, scopeCol] = r.keyColumns;\n\tconst content = r.contentColumns;\n\n\treturn store.transaction(async (tx) => {\n\t\tawait tx.query(lockSql, [opts.key, opts.scope ?? '']);\n\n\t\tconst [target] = await tx.query<Record<string, unknown>>(\n\t\t\t`SELECT ${content.join(', ')} FROM ${r.revisions} WHERE ${keyMatch(r)} AND version = $3`,\n\t\t\t[opts.key, opts.scope, opts.toVersion],\n\t\t);\n\t\tif (!target) {\n\t\t\tthrow new Error(`\"${opts.key}\" (${opts.scope ?? 'base'}) has no revision ${opts.toVersion}`);\n\t\t}\n\t\tconst [cur] = await tx.query<{ version: number }>(\n\t\t\t`SELECT version FROM ${r.table} WHERE ${keyMatch(r)} FOR UPDATE`,\n\t\t\t[opts.key, opts.scope],\n\t\t);\n\t\tif (!cur) throw new Error(`\"${opts.key}\" (${opts.scope ?? 'base'}) does not exist`);\n\t\tconst version = cur.version + 1;\n\t\tconst contentVals = content.map((c) => target[c] ?? null);\n\t\tconst writeVals = [opts.key, opts.scope, ...contentVals, version, opts.actor];\n\n\t\tconst sets = [\n\t\t\t...content.map((c, i) => `${c} = $${3 + i}`),\n\t\t\t`version = $${3 + content.length}`,\n\t\t\t`updated_by = $${4 + content.length}`,\n\t\t\t`updated_at = now()`,\n\t\t];\n\t\tconst [row] = await tx.query<T>(\n\t\t\t`UPDATE ${r.table} SET ${sets.join(', ')} WHERE ${keyMatch(r)} RETURNING ${r.returning}`,\n\t\t\twriteVals,\n\t\t);\n\n\t\tconst revCols = [idCol, scopeCol, ...content, 'version', 'actor'];\n\t\tconst revPh = revCols.map((_, i) => `$${i + 1}`);\n\t\tawait tx.query(\n\t\t\t`INSERT INTO ${r.revisions} (${revCols.join(', ')}) VALUES (${revPh.join(', ')})`,\n\t\t\twriteVals.slice(0, 2 + content.length).concat(version, opts.actor),\n\t\t);\n\t\tawait tx.query(`SELECT pg_notify('${r.channel}', $1)`, [opts.scope ?? '']);\n\n\t\tif (!row) throw new Error('rollback failed');\n\t\treturn row;\n\t});\n}\n"],"mappings":"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","names":["sql","join","sql","result"]}
|
package/dist/migrations/index.js
CHANGED
|
@@ -22,6 +22,12 @@ var MigrationRunner = class {
|
|
|
22
22
|
const sql = await readFile(join(this.migrationsDir, file), "utf8");
|
|
23
23
|
this.assertNoReservedPrefix(file, sql);
|
|
24
24
|
await this.store.transaction(async (tx) => {
|
|
25
|
+
await tx.query(`SELECT pg_advisory_xact_lock(hashtext('${MIGRATIONS_TABLE}'))`);
|
|
26
|
+
const already = await tx.query(
|
|
27
|
+
`SELECT name FROM ${MIGRATIONS_TABLE} WHERE name = $1`,
|
|
28
|
+
[file]
|
|
29
|
+
);
|
|
30
|
+
if (already.length > 0) return;
|
|
25
31
|
await tx.query(sql);
|
|
26
32
|
await tx.query(`INSERT INTO ${MIGRATIONS_TABLE} (name, applied_at) VALUES ($1, now())`, [
|
|
27
33
|
file
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/migrations/runner.ts","../../src/migrations/path.ts"],"sourcesContent":["import { join } from 'node:path';\nimport { readdir, readFile } from 'node:fs/promises';\n\nimport type { IStoreAdapter } from '../types';\n\nconst MIGRATIONS_TABLE = 'fonderie_migrations';\nconst RESERVED_PREFIX_RE = /\\bfonderie_/i;\n\nexport class MigrationRunner {\n\tconstructor(\n\t\tprivate store: IStoreAdapter,\n\t\tprivate migrationsDir: string,\n\t) {}\n\n\tasync run(): Promise<void> {\n\t\tawait this.ensureTable();\n\n\t\tconst [applied, files] = await Promise.all([this.getApplied(), this.getFiles()]);\n\n\t\tconst pending = files.filter((f) => !applied.has(f));\n\n\t\tif (pending.length === 0) {\n\t\t\tconsole.log('[store] migrations: up to date');\n\t\t\treturn;\n\t\t}\n\n\t\tfor (const file of pending) {\n\t\t\tconst sql = await readFile(join(this.migrationsDir, file), 'utf8');\n\n\t\t\tthis.assertNoReservedPrefix(file, sql);\n\n\t\t\tawait this.store.transaction(async (tx) => {\n\t\t\t\tawait tx.query(sql);\n\t\t\t\tawait tx.query(`INSERT INTO ${MIGRATIONS_TABLE} (name, applied_at) VALUES ($1, now())`, [\n\t\t\t\t\tfile,\n\t\t\t\t]);\n\t\t\t});\n\n\t\t\tconsole.log(`[store] migrations: applied ${file}`);\n\t\t}\n\t}\n\n\tprotected assertNoReservedPrefix(file: string, sql: string): void {\n\t\tif (RESERVED_PREFIX_RE.test(sql)) {\n\t\t\tthrow new Error(\n\t\t\t\t`[store] migration \"${file}\" uses the reserved \"fonderie_\" prefix. ` +\n\t\t\t\t`Use InternalMigrationRunner for fonderie-owned migrations.`,\n\t\t\t);\n\t\t}\n\t}\n\n\tprivate async ensureTable(): Promise<void> {\n\t\tawait this.store.query(`\n\t\t\tCREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (\n\t\t\t name TEXT PRIMARY KEY,\n\t\t\t applied_at TIMESTAMPTZ NOT NULL DEFAULT now()\n\t\t\t)\n\t\t`);\n\t}\n\n\tprivate async getApplied(): Promise<Set<string>> {\n\t\tconst rows = await this.store.query<{ name: string }>(\n\t\t\t`SELECT name FROM ${MIGRATIONS_TABLE} ORDER BY name`,\n\t\t);\n\t\treturn new Set(rows.map((r) => r.name));\n\t}\n\n\tprivate async getFiles(): Promise<string[]> {\n\t\tconst all = await readdir(this.migrationsDir);\n\t\treturn all.filter((f) => f.endsWith('.sql')).sort(); // lexicographic — timestamp prefix keeps order correct\n\t}\n}\n\n// For fonderie-internal use only. Skips the reserved-prefix guard.\nexport class InternalMigrationRunner extends MigrationRunner {\n\tprotected override assertNoReservedPrefix(_file: string, _sql: string): void {}\n}\n","import { fileURLToPath } from 'node:url';\nimport { dirname, join } from 'node:path';\n\n// Each package calls this with its own import.meta.url to resolve\n// the absolute path to its compiled migrations/sql/ directory.\n// Called from dist/migrations/index.js, so dirname is already dist/migrations/.\n//\n// Usage in any package's migrations/index.ts:\n// export const getMigrationsPath = () => createMigrationsPath(import.meta.url)\nexport function createMigrationsPath(importMetaUrl: string): string {\n\treturn join(dirname(fileURLToPath(importMetaUrl)), 'sql');\n}\n"],"mappings":";AAAA,SAAS,YAAY;AACrB,SAAS,SAAS,gBAAgB;AAIlC,IAAM,mBAAmB;AACzB,IAAM,qBAAqB;AAEpB,IAAM,kBAAN,MAAsB;AAAA,EAC5B,YACS,OACA,eACP;AAFO;AACA;AAAA,EACN;AAAA,EAFM;AAAA,EACA;AAAA,EAGT,MAAM,MAAqB;AAC1B,UAAM,KAAK,YAAY;AAEvB,UAAM,CAAC,SAAS,KAAK,IAAI,MAAM,QAAQ,IAAI,CAAC,KAAK,WAAW,GAAG,KAAK,SAAS,CAAC,CAAC;AAE/E,UAAM,UAAU,MAAM,OAAO,CAAC,MAAM,CAAC,QAAQ,IAAI,CAAC,CAAC;AAEnD,QAAI,QAAQ,WAAW,GAAG;AACzB,cAAQ,IAAI,gCAAgC;AAC5C;AAAA,IACD;AAEA,eAAW,QAAQ,SAAS;AAC3B,YAAM,MAAM,MAAM,SAAS,KAAK,KAAK,eAAe,IAAI,GAAG,MAAM;AAEjE,WAAK,uBAAuB,MAAM,GAAG;AAErC,YAAM,KAAK,MAAM,YAAY,OAAO,OAAO;
|
|
1
|
+
{"version":3,"sources":["../../src/migrations/runner.ts","../../src/migrations/path.ts"],"sourcesContent":["import { join } from 'node:path';\nimport { readdir, readFile } from 'node:fs/promises';\n\nimport type { IStoreAdapter } from '../types';\n\nconst MIGRATIONS_TABLE = 'fonderie_migrations';\nconst RESERVED_PREFIX_RE = /\\bfonderie_/i;\n\nexport class MigrationRunner {\n\tconstructor(\n\t\tprivate store: IStoreAdapter,\n\t\tprivate migrationsDir: string,\n\t) {}\n\n\tasync run(): Promise<void> {\n\t\tawait this.ensureTable();\n\n\t\tconst [applied, files] = await Promise.all([this.getApplied(), this.getFiles()]);\n\n\t\tconst pending = files.filter((f) => !applied.has(f));\n\n\t\tif (pending.length === 0) {\n\t\t\tconsole.log('[store] migrations: up to date');\n\t\t\treturn;\n\t\t}\n\n\t\tfor (const file of pending) {\n\t\t\tconst sql = await readFile(join(this.migrationsDir, file), 'utf8');\n\n\t\t\tthis.assertNoReservedPrefix(file, sql);\n\n\t\t\tawait this.store.transaction(async (tx) => {\n\t\t\t\t// Cross-process serialization: several instances booting at once all\n\t\t\t\t// see the same pending list. The advisory xact-lock makes appliers\n\t\t\t\t// queue, and the in-lock recheck turns the loser's attempt into a\n\t\t\t\t// no-op instead of a duplicate DDL failure (or worse, a partial\n\t\t\t\t// double-application on non-idempotent SQL).\n\t\t\t\tawait tx.query(`SELECT pg_advisory_xact_lock(hashtext('${MIGRATIONS_TABLE}'))`);\n\t\t\t\tconst already = await tx.query<{ name: string }>(\n\t\t\t\t\t`SELECT name FROM ${MIGRATIONS_TABLE} WHERE name = $1`,\n\t\t\t\t\t[file],\n\t\t\t\t);\n\t\t\t\tif (already.length > 0) return;\n\n\t\t\t\tawait tx.query(sql);\n\t\t\t\tawait tx.query(`INSERT INTO ${MIGRATIONS_TABLE} (name, applied_at) VALUES ($1, now())`, [\n\t\t\t\t\tfile,\n\t\t\t\t]);\n\t\t\t});\n\n\t\t\tconsole.log(`[store] migrations: applied ${file}`);\n\t\t}\n\t}\n\n\tprotected assertNoReservedPrefix(file: string, sql: string): void {\n\t\tif (RESERVED_PREFIX_RE.test(sql)) {\n\t\t\tthrow new Error(\n\t\t\t\t`[store] migration \"${file}\" uses the reserved \"fonderie_\" prefix. ` +\n\t\t\t\t`Use InternalMigrationRunner for fonderie-owned migrations.`,\n\t\t\t);\n\t\t}\n\t}\n\n\tprivate async ensureTable(): Promise<void> {\n\t\tawait this.store.query(`\n\t\t\tCREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (\n\t\t\t name TEXT PRIMARY KEY,\n\t\t\t applied_at TIMESTAMPTZ NOT NULL DEFAULT now()\n\t\t\t)\n\t\t`);\n\t}\n\n\tprivate async getApplied(): Promise<Set<string>> {\n\t\tconst rows = await this.store.query<{ name: string }>(\n\t\t\t`SELECT name FROM ${MIGRATIONS_TABLE} ORDER BY name`,\n\t\t);\n\t\treturn new Set(rows.map((r) => r.name));\n\t}\n\n\tprivate async getFiles(): Promise<string[]> {\n\t\tconst all = await readdir(this.migrationsDir);\n\t\treturn all.filter((f) => f.endsWith('.sql')).sort(); // lexicographic — timestamp prefix keeps order correct\n\t}\n}\n\n// For fonderie-internal use only. Skips the reserved-prefix guard.\nexport class InternalMigrationRunner extends MigrationRunner {\n\tprotected override assertNoReservedPrefix(_file: string, _sql: string): void {}\n}\n","import { fileURLToPath } from 'node:url';\nimport { dirname, join } from 'node:path';\n\n// Each package calls this with its own import.meta.url to resolve\n// the absolute path to its compiled migrations/sql/ directory.\n// Called from dist/migrations/index.js, so dirname is already dist/migrations/.\n//\n// Usage in any package's migrations/index.ts:\n// export const getMigrationsPath = () => createMigrationsPath(import.meta.url)\nexport function createMigrationsPath(importMetaUrl: string): string {\n\treturn join(dirname(fileURLToPath(importMetaUrl)), 'sql');\n}\n"],"mappings":";AAAA,SAAS,YAAY;AACrB,SAAS,SAAS,gBAAgB;AAIlC,IAAM,mBAAmB;AACzB,IAAM,qBAAqB;AAEpB,IAAM,kBAAN,MAAsB;AAAA,EAC5B,YACS,OACA,eACP;AAFO;AACA;AAAA,EACN;AAAA,EAFM;AAAA,EACA;AAAA,EAGT,MAAM,MAAqB;AAC1B,UAAM,KAAK,YAAY;AAEvB,UAAM,CAAC,SAAS,KAAK,IAAI,MAAM,QAAQ,IAAI,CAAC,KAAK,WAAW,GAAG,KAAK,SAAS,CAAC,CAAC;AAE/E,UAAM,UAAU,MAAM,OAAO,CAAC,MAAM,CAAC,QAAQ,IAAI,CAAC,CAAC;AAEnD,QAAI,QAAQ,WAAW,GAAG;AACzB,cAAQ,IAAI,gCAAgC;AAC5C;AAAA,IACD;AAEA,eAAW,QAAQ,SAAS;AAC3B,YAAM,MAAM,MAAM,SAAS,KAAK,KAAK,eAAe,IAAI,GAAG,MAAM;AAEjE,WAAK,uBAAuB,MAAM,GAAG;AAErC,YAAM,KAAK,MAAM,YAAY,OAAO,OAAO;AAM1C,cAAM,GAAG,MAAM,0CAA0C,gBAAgB,KAAK;AAC9E,cAAM,UAAU,MAAM,GAAG;AAAA,UACxB,oBAAoB,gBAAgB;AAAA,UACpC,CAAC,IAAI;AAAA,QACN;AACA,YAAI,QAAQ,SAAS,EAAG;AAExB,cAAM,GAAG,MAAM,GAAG;AAClB,cAAM,GAAG,MAAM,eAAe,gBAAgB,0CAA0C;AAAA,UACvF;AAAA,QACD,CAAC;AAAA,MACF,CAAC;AAED,cAAQ,IAAI,+BAA+B,IAAI,EAAE;AAAA,IAClD;AAAA,EACD;AAAA,EAEU,uBAAuB,MAAc,KAAmB;AACjE,QAAI,mBAAmB,KAAK,GAAG,GAAG;AACjC,YAAM,IAAI;AAAA,QACT,sBAAsB,IAAI;AAAA,MAE3B;AAAA,IACD;AAAA,EACD;AAAA,EAEA,MAAc,cAA6B;AAC1C,UAAM,KAAK,MAAM,MAAM;AAAA,gCACO,gBAAgB;AAAA;AAAA;AAAA;AAAA,GAI7C;AAAA,EACF;AAAA,EAEA,MAAc,aAAmC;AAChD,UAAM,OAAO,MAAM,KAAK,MAAM;AAAA,MAC7B,oBAAoB,gBAAgB;AAAA,IACrC;AACA,WAAO,IAAI,IAAI,KAAK,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC;AAAA,EACvC;AAAA,EAEA,MAAc,WAA8B;AAC3C,UAAM,MAAM,MAAM,QAAQ,KAAK,aAAa;AAC5C,WAAO,IAAI,OAAO,CAAC,MAAM,EAAE,SAAS,MAAM,CAAC,EAAE,KAAK;AAAA,EACnD;AACD;AAGO,IAAM,0BAAN,cAAsC,gBAAgB;AAAA,EACzC,uBAAuB,OAAe,MAAoB;AAAA,EAAC;AAC/E;;;ACxFA,SAAS,qBAAqB;AAC9B,SAAS,SAAS,QAAAA,aAAY;AAQvB,SAAS,qBAAqB,eAA+B;AACnE,SAAOA,MAAK,QAAQ,cAAc,aAAa,CAAC,GAAG,KAAK;AACzD;","names":["join"]}
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fonderie/store",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Database abstraction layer — IStoreAdapter interface, PostgreSQL driver, sequential migration runner, and SQL tagged-template helpers. The only package every other module depends on.",
|
|
5
5
|
"keywords": [
|
|
6
|
-
"
|
|
6
|
+
"fonderiejs",
|
|
7
7
|
"postgres",
|
|
8
8
|
"postgresql",
|
|
9
9
|
"database",
|
|
@@ -52,13 +52,13 @@
|
|
|
52
52
|
"check": "biome check --write src"
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
|
-
"pg": "^8.
|
|
55
|
+
"pg": "^8.23.0"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
|
-
"@types/node": "^
|
|
59
|
-
"@types/pg": "^8.
|
|
58
|
+
"@types/node": "^26.4.1",
|
|
59
|
+
"@types/pg": "^8.21.0",
|
|
60
60
|
"tsup": "^8.5.1",
|
|
61
|
-
"tsx": "^4.
|
|
61
|
+
"tsx": "^4.23.13",
|
|
62
62
|
"typescript": "^6.0.3"
|
|
63
63
|
},
|
|
64
64
|
"publishConfig": {
|
|
@@ -72,11 +72,11 @@
|
|
|
72
72
|
],
|
|
73
73
|
"repository": {
|
|
74
74
|
"type": "git",
|
|
75
|
-
"url": "git+https://github.com/fonderiejs/
|
|
75
|
+
"url": "git+https://github.com/fonderiejs/fonderie.git",
|
|
76
76
|
"directory": "packages/store"
|
|
77
77
|
},
|
|
78
|
-
"homepage": "https://github.com/fonderiejs/
|
|
78
|
+
"homepage": "https://github.com/fonderiejs/fonderie/tree/main/packages/store#readme",
|
|
79
79
|
"bugs": {
|
|
80
|
-
"url": "https://github.com/fonderiejs/
|
|
80
|
+
"url": "https://github.com/fonderiejs/fonderie/issues"
|
|
81
81
|
}
|
|
82
82
|
}
|