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.
Files changed (97) hide show
  1. package/CLAUDE.md +24 -25
  2. package/package.json +1 -2
  3. package/packages/cli/dist/bin.js +20698 -18983
  4. package/packages/cli/src/bin.ts +28 -71
  5. package/packages/cli/src/commands/migrate.ts +36 -75
  6. package/packages/cli/src/commands/migrateRollback.ts +10 -1
  7. package/packages/cli/src/commands/test.ts +92 -21
  8. package/packages/core/dist/index.js +20561 -18828
  9. package/packages/core/public/js/tina4-dev-admin.min.js +23 -19
  10. package/packages/core/src/ai.ts +38 -13
  11. package/packages/core/src/api.ts +13 -5
  12. package/packages/core/src/background.ts +9 -3
  13. package/packages/core/src/devAdmin.ts +135 -20
  14. package/packages/core/src/dispatchPipeline.ts +185 -1
  15. package/packages/core/src/docs.ts +33 -5
  16. package/packages/core/src/env.ts +1 -1
  17. package/packages/core/src/errorOverlay.ts +39 -48
  18. package/packages/core/src/fakeData.ts +15 -0
  19. package/packages/core/src/index.ts +17 -6
  20. package/packages/core/src/logger.ts +892 -572
  21. package/packages/core/src/mcp.ts +9 -1
  22. package/packages/core/src/messenger.ts +31 -4
  23. package/packages/core/src/middleware.ts +169 -43
  24. package/packages/core/src/portTakeover.ts +232 -0
  25. package/packages/core/src/request.ts +57 -8
  26. package/packages/core/src/response.ts +67 -0
  27. package/packages/core/src/router.ts +35 -7
  28. package/packages/core/src/server.ts +450 -190
  29. package/packages/core/src/static.ts +81 -12
  30. package/packages/core/src/testClient.ts +126 -137
  31. package/packages/core/src/testing.ts +16 -12
  32. package/packages/core/src/types.ts +21 -9
  33. package/packages/core/src/version.ts +66 -0
  34. package/packages/core/src/websocket.ts +2 -2
  35. package/packages/core/src/websocketBackplane.ts +2 -2
  36. package/packages/frond/dist/index.js +149 -49
  37. package/packages/frond/src/engine.ts +234 -52
  38. package/packages/orm/dist/index.js +10941 -9231
  39. package/packages/orm/src/adapters/firebird.ts +200 -27
  40. package/packages/orm/src/adapters/mongodb.ts +160 -10
  41. package/packages/orm/src/adapters/mssql.ts +38 -11
  42. package/packages/orm/src/adapters/mysql.ts +24 -1
  43. package/packages/orm/src/adapters/odbc.ts +127 -29
  44. package/packages/orm/src/adapters/postgres.ts +18 -0
  45. package/packages/orm/src/adapters/sqlite.ts +93 -14
  46. package/packages/orm/src/autoCrud.ts +72 -8
  47. package/packages/orm/src/baseModel.ts +323 -71
  48. package/packages/orm/src/cachedDatabase.ts +48 -1
  49. package/packages/orm/src/database.ts +162 -59
  50. package/packages/orm/src/fakeData.ts +6 -2
  51. package/packages/orm/src/index.ts +4 -1
  52. package/packages/orm/src/migration.ts +95 -52
  53. package/packages/orm/src/query.ts +16 -4
  54. package/packages/orm/src/seeder.ts +43 -25
  55. package/packages/orm/src/sqlTranslator.ts +104 -19
  56. package/packages/orm/src/types.ts +97 -21
  57. package/packages/orm/src/validation.ts +5 -1
  58. package/packages/swagger/dist/index.js +3 -2
  59. package/packages/swagger/src/generator.ts +19 -4
  60. package/packages/swagger/src/ui.ts +6 -4
  61. package/types/cli/src/bin.d.ts +0 -22
  62. package/types/core/src/ai.d.ts +29 -0
  63. package/types/core/src/api.d.ts +11 -4
  64. package/types/core/src/background.d.ts +5 -2
  65. package/types/core/src/devAdmin.d.ts +35 -0
  66. package/types/core/src/dispatchPipeline.d.ts +41 -1
  67. package/types/core/src/errorOverlay.d.ts +13 -13
  68. package/types/core/src/index.d.ts +9 -6
  69. package/types/core/src/logger.d.ts +111 -185
  70. package/types/core/src/middleware.d.ts +40 -5
  71. package/types/core/src/portTakeover.d.ts +50 -0
  72. package/types/core/src/request.d.ts +15 -0
  73. package/types/core/src/response.d.ts +29 -0
  74. package/types/core/src/server.d.ts +92 -0
  75. package/types/core/src/testClient.d.ts +29 -3
  76. package/types/core/src/testing.d.ts +16 -12
  77. package/types/core/src/types.d.ts +21 -9
  78. package/types/core/src/version.d.ts +11 -0
  79. package/types/core/src/websocketBackplane.d.ts +1 -1
  80. package/types/frond/src/engine.d.ts +60 -8
  81. package/types/orm/src/adapters/firebird.d.ts +61 -2
  82. package/types/orm/src/adapters/mongodb.d.ts +20 -0
  83. package/types/orm/src/adapters/mssql.d.ts +11 -0
  84. package/types/orm/src/adapters/mysql.d.ts +11 -0
  85. package/types/orm/src/adapters/odbc.d.ts +35 -4
  86. package/types/orm/src/adapters/postgres.d.ts +11 -0
  87. package/types/orm/src/adapters/sqlite.d.ts +23 -4
  88. package/types/orm/src/baseModel.d.ts +45 -25
  89. package/types/orm/src/cachedDatabase.d.ts +27 -1
  90. package/types/orm/src/database.d.ts +56 -6
  91. package/types/orm/src/index.d.ts +3 -2
  92. package/types/orm/src/migration.d.ts +23 -5
  93. package/types/orm/src/query.d.ts +3 -0
  94. package/types/orm/src/seeder.d.ts +15 -2
  95. package/types/orm/src/sqlTranslator.d.ts +17 -4
  96. package/types/orm/src/types.d.ts +75 -16
  97. 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
- () => new Promise<any>((resolve, reject) => {
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
- return new Promise((resolve, reject) => {
334
- const translated = this.translateSql(sql);
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
- return new Promise((resolve, reject) => {
344
- const translated = this.translateSql(sql);
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
- return (rows as T[]).map(row => this.decodeBlobs(foldColumnNames(row)));
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
- /** Ensure BLOB columns are readable — node-firebird may return callback-based
386
- * blob readers. Convert to Buffer. Regular buffers pass through unchanged. */
387
- private decodeBlobs<T>(row: T): T {
388
- // node-firebird returns BLOBs as Buffer by default when using
389
- // query(sql, params, callback) already raw bytes.
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
- return { success: true, affectedRows: result.totalAffected, lastId: result.lastId };
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
- // Firebird doesn't have a generic last_insert_id return success without id
447
- return { success: true, affectedRows: 1 };
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.executePromise(
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: 1 };
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.executePromise(sql, values);
481
- return { success: true, affectedRows: 1 };
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.executePromise(`DELETE FROM ${fbQuote(table)}${where}`, params ?? []);
496
- return { success: true, affectedRows: 1 };
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.executePromise(sql, values);
505
- return { success: true, affectedRows: 1 };
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
- return "SMALLINT";
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+(.*?)\s+WHERE\s+(.+)$/is,
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
- const { filter } = parseWhereClause(whereClause.trim(), params, setParamIndex);
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
- const result = await col.updateMany(op.filter ?? {}, op.update!, { session: this.session });
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
- const result = await col.deleteMany(op.filter ?? {}, { session: this.session });
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 not directly translatable; delete nothing safely
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
- // Empty filter = delete all documents
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) {