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 +2 -1
- package/utils/index.js +2 -0
- package/utils/utils/db.js +946 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cynx-lib",
|
|
3
|
-
"version": "1.3.
|
|
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
|
+
};
|