@b9g/zen 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,555 @@
1
+ /// <reference types="./postgres.d.ts" />
2
+ import {
3
+ quoteIdent,
4
+ renderDDL
5
+ } from "../chunk-2IEEEMRN.js";
6
+ import {
7
+ generateColumnDDL,
8
+ generateDDL
9
+ } from "../chunk-QXGEP5PB.js";
10
+ import {
11
+ ConstraintPreflightError,
12
+ EnsureError,
13
+ SchemaDriftError,
14
+ getTableMeta,
15
+ resolveSQLBuiltin
16
+ } from "../chunk-56M5Z3A6.js";
17
+
18
+ // src/postgres.ts
19
+ import {
20
+ ConstraintViolationError,
21
+ isSQLBuiltin,
22
+ isSQLIdentifier
23
+ } from "./zen.js";
24
+ import postgres from "postgres";
25
+ var DIALECT = "postgresql";
26
+ function quoteIdent2(name) {
27
+ return quoteIdent(name, DIALECT);
28
+ }
29
+ function buildSQL(strings, values) {
30
+ let sql = strings[0];
31
+ const params = [];
32
+ let paramIndex = 1;
33
+ for (let i = 0; i < values.length; i++) {
34
+ const value = values[i];
35
+ if (isSQLBuiltin(value)) {
36
+ sql += resolveSQLBuiltin(value) + strings[i + 1];
37
+ } else if (isSQLIdentifier(value)) {
38
+ sql += quoteIdent2(value.name) + strings[i + 1];
39
+ } else {
40
+ sql += `$${paramIndex++}` + strings[i + 1];
41
+ params.push(value);
42
+ }
43
+ }
44
+ return { sql, params };
45
+ }
46
+ var PostgresDriver = class {
47
+ supportsReturning = true;
48
+ #sql;
49
+ constructor(url, options = {}) {
50
+ this.#sql = postgres(url, {
51
+ max: options.max ?? 10,
52
+ idle_timeout: options.idleTimeout ?? 30,
53
+ connect_timeout: options.connectTimeout ?? 30
54
+ });
55
+ }
56
+ /**
57
+ * Convert PostgreSQL errors to Zealot errors.
58
+ */
59
+ #handleError(error) {
60
+ if (error && typeof error === "object" && "code" in error) {
61
+ const code = error.code;
62
+ const message = error.message || String(error);
63
+ const constraint = error.constraint_name || error.constraint;
64
+ const table = error.table_name || error.table;
65
+ const column = error.column_name || error.column;
66
+ let kind = "unknown";
67
+ if (code === "23505")
68
+ kind = "unique";
69
+ else if (code === "23503")
70
+ kind = "foreign_key";
71
+ else if (code === "23514")
72
+ kind = "check";
73
+ else if (code === "23502")
74
+ kind = "not_null";
75
+ if (kind !== "unknown") {
76
+ throw new ConstraintViolationError(
77
+ message,
78
+ {
79
+ kind,
80
+ constraint,
81
+ table,
82
+ column
83
+ },
84
+ {
85
+ cause: error
86
+ }
87
+ );
88
+ }
89
+ }
90
+ throw error;
91
+ }
92
+ async all(strings, values) {
93
+ try {
94
+ const { sql, params } = buildSQL(strings, values);
95
+ const result = await this.#sql.unsafe(sql, params);
96
+ return result;
97
+ } catch (error) {
98
+ return this.#handleError(error);
99
+ }
100
+ }
101
+ async get(strings, values) {
102
+ try {
103
+ const { sql, params } = buildSQL(strings, values);
104
+ const result = await this.#sql.unsafe(sql, params);
105
+ return result[0] ?? null;
106
+ } catch (error) {
107
+ return this.#handleError(error);
108
+ }
109
+ }
110
+ async run(strings, values) {
111
+ try {
112
+ const { sql, params } = buildSQL(strings, values);
113
+ const result = await this.#sql.unsafe(sql, params);
114
+ return result.count;
115
+ } catch (error) {
116
+ return this.#handleError(error);
117
+ }
118
+ }
119
+ async val(strings, values) {
120
+ try {
121
+ const { sql, params } = buildSQL(strings, values);
122
+ const result = await this.#sql.unsafe(sql, params);
123
+ const row = result[0];
124
+ if (!row)
125
+ return null;
126
+ const rowValues = Object.values(row);
127
+ return rowValues[0];
128
+ } catch (error) {
129
+ return this.#handleError(error);
130
+ }
131
+ }
132
+ async close() {
133
+ await this.#sql.end();
134
+ }
135
+ async transaction(fn) {
136
+ const handleError = this.#handleError.bind(this);
137
+ const result = await this.#sql.begin(async (txSql) => {
138
+ const txDriver = {
139
+ supportsReturning: true,
140
+ all: async (strings, values) => {
141
+ try {
142
+ const { sql, params } = buildSQL(strings, values);
143
+ const result2 = await txSql.unsafe(sql, params);
144
+ return result2;
145
+ } catch (error) {
146
+ return handleError(error);
147
+ }
148
+ },
149
+ get: async (strings, values) => {
150
+ try {
151
+ const { sql, params } = buildSQL(strings, values);
152
+ const result2 = await txSql.unsafe(sql, params);
153
+ return result2[0] ?? null;
154
+ } catch (error) {
155
+ return handleError(error);
156
+ }
157
+ },
158
+ run: async (strings, values) => {
159
+ try {
160
+ const { sql, params } = buildSQL(strings, values);
161
+ const result2 = await txSql.unsafe(sql, params);
162
+ return result2.count;
163
+ } catch (error) {
164
+ return handleError(error);
165
+ }
166
+ },
167
+ val: async (strings, values) => {
168
+ try {
169
+ const { sql, params } = buildSQL(strings, values);
170
+ const result2 = await txSql.unsafe(sql, params);
171
+ const row = result2[0];
172
+ if (!row)
173
+ return null;
174
+ const rowValues = Object.values(row);
175
+ return rowValues[0];
176
+ } catch (error) {
177
+ return handleError(error);
178
+ }
179
+ },
180
+ close: async () => {
181
+ },
182
+ transaction: async () => {
183
+ throw new Error("Nested transactions are not supported");
184
+ }
185
+ };
186
+ return await fn(txDriver);
187
+ });
188
+ return result;
189
+ }
190
+ async withMigrationLock(fn) {
191
+ const MIGRATION_LOCK_ID = 1952393421;
192
+ await this.#sql`SELECT pg_advisory_lock(${MIGRATION_LOCK_ID})`;
193
+ try {
194
+ return await fn();
195
+ } finally {
196
+ await this.#sql`SELECT pg_advisory_unlock(${MIGRATION_LOCK_ID})`;
197
+ }
198
+ }
199
+ // ========================================================================
200
+ // Schema Management Methods
201
+ // ========================================================================
202
+ /**
203
+ * Ensure table exists with the specified structure.
204
+ * Creates table if missing, adds missing columns/indexes.
205
+ * Throws SchemaDriftError if constraints are missing.
206
+ */
207
+ async ensureTable(table) {
208
+ const tableName = table.name;
209
+ let step = 0;
210
+ let applied = false;
211
+ try {
212
+ const exists = await this.#tableExists(tableName);
213
+ if (!exists) {
214
+ step = 1;
215
+ const ddlTemplate = generateDDL(table, { dialect: DIALECT });
216
+ const ddlSQL = renderDDL(ddlTemplate[0], ddlTemplate.slice(1), DIALECT);
217
+ for (const stmt of ddlSQL.split(";").filter((s) => s.trim())) {
218
+ await this.#sql.unsafe(stmt.trim());
219
+ }
220
+ applied = true;
221
+ } else {
222
+ step = 2;
223
+ const columnsApplied = await this.#ensureMissingColumns(table);
224
+ applied = applied || columnsApplied;
225
+ step = 3;
226
+ const indexesApplied = await this.#ensureMissingIndexes(table);
227
+ applied = applied || indexesApplied;
228
+ step = 4;
229
+ await this.#checkMissingConstraints(table);
230
+ }
231
+ return { applied };
232
+ } catch (error) {
233
+ if (error instanceof SchemaDriftError || error instanceof ConstraintPreflightError) {
234
+ throw error;
235
+ }
236
+ throw new EnsureError(
237
+ `Failed to ensure table "${tableName}" exists (step ${step})`,
238
+ {
239
+ operation: "ensureTable",
240
+ table: tableName,
241
+ step
242
+ },
243
+ {
244
+ cause: error
245
+ }
246
+ );
247
+ }
248
+ }
249
+ /**
250
+ * Ensure constraints exist on the table.
251
+ * Applies unique and foreign key constraints with preflight checks.
252
+ */
253
+ async ensureConstraints(table) {
254
+ const tableName = table.name;
255
+ let step = 0;
256
+ let applied = false;
257
+ try {
258
+ step = 1;
259
+ const existingConstraints = await this.#getConstraints(tableName);
260
+ step = 2;
261
+ const uniqueApplied = await this.#ensureUniqueConstraints(
262
+ table,
263
+ existingConstraints
264
+ );
265
+ applied = applied || uniqueApplied;
266
+ step = 3;
267
+ const fkApplied = await this.#ensureForeignKeys(
268
+ table,
269
+ existingConstraints
270
+ );
271
+ applied = applied || fkApplied;
272
+ return { applied };
273
+ } catch (error) {
274
+ if (error instanceof SchemaDriftError || error instanceof ConstraintPreflightError) {
275
+ throw error;
276
+ }
277
+ throw new EnsureError(
278
+ `Failed to ensure constraints on table "${tableName}" (step ${step})`,
279
+ {
280
+ operation: "ensureConstraints",
281
+ table: tableName,
282
+ step
283
+ },
284
+ {
285
+ cause: error
286
+ }
287
+ );
288
+ }
289
+ }
290
+ // ========================================================================
291
+ // Private Helper Methods
292
+ // ========================================================================
293
+ async #tableExists(tableName) {
294
+ const result = await this.#sql`
295
+ SELECT EXISTS (
296
+ SELECT FROM information_schema.tables
297
+ WHERE table_schema = 'public'
298
+ AND table_name = ${tableName}
299
+ ) as exists
300
+ `;
301
+ return result[0]?.exists ?? false;
302
+ }
303
+ async #getColumns(tableName) {
304
+ const result = await this.#sql`
305
+ SELECT column_name, data_type, is_nullable
306
+ FROM information_schema.columns
307
+ WHERE table_schema = 'public'
308
+ AND table_name = ${tableName}
309
+ ORDER BY ordinal_position
310
+ `;
311
+ return result.map((row) => ({
312
+ name: row.column_name,
313
+ type: row.data_type,
314
+ notnull: row.is_nullable === "NO"
315
+ }));
316
+ }
317
+ async #getIndexes(tableName) {
318
+ const result = await this.#sql`
319
+ SELECT indexname, indexdef
320
+ FROM pg_indexes
321
+ WHERE schemaname = 'public'
322
+ AND tablename = ${tableName}
323
+ `;
324
+ return result.map((row) => {
325
+ const match = row.indexdef.match(/\((.*?)\)/);
326
+ const columns = match ? match[1].split(",").map((c) => c.trim().replace(/"/g, "")) : [];
327
+ const unique = row.indexdef.includes("UNIQUE INDEX");
328
+ return {
329
+ name: row.indexname,
330
+ columns,
331
+ unique
332
+ };
333
+ });
334
+ }
335
+ async #getConstraints(tableName) {
336
+ const result = await this.#sql`
337
+ SELECT
338
+ tc.constraint_name,
339
+ tc.constraint_type,
340
+ array_agg(kcu.column_name ORDER BY kcu.ordinal_position)::text as column_names,
341
+ ccu.table_name as foreign_table_name,
342
+ array_agg(ccu.column_name ORDER BY kcu.ordinal_position)::text as foreign_column_names
343
+ FROM information_schema.table_constraints tc
344
+ LEFT JOIN information_schema.key_column_usage kcu
345
+ ON tc.constraint_name = kcu.constraint_name
346
+ AND tc.table_schema = kcu.table_schema
347
+ LEFT JOIN information_schema.constraint_column_usage ccu
348
+ ON tc.constraint_name = ccu.constraint_name
349
+ AND tc.table_schema = ccu.table_schema
350
+ WHERE tc.table_schema = 'public'
351
+ AND tc.table_name = ${tableName}
352
+ GROUP BY tc.constraint_name, tc.constraint_type, ccu.table_name
353
+ `;
354
+ return result.map((row) => {
355
+ let type;
356
+ if (row.constraint_type === "UNIQUE")
357
+ type = "unique";
358
+ else if (row.constraint_type === "FOREIGN KEY")
359
+ type = "foreign_key";
360
+ else if (row.constraint_type === "PRIMARY KEY")
361
+ type = "primary_key";
362
+ else
363
+ type = "check";
364
+ const parseArray = (str) => {
365
+ if (!str)
366
+ return [];
367
+ const match = str.match(/^\{(.*)\}$/);
368
+ return match ? match[1].split(",").map((s) => s.trim()) : [];
369
+ };
370
+ return {
371
+ name: row.constraint_name,
372
+ type,
373
+ columns: parseArray(row.column_names),
374
+ referencedTable: row.foreign_table_name ?? void 0,
375
+ referencedColumns: row.foreign_column_names ? parseArray(row.foreign_column_names) : void 0
376
+ };
377
+ });
378
+ }
379
+ async #ensureMissingColumns(table) {
380
+ const existingCols = await this.#getColumns(table.name);
381
+ const existingColNames = new Set(existingCols.map((c) => c.name));
382
+ const schemaFields = Object.keys(table.schema.shape);
383
+ let applied = false;
384
+ for (const fieldName of schemaFields) {
385
+ if (!existingColNames.has(fieldName)) {
386
+ await this.#addColumn(table, fieldName);
387
+ applied = true;
388
+ }
389
+ }
390
+ return applied;
391
+ }
392
+ async #addColumn(table, fieldName) {
393
+ const zodType = table.schema.shape[fieldName];
394
+ const fieldMeta = getTableMeta(table).fields[fieldName] || {};
395
+ const colTemplate = generateColumnDDL(
396
+ fieldName,
397
+ zodType,
398
+ fieldMeta,
399
+ DIALECT
400
+ );
401
+ const colSQL = renderDDL(colTemplate[0], colTemplate.slice(1), DIALECT);
402
+ await this.#sql.unsafe(
403
+ `ALTER TABLE ${quoteIdent2(table.name)} ADD COLUMN IF NOT EXISTS ${colSQL}`
404
+ );
405
+ }
406
+ async #ensureMissingIndexes(table) {
407
+ const existingIndexes = await this.#getIndexes(table.name);
408
+ const existingIndexNames = new Set(existingIndexes.map((idx) => idx.name));
409
+ const meta = getTableMeta(table);
410
+ let applied = false;
411
+ for (const fieldName of meta.indexed) {
412
+ const indexName = `idx_${table.name}_${fieldName}`;
413
+ if (!existingIndexNames.has(indexName)) {
414
+ await this.#createIndex(table.name, [fieldName], false);
415
+ applied = true;
416
+ }
417
+ }
418
+ for (const indexCols of table.indexes) {
419
+ const indexName = `idx_${table.name}_${indexCols.join("_")}`;
420
+ if (!existingIndexNames.has(indexName)) {
421
+ await this.#createIndex(table.name, indexCols, false);
422
+ applied = true;
423
+ }
424
+ }
425
+ return applied;
426
+ }
427
+ async #createIndex(tableName, columns, unique) {
428
+ const prefix = unique ? "uniq" : "idx";
429
+ const indexName = `${prefix}_${tableName}_${columns.join("_")}`;
430
+ const uniqueClause = unique ? "UNIQUE " : "";
431
+ const columnList = columns.map(quoteIdent2).join(", ");
432
+ const sql = `CREATE ${uniqueClause}INDEX IF NOT EXISTS ${quoteIdent2(indexName)} ON ${quoteIdent2(tableName)} (${columnList})`;
433
+ await this.#sql.unsafe(sql);
434
+ return indexName;
435
+ }
436
+ async #checkMissingConstraints(table) {
437
+ const existingConstraints = await this.#getConstraints(table.name);
438
+ const meta = getTableMeta(table);
439
+ for (const fieldName of Object.keys(meta.fields)) {
440
+ const fieldMeta = meta.fields[fieldName];
441
+ if (fieldMeta.unique) {
442
+ const hasConstraint = existingConstraints.some(
443
+ (c) => c.type === "unique" && c.columns.length === 1 && c.columns[0] === fieldName
444
+ );
445
+ if (!hasConstraint) {
446
+ throw new SchemaDriftError(
447
+ `Table "${table.name}" is missing UNIQUE constraint on column "${fieldName}"`,
448
+ {
449
+ table: table.name,
450
+ drift: `missing unique:${fieldName}`,
451
+ suggestion: `Run ensureConstraints() to apply constraints`
452
+ }
453
+ );
454
+ }
455
+ }
456
+ }
457
+ for (const ref of meta.references) {
458
+ const hasFK = existingConstraints.some(
459
+ (c) => c.type === "foreign_key" && c.columns.length === 1 && c.columns[0] === ref.fieldName && c.referencedTable === ref.table.name && c.referencedColumns?.[0] === ref.referencedField
460
+ );
461
+ if (!hasFK) {
462
+ throw new SchemaDriftError(
463
+ `Table "${table.name}" is missing FOREIGN KEY constraint on column "${ref.fieldName}"`,
464
+ {
465
+ table: table.name,
466
+ drift: `missing foreign_key:${ref.fieldName}->${ref.table.name}.${ref.referencedField}`,
467
+ suggestion: `Run ensureConstraints() to apply constraints`
468
+ }
469
+ );
470
+ }
471
+ }
472
+ }
473
+ async #ensureUniqueConstraints(table, existingConstraints) {
474
+ const meta = getTableMeta(table);
475
+ let applied = false;
476
+ for (const fieldName of Object.keys(meta.fields)) {
477
+ const fieldMeta = meta.fields[fieldName];
478
+ if (fieldMeta.unique) {
479
+ const hasConstraint = existingConstraints.some(
480
+ (c) => c.type === "unique" && c.columns.includes(fieldName)
481
+ );
482
+ if (!hasConstraint) {
483
+ await this.#preflightUnique(table.name, [fieldName]);
484
+ await this.#createIndex(table.name, [fieldName], true);
485
+ applied = true;
486
+ }
487
+ }
488
+ }
489
+ return applied;
490
+ }
491
+ async #ensureForeignKeys(table, existingConstraints) {
492
+ const meta = getTableMeta(table);
493
+ let applied = false;
494
+ for (const ref of meta.references) {
495
+ const hasFK = existingConstraints.some(
496
+ (c) => c.type === "foreign_key" && c.columns.length === 1 && c.columns[0] === ref.fieldName && c.referencedTable === ref.table.name && c.referencedColumns?.[0] === ref.referencedField
497
+ );
498
+ if (!hasFK) {
499
+ await this.#preflightForeignKey(
500
+ table.name,
501
+ ref.fieldName,
502
+ ref.table.name,
503
+ ref.referencedField
504
+ );
505
+ const constraintName = `fk_${table.name}_${ref.fieldName}`;
506
+ const onDelete = ref.onDelete ? ` ON DELETE ${ref.onDelete.toUpperCase()}` : "";
507
+ await this.#sql.unsafe(
508
+ `ALTER TABLE ${quoteIdent2(table.name)} ADD CONSTRAINT ${quoteIdent2(constraintName)} FOREIGN KEY (${quoteIdent2(ref.fieldName)}) REFERENCES ${quoteIdent2(ref.table.name)} (${quoteIdent2(ref.referencedField)})${onDelete}`
509
+ );
510
+ applied = true;
511
+ }
512
+ }
513
+ return applied;
514
+ }
515
+ async #preflightUnique(tableName, columns) {
516
+ const columnList = columns.map(quoteIdent2).join(", ");
517
+ const result = await this.#sql.unsafe(
518
+ `SELECT COUNT(*) as count FROM ${quoteIdent2(tableName)} GROUP BY ${columnList} HAVING COUNT(*) > 1`
519
+ );
520
+ const violationCount = result.length;
521
+ if (violationCount > 0) {
522
+ const diagQuery = `SELECT ${columnList}, COUNT(*) FROM ${quoteIdent2(tableName)} GROUP BY ${columnList} HAVING COUNT(*) > 1`;
523
+ throw new ConstraintPreflightError(
524
+ `Cannot add UNIQUE constraint on "${tableName}"(${columns.join(", ")}): duplicate values exist`,
525
+ {
526
+ table: tableName,
527
+ constraint: `unique:${columns.join(",")}`,
528
+ violationCount,
529
+ query: diagQuery
530
+ }
531
+ );
532
+ }
533
+ }
534
+ async #preflightForeignKey(tableName, column, refTable, refColumn) {
535
+ const result = await this.#sql.unsafe(
536
+ `SELECT COUNT(*) as count FROM ${quoteIdent2(tableName)} t WHERE t.${quoteIdent2(column)} IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${quoteIdent2(refTable)} r WHERE r.${quoteIdent2(refColumn)} = t.${quoteIdent2(column)})`
537
+ );
538
+ const violationCount = parseInt(result[0]?.count ?? "0", 10);
539
+ if (violationCount > 0) {
540
+ const diagQuery = `SELECT t.${quoteIdent2(column)} FROM ${quoteIdent2(tableName)} t WHERE t.${quoteIdent2(column)} IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${quoteIdent2(refTable)} r WHERE r.${quoteIdent2(refColumn)} = t.${quoteIdent2(column)})`;
541
+ throw new ConstraintPreflightError(
542
+ `Cannot add FOREIGN KEY constraint on "${tableName}"(${column}): ${violationCount} orphaned rows exist`,
543
+ {
544
+ table: tableName,
545
+ constraint: `foreign_key:${column}->${refTable}.${refColumn}`,
546
+ violationCount,
547
+ query: diagQuery
548
+ }
549
+ );
550
+ }
551
+ }
552
+ };
553
+ export {
554
+ PostgresDriver as default
555
+ };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * better-sqlite3 adapter for @b9g/zen
3
+ *
4
+ * Provides a Driver implementation for better-sqlite3 (Node.js).
5
+ * The connection is persistent - call close() when done.
6
+ *
7
+ * Requires: better-sqlite3
8
+ */
9
+ import type { Driver, EnsureResult } from "./zen.js";
10
+ import type { Table } from "./impl/table.js";
11
+ /**
12
+ * SQLite driver using better-sqlite3.
13
+ *
14
+ * @example
15
+ * import SQLiteDriver from "@b9g/zen/sqlite";
16
+ * import {Database} from "@b9g/zen";
17
+ *
18
+ * const driver = new SQLiteDriver("file:app.db");
19
+ * const db = new Database(driver);
20
+ *
21
+ * db.addEventListener("upgradeneeded", (e) => {
22
+ * e.waitUntil(runMigrations(e));
23
+ * });
24
+ *
25
+ * await db.open(1);
26
+ *
27
+ * // When done:
28
+ * await driver.close();
29
+ */
30
+ export default class SQLiteDriver implements Driver {
31
+ #private;
32
+ readonly supportsReturning = true;
33
+ constructor(url: string);
34
+ all<T>(strings: TemplateStringsArray, values: unknown[]): Promise<T[]>;
35
+ get<T>(strings: TemplateStringsArray, values: unknown[]): Promise<T | null>;
36
+ run(strings: TemplateStringsArray, values: unknown[]): Promise<number>;
37
+ val<T>(strings: TemplateStringsArray, values: unknown[]): Promise<T | null>;
38
+ close(): Promise<void>;
39
+ transaction<T>(fn: (txDriver: Driver) => Promise<T>): Promise<T>;
40
+ withMigrationLock<T>(fn: () => Promise<T>): Promise<T>;
41
+ ensureTable<T extends Table<any>>(table: T): Promise<EnsureResult>;
42
+ ensureConstraints<T extends Table<any>>(table: T): Promise<EnsureResult>;
43
+ }