cynx-lib 1.3.37 → 1.3.38

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cynx-lib",
3
- "version": "1.3.37",
3
+ "version": "1.3.38",
4
4
  "type": "module",
5
5
  "main": "./index.js",
6
6
  "bin": {
@@ -10,6 +10,7 @@
10
10
  ".": "./index.js",
11
11
  "./ui": "./ui/index.js",
12
12
  "./utils": "./utils/index.js",
13
+ "./db": "./utils/utils/db.js",
13
14
  "./gen": "./gen.js"
14
15
  },
15
16
  "peerDependencies": {
package/utils/index.js CHANGED
@@ -7,6 +7,7 @@ export * from './utils/storage.js';
7
7
  export * from './utils/duration.js';
8
8
  export * from './utils/session.js';
9
9
  export * from './utils/ui.js';
10
+ export * from './utils/db.js';
10
11
 
11
12
  export { default as uidUtil } from './utils/uid.js';
12
13
  export { default as realtimeUtil } from './utils/realtime.js';
@@ -16,3 +17,4 @@ export { default as storageUtil } from './utils/storage.js';
16
17
  export { default as durationUtil } from './utils/duration.js';
17
18
  export { default as sessionUtil } from './utils/session.js';
18
19
  export { default as uiUtil } from './utils/ui.js';
20
+ export { default as dbUtil } from './utils/db.js';
@@ -0,0 +1,946 @@
1
+ /**
2
+ * cynx/utils — Universal Zero-Lock Database Schema Sync Engine (db.js)
3
+ *
4
+ * Automatically and non-destructively synchronizes PostgreSQL databases with a declarative schema.sql:
5
+ * - Fresh database detection (applies full schema.sql in one fast pass)
6
+ * - Zero-Lock catalog inspection (queries information_schema and pg_catalog with 0ms lock)
7
+ * - Incremental sync: adds missing tables, columns, constraints, indexes without dropping existing data
8
+ * - Executes custom data migration/seed `DO $$ ... $$` blocks
9
+ * - Safe DDL execution with retry on lock timeouts and deadlocks
10
+ * - Supports dry-run and custom loggers
11
+ *
12
+ * Works in Node.js (CommonJS & ESM), Serverless, and Container environments.
13
+ * Zero external dependencies.
14
+ */
15
+
16
+ import fs from 'fs';
17
+ import path from 'path';
18
+
19
+ // ─── SAFE DDL RUNNER ────────────────────────────────────────────────────────────
20
+
21
+ /**
22
+ * Run an individual DDL statement safely with automatic retries on lock timeouts / deadlocks.
23
+ *
24
+ * @param {Function} queryFn (sql: string) => Promise<any>
25
+ * @param {string} sql DDL SQL command
26
+ * @param {string} [label='DDL'] Operation label for logging
27
+ * @param {object} [options]
28
+ * @param {number} [options.maxRetries=3] Maximum number of retry attempts
29
+ * @param {number} [options.retryDelayMs=300] Base backoff delay in ms
30
+ * @param {object} [options.logger=console] Logger instance
31
+ * @returns {Promise<boolean>} True if executed successfully, false otherwise
32
+ */
33
+ export async function runSafeDdl(queryFn, sql, label = 'DDL', { maxRetries = 3, retryDelayMs = 300, logger = console } = {}) {
34
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
35
+ try {
36
+ await queryFn(sql);
37
+ return true;
38
+ } catch (err) {
39
+ const msg = err && err.message ? err.message : String(err);
40
+ const isLockTimeout = msg.includes('lock timeout') || msg.includes('deadlock') || msg.includes('canceling statement due to lock timeout');
41
+
42
+ if (isLockTimeout && attempt < maxRetries) {
43
+ if (logger && logger.warn) {
44
+ logger.warn(`[DB Sync] Lock timeout on ${label} (attempt ${attempt}/${maxRetries}), retrying in ${retryDelayMs * attempt}ms...`);
45
+ }
46
+ await new Promise((resolve) => setTimeout(resolve, retryDelayMs * attempt));
47
+ continue;
48
+ }
49
+
50
+ if (logger && logger.warn) {
51
+ logger.warn(`[DB Sync] Notice on ${label}: ${msg}`);
52
+ }
53
+ return false;
54
+ }
55
+ }
56
+ return false;
57
+ }
58
+
59
+ // ─── SCHEMA PARSER FUNCTIONS ───────────────────────────────────────────────────
60
+
61
+ /**
62
+ * Parse CREATE TABLE statements and extract table names and column definitions.
63
+ *
64
+ * @param {string} schemaSql
65
+ * @returns {Array<{ name: string, columns: Array<{ name: string, def: string, raw: string }>, fullCreateSql: string }>}
66
+ */
67
+ export function parseSchemaTables(schemaSql) {
68
+ if (!schemaSql || typeof schemaSql !== 'string') return [];
69
+ const tableRegex = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([a-zA-Z0-9_]+)\s*\(([\s\S]*?)\);/gi;
70
+ let match;
71
+ const tables = [];
72
+
73
+ while ((match = tableRegex.exec(schemaSql)) !== null) {
74
+ const tableName = match[1].toLowerCase();
75
+ const body = match[2];
76
+ const columns = [];
77
+
78
+ const lines = body.split('\n');
79
+ for (const rawLine of lines) {
80
+ const line = rawLine.trim().replace(/,$/, '');
81
+ if (!line || line.startsWith('--') || line.startsWith('/*')) continue;
82
+ if (/^(PRIMARY\s+KEY|FOREIGN\s+KEY|UNIQUE|CONSTRAINT|CHECK)\b/i.test(line)) continue;
83
+
84
+ const colMatch = line.match(/^([a-zA-Z0-9_]+)\s+([\s\S]+)$/i);
85
+ if (colMatch) {
86
+ const colName = colMatch[1].toLowerCase();
87
+ let colDef = colMatch[2].trim();
88
+ // Remove PRIMARY KEY from incremental ADD COLUMN definition (handled at table creation)
89
+ colDef = colDef.replace(/\bPRIMARY\s+KEY\b/gi, '').trim();
90
+ columns.push({ name: colName, def: colDef, raw: line });
91
+ }
92
+ }
93
+
94
+ tables.push({
95
+ name: tableName,
96
+ columns,
97
+ fullCreateSql: match[0],
98
+ });
99
+ }
100
+
101
+ return tables;
102
+ }
103
+
104
+ /**
105
+ * Parse explicit ALTER TABLE ADD COLUMN statements.
106
+ *
107
+ * @param {string} schemaSql
108
+ * @returns {Array<{ table: string, column: string, def: string, raw: string }>}
109
+ */
110
+ export function parseSchemaAlterColumns(schemaSql) {
111
+ if (!schemaSql || typeof schemaSql !== 'string') return [];
112
+ const alterAddRegex = /ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?([a-zA-Z0-9_]+)\s+ADD\s+COLUMN\s+(?:IF\s+NOT\s+EXISTS\s+)?([a-zA-Z0-9_]+)\s+([^;]+);/gi;
113
+ let match;
114
+ const alterAdds = [];
115
+
116
+ while ((match = alterAddRegex.exec(schemaSql)) !== null) {
117
+ alterAdds.push({
118
+ table: match[1].toLowerCase(),
119
+ column: match[2].toLowerCase(),
120
+ def: match[3].trim(),
121
+ raw: match[0],
122
+ });
123
+ }
124
+
125
+ return alterAdds;
126
+ }
127
+
128
+ /**
129
+ * Parse ALTER TABLE DROP CONSTRAINT statements.
130
+ *
131
+ * @param {string} schemaSql
132
+ * @returns {Array<{ table: string, constraint: string, raw: string }>}
133
+ */
134
+ export function parseSchemaDropConstraints(schemaSql) {
135
+ if (!schemaSql || typeof schemaSql !== 'string') return [];
136
+ const dropConstRegex = /ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?([a-zA-Z0-9_]+)\s+DROP\s+CONSTRAINT\s+(?:IF\s+EXISTS\s+)?([a-zA-Z0-9_]+);/gi;
137
+ let match;
138
+ const dropConstraints = [];
139
+
140
+ while ((match = dropConstRegex.exec(schemaSql)) !== null) {
141
+ dropConstraints.push({
142
+ table: match[1].toLowerCase(),
143
+ constraint: match[2].toLowerCase(),
144
+ raw: match[0],
145
+ });
146
+ }
147
+
148
+ return dropConstraints;
149
+ }
150
+
151
+ /**
152
+ * Parse DROP INDEX statements.
153
+ *
154
+ * @param {string} schemaSql
155
+ * @returns {Array<{ name: string, raw: string }>}
156
+ */
157
+ export function parseSchemaDropIndexes(schemaSql) {
158
+ if (!schemaSql || typeof schemaSql !== 'string') return [];
159
+ const dropIndexRegex = /DROP\s+INDEX\s+(?:IF\s+EXISTS\s+)?([a-zA-Z0-9_]+);/gi;
160
+ let match;
161
+ const dropIndexes = [];
162
+
163
+ while ((match = dropIndexRegex.exec(schemaSql)) !== null) {
164
+ dropIndexes.push({
165
+ name: match[1].toLowerCase(),
166
+ raw: match[0],
167
+ });
168
+ }
169
+
170
+ return dropIndexes;
171
+ }
172
+
173
+ /**
174
+ * Parse CREATE [UNIQUE] INDEX statements.
175
+ *
176
+ * @param {string} schemaSql
177
+ * @returns {Array<{ unique: boolean, name: string, table: string, rest: string, raw: string }>}
178
+ */
179
+ export function parseSchemaIndexes(schemaSql) {
180
+ if (!schemaSql || typeof schemaSql !== 'string') return [];
181
+ const indexRegex = /CREATE\s+(UNIQUE\s+)?INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+NOT\s+EXISTS\s+)?([a-zA-Z0-9_]+)\s+ON\s+([a-zA-Z0-9_]+)\s*([\s\S]*?);/gi;
182
+ let match;
183
+ const indexes = [];
184
+
185
+ while ((match = indexRegex.exec(schemaSql)) !== null) {
186
+ indexes.push({
187
+ unique: Boolean(match[1]),
188
+ name: match[2].toLowerCase(),
189
+ table: match[3].toLowerCase(),
190
+ rest: match[4].trim(),
191
+ raw: match[0],
192
+ });
193
+ }
194
+
195
+ return indexes;
196
+ }
197
+
198
+ /**
199
+ * Parse procedural DO $$ ... $$ blocks (used for data migrations and seeding).
200
+ *
201
+ * @param {string} schemaSql
202
+ * @returns {string[]} Array of DO $$ ... $$ SQL strings
203
+ */
204
+ export function parseSchemaDoBlocks(schemaSql) {
205
+ if (!schemaSql || typeof schemaSql !== 'string') return [];
206
+ const doRegex = /DO\s+\$\$\s*BEGIN([\s\S]*?)END\s+\$\$;/gi;
207
+ let match;
208
+ const doBlocks = [];
209
+
210
+ while ((match = doRegex.exec(schemaSql)) !== null) {
211
+ doBlocks.push(match[0]);
212
+ }
213
+
214
+ return doBlocks;
215
+ }
216
+
217
+ /**
218
+ * Parse entire schema SQL into structured components.
219
+ *
220
+ * @param {string} schemaSql
221
+ * @returns {{
222
+ * tables: ReturnType<typeof parseSchemaTables>,
223
+ * alterAdds: ReturnType<typeof parseSchemaAlterColumns>,
224
+ * dropConstraints: ReturnType<typeof parseSchemaDropConstraints>,
225
+ * dropIndexes: ReturnType<typeof parseSchemaDropIndexes>,
226
+ * indexes: ReturnType<typeof parseSchemaIndexes>,
227
+ * doBlocks: string[]
228
+ * }}
229
+ */
230
+ export function parseSchema(schemaSql) {
231
+ return {
232
+ tables: parseSchemaTables(schemaSql),
233
+ alterAdds: parseSchemaAlterColumns(schemaSql),
234
+ dropConstraints: parseSchemaDropConstraints(schemaSql),
235
+ dropIndexes: parseSchemaDropIndexes(schemaSql),
236
+ indexes: parseSchemaIndexes(schemaSql),
237
+ doBlocks: parseSchemaDoBlocks(schemaSql),
238
+ };
239
+ }
240
+
241
+ // ─── MAIN DB SYNC FUNCTION ─────────────────────────────────────────────────────
242
+
243
+ /**
244
+ * Universal Zero-Lock Database Schema Synchronizer.
245
+ *
246
+ * @param {object} options
247
+ * @param {object} [options.pool] Postgres Pool instance (e.g. from 'pg')
248
+ * @param {Function} [options.query] Custom query function `(sql, params) => Promise<{ rows: any[], rowCount: number }>`
249
+ * @param {string} [options.schemaSql] Raw SQL string containing the schema definitions
250
+ * @param {string} [options.schemaPath] Path to schema.sql file
251
+ * @param {string} [options.tableSchema='public'] PostgreSQL schema name (default: 'public')
252
+ * @param {string} [options.freshCheckTable] Optional table name to test for fresh DB (defaults to checking if table count is 0)
253
+ * @param {object|boolean} [options.logger=console] Logger instance or false to suppress logs
254
+ * @param {boolean} [options.dryRun=false] If true, previews changes without executing DDL
255
+ * @param {number} [options.maxRetries=3] Max retries on lock timeouts
256
+ * @param {number} [options.retryDelayMs=300] Base backoff delay on lock timeouts
257
+ * @param {boolean} [options.throwOnError=false] If true, rethrows caught errors
258
+ * @returns {Promise<{
259
+ * success: boolean,
260
+ * isFreshDb: boolean,
261
+ * createdTables: string[],
262
+ * addedColumns: string[],
263
+ * createdIndexes: string[],
264
+ * droppedConstraints: string[],
265
+ * droppedIndexes: string[],
266
+ * executedDoBlocks: number,
267
+ * changesCount: number,
268
+ * durationMs: number,
269
+ * summary: string,
270
+ * error?: Error
271
+ * }>}
272
+ */
273
+ export async function sync_db(options = {}) {
274
+ const startTime = Date.now();
275
+ const {
276
+ pool,
277
+ query: customQuery,
278
+ schemaSql: directSql,
279
+ schemaPath,
280
+ tableSchema = 'public',
281
+ freshCheckTable,
282
+ logger: rawLogger = console,
283
+ dryRun = false,
284
+ maxRetries = 3,
285
+ retryDelayMs = 300,
286
+ throwOnError = false,
287
+ } = options;
288
+
289
+ const logger = rawLogger === false ? null : rawLogger || console;
290
+ const log = (msg) => { if (logger && logger.log) logger.log(`[DB Sync] ${msg}`); };
291
+ const warn = (msg) => { if (logger && logger.warn) logger.warn(`[DB Sync] ${msg}`); };
292
+ const error = (msg, err) => { if (logger && logger.error) logger.error(`[DB Sync] ${msg}`, err || ''); };
293
+
294
+ // Resolve query executor
295
+ let queryFn = customQuery;
296
+ if (!queryFn && pool) {
297
+ queryFn = (text, params) => pool.query(text, params);
298
+ }
299
+
300
+ if (typeof queryFn !== 'function') {
301
+ const err = new Error('[cynx/db] No database query function or pool provided to sync_db()');
302
+ error(err.message);
303
+ if (throwOnError) throw err;
304
+ return {
305
+ success: false,
306
+ isFreshDb: false,
307
+ createdTables: [],
308
+ addedColumns: [],
309
+ createdIndexes: [],
310
+ droppedConstraints: [],
311
+ droppedIndexes: [],
312
+ executedDoBlocks: 0,
313
+ changesCount: 0,
314
+ durationMs: 0,
315
+ summary: 'Failed: No query executor',
316
+ error: err,
317
+ };
318
+ }
319
+
320
+ // Resolve schema SQL
321
+ let schemaSql = directSql;
322
+ if (!schemaSql && schemaPath) {
323
+ const resolvedPath = path.resolve(schemaPath);
324
+ if (!fs.existsSync(resolvedPath)) {
325
+ const err = new Error(`[cynx/db] schema.sql not found at: ${resolvedPath}`);
326
+ warn(err.message);
327
+ if (throwOnError) throw err;
328
+ return {
329
+ success: false,
330
+ isFreshDb: false,
331
+ createdTables: [],
332
+ addedColumns: [],
333
+ createdIndexes: [],
334
+ droppedConstraints: [],
335
+ droppedIndexes: [],
336
+ executedDoBlocks: 0,
337
+ changesCount: 0,
338
+ durationMs: 0,
339
+ summary: 'Failed: schema.sql file not found',
340
+ error: err,
341
+ };
342
+ }
343
+ schemaSql = fs.readFileSync(resolvedPath, 'utf-8');
344
+ }
345
+
346
+ if (!schemaSql || typeof schemaSql !== 'string' || !schemaSql.trim()) {
347
+ const err = new Error('[cynx/db] No schema SQL provided to sync_db()');
348
+ warn(err.message);
349
+ if (throwOnError) throw err;
350
+ return {
351
+ success: false,
352
+ isFreshDb: false,
353
+ createdTables: [],
354
+ addedColumns: [],
355
+ createdIndexes: [],
356
+ droppedConstraints: [],
357
+ droppedIndexes: [],
358
+ executedDoBlocks: 0,
359
+ changesCount: 0,
360
+ durationMs: 0,
361
+ summary: 'Failed: Empty schema SQL',
362
+ error: err,
363
+ };
364
+ }
365
+
366
+ try {
367
+ // ── 1. Fresh Database Check ────────────────────────────────────────────────
368
+ let isFreshDb = false;
369
+
370
+ if (freshCheckTable) {
371
+ const tableCheck = await queryFn(
372
+ "SELECT 1 FROM information_schema.tables WHERE table_schema = $1 AND table_name = $2 LIMIT 1",
373
+ [tableSchema, String(freshCheckTable).toLowerCase()]
374
+ );
375
+ isFreshDb = tableCheck.rowCount === 0;
376
+ } else {
377
+ const countCheck = await queryFn(
378
+ "SELECT count(*)::int as count FROM information_schema.tables WHERE table_schema = $1 AND table_type = 'BASE TABLE'",
379
+ [tableSchema]
380
+ );
381
+ const tableCount = countCheck.rows && countCheck.rows[0] ? Number(countCheck.rows[0].count) : 0;
382
+ isFreshDb = tableCount === 0;
383
+ }
384
+
385
+ if (isFreshDb) {
386
+ log('Fresh database detected. Applying full schema.sql in one pass...');
387
+ if (!dryRun) {
388
+ await queryFn(schemaSql);
389
+ }
390
+ const durationMs = Date.now() - startTime;
391
+ log(`Fresh database schema initialized successfully (${durationMs}ms)`);
392
+ return {
393
+ success: true,
394
+ isFreshDb: true,
395
+ createdTables: ['* (full schema applied)'],
396
+ addedColumns: [],
397
+ createdIndexes: [],
398
+ droppedConstraints: [],
399
+ droppedIndexes: [],
400
+ executedDoBlocks: 0,
401
+ changesCount: 1,
402
+ durationMs,
403
+ summary: `Fresh database initialized in ${durationMs}ms`,
404
+ };
405
+ }
406
+
407
+ log('Analyzing schema.sql and comparing with database catalogs (Zero-Lock)...');
408
+
409
+ // ── 2. Query System Catalogs in parallel (AccessShareLock only - 0ms blocking) ──
410
+ const [tablesRes, colsRes, idxRes, conRes] = await Promise.all([
411
+ queryFn("SELECT table_name FROM information_schema.tables WHERE table_schema = $1 AND table_type = 'BASE TABLE'", [tableSchema]),
412
+ queryFn("SELECT table_name, column_name FROM information_schema.columns WHERE table_schema = $1", [tableSchema]),
413
+ queryFn("SELECT indexname FROM pg_indexes WHERE schemaname = $1", [tableSchema]),
414
+ queryFn("SELECT conname FROM pg_constraint"),
415
+ ]);
416
+
417
+ const existingTables = new Set((tablesRes.rows || []).map((r) => String(r.table_name).toLowerCase()));
418
+ const existingColumns = new Set((colsRes.rows || []).map((r) => `${String(r.table_name).toLowerCase()}.${String(r.column_name).toLowerCase()}`));
419
+ const existingIndexes = new Set((idxRes.rows || []).map((r) => String(r.indexname).toLowerCase()));
420
+ const existingConstraints = new Set((conRes.rows || []).map((r) => String(r.conname).toLowerCase()));
421
+
422
+ const createdTables = [];
423
+ const addedColumns = [];
424
+ const createdIndexes = [];
425
+ const droppedConstraints = [];
426
+ const droppedIndexes = [];
427
+ let executedDoBlocks = 0;
428
+
429
+ // ── 3. Parse Schema Rules from schema.sql ──────────────────────────────────
430
+ const parsed = parseSchema(schemaSql);
431
+ const safeDdlOpts = { maxRetries, retryDelayMs, logger };
432
+
433
+ // ── Step 0: Execute Custom DO Blocks (Data migrations / fixes / seed updates)
434
+ for (const doSql of parsed.doBlocks) {
435
+ if (!dryRun) {
436
+ await runSafeDdl(queryFn, doSql, 'schema do-block', safeDdlOpts);
437
+ }
438
+ executedDoBlocks++;
439
+ }
440
+
441
+ // ── Step A: Ensure Tables ─────────────────────────────────────────────────
442
+ for (const table of parsed.tables) {
443
+ if (!existingTables.has(table.name)) {
444
+ if (!dryRun) {
445
+ const ok = await runSafeDdl(queryFn, table.fullCreateSql, `create table ${table.name}`, safeDdlOpts);
446
+ if (ok) {
447
+ existingTables.add(table.name);
448
+ createdTables.push(table.name);
449
+ for (const col of table.columns) {
450
+ existingColumns.add(`${table.name}.${col.name}`);
451
+ }
452
+ log(`Created missing table: ${table.name}`);
453
+ }
454
+ } else {
455
+ createdTables.push(table.name);
456
+ log(`[DryRun] Would create table: ${table.name}`);
457
+ }
458
+ }
459
+ }
460
+
461
+ // ── Step B: Ensure Columns from CREATE TABLE definitions ───────────────────
462
+ for (const table of parsed.tables) {
463
+ if (existingTables.has(table.name)) {
464
+ for (const col of table.columns) {
465
+ const colKey = `${table.name}.${col.name}`;
466
+ if (!existingColumns.has(colKey)) {
467
+ const sql = `ALTER TABLE ${table.name} ADD COLUMN IF NOT EXISTS ${col.name} ${col.def};`;
468
+ if (!dryRun) {
469
+ const ok = await runSafeDdl(queryFn, sql, `add column ${colKey}`, safeDdlOpts);
470
+ if (ok) {
471
+ existingColumns.add(colKey);
472
+ addedColumns.push(colKey);
473
+ log(`Added missing column: ${colKey}`);
474
+ }
475
+ } else {
476
+ addedColumns.push(colKey);
477
+ log(`[DryRun] Would add column: ${colKey}`);
478
+ }
479
+ }
480
+ }
481
+ }
482
+ }
483
+
484
+ // ── Step C: Ensure Columns from explicit ALTER TABLE ADD COLUMN ────────────
485
+ for (const alter of parsed.alterAdds) {
486
+ const colKey = `${alter.table}.${alter.column}`;
487
+ if (existingTables.has(alter.table) && !existingColumns.has(colKey)) {
488
+ const sql = `ALTER TABLE ${alter.table} ADD COLUMN IF NOT EXISTS ${alter.column} ${alter.def};`;
489
+ if (!dryRun) {
490
+ const ok = await runSafeDdl(queryFn, sql, `alter add column ${colKey}`, safeDdlOpts);
491
+ if (ok) {
492
+ existingColumns.add(colKey);
493
+ addedColumns.push(colKey);
494
+ log(`Added missing column: ${colKey}`);
495
+ }
496
+ } else {
497
+ addedColumns.push(colKey);
498
+ log(`[DryRun] Would add column: ${colKey}`);
499
+ }
500
+ }
501
+ }
502
+
503
+ // ── Step D: Drop Obsolete Constraints & Indexes ───────────────────────────
504
+ for (const drop of parsed.dropConstraints) {
505
+ if (existingConstraints.has(drop.constraint)) {
506
+ if (!dryRun) {
507
+ const ok = await runSafeDdl(queryFn, drop.raw, `drop constraint ${drop.constraint}`, safeDdlOpts);
508
+ if (ok) {
509
+ existingConstraints.delete(drop.constraint);
510
+ droppedConstraints.push(`${drop.table}.${drop.constraint}`);
511
+ log(`Dropped obsolete constraint: ${drop.table}.${drop.constraint}`);
512
+ }
513
+ } else {
514
+ droppedConstraints.push(`${drop.table}.${drop.constraint}`);
515
+ log(`[DryRun] Would drop constraint: ${drop.table}.${drop.constraint}`);
516
+ }
517
+ }
518
+ }
519
+
520
+ for (const drop of parsed.dropIndexes) {
521
+ if (existingIndexes.has(drop.name)) {
522
+ if (!dryRun) {
523
+ const ok = await runSafeDdl(queryFn, drop.raw, `drop index ${drop.name}`, safeDdlOpts);
524
+ if (ok) {
525
+ existingIndexes.delete(drop.name);
526
+ droppedIndexes.push(drop.name);
527
+ log(`Dropped obsolete index: ${drop.name}`);
528
+ }
529
+ } else {
530
+ droppedIndexes.push(drop.name);
531
+ log(`[DryRun] Would drop index: ${drop.name}`);
532
+ }
533
+ }
534
+ }
535
+
536
+ // ── Step E: Ensure Indexes (Non-blocking) ──────────────────────────────────
537
+ for (const idx of parsed.indexes) {
538
+ if (existingTables.has(idx.table) && !existingIndexes.has(idx.name)) {
539
+ const uniqueClause = idx.unique ? 'UNIQUE ' : '';
540
+ const sql = `CREATE ${uniqueClause}INDEX IF NOT EXISTS ${idx.name} ON ${idx.table} ${idx.rest};`;
541
+ if (!dryRun) {
542
+ const ok = await runSafeDdl(queryFn, sql, `create index ${idx.name}`, safeDdlOpts);
543
+ if (ok) {
544
+ existingIndexes.add(idx.name);
545
+ createdIndexes.push(idx.name);
546
+ log(`Created index: ${idx.name}`);
547
+ }
548
+ } else {
549
+ createdIndexes.push(idx.name);
550
+ log(`[DryRun] Would create index: ${idx.name}`);
551
+ }
552
+ }
553
+ }
554
+
555
+ const durationMs = Date.now() - startTime;
556
+ const summaryParts = [];
557
+ if (createdTables.length > 0) summaryParts.push(`${createdTables.length} tables created`);
558
+ if (addedColumns.length > 0) summaryParts.push(`${addedColumns.length} columns added`);
559
+ if (createdIndexes.length > 0) summaryParts.push(`${createdIndexes.length} indexes created`);
560
+ if (droppedConstraints.length > 0) summaryParts.push(`${droppedConstraints.length} constraints dropped`);
561
+ if (droppedIndexes.length > 0) summaryParts.push(`${droppedIndexes.length} indexes dropped`);
562
+ if (executedDoBlocks > 0) summaryParts.push(`${executedDoBlocks} migration blocks run`);
563
+
564
+ const changesCount = createdTables.length + addedColumns.length + createdIndexes.length + droppedConstraints.length + droppedIndexes.length;
565
+ const summary = summaryParts.length > 0
566
+ ? `Database synced in ${durationMs}ms: ${summaryParts.join(', ')}`
567
+ : `Database schema is up to date (0 changes needed, ${durationMs}ms)`;
568
+
569
+ log(summary);
570
+
571
+ return {
572
+ success: true,
573
+ isFreshDb: false,
574
+ createdTables,
575
+ addedColumns,
576
+ createdIndexes,
577
+ droppedConstraints,
578
+ droppedIndexes,
579
+ executedDoBlocks,
580
+ changesCount,
581
+ durationMs,
582
+ summary,
583
+ };
584
+ } catch (err) {
585
+ error('Database sync failed:', err.message);
586
+ if (throwOnError) throw err;
587
+ return {
588
+ success: false,
589
+ isFreshDb: false,
590
+ createdTables: [],
591
+ addedColumns: [],
592
+ createdIndexes: [],
593
+ droppedConstraints: [],
594
+ droppedIndexes: [],
595
+ executedDoBlocks: 0,
596
+ changesCount: 0,
597
+ durationMs: Date.now() - startTime,
598
+ summary: `Failed: ${err.message}`,
599
+ error: err,
600
+ };
601
+ }
602
+ }
603
+
604
+ // ─── DROP & RESET UTILITIES ───────────────────────────────────────────────────
605
+
606
+ /**
607
+ * Universal Database Table Dropper (Clean Reset).
608
+ * Dynamically discovers all tables in the given schema and drops them with CASCADE (no hardcoded list).
609
+ *
610
+ * @param {object} options
611
+ * @param {object} [options.pool] Postgres Pool instance
612
+ * @param {Function} [options.query] Custom query function
613
+ * @param {string} [options.tableSchema='public'] PostgreSQL schema name (default: 'public')
614
+ * @param {object|boolean} [options.logger=console] Logger instance or false
615
+ * @param {boolean} [options.dryRun=false] If true, lists tables without executing drop
616
+ * @returns {Promise<{ success: boolean, droppedTables: string[], durationMs: number, summary: string, error?: Error }>}
617
+ */
618
+ export async function drop_db(options = {}) {
619
+ const startTime = Date.now();
620
+ const {
621
+ pool,
622
+ query: customQuery,
623
+ tableSchema = 'public',
624
+ logger: rawLogger = console,
625
+ dryRun = false,
626
+ } = options;
627
+
628
+ const logger = rawLogger === false ? null : rawLogger || console;
629
+ const log = (msg) => { if (logger && logger.log) logger.log(`[DB Drop] ${msg}`); };
630
+ const error = (msg, err) => { if (logger && logger.error) logger.error(`[DB Drop] ${msg}`, err || ''); };
631
+
632
+ let queryFn = customQuery;
633
+ if (!queryFn && pool) {
634
+ queryFn = (text, params) => pool.query(text, params);
635
+ }
636
+
637
+ if (typeof queryFn !== 'function') {
638
+ const err = new Error('[cynx/db] No database query function or pool provided to drop_db()');
639
+ error(err.message);
640
+ return {
641
+ success: false,
642
+ droppedTables: [],
643
+ durationMs: 0,
644
+ summary: 'Failed: No query executor',
645
+ error: err,
646
+ };
647
+ }
648
+
649
+ try {
650
+ const res = await queryFn(
651
+ "SELECT table_name FROM information_schema.tables WHERE table_schema = $1 AND table_type = 'BASE TABLE'",
652
+ [tableSchema]
653
+ );
654
+ const tables = (res.rows || []).map((r) => r.table_name);
655
+
656
+ if (tables.length === 0) {
657
+ log(`No tables found in schema '${tableSchema}'. Nothing to drop.`);
658
+ return {
659
+ success: true,
660
+ droppedTables: [],
661
+ durationMs: Date.now() - startTime,
662
+ summary: `0 tables dropped (schema '${tableSchema}' was already empty)`,
663
+ };
664
+ }
665
+
666
+ if (dryRun) {
667
+ log(`[DryRun] Would drop ${tables.length} tables: ${tables.join(', ')}`);
668
+ return {
669
+ success: true,
670
+ droppedTables: tables,
671
+ durationMs: Date.now() - startTime,
672
+ summary: `[DryRun] ${tables.length} tables listed for drop`,
673
+ };
674
+ }
675
+
676
+ log(`Dropping ${tables.length} tables from schema '${tableSchema}' with CASCADE...`);
677
+
678
+ const dropSql = `
679
+ DO $$ DECLARE
680
+ r RECORD;
681
+ BEGIN
682
+ FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = '${tableSchema.replace(/'/g, "''")}') LOOP
683
+ EXECUTE 'DROP TABLE IF EXISTS ' || quote_ident(r.tablename) || ' CASCADE';
684
+ END LOOP;
685
+ END $$;
686
+ `;
687
+
688
+ await queryFn(dropSql);
689
+ const durationMs = Date.now() - startTime;
690
+ const summary = `Successfully dropped ${tables.length} tables in ${durationMs}ms`;
691
+ log(summary);
692
+
693
+ return {
694
+ success: true,
695
+ droppedTables: tables,
696
+ durationMs,
697
+ summary,
698
+ };
699
+ } catch (err) {
700
+ error('Failed to drop tables:', err.message);
701
+ return {
702
+ success: false,
703
+ droppedTables: [],
704
+ durationMs: Date.now() - startTime,
705
+ summary: `Failed: ${err.message}`,
706
+ error: err,
707
+ };
708
+ }
709
+ }
710
+
711
+ /**
712
+ * Reset / Truncate a specific table, delete specific rows, or drop and recreate a table cleanly.
713
+ *
714
+ * Examples:
715
+ * // 1. Truncate entire table & restart auto-increment sequence:
716
+ * await reset_table('late_records', { pool });
717
+ *
718
+ * // 2. Delete specific rows matching criteria:
719
+ * await reset_table('users', { pool, where: { role: 'tester', is_active: false } });
720
+ * await reset_table('logs', { pool, where: "created_at < NOW() - INTERVAL '30 days'" });
721
+ *
722
+ * // 3. Drop table and recreate from schema.sql:
723
+ * await reset_table('metrics', { pool, schemaPath: './schema.sql', recreate: true });
724
+ *
725
+ * @param {string|object} tableOrOptions Target table name or options object
726
+ * @param {object} [options]
727
+ * @param {object} [options.pool] Postgres Pool instance
728
+ * @param {Function} [options.query] Custom query function
729
+ * @param {string} [options.table] Target table name (if not passed as 1st argument)
730
+ * @param {string|object|Array} [options.where] WHERE condition to delete specific rows instead of full truncate
731
+ * @param {boolean} [options.cascade=true] Truncate with CASCADE
732
+ * @param {boolean} [options.restartIdentity=true] Truncate with RESTART IDENTITY (resets sequences)
733
+ * @param {boolean} [options.recreate=false] If true, drops table and recreates it from schema.sql
734
+ * @param {string} [options.schemaSql] Schema SQL for recreate
735
+ * @param {string} [options.schemaPath] Path to schema.sql for recreate
736
+ * @param {object|boolean} [options.logger=console] Logger instance or false
737
+ * @returns {Promise<{ success: boolean, table: string, mode: 'truncate'|'delete'|'recreate', rowCount?: number, durationMs: number, summary: string, error?: Error }>}
738
+ */
739
+ export async function reset_table(tableOrOptions, options = {}) {
740
+ const startTime = Date.now();
741
+ const opts = typeof tableOrOptions === 'object' && tableOrOptions !== null ? { ...tableOrOptions, ...options } : { ...options, table: tableOrOptions };
742
+ const {
743
+ table,
744
+ pool,
745
+ query: customQuery,
746
+ where,
747
+ cascade = true,
748
+ restartIdentity = true,
749
+ recreate = false,
750
+ schemaSql,
751
+ schemaPath,
752
+ logger: rawLogger = console,
753
+ } = opts;
754
+
755
+ const logger = rawLogger === false ? null : rawLogger || console;
756
+ const log = (msg) => { if (logger && logger.log) logger.log(`[DB Reset Table] ${msg}`); };
757
+ const error = (msg, err) => { if (logger && logger.error) logger.error(`[DB Reset Table] ${msg}`, err || ''); };
758
+
759
+ if (!table || typeof table !== 'string') {
760
+ const err = new Error('[cynx/db] Table name is required for reset_table()');
761
+ error(err.message);
762
+ return {
763
+ success: false,
764
+ table: '',
765
+ mode: 'truncate',
766
+ durationMs: 0,
767
+ summary: 'Failed: Missing table name',
768
+ error: err,
769
+ };
770
+ }
771
+
772
+ const cleanTable = table.trim().toLowerCase();
773
+ let queryFn = customQuery;
774
+ if (!queryFn && pool) {
775
+ queryFn = (text, params) => pool.query(text, params);
776
+ }
777
+
778
+ if (typeof queryFn !== 'function') {
779
+ const err = new Error('[cynx/db] No database query function or pool provided to reset_table()');
780
+ error(err.message);
781
+ return {
782
+ success: false,
783
+ table: cleanTable,
784
+ mode: 'truncate',
785
+ durationMs: 0,
786
+ summary: 'Failed: No query executor',
787
+ error: err,
788
+ };
789
+ }
790
+
791
+ try {
792
+ // ── Mode 1: Delete Specific Rows (WHERE condition provided) ────────────────
793
+ if (where) {
794
+ let whereClause = '';
795
+ let params = [];
796
+
797
+ if (typeof where === 'string') {
798
+ whereClause = where.trim().replace(/^WHERE\s+/i, '');
799
+ } else if (Array.isArray(where) && where.length > 0) {
800
+ whereClause = where[0].replace(/^WHERE\s+/i, '');
801
+ params = Array.isArray(where[1]) ? where[1] : [];
802
+ } else if (typeof where === 'object') {
803
+ const conditions = [];
804
+ let pIndex = 1;
805
+ for (const [key, val] of Object.entries(where)) {
806
+ if (val === null) {
807
+ conditions.push(`${key} IS NULL`);
808
+ } else {
809
+ conditions.push(`${key} = $${pIndex++}`);
810
+ params.push(val);
811
+ }
812
+ }
813
+ whereClause = conditions.join(' AND ');
814
+ }
815
+
816
+ if (!whereClause) {
817
+ throw new Error('[cynx/db] Invalid WHERE condition provided for row reset');
818
+ }
819
+
820
+ const sql = `DELETE FROM ${cleanTable} WHERE ${whereClause};`;
821
+ const res = await queryFn(sql, params);
822
+ const rowCount = res && typeof res.rowCount === 'number' ? res.rowCount : 0;
823
+ const durationMs = Date.now() - startTime;
824
+ const summary = `Deleted ${rowCount} rows from table '${cleanTable}' (${durationMs}ms)`;
825
+ log(summary);
826
+
827
+ return {
828
+ success: true,
829
+ table: cleanTable,
830
+ mode: 'delete',
831
+ rowCount,
832
+ durationMs,
833
+ summary,
834
+ };
835
+ }
836
+
837
+ // ── Mode 2: Recreate Table from Schema (DROP + SYNC) ───────────────────────
838
+ if (recreate) {
839
+ log(`Dropping table '${cleanTable}' with CASCADE for full recreation...`);
840
+ await queryFn(`DROP TABLE IF EXISTS ${cleanTable} CASCADE;`);
841
+
842
+ log(`Recreating table '${cleanTable}' via sync_db...`);
843
+ const syncResult = await sync_db({
844
+ pool,
845
+ query: queryFn,
846
+ schemaSql,
847
+ schemaPath,
848
+ logger,
849
+ });
850
+
851
+ const durationMs = Date.now() - startTime;
852
+ const summary = `Recreated table '${cleanTable}' from schema (${durationMs}ms)`;
853
+ log(summary);
854
+
855
+ return {
856
+ success: syncResult.success,
857
+ table: cleanTable,
858
+ mode: 'recreate',
859
+ durationMs,
860
+ summary,
861
+ };
862
+ }
863
+
864
+ // ── Mode 3: Truncate Table (Default: Fast, cascades, resets identity) ───────
865
+ const restartClause = restartIdentity ? ' RESTART IDENTITY' : '';
866
+ const cascadeClause = cascade ? ' CASCADE' : '';
867
+ const sql = `TRUNCATE TABLE ${cleanTable}${restartClause}${cascadeClause};`;
868
+
869
+ await queryFn(sql);
870
+ const durationMs = Date.now() - startTime;
871
+ const summary = `Truncated table '${cleanTable}'${restartClause}${cascadeClause} in ${durationMs}ms`;
872
+ log(summary);
873
+
874
+ return {
875
+ success: true,
876
+ table: cleanTable,
877
+ mode: 'truncate',
878
+ durationMs,
879
+ summary,
880
+ };
881
+ } catch (err) {
882
+ error(`Failed to reset table '${cleanTable}':`, err.message);
883
+ return {
884
+ success: false,
885
+ table: cleanTable,
886
+ mode: where ? 'delete' : recreate ? 'recreate' : 'truncate',
887
+ durationMs: Date.now() - startTime,
888
+ summary: `Failed: ${err.message}`,
889
+ error: err,
890
+ };
891
+ }
892
+ }
893
+
894
+ /**
895
+ * Hard Reset Database (Drop All Tables + Fresh Schema Sync).
896
+ *
897
+ * @param {object} options
898
+ * @returns {Promise<{ success: boolean, dropResult: object, syncResult: object }>}
899
+ */
900
+ export async function reset_db(options = {}) {
901
+ const dropResult = await drop_db(options);
902
+ const syncResult = await sync_db(options);
903
+ return {
904
+ success: dropResult.success && syncResult.success,
905
+ dropResult,
906
+ syncResult,
907
+ };
908
+ }
909
+
910
+ // ─── CONVENIENCE ALIASES & EXPORTS ─────────────────────────────────────────────
911
+
912
+ export const init_db = sync_db;
913
+ export const initDb = sync_db;
914
+ export const dropDb = drop_db;
915
+ export const dropAllTables = drop_db;
916
+ export const resetDb = reset_db;
917
+ export const resetTable = reset_table;
918
+ export const truncate_table = reset_table;
919
+ export const truncateTable = reset_table;
920
+ export const syncDb = sync_db;
921
+ export const syncDatabaseSchema = sync_db;
922
+
923
+ export default {
924
+ sync_db,
925
+ syncDb,
926
+ syncDatabaseSchema,
927
+ init_db,
928
+ initDb,
929
+ drop_db,
930
+ dropDb,
931
+ dropAllTables,
932
+ reset_db,
933
+ resetDb,
934
+ reset_table,
935
+ resetTable,
936
+ truncate_table,
937
+ truncateTable,
938
+ runSafeDdl,
939
+ parseSchema,
940
+ parseSchemaTables,
941
+ parseSchemaAlterColumns,
942
+ parseSchemaDropConstraints,
943
+ parseSchemaDropIndexes,
944
+ parseSchemaIndexes,
945
+ parseSchemaDoBlocks,
946
+ };