tina4-nodejs 3.13.98 → 3.13.100
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/CLAUDE.md +24 -25
- package/package.json +1 -2
- package/packages/cli/dist/bin.js +20698 -18983
- package/packages/cli/src/bin.ts +28 -71
- package/packages/cli/src/commands/migrate.ts +36 -75
- package/packages/cli/src/commands/migrateRollback.ts +10 -1
- package/packages/cli/src/commands/test.ts +92 -21
- package/packages/core/dist/index.js +20561 -18828
- package/packages/core/public/js/tina4-dev-admin.min.js +23 -19
- package/packages/core/src/ai.ts +38 -13
- package/packages/core/src/api.ts +13 -5
- package/packages/core/src/background.ts +9 -3
- package/packages/core/src/devAdmin.ts +135 -20
- package/packages/core/src/dispatchPipeline.ts +185 -1
- package/packages/core/src/docs.ts +33 -5
- package/packages/core/src/env.ts +1 -1
- package/packages/core/src/errorOverlay.ts +39 -48
- package/packages/core/src/fakeData.ts +15 -0
- package/packages/core/src/index.ts +17 -6
- package/packages/core/src/logger.ts +892 -572
- package/packages/core/src/mcp.ts +9 -1
- package/packages/core/src/messenger.ts +31 -4
- package/packages/core/src/middleware.ts +169 -43
- package/packages/core/src/portTakeover.ts +232 -0
- package/packages/core/src/request.ts +57 -8
- package/packages/core/src/response.ts +67 -0
- package/packages/core/src/router.ts +35 -7
- package/packages/core/src/server.ts +450 -190
- package/packages/core/src/static.ts +81 -12
- package/packages/core/src/testClient.ts +126 -137
- package/packages/core/src/testing.ts +16 -12
- package/packages/core/src/types.ts +21 -9
- package/packages/core/src/version.ts +66 -0
- package/packages/core/src/websocket.ts +2 -2
- package/packages/core/src/websocketBackplane.ts +2 -2
- package/packages/frond/dist/index.js +149 -49
- package/packages/frond/src/engine.ts +234 -52
- package/packages/orm/dist/index.js +10941 -9231
- package/packages/orm/src/adapters/firebird.ts +200 -27
- package/packages/orm/src/adapters/mongodb.ts +160 -10
- package/packages/orm/src/adapters/mssql.ts +38 -11
- package/packages/orm/src/adapters/mysql.ts +24 -1
- package/packages/orm/src/adapters/odbc.ts +127 -29
- package/packages/orm/src/adapters/postgres.ts +18 -0
- package/packages/orm/src/adapters/sqlite.ts +93 -14
- package/packages/orm/src/autoCrud.ts +72 -8
- package/packages/orm/src/baseModel.ts +323 -71
- package/packages/orm/src/cachedDatabase.ts +48 -1
- package/packages/orm/src/database.ts +162 -59
- package/packages/orm/src/fakeData.ts +6 -2
- package/packages/orm/src/index.ts +4 -1
- package/packages/orm/src/migration.ts +95 -52
- package/packages/orm/src/query.ts +16 -4
- package/packages/orm/src/seeder.ts +43 -25
- package/packages/orm/src/sqlTranslator.ts +104 -19
- package/packages/orm/src/types.ts +97 -21
- package/packages/orm/src/validation.ts +5 -1
- package/packages/swagger/dist/index.js +3 -2
- package/packages/swagger/src/generator.ts +19 -4
- package/packages/swagger/src/ui.ts +6 -4
- package/types/cli/src/bin.d.ts +0 -22
- package/types/core/src/ai.d.ts +29 -0
- package/types/core/src/api.d.ts +11 -4
- package/types/core/src/background.d.ts +5 -2
- package/types/core/src/devAdmin.d.ts +35 -0
- package/types/core/src/dispatchPipeline.d.ts +41 -1
- package/types/core/src/errorOverlay.d.ts +13 -13
- package/types/core/src/index.d.ts +9 -6
- package/types/core/src/logger.d.ts +111 -185
- package/types/core/src/middleware.d.ts +40 -5
- package/types/core/src/portTakeover.d.ts +50 -0
- package/types/core/src/request.d.ts +15 -0
- package/types/core/src/response.d.ts +29 -0
- package/types/core/src/server.d.ts +92 -0
- package/types/core/src/testClient.d.ts +29 -3
- package/types/core/src/testing.d.ts +16 -12
- package/types/core/src/types.d.ts +21 -9
- package/types/core/src/version.d.ts +11 -0
- package/types/core/src/websocketBackplane.d.ts +1 -1
- package/types/frond/src/engine.d.ts +60 -8
- package/types/orm/src/adapters/firebird.d.ts +61 -2
- package/types/orm/src/adapters/mongodb.d.ts +20 -0
- package/types/orm/src/adapters/mssql.d.ts +11 -0
- package/types/orm/src/adapters/mysql.d.ts +11 -0
- package/types/orm/src/adapters/odbc.d.ts +35 -4
- package/types/orm/src/adapters/postgres.d.ts +11 -0
- package/types/orm/src/adapters/sqlite.d.ts +23 -4
- package/types/orm/src/baseModel.d.ts +45 -25
- package/types/orm/src/cachedDatabase.d.ts +27 -1
- package/types/orm/src/database.d.ts +56 -6
- package/types/orm/src/index.d.ts +3 -2
- package/types/orm/src/migration.d.ts +23 -5
- package/types/orm/src/query.d.ts +3 -0
- package/types/orm/src/seeder.d.ts +15 -2
- package/types/orm/src/sqlTranslator.d.ts +17 -4
- package/types/orm/src/types.d.ts +75 -16
- package/packages/core/src/errorOverlay.test.ts +0 -122
|
@@ -201,10 +201,51 @@ export class FirebirdAdapter implements DatabaseAdapter {
|
|
|
201
201
|
private db: any = null;
|
|
202
202
|
private transaction: any = null;
|
|
203
203
|
private _lastInsertId: number | bigint | null = null;
|
|
204
|
+
/** Resolved node-firebird config, kept so a dead connection can re-attach. */
|
|
205
|
+
private fbConfig: any = null;
|
|
206
|
+
|
|
207
|
+
// Substring markers (lowercased) that identify a dead-socket Firebird error
|
|
208
|
+
// worth reconnecting for (FB-DEC-01). Idle Firebird connections die behind NAT
|
|
209
|
+
// timeouts, server-side ConnectionIdleTimeout, or Docker network rotation.
|
|
210
|
+
// MEASURED on the lab: a killed attachment raises "Connection shutdown, Killed
|
|
211
|
+
// by database administrator." Node had no reconnect path before -- this closes
|
|
212
|
+
// the parity gap with Python/PHP/Ruby.
|
|
213
|
+
private static readonly DEAD_CONN_MARKERS = [
|
|
214
|
+
"error writing data to the connection",
|
|
215
|
+
"error reading data from the connection",
|
|
216
|
+
"connection shutdown",
|
|
217
|
+
"connection lost",
|
|
218
|
+
"network error",
|
|
219
|
+
"connection is not active",
|
|
220
|
+
"broken pipe",
|
|
221
|
+
];
|
|
222
|
+
|
|
223
|
+
/** Is this a dead-socket error worth a transparent reconnect (not a logical SQL error)? */
|
|
224
|
+
static isDeadConnection(err: unknown): boolean {
|
|
225
|
+
const message = String((err as Error)?.message ?? err ?? "").toLowerCase();
|
|
226
|
+
if (!message) return false;
|
|
227
|
+
return FirebirdAdapter.DEAD_CONN_MARKERS.some((marker) => message.includes(marker));
|
|
228
|
+
}
|
|
204
229
|
|
|
205
230
|
constructor(private config: FirebirdConfig | string) {}
|
|
206
231
|
|
|
207
232
|
/** Connect to Firebird. Must be called before using the adapter. */
|
|
233
|
+
/** ADR-0044 required adapter capability. */
|
|
234
|
+
getDatabaseType(): string {
|
|
235
|
+
return 'firebird';
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** ADR-0044: readable/writable native boolean. */
|
|
239
|
+
autocommit = true;
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* ADR-0044 / DBA-P02: every built-in adapter can guarantee an atomic
|
|
243
|
+
* multi-row batch by default. A test-only deployment representing one
|
|
244
|
+
* that cannot sets this false so executeMany rejects BEFORE the first
|
|
245
|
+
* write rather than risking partial durability.
|
|
246
|
+
*/
|
|
247
|
+
supportsAtomicBatch = true;
|
|
248
|
+
|
|
208
249
|
async connect(): Promise<void> {
|
|
209
250
|
const fb = requireFirebird();
|
|
210
251
|
|
|
@@ -253,14 +294,16 @@ export class FirebirdAdapter implements DatabaseAdapter {
|
|
|
253
294
|
fbConfig.database = normalizeFirebirdDbIdentifier(fbConfig.database);
|
|
254
295
|
}
|
|
255
296
|
|
|
297
|
+
// Kept so a dead connection can re-attach with the same config (FB-DEC-01).
|
|
298
|
+
this.fbConfig = fbConfig;
|
|
299
|
+
|
|
256
300
|
// node-firebird has NO connect-timeout option of its own, so there is no
|
|
257
301
|
// driver timer to translate and the outer bound is the ONLY thing standing
|
|
258
302
|
// between a silent driver and a permanently hung boot. This is the adapter
|
|
259
|
-
// the 16-minute probe measured.
|
|
303
|
+
// the 16-minute probe measured. The SRP-login retry lives inside the bound
|
|
304
|
+
// (FB-DEC-03), so the whole retry sequence is still capped by the timeout.
|
|
260
305
|
this.db = await withConnectTimeout(
|
|
261
|
-
() =>
|
|
262
|
-
fb.attach(fbConfig, (err: Error | null, db: any) => (err ? reject(err) : resolve(db)));
|
|
263
|
-
}),
|
|
306
|
+
() => this.attachWithRetry(fbConfig),
|
|
264
307
|
connectTimeoutMillis(),
|
|
265
308
|
fbConfig.host,
|
|
266
309
|
fbConfig.port,
|
|
@@ -269,6 +312,58 @@ export class FirebirdAdapter implements DatabaseAdapter {
|
|
|
269
312
|
);
|
|
270
313
|
}
|
|
271
314
|
|
|
315
|
+
private attachOnce(config: any): Promise<any> {
|
|
316
|
+
const fb = requireFirebird();
|
|
317
|
+
return new Promise((resolve, reject) => {
|
|
318
|
+
fb.attach(config, (err: Error | null, db: any) => (err ? reject(err) : resolve(db)));
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Attach with a BOUNDED retry (FB-DEC-03). node-firebird's SRP login over
|
|
324
|
+
* WireCrypt is intermittently flaky (~12% measured historically), and a flake
|
|
325
|
+
* surfaces as an auth/handshake error indistinguishable from a real one, so a
|
|
326
|
+
* bounded retry-all is the robust, honest handling: a transient handshake
|
|
327
|
+
* failure recovers, while a genuine bad credential still fails after the bound
|
|
328
|
+
* -- never skipped, never papered over.
|
|
329
|
+
*/
|
|
330
|
+
private async attachWithRetry(config: any, attempts = 4): Promise<any> {
|
|
331
|
+
let lastError: unknown;
|
|
332
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
333
|
+
try {
|
|
334
|
+
return await this.attachOnce(config);
|
|
335
|
+
} catch (err) {
|
|
336
|
+
lastError = err;
|
|
337
|
+
if (attempt < attempts - 1) {
|
|
338
|
+
await new Promise((resolve) => setTimeout(resolve, 100 * (attempt + 1)));
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
throw lastError;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Run a node-firebird op; on a DEAD-connection error (outside an explicit
|
|
347
|
+
* transaction) re-attach once and retry (FB-DEC-01). Inside a transaction the
|
|
348
|
+
* error surfaces -- atomicity beats resilience, and the caller rolls back.
|
|
349
|
+
*/
|
|
350
|
+
private async withReconnect<T>(op: () => Promise<T>): Promise<T> {
|
|
351
|
+
try {
|
|
352
|
+
return await op();
|
|
353
|
+
} catch (err) {
|
|
354
|
+
if (this.transaction || !FirebirdAdapter.isDeadConnection(err)) throw err;
|
|
355
|
+
await this.reconnectFirebird();
|
|
356
|
+
return op();
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
private async reconnectFirebird(): Promise<void> {
|
|
361
|
+
try { this.db?.detach?.(() => {}); } catch { /* already gone */ }
|
|
362
|
+
this.db = null;
|
|
363
|
+
if (!this.fbConfig) throw new Error("Firebird reconnect called before connect().");
|
|
364
|
+
this.db = await this.attachWithRetry(this.fbConfig);
|
|
365
|
+
}
|
|
366
|
+
|
|
272
367
|
private parseUrl(url: string): { host?: string; port?: number; user?: string; password?: string; database?: string } {
|
|
273
368
|
// firebird://user:pass@host:port/path/to/db.fdb[?charset=...]
|
|
274
369
|
// The path part after the host is normalised by normalizeFirebirdDbIdentifier()
|
|
@@ -330,22 +425,75 @@ export class FirebirdAdapter implements DatabaseAdapter {
|
|
|
330
425
|
}
|
|
331
426
|
|
|
332
427
|
private queryPromise(sql: string, params?: unknown[]): Promise<any[]> {
|
|
333
|
-
|
|
334
|
-
|
|
428
|
+
const translated = this.translateSql(sql);
|
|
429
|
+
// statementHandle() is read INSIDE the op so a reconnect (which replaces
|
|
430
|
+
// this.db) is picked up on the retry.
|
|
431
|
+
return this.withReconnect(() => new Promise<any[]>((resolve, reject) => {
|
|
335
432
|
this.statementHandle().query(translated, params ?? [], (err: Error | null, result: any[]) => {
|
|
336
433
|
if (err) reject(err);
|
|
337
434
|
else resolve(result ?? []);
|
|
338
435
|
});
|
|
339
|
-
});
|
|
436
|
+
}));
|
|
340
437
|
}
|
|
341
438
|
|
|
342
439
|
private executePromise(sql: string, params?: unknown[]): Promise<void> {
|
|
343
|
-
|
|
344
|
-
|
|
440
|
+
const translated = this.translateSql(sql);
|
|
441
|
+
return this.withReconnect(() => new Promise<void>((resolve, reject) => {
|
|
345
442
|
this.statementHandle().execute(translated, params ?? [], (err: Error | null) => {
|
|
346
443
|
if (err) reject(err);
|
|
347
444
|
else resolve();
|
|
348
445
|
});
|
|
446
|
+
}));
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* The real affected-row count. node-firebird gives NO DML count of its own
|
|
451
|
+
* (the callback result is undefined -- MEASURED), but Firebird 5 multi-row
|
|
452
|
+
* RETURNING surfaces one row per affected row, so `... RETURNING 1` + the row
|
|
453
|
+
* count IS the real count (FB-AFFECTED-FAB replaces the hardcoded 1). RETURNING
|
|
454
|
+
* a constant, not `*`, so a large update/delete does not materialise full rows.
|
|
455
|
+
*/
|
|
456
|
+
private async executeReturningCount(sql: string, params?: unknown[]): Promise<number> {
|
|
457
|
+
const rows = await this.queryPromise(`${sql} RETURNING 1`, params);
|
|
458
|
+
return Array.isArray(rows) ? rows.length : 0;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Firebird has no generic last_insert_id -- read the GEN_<TABLE>_ID generator
|
|
463
|
+
* the row's BEFORE INSERT trigger drew from (FB-LASTID-GAP). Column-name-
|
|
464
|
+
* independent, so correct for a non-`id` PK too. null when the table has no
|
|
465
|
+
* such generator (GEN_ID then throws -> caught).
|
|
466
|
+
*/
|
|
467
|
+
private async readGeneratorId(table: string): Promise<number | bigint | null> {
|
|
468
|
+
const generator = "GEN_" + table.replace(/"/g, "").toUpperCase() + "_ID";
|
|
469
|
+
try {
|
|
470
|
+
const rows = await this.queryPromise(`SELECT GEN_ID(${generator}, 0) AS LID FROM RDB$DATABASE`);
|
|
471
|
+
const value = (rows[0]?.["LID"] ?? rows[0]?.["lid"]) as number | bigint | undefined;
|
|
472
|
+
this._lastInsertId = value ?? null;
|
|
473
|
+
return this._lastInsertId;
|
|
474
|
+
} catch {
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Read a node-firebird BLOB column into a Buffer. A BLOB arrives as a STREAMING
|
|
481
|
+
* FUNCTION (fn((err, name, emitter) => emitter.on('data'|'end'))), NOT a Buffer
|
|
482
|
+
* -- MEASURED -- so the old decodeBlobs no-op leaked the function to the caller
|
|
483
|
+
* and no bytes round-tripped (FB-BLOB-SRP-UNVERIFIED).
|
|
484
|
+
*/
|
|
485
|
+
private readBlob(
|
|
486
|
+
blobFn: (cb: (err: Error | null, name: string, emitter: any) => void) => void,
|
|
487
|
+
): Promise<Buffer | null> {
|
|
488
|
+
return new Promise((resolve, reject) => {
|
|
489
|
+
blobFn((err, _name, emitter) => {
|
|
490
|
+
if (err) return reject(err);
|
|
491
|
+
if (!emitter) return resolve(null);
|
|
492
|
+
const chunks: Buffer[] = [];
|
|
493
|
+
emitter.on("data", (chunk: Buffer) => chunks.push(Buffer.from(chunk)));
|
|
494
|
+
emitter.on("end", () => resolve(Buffer.concat(chunks)));
|
|
495
|
+
emitter.on("error", (streamErr: Error) => reject(streamErr));
|
|
496
|
+
});
|
|
349
497
|
});
|
|
350
498
|
}
|
|
351
499
|
|
|
@@ -379,14 +527,29 @@ export class FirebirdAdapter implements DatabaseAdapter {
|
|
|
379
527
|
async queryAsync<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]> {
|
|
380
528
|
this.ensureConnected();
|
|
381
529
|
const rows = await this.queryPromise(sql, params);
|
|
382
|
-
|
|
530
|
+
const decoded: T[] = [];
|
|
531
|
+
for (const row of rows as T[]) {
|
|
532
|
+
decoded.push(await this.decodeBlobs(foldColumnNames(row)));
|
|
533
|
+
}
|
|
534
|
+
return decoded;
|
|
383
535
|
}
|
|
384
536
|
|
|
385
|
-
/**
|
|
386
|
-
*
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
537
|
+
/**
|
|
538
|
+
* Read out any BLOB columns to Buffers. node-firebird returns a BLOB as a
|
|
539
|
+
* STREAMING FUNCTION, not a Buffer (MEASURED), so a column whose value is a
|
|
540
|
+
* function is read via readBlob(); everything else passes through unchanged
|
|
541
|
+
* (FB-BLOB-SRP-UNVERIFIED -- the old no-op leaked the function to the caller).
|
|
542
|
+
*/
|
|
543
|
+
private async decodeBlobs<T>(row: T): Promise<T> {
|
|
544
|
+
if (row === null || typeof row !== "object") return row;
|
|
545
|
+
const record = row as Record<string, unknown>;
|
|
546
|
+
for (const key of Object.keys(record)) {
|
|
547
|
+
if (typeof record[key] === "function") {
|
|
548
|
+
record[key] = await this.readBlob(
|
|
549
|
+
record[key] as (cb: (err: Error | null, name: string, emitter: any) => void) => void,
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
390
553
|
return row;
|
|
391
554
|
}
|
|
392
555
|
|
|
@@ -431,7 +594,9 @@ export class FirebirdAdapter implements DatabaseAdapter {
|
|
|
431
594
|
const sql = buildInsert(FB_DIALECT, table, keys);
|
|
432
595
|
const paramsList = data.map((row) => keys.map((k) => row[k]));
|
|
433
596
|
const result = await this.executeManyAsync(sql, paramsList);
|
|
434
|
-
|
|
597
|
+
// The generator holds the LAST inserted id after the batch (FB-LASTID-GAP).
|
|
598
|
+
const lastId = (await this.readGeneratorId(table)) ?? result.lastId;
|
|
599
|
+
return { success: true, affectedRows: result.totalAffected, lastId: lastId ?? undefined };
|
|
435
600
|
}
|
|
436
601
|
|
|
437
602
|
const keys = Object.keys(data);
|
|
@@ -443,8 +608,10 @@ export class FirebirdAdapter implements DatabaseAdapter {
|
|
|
443
608
|
// is what hid a wholly broken write path — the caller awaited a resolved
|
|
444
609
|
// promise, read back zero rows, and no error surfaced anywhere.
|
|
445
610
|
await this.executePromise(sql, values);
|
|
446
|
-
//
|
|
447
|
-
|
|
611
|
+
// Derive the last-id from the GEN_<TABLE>_ID generator the trigger drew from
|
|
612
|
+
// (FB-LASTID-GAP); null when the table has no such generator.
|
|
613
|
+
const lastId = await this.readGeneratorId(table);
|
|
614
|
+
return { success: true, affectedRows: 1, lastId: lastId ?? undefined };
|
|
448
615
|
}
|
|
449
616
|
|
|
450
617
|
update(table: string, data: Record<string, unknown>, filter: Record<string, unknown>, params?: unknown[]): DatabaseResult {
|
|
@@ -466,19 +633,19 @@ export class FirebirdAdapter implements DatabaseAdapter {
|
|
|
466
633
|
// Firebird already uses `?`, so the fragment needs no rewriting.
|
|
467
634
|
if (typeof filter === "string") {
|
|
468
635
|
const where = filter ? ` WHERE ${filter}` : "";
|
|
469
|
-
await this.
|
|
636
|
+
const affected = await this.executeReturningCount(
|
|
470
637
|
`UPDATE ${fbQuote(table)} SET ${setClauses}${where}`,
|
|
471
638
|
[...Object.values(data), ...(params ?? [])],
|
|
472
639
|
);
|
|
473
|
-
return { success: true, affectedRows:
|
|
640
|
+
return { success: true, affectedRows: affected };
|
|
474
641
|
}
|
|
475
642
|
|
|
476
643
|
const whereClauses = buildWhereClause(FB_DIALECT, Object.keys(filter));
|
|
477
644
|
const sql = `UPDATE ${FB_DIALECT.quote(table)} SET ${setClauses} WHERE ${whereClauses}`;
|
|
478
645
|
const values = [...Object.values(data), ...Object.values(filter)];
|
|
479
646
|
|
|
480
|
-
await this.
|
|
481
|
-
return { success: true, affectedRows:
|
|
647
|
+
const affected = await this.executeReturningCount(sql, values);
|
|
648
|
+
return { success: true, affectedRows: affected };
|
|
482
649
|
}
|
|
483
650
|
|
|
484
651
|
delete(table: string, filter: Record<string, unknown>, params?: unknown[]): DatabaseResult {
|
|
@@ -492,8 +659,8 @@ export class FirebirdAdapter implements DatabaseAdapter {
|
|
|
492
659
|
// string as an object — db.truncate() was broken outright.
|
|
493
660
|
if (typeof filter === "string") {
|
|
494
661
|
const where = filter ? ` WHERE ${filter}` : "";
|
|
495
|
-
await this.
|
|
496
|
-
return { success: true, affectedRows:
|
|
662
|
+
const affected = await this.executeReturningCount(`DELETE FROM ${fbQuote(table)}${where}`, params ?? []);
|
|
663
|
+
return { success: true, affectedRows: affected };
|
|
497
664
|
}
|
|
498
665
|
|
|
499
666
|
// Same fbQuote policy as insert/update — see updateAsync.
|
|
@@ -501,8 +668,8 @@ export class FirebirdAdapter implements DatabaseAdapter {
|
|
|
501
668
|
const sql = `DELETE FROM ${FB_DIALECT.quote(table)} WHERE ${whereClauses}`;
|
|
502
669
|
const values = Object.values(filter);
|
|
503
670
|
|
|
504
|
-
await this.
|
|
505
|
-
return { success: true, affectedRows:
|
|
671
|
+
const affected = await this.executeReturningCount(sql, values);
|
|
672
|
+
return { success: true, affectedRows: affected };
|
|
506
673
|
}
|
|
507
674
|
|
|
508
675
|
startTransaction(): void {
|
|
@@ -741,8 +908,14 @@ function fieldTypeToFirebird(def: FieldDefinition): string {
|
|
|
741
908
|
case "number":
|
|
742
909
|
case "numeric":
|
|
743
910
|
return "DOUBLE PRECISION";
|
|
911
|
+
case "decimal":
|
|
912
|
+
return `DECIMAL(${def.precision ?? 10},${def.scale ?? 2})`;
|
|
744
913
|
case "boolean":
|
|
745
|
-
|
|
914
|
+
// INTEGER, not SMALLINT: parity with the Python master + PHP + Ruby, which
|
|
915
|
+
// all map a Firebird boolean column to INTEGER (the driver round-trip for a
|
|
916
|
+
// native BOOLEAN is uneven across Firebird versions). The real-engine DDL
|
|
917
|
+
// contract (feature 18) pins this four-way.
|
|
918
|
+
return "INTEGER";
|
|
746
919
|
case "datetime":
|
|
747
920
|
return "TIMESTAMP";
|
|
748
921
|
case "text":
|
|
@@ -32,6 +32,28 @@ interface MongoOperation {
|
|
|
32
32
|
skip?: number;
|
|
33
33
|
sort?: Record<string, 1 | -1>;
|
|
34
34
|
pipeline?: Record<string, unknown>[];
|
|
35
|
+
/**
|
|
36
|
+
* The write's empty filter is an INTENTIONAL match-all (an explicit 1=1
|
|
37
|
+
* tautology, e.g. truncate()'s WHERE 1 = 1), not a blank/absent WHERE. The
|
|
38
|
+
* executor uses this to let the whole-collection write through the
|
|
39
|
+
* requireWriteFilter guard, which otherwise (correctly) refuses an empty
|
|
40
|
+
* filter as the mass-write footgun.
|
|
41
|
+
*/
|
|
42
|
+
matchAll?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The explicit whole-collection tautology: the WHERE clause truncate() issues,
|
|
47
|
+
* "1 = 1". It means MATCH-ALL, so it translates to an empty {} filter and the
|
|
48
|
+
* write reaches deleteMany({}) / updateMany({}) -- emptying or rewriting every
|
|
49
|
+
* document -- exactly as Python/PHP/Ruby do. It is NOT an unparseable WHERE (the
|
|
50
|
+
* fail-closed parse still throws for unsupported SQL) and NOT a blank/absent
|
|
51
|
+
* WHERE (requireWriteFilter still refuses that), so the mass-write guard stays
|
|
52
|
+
* intact.
|
|
53
|
+
*/
|
|
54
|
+
const MATCH_ALL_WHERE = /^\s*1\s*=\s*1\s*$/;
|
|
55
|
+
function isMatchAllWhere(where: string | null | undefined): boolean {
|
|
56
|
+
return where != null && MATCH_ALL_WHERE.test(where);
|
|
35
57
|
}
|
|
36
58
|
|
|
37
59
|
/** Convert SQL WHERE clause tokens into a MongoDB filter object. */
|
|
@@ -44,6 +66,12 @@ function parseWhereClause(where: string, params: unknown[], paramOffset = 0): {
|
|
|
44
66
|
for (const part of parts) {
|
|
45
67
|
const trimmed = part.trim();
|
|
46
68
|
|
|
69
|
+
// The explicit 1=1 tautology contributes no constraint (match-all): skip it
|
|
70
|
+
// rather than mis-parsing "1 = 1" as { "1": 1 } (which matched nothing and
|
|
71
|
+
// made truncate() a silent no-op). NOT an unparseable WHERE -- the
|
|
72
|
+
// fail-closed throw below still fires for genuinely unsupported SQL.
|
|
73
|
+
if (MATCH_ALL_WHERE.test(trimmed)) continue;
|
|
74
|
+
|
|
47
75
|
// key = ?
|
|
48
76
|
const eqParam = trimmed.match(/^["']?(\w+)["']?\s*=\s*\?$/i);
|
|
49
77
|
if (eqParam) {
|
|
@@ -118,11 +146,43 @@ function parseWhereClause(where: string, params: unknown[], paramOffset = 0): {
|
|
|
118
146
|
filter[nullMatch[1]] = nullMatch[2] ? { $ne: null } : null;
|
|
119
147
|
continue;
|
|
120
148
|
}
|
|
149
|
+
|
|
150
|
+
// Fail closed. An unrecognised condition must NEVER be silently dropped:
|
|
151
|
+
// dropping it leaves an empty (match-all) filter, and on a DELETE/UPDATE
|
|
152
|
+
// that empty filter reaches deleteMany({})/updateMany({}) and wipes or
|
|
153
|
+
// rewrites the WHOLE collection. Throw so the caller sees the unsupported
|
|
154
|
+
// SQL instead of silently losing data.
|
|
155
|
+
throw new Error(
|
|
156
|
+
`Unsupported MongoDB WHERE condition: ${JSON.stringify(trimmed)}. The ` +
|
|
157
|
+
`MongoDB SQL provider fails closed rather than matching every document. ` +
|
|
158
|
+
`Supported: = != <> > >= < <= LIKE, IN, IS [NOT] NULL, AND.`,
|
|
159
|
+
);
|
|
121
160
|
}
|
|
122
161
|
|
|
123
162
|
return { filter, consumed: paramIndex - paramOffset };
|
|
124
163
|
}
|
|
125
164
|
|
|
165
|
+
/**
|
|
166
|
+
* Fail closed: a DELETE/UPDATE must carry a real filter.
|
|
167
|
+
*
|
|
168
|
+
* An empty MongoDB filter matches EVERY document, so deleteMany({}) /
|
|
169
|
+
* updateMany({}) would wipe or rewrite the whole collection. Refuse it -- UNLESS
|
|
170
|
+
* the empty filter is an intentional match-all (an explicit 1=1 tautology, the
|
|
171
|
+
* spelling truncate() uses; the caller signals that with the operation's
|
|
172
|
+
* `matchAll` flag or an isMatchAllWhere() check and bypasses this guard). The
|
|
173
|
+
* raw driver is the escape hatch for anything the SQL subset cannot express.
|
|
174
|
+
* Shared by both write paths so the guard cannot drift.
|
|
175
|
+
*/
|
|
176
|
+
function requireWriteFilter(filter: Record<string, unknown> | undefined, operation: string, table: string | undefined): void {
|
|
177
|
+
if (!filter || Object.keys(filter).length === 0) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
`Refusing to ${operation} every document in ${table}: the statement has no ` +
|
|
180
|
+
`WHERE clause, which would affect the whole collection. Add a WHERE, or use ` +
|
|
181
|
+
`truncate() to clear it explicitly.`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
126
186
|
/** Parse a SQL string into a MongoOperation. Returns null if parsing is not supported. */
|
|
127
187
|
function parseSql(sql: string, params: unknown[] = []): MongoOperation | null {
|
|
128
188
|
const s = sql.trim();
|
|
@@ -199,8 +259,12 @@ function parseSql(sql: string, params: unknown[] = []): MongoOperation | null {
|
|
|
199
259
|
}
|
|
200
260
|
|
|
201
261
|
// ---- UPDATE ----
|
|
262
|
+
// WHERE is OPTIONAL in the grammar so a filterless UPDATE ("UPDATE t SET x=1")
|
|
263
|
+
// is RECOGNISED as an updateMany with an empty filter and reaches the
|
|
264
|
+
// fail-closed guard -- rather than failing the regex, returning null, and
|
|
265
|
+
// being silently acknowledged as a no-op (a silent wrong result).
|
|
202
266
|
const updateMatch = s.match(
|
|
203
|
-
/^UPDATE\s+["']?(\w+)["']?\s+SET\s+(.*?)
|
|
267
|
+
/^UPDATE\s+["']?(\w+)["']?\s+SET\s+(.*?)(?:\s+WHERE\s+(.+))?$/is,
|
|
204
268
|
);
|
|
205
269
|
if (updateMatch) {
|
|
206
270
|
const [, collection, setClause, whereClause] = updateMatch;
|
|
@@ -220,10 +284,15 @@ function parseSql(sql: string, params: unknown[] = []): MongoOperation | null {
|
|
|
220
284
|
}
|
|
221
285
|
}
|
|
222
286
|
|
|
223
|
-
// Parse WHERE clause (params start after SET params)
|
|
224
|
-
|
|
287
|
+
// Parse WHERE clause (params start after SET params). No WHERE -> empty
|
|
288
|
+
// filter, which the write guard refuses; an explicit 1=1 tautology -> an
|
|
289
|
+
// empty filter that IS an intentional match-all (matchAll bypasses the guard).
|
|
290
|
+
const matchAll = isMatchAllWhere(whereClause?.trim());
|
|
291
|
+
const filter = whereClause
|
|
292
|
+
? parseWhereClause(whereClause.trim(), params, setParamIndex).filter
|
|
293
|
+
: {};
|
|
225
294
|
|
|
226
|
-
return { type: "updateMany", collection, filter, update: { $set: setDoc } };
|
|
295
|
+
return { type: "updateMany", collection, filter, update: { $set: setDoc }, matchAll };
|
|
227
296
|
}
|
|
228
297
|
|
|
229
298
|
// ---- DELETE ----
|
|
@@ -232,10 +301,13 @@ function parseSql(sql: string, params: unknown[] = []): MongoOperation | null {
|
|
|
232
301
|
);
|
|
233
302
|
if (deleteMatch) {
|
|
234
303
|
const [, collection, whereClause] = deleteMatch;
|
|
304
|
+
// An explicit 1=1 tautology (truncate()'s WHERE) is an intentional match-all;
|
|
305
|
+
// a blank/absent WHERE is the mass-delete footgun the executor guard refuses.
|
|
306
|
+
const matchAll = isMatchAllWhere(whereClause?.trim());
|
|
235
307
|
const filter = whereClause
|
|
236
308
|
? parseWhereClause(whereClause.trim(), params).filter
|
|
237
309
|
: {};
|
|
238
|
-
return { type: "deleteMany", collection, filter };
|
|
310
|
+
return { type: "deleteMany", collection, filter, matchAll };
|
|
239
311
|
}
|
|
240
312
|
|
|
241
313
|
// ---- CREATE TABLE (treated as createCollection) ----
|
|
@@ -296,6 +368,22 @@ export class MongodbAdapter implements DatabaseAdapter {
|
|
|
296
368
|
}
|
|
297
369
|
|
|
298
370
|
/** Connect to MongoDB. Must be called before using the adapter. */
|
|
371
|
+
/** ADR-0044 required adapter capability. */
|
|
372
|
+
getDatabaseType(): string {
|
|
373
|
+
return 'mongodb';
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** ADR-0044: readable/writable native boolean. */
|
|
377
|
+
autocommit = true;
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* ADR-0044 / DBA-P02: every built-in adapter can guarantee an atomic
|
|
381
|
+
* multi-row batch by default. A test-only deployment representing one
|
|
382
|
+
* that cannot sets this false so executeMany rejects BEFORE the first
|
|
383
|
+
* write rather than risking partial durability.
|
|
384
|
+
*/
|
|
385
|
+
supportsAtomicBatch = true;
|
|
386
|
+
|
|
299
387
|
async connect(): Promise<void> {
|
|
300
388
|
let MongoClient: any;
|
|
301
389
|
try {
|
|
@@ -368,11 +456,15 @@ export class MongodbAdapter implements DatabaseAdapter {
|
|
|
368
456
|
return result;
|
|
369
457
|
}
|
|
370
458
|
case "updateMany": {
|
|
371
|
-
|
|
459
|
+
// matchAll = an explicit 1=1 tautology; its empty filter is an
|
|
460
|
+
// intentional whole-collection write, not the blank-WHERE footgun.
|
|
461
|
+
if (!op.matchAll) requireWriteFilter(op.filter, "UPDATE", op.collection);
|
|
462
|
+
const result = await col.updateMany(op.filter!, op.update!, { session: this.session });
|
|
372
463
|
return result;
|
|
373
464
|
}
|
|
374
465
|
case "deleteMany": {
|
|
375
|
-
|
|
466
|
+
if (!op.matchAll) requireWriteFilter(op.filter, "DELETE", op.collection);
|
|
467
|
+
const result = await col.deleteMany(op.filter!, { session: this.session });
|
|
376
468
|
return result;
|
|
377
469
|
}
|
|
378
470
|
case "find": {
|
|
@@ -465,6 +557,53 @@ export class MongodbAdapter implements DatabaseAdapter {
|
|
|
465
557
|
return rows[0] ?? null;
|
|
466
558
|
}
|
|
467
559
|
|
|
560
|
+
/**
|
|
561
|
+
* Atomic, monotonic, concurrency-safe next id — feature 16. A
|
|
562
|
+
* findOneAndUpdate($inc) on the tina4_sequences collection, keyed by _id (its
|
|
563
|
+
* built-in unique index makes concurrent first-use upserts race-safe: two
|
|
564
|
+
* callers can never create two counters for one table). Seeds from
|
|
565
|
+
* MAX(pkColumn) the FIRST time only ($setOnInsert). Throws on an impossible
|
|
566
|
+
* empty result rather than returning a fixed id that could collide with a row.
|
|
567
|
+
*/
|
|
568
|
+
async getNextId(table: string, pkColumn = "id"): Promise<number> {
|
|
569
|
+
this.ensureConnected();
|
|
570
|
+
const sequences = this.db.collection("tina4_sequences");
|
|
571
|
+
const seqName = `${table}.${pkColumn}`;
|
|
572
|
+
|
|
573
|
+
const existing = await sequences.findOne({ _id: seqName }, { session: this.session });
|
|
574
|
+
if (existing == null) {
|
|
575
|
+
let seed = 0;
|
|
576
|
+
try {
|
|
577
|
+
const maxDoc = await this.db.collection(table)
|
|
578
|
+
.find({}, { session: this.session })
|
|
579
|
+
.sort({ [pkColumn]: -1 })
|
|
580
|
+
.limit(1)
|
|
581
|
+
.next();
|
|
582
|
+
if (maxDoc && maxDoc[pkColumn] != null) seed = Number(maxDoc[pkColumn]);
|
|
583
|
+
} catch { /* collection may not exist yet — seed 0 */ }
|
|
584
|
+
try {
|
|
585
|
+
await sequences.updateOne(
|
|
586
|
+
{ _id: seqName },
|
|
587
|
+
{ $setOnInsert: { current_value: seed } },
|
|
588
|
+
{ upsert: true, session: this.session },
|
|
589
|
+
);
|
|
590
|
+
} catch { /* race — another caller seeded first; the $inc below still holds */ }
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const res = await sequences.findOneAndUpdate(
|
|
594
|
+
{ _id: seqName },
|
|
595
|
+
{ $inc: { current_value: 1 } },
|
|
596
|
+
{ upsert: true, returnDocument: "after", session: this.session },
|
|
597
|
+
);
|
|
598
|
+
// The mongodb driver returns the doc directly (v5/v6) or wrapped as
|
|
599
|
+
// { value: doc } (v4). Our counter doc has no `value` field, so this is safe.
|
|
600
|
+
const doc = res != null && (res as any).value !== undefined ? (res as any).value : res;
|
|
601
|
+
if (!doc || (doc as any).current_value == null) {
|
|
602
|
+
throw new Error(`getNextId: MongoDB counter '${seqName}' produced no value`);
|
|
603
|
+
}
|
|
604
|
+
return Number((doc as any).current_value);
|
|
605
|
+
}
|
|
606
|
+
|
|
468
607
|
insert(table: string, data: Record<string, unknown> | Record<string, unknown>[]): DatabaseResult {
|
|
469
608
|
throw new Error("Use insertAsync() for MongoDB — async adapter requires async methods.");
|
|
470
609
|
}
|
|
@@ -491,6 +630,7 @@ export class MongodbAdapter implements DatabaseAdapter {
|
|
|
491
630
|
|
|
492
631
|
async updateAsync(table: string, data: Record<string, unknown>, filter: Record<string, unknown>): Promise<DatabaseResult> {
|
|
493
632
|
this.ensureConnected();
|
|
633
|
+
requireWriteFilter(filter, "UPDATE", table);
|
|
494
634
|
const col = this.db.collection(table);
|
|
495
635
|
try {
|
|
496
636
|
const result = await col.updateMany(filter, { $set: data }, { session: this.session });
|
|
@@ -511,25 +651,35 @@ export class MongodbAdapter implements DatabaseAdapter {
|
|
|
511
651
|
if (Array.isArray(filter)) {
|
|
512
652
|
let total = 0;
|
|
513
653
|
for (const f of filter) {
|
|
654
|
+
requireWriteFilter(f, "DELETE", table);
|
|
514
655
|
const r = await col.deleteMany(f, { session: this.session });
|
|
515
656
|
total += r.deletedCount;
|
|
516
657
|
}
|
|
517
658
|
return { success: true, affectedRows: total };
|
|
518
659
|
}
|
|
519
660
|
|
|
520
|
-
// String WHERE clause
|
|
661
|
+
// String WHERE clause. A BLANK/absent WHERE is REFUSED -- it would delete
|
|
662
|
+
// every document. The explicit 1=1 tautology (truncate()'s spelling) is
|
|
663
|
+
// the ONE intentional whole-collection delete: the WHERE is present and
|
|
664
|
+
// non-blank, so it is NOT a filterless write -- deleteMany({}) empties the
|
|
665
|
+
// collection, matching Python/PHP/Ruby (where "1 = 1" translates to an
|
|
666
|
+
// empty match-all filter). An unparseable WHERE still throws in
|
|
667
|
+
// parseWhereClause below.
|
|
521
668
|
if (typeof filter === "string") {
|
|
522
669
|
if (!filter.trim()) {
|
|
523
|
-
|
|
670
|
+
requireWriteFilter({}, "DELETE", table); // always throws: no filter
|
|
671
|
+
}
|
|
672
|
+
if (isMatchAllWhere(filter.trim())) {
|
|
524
673
|
const r = await col.deleteMany({}, { session: this.session });
|
|
525
674
|
return { success: true, affectedRows: r.deletedCount };
|
|
526
675
|
}
|
|
527
|
-
// Attempt parse via dummy SELECT wrapping
|
|
528
676
|
const { filter: parsedFilter } = parseWhereClause(filter, []);
|
|
677
|
+
requireWriteFilter(parsedFilter, "DELETE", table);
|
|
529
678
|
const r = await col.deleteMany(parsedFilter, { session: this.session });
|
|
530
679
|
return { success: true, affectedRows: r.deletedCount };
|
|
531
680
|
}
|
|
532
681
|
|
|
682
|
+
requireWriteFilter(filter as Record<string, unknown>, "DELETE", table);
|
|
533
683
|
const result = await col.deleteMany(filter as Record<string, unknown>, { session: this.session });
|
|
534
684
|
return { success: true, affectedRows: result.deletedCount };
|
|
535
685
|
} catch (e) {
|