tina4-nodejs 3.13.97 → 3.13.99

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 (96) hide show
  1. package/CLAUDE.md +60 -25
  2. package/package.json +1 -2
  3. package/packages/cli/dist/bin.js +20620 -18995
  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 +20459 -18815
  9. package/packages/core/public/js/tina4-dev-admin.min.js +23 -19
  10. package/packages/core/src/ai.ts +28 -12
  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 +31 -13
  37. package/packages/frond/src/engine.ts +39 -7
  38. package/packages/orm/dist/index.js +10879 -9258
  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/api.d.ts +11 -4
  63. package/types/core/src/background.d.ts +5 -2
  64. package/types/core/src/devAdmin.d.ts +35 -0
  65. package/types/core/src/dispatchPipeline.d.ts +41 -1
  66. package/types/core/src/errorOverlay.d.ts +13 -13
  67. package/types/core/src/index.d.ts +9 -6
  68. package/types/core/src/logger.d.ts +111 -185
  69. package/types/core/src/middleware.d.ts +40 -5
  70. package/types/core/src/portTakeover.d.ts +50 -0
  71. package/types/core/src/request.d.ts +15 -0
  72. package/types/core/src/response.d.ts +29 -0
  73. package/types/core/src/server.d.ts +92 -0
  74. package/types/core/src/testClient.d.ts +29 -3
  75. package/types/core/src/testing.d.ts +16 -12
  76. package/types/core/src/types.d.ts +21 -9
  77. package/types/core/src/version.d.ts +11 -0
  78. package/types/core/src/websocketBackplane.d.ts +1 -1
  79. package/types/frond/src/engine.d.ts +10 -0
  80. package/types/orm/src/adapters/firebird.d.ts +61 -2
  81. package/types/orm/src/adapters/mongodb.d.ts +20 -0
  82. package/types/orm/src/adapters/mssql.d.ts +11 -0
  83. package/types/orm/src/adapters/mysql.d.ts +11 -0
  84. package/types/orm/src/adapters/odbc.d.ts +35 -4
  85. package/types/orm/src/adapters/postgres.d.ts +11 -0
  86. package/types/orm/src/adapters/sqlite.d.ts +23 -4
  87. package/types/orm/src/baseModel.d.ts +45 -25
  88. package/types/orm/src/cachedDatabase.d.ts +27 -1
  89. package/types/orm/src/database.d.ts +56 -6
  90. package/types/orm/src/index.d.ts +3 -2
  91. package/types/orm/src/migration.d.ts +23 -5
  92. package/types/orm/src/query.d.ts +3 -0
  93. package/types/orm/src/seeder.d.ts +15 -2
  94. package/types/orm/src/sqlTranslator.d.ts +17 -4
  95. package/types/orm/src/types.d.ts +75 -16
  96. package/packages/core/src/errorOverlay.test.ts +0 -122
@@ -58,6 +58,22 @@ export class MssqlAdapter implements DatabaseAdapter {
58
58
  constructor(private config: MssqlConfig | string) {}
59
59
 
60
60
  /** Connect to MSSQL. Must be called before using the adapter. */
61
+ /** ADR-0044 required adapter capability. */
62
+ getDatabaseType(): string {
63
+ return 'mssql';
64
+ }
65
+
66
+ /** ADR-0044: readable/writable native boolean. */
67
+ autocommit = true;
68
+
69
+ /**
70
+ * ADR-0044 / DBA-P02: every built-in adapter can guarantee an atomic
71
+ * multi-row batch by default. A test-only deployment representing one
72
+ * that cannot sets this false so executeMany rejects BEFORE the first
73
+ * write rather than risking partial durability.
74
+ */
75
+ supportsAtomicBatch = true;
76
+
61
77
  async connect(): Promise<void> {
62
78
  const tediousModule = requireTedious();
63
79
  const Connection = tediousModule.Connection;
@@ -176,7 +192,11 @@ export class MssqlAdapter implements DatabaseAdapter {
176
192
  params.forEach((p, i) => {
177
193
  const paramName = `p${i}`;
178
194
  let type = TYPES.NVarChar;
179
- if (typeof p === "number") type = Number.isInteger(p) ? TYPES.Int : TYPES.Float;
195
+ // MSSQL-BUFFER-NODE: a Buffer is raw bytes -> VarBinary. Checked FIRST so
196
+ // it can never fall through to the NVarChar default, which applied a text
197
+ // encoding to the bytes and corrupted every binary write.
198
+ if (Buffer.isBuffer(p)) type = TYPES.VarBinary;
199
+ else if (typeof p === "number") type = Number.isInteger(p) ? TYPES.Int : TYPES.Float;
180
200
  else if (typeof p === "boolean") type = TYPES.Bit;
181
201
  else if (p instanceof Date) type = TYPES.DateTime;
182
202
  request.addParameter(paramName, type, p);
@@ -267,17 +287,22 @@ export class MssqlAdapter implements DatabaseAdapter {
267
287
 
268
288
  async fetchAsync<T = Record<string, unknown>>(sql: string, params?: unknown[], limit?: number, skip?: number): Promise<T[]> {
269
289
  let effectiveSql = sql;
270
- if (limit !== undefined) {
271
- if (skip !== undefined && skip > 0) {
272
- // MSSQL uses OFFSET...FETCH for pagination (requires ORDER BY)
273
- if (!/ORDER BY/i.test(effectiveSql)) {
274
- effectiveSql += " ORDER BY (SELECT NULL)";
275
- }
276
- effectiveSql += ` OFFSET ${skip} ROWS FETCH NEXT ${limit} ROWS ONLY`;
277
- } else {
278
- // Use TOP for simple limit
279
- effectiveSql = effectiveSql.replace(/^(SELECT)\b/i, `$1 TOP ${limit}`);
290
+ if (limit !== undefined && limit > 0) {
291
+ // MSSQL-PAGINATION-DIVERGE: ONE pagination strategy across all four
292
+ // frameworks - OFFSET/FETCH (the modern standard, requiring an ORDER BY),
293
+ // matching Python/PHP/Ruby. Node used to branch to `TOP n` for the first
294
+ // page (skip 0): it returned the same window but diverged the generated SQL
295
+ // and the `^SELECT` regex could not prefix a CTE / leading-comment / nested
296
+ // SELECT. OFFSET/FETCH appended after the ORDER BY is uniform and robust.
297
+ if (!/ORDER BY/i.test(effectiveSql)) {
298
+ effectiveSql += " ORDER BY (SELECT NULL)";
280
299
  }
300
+ effectiveSql += ` OFFSET ${skip ?? 0} ROWS FETCH NEXT ${limit} ROWS ONLY`;
301
+ } else if (limit === 0) {
302
+ // limit 0 == "zero rows" (the LIMIT 0 semantics the other Node adapters
303
+ // keep). OFFSET/FETCH cannot express `FETCH NEXT 0`, so TOP 0 remains for
304
+ // this one degenerate case only.
305
+ effectiveSql = effectiveSql.replace(/^(\s*SELECT)\b/i, "$1 TOP 0");
281
306
  }
282
307
  return this.queryAsync<T>(effectiveSql, params);
283
308
  }
@@ -580,6 +605,8 @@ function fieldTypeToMssql(def: FieldDefinition): string {
580
605
  case "number":
581
606
  case "numeric":
582
607
  return "FLOAT";
608
+ case "decimal":
609
+ return `DECIMAL(${def.precision ?? 10},${def.scale ?? 2})`;
583
610
  case "boolean":
584
611
  return "BIT";
585
612
  case "datetime":
@@ -57,6 +57,22 @@ export class MysqlAdapter implements DatabaseAdapter {
57
57
  constructor(private config: MysqlConfig | string) {}
58
58
 
59
59
  /** Connect to MySQL. Must be called before using the adapter. */
60
+ /** ADR-0044 required adapter capability. */
61
+ getDatabaseType(): string {
62
+ return 'mysql';
63
+ }
64
+
65
+ /** ADR-0044: readable/writable native boolean. */
66
+ autocommit = true;
67
+
68
+ /**
69
+ * ADR-0044 / DBA-P02: every built-in adapter can guarantee an atomic
70
+ * multi-row batch by default. A test-only deployment representing one
71
+ * that cannot sets this false so executeMany rejects BEFORE the first
72
+ * write rather than risking partial durability.
73
+ */
74
+ supportsAtomicBatch = true;
75
+
60
76
  async connect(): Promise<void> {
61
77
  const mod = requireMysql2();
62
78
 
@@ -368,8 +384,13 @@ export class MysqlAdapter implements DatabaseAdapter {
368
384
  async columnsAsync(table: string): Promise<ColumnInfo[]> {
369
385
  // v3.13.14 (#48): a qualified name ("db.table") must back-quote each part
370
386
  // separately, otherwise the dot is read as part of one identifier.
387
+ // MYSQL-DESCRIBE-UNPARAM: DESCRIBE takes an IDENTIFIER, not a bind parameter,
388
+ // so each part is STRICT-quoted with embedded backticks ESCAPED (doubled) -
389
+ // a crafted/odd name becomes ONE escaped identifier (a clean "unknown table",
390
+ // never runnable SQL) instead of a backtick in the name closing the quote.
371
391
  const [schema, tbl] = SQLTranslator.splitSchema(table);
372
- const target = schema ? `\`${schema}\`.\`${tbl}\`` : `\`${tbl}\``;
392
+ const q = (part: string): string => "`" + String(part).replace(/`/g, "``") + "`";
393
+ const target = schema ? `${q(schema)}.${q(tbl)}` : q(tbl);
373
394
  const rows = await this.queryAsync<{
374
395
  Field: string;
375
396
  Type: string;
@@ -455,6 +476,8 @@ function fieldTypeToMysql(def: FieldDefinition): string {
455
476
  case "number":
456
477
  case "numeric":
457
478
  return "DOUBLE";
479
+ case "decimal":
480
+ return `DECIMAL(${def.precision ?? 10},${def.scale ?? 2})`;
458
481
  case "boolean":
459
482
  return "TINYINT(1)";
460
483
  case "datetime":
@@ -36,6 +36,10 @@ function requireOdbc(): any {
36
36
  export interface OdbcConfig {
37
37
  /** Full ODBC connection string, e.g. "DSN=MyDSN" or "DRIVER={SQL Server};SERVER=host;DATABASE=db" */
38
38
  connectionString: string;
39
+ /** Optional username; appended as UID when not already in the connection string. */
40
+ username?: string;
41
+ /** Optional password; appended as PWD when not already in the connection string. */
42
+ password?: string;
39
43
  }
40
44
 
41
45
  export class OdbcAdapter implements DatabaseAdapter {
@@ -56,6 +60,22 @@ export class OdbcAdapter implements DatabaseAdapter {
56
60
  return this.config.connectionString;
57
61
  }
58
62
 
63
+ /**
64
+ * The connection string with credentials applied. ODBC has no separate-
65
+ * credentials API (odbc.connect() reads only the string), so a username/
66
+ * password passed to Database.create() must be folded in as UID/PWD - the
67
+ * adapter used to drop them. Never used for diagnostics (describeTarget reads
68
+ * the raw string), so the password never reaches an error message.
69
+ */
70
+ private effectiveConnectionString(): string {
71
+ let connStr = this.getConnectionString();
72
+ if (typeof this.config === "string") return connStr;
73
+ const { username, password } = this.config;
74
+ if (username && !/(?:^|;)\s*UID\s*=/i.test(connStr)) connStr += `;UID=${username}`;
75
+ if (password && !/(?:^|;)\s*PWD\s*=/i.test(connStr)) connStr += `;PWD=${password}`;
76
+ return connStr;
77
+ }
78
+
59
79
  /**
60
80
  * The address for a diagnostic message. ODBC hides it inside an opaque
61
81
  * driver keyword string, so this reads the standard keywords and falls back to
@@ -72,9 +92,25 @@ export class OdbcAdapter implements DatabaseAdapter {
72
92
  }
73
93
 
74
94
  /** Connect to the ODBC data source. Must be called before using the adapter. */
95
+ /** ADR-0044 required adapter capability. */
96
+ getDatabaseType(): string {
97
+ return 'odbc';
98
+ }
99
+
100
+ /** ADR-0044: readable/writable native boolean. */
101
+ autocommit = true;
102
+
103
+ /**
104
+ * ADR-0044 / DBA-P02: every built-in adapter can guarantee an atomic
105
+ * multi-row batch by default. A test-only deployment representing one
106
+ * that cannot sets this false so executeMany rejects BEFORE the first
107
+ * write rather than risking partial durability.
108
+ */
109
+ supportsAtomicBatch = true;
110
+
75
111
  async connect(): Promise<void> {
76
112
  const odbc = requireOdbc();
77
- const connStr = this.getConnectionString();
113
+ const connStr = this.effectiveConnectionString();
78
114
  // odbc package may expose connect as default export or named export
79
115
  const connectFn = odbc.connect ?? odbc.default?.connect;
80
116
  if (!connectFn) {
@@ -192,22 +228,31 @@ export class OdbcAdapter implements DatabaseAdapter {
192
228
  async executeManyAsync(sql: string, paramsList: unknown[][]): Promise<{ totalAffected: number; lastId?: number | bigint }> {
193
229
  this.ensureConnected();
194
230
  let totalAffected = 0;
195
- let lastId: number | bigint | undefined;
196
231
 
197
- await this.startTransactionAsync();
232
+ // Owns-guard (mirrors pg/mysql/mssql + the Python master): only manage the
233
+ // transaction when NOT already inside an explicit one. Without it, nested in
234
+ // a caller's transaction this method's commit() committed the OUTER
235
+ // transaction early.
236
+ const owns = !this._inTransaction;
237
+ if (owns) await this.startTransactionAsync();
198
238
  try {
199
239
  for (const params of paramsList) {
200
- await this.connection.query(sql, params);
201
- totalAffected++;
240
+ const result = await this.connection.query(sql, params);
241
+ totalAffected += this.affectedCount(result);
202
242
  }
203
- await this.commitAsync();
243
+ if (owns) await this.commitAsync();
204
244
  } catch (e) {
205
- await this.rollbackAsync();
245
+ if (owns) await this.rollbackAsync();
206
246
  throw e;
207
247
  }
208
248
 
209
- if (lastId !== undefined) this._lastInsertId = lastId;
210
- return { totalAffected, lastId: lastId };
249
+ return { totalAffected };
250
+ }
251
+
252
+ /** The real affected-row count from an odbc result, when the driver reports it. */
253
+ private affectedCount(result: any): number {
254
+ const n = result?.count;
255
+ return typeof n === "number" && n >= 0 ? n : 1;
211
256
  }
212
257
 
213
258
  /** Run a SELECT and return all matching rows. */
@@ -238,32 +283,64 @@ export class OdbcAdapter implements DatabaseAdapter {
238
283
  return rows[0] ?? null;
239
284
  }
240
285
 
241
- /** Insert a single row into a table. */
242
- async insertAsync(table: string, data: Record<string, unknown>): Promise<DatabaseResult> {
286
+ /** Insert a single row, or a list of rows as a batch. */
287
+ async insertAsync(table: string, data: Record<string, unknown> | Record<string, unknown>[]): Promise<DatabaseResult> {
243
288
  this.ensureConnected();
289
+
290
+ // A list of rows is a batch: ONE parameterised statement run per row through
291
+ // executeManyAsync (owns-guarded, one transaction), matching pg/mysql and the
292
+ // Python master. The single-object path used to run Object.keys() over the
293
+ // array here -> ["0","1",...], a broken INSERT, so batch insert never worked.
294
+ if (Array.isArray(data)) {
295
+ if (data.length === 0) return { success: true, affectedRows: 0 };
296
+ const keys = Object.keys(data[0]);
297
+ const sql = buildInsert(ANSI_DIALECT, table, keys);
298
+ const paramsList = data.map((row) => keys.map((k) => (row as Record<string, unknown>)[k]));
299
+ const { totalAffected } = await this.executeManyAsync(sql, paramsList);
300
+ return { success: true, affectedRows: totalAffected };
301
+ }
302
+
244
303
  const keys = Object.keys(data);
245
304
  const sql = buildInsert(ANSI_DIALECT, table, keys);
246
305
  const values = Object.values(data);
247
306
 
248
307
  try {
249
- await this.connection.query(sql, values);
250
- return { success: true, affectedRows: 1, lastId: this._lastInsertId ?? undefined };
308
+ const result = await this.connection.query(sql, values);
309
+ return { success: true, affectedRows: this.affectedCount(result), lastId: this._lastInsertId ?? undefined };
251
310
  } catch (e) {
252
311
  return { success: false, affectedRows: 0, error: (e as Error).message };
253
312
  }
254
313
  }
255
314
 
256
315
  /** Update rows in a table matching filter. */
257
- async updateAsync(table: string, data: Record<string, unknown>, filter: Record<string, unknown>): Promise<DatabaseResult> {
316
+ async updateAsync(
317
+ table: string,
318
+ data: Record<string, unknown>,
319
+ filter: Record<string, unknown> | string,
320
+ params?: unknown[],
321
+ ): Promise<DatabaseResult> {
258
322
  this.ensureConnected();
259
323
  const setClauses = buildSetClause(ANSI_DIALECT, Object.keys(data));
260
- const whereClauses = buildWhereClause(ANSI_DIALECT, Object.keys(filter));
261
- const sql = `UPDATE ${ANSI_DIALECT.quote(table)} SET ${setClauses} WHERE ${whereClauses}`;
262
- const values = [...Object.values(data), ...Object.values(filter)];
324
+
325
+ let whereSql: string;
326
+ let values: unknown[];
327
+ if (typeof filter === "string") {
328
+ // The string form ("id = ?" + params). Without this branch Object.keys()
329
+ // walked the STRING -> ["0","1",...], building a nonsense WHERE clause -
330
+ // the exact bug the pg/mysql/mssql adapters guard against. params was also
331
+ // dropped entirely (the method never took it), so a parameterised string
332
+ // filter could not bind at all.
333
+ whereSql = filter;
334
+ values = [...Object.values(data), ...(params ?? [])];
335
+ } else {
336
+ whereSql = buildWhereClause(ANSI_DIALECT, Object.keys(filter));
337
+ values = [...Object.values(data), ...Object.values(filter)];
338
+ }
339
+ const sql = `UPDATE ${ANSI_DIALECT.quote(table)} SET ${setClauses} WHERE ${whereSql}`;
263
340
 
264
341
  try {
265
- await this.connection.query(sql, values);
266
- return { success: true, affectedRows: 1 };
342
+ const result = await this.connection.query(sql, values);
343
+ return { success: true, affectedRows: this.affectedCount(result) };
267
344
  } catch (e) {
268
345
  return { success: false, affectedRows: 0, error: (e as Error).message };
269
346
  }
@@ -273,6 +350,7 @@ export class OdbcAdapter implements DatabaseAdapter {
273
350
  async deleteAsync(
274
351
  table: string,
275
352
  filter: Record<string, unknown> | string | Record<string, unknown>[],
353
+ params?: unknown[],
276
354
  ): Promise<DatabaseResult> {
277
355
  this.ensureConnected();
278
356
 
@@ -286,12 +364,13 @@ export class OdbcAdapter implements DatabaseAdapter {
286
364
  }
287
365
 
288
366
  if (typeof filter === "string") {
367
+ // The string form binds its own params (was dropped: query ran with []).
289
368
  const sql = filter
290
369
  ? `DELETE FROM "${table}" WHERE ${filter}`
291
370
  : `DELETE FROM "${table}"`;
292
371
  try {
293
- await this.connection.query(sql, []);
294
- return { success: true, affectedRows: 1 };
372
+ const result = await this.connection.query(sql, params ?? []);
373
+ return { success: true, affectedRows: this.affectedCount(result) };
295
374
  } catch (e) {
296
375
  return { success: false, affectedRows: 0, error: (e as Error).message };
297
376
  }
@@ -302,8 +381,8 @@ export class OdbcAdapter implements DatabaseAdapter {
302
381
  const values = Object.values(filter);
303
382
 
304
383
  try {
305
- await this.connection.query(sql, values);
306
- return { success: true, affectedRows: 1 };
384
+ const result = await this.connection.query(sql, values);
385
+ return { success: true, affectedRows: this.affectedCount(result) };
307
386
  } catch (e) {
308
387
  return { success: false, affectedRows: 0, error: (e as Error).message };
309
388
  }
@@ -349,17 +428,35 @@ export class OdbcAdapter implements DatabaseAdapter {
349
428
  /** Get column metadata for a table using ODBC catalog functions. */
350
429
  async columnsAsync(table: string): Promise<ColumnInfo[]> {
351
430
  this.ensureConnected();
352
- // odbc.Connection.getColumns(catalog, schema, table, column)
431
+ // odbc.Connection.columns(catalog, schema, table, column)
353
432
  const rows: any[] = await this.connection.columns(null, null, table, null);
433
+ // Real PK, from the ODBC catalog (SQLPrimaryKeys) - not the old `false` stub.
434
+ // Feature 4's filterless-write guard reads primaryKey, so without this a
435
+ // PK-keyed update(table, data) on ODBC could not introspect the key.
436
+ const pk = await this.primaryKeyColumns(table);
354
437
  return rows.map((r: any) => ({
355
438
  name: r.COLUMN_NAME ?? r.column_name,
356
439
  type: r.TYPE_NAME ?? r.type_name ?? r.DATA_TYPE ?? "",
357
440
  nullable: (r.NULLABLE ?? r.nullable) === 1,
358
441
  default: r.COLUMN_DEF ?? r.column_def ?? null,
359
- primaryKey: false, // ODBC catalog doesn't easily expose PK; requires separate primaryKeys() call
442
+ primaryKey: pk.has(String(r.COLUMN_NAME ?? r.column_name ?? "").toLowerCase()),
360
443
  }));
361
444
  }
362
445
 
446
+ /**
447
+ * The table's primary-key columns from the ODBC catalog (SQLPrimaryKeys),
448
+ * lower-cased for case-insensitive matching. Empty on any target that does not
449
+ * report them - the write-guard then requires an explicit filter.
450
+ */
451
+ private async primaryKeyColumns(table: string): Promise<Set<string>> {
452
+ try {
453
+ const rows: any[] = await this.connection.primaryKeys(null, null, table);
454
+ return new Set(rows.map((r: any) => String(r.COLUMN_NAME ?? r.column_name ?? "").toLowerCase()));
455
+ } catch {
456
+ return new Set();
457
+ }
458
+ }
459
+
363
460
  /** Check whether a table exists. */
364
461
  async tableExistsAsync(name: string): Promise<boolean> {
365
462
  this.ensureConnected();
@@ -371,7 +468,7 @@ export class OdbcAdapter implements DatabaseAdapter {
371
468
  async createTableAsync(name: string, columns: Record<string, FieldDefinition>): Promise<void> {
372
469
  const colDefs: string[] = [];
373
470
  for (const [colName, def] of Object.entries(columns)) {
374
- const sqlType = fieldTypeToOdbc(def.type);
471
+ const sqlType = fieldTypeToOdbc(def);
375
472
  const parts = [`"${colName}" ${sqlType}`];
376
473
  if (def.primaryKey) parts.push("PRIMARY KEY");
377
474
  if (def.autoIncrement) parts.push("GENERATED ALWAYS AS IDENTITY"); // ANSI SQL
@@ -395,7 +492,7 @@ export class OdbcAdapter implements DatabaseAdapter {
395
492
 
396
493
  /** Add a column to an existing table. */
397
494
  async addColumnAsync(table: string, colName: string, def: FieldDefinition): Promise<void> {
398
- const sqlType = fieldTypeToOdbc(def.type);
495
+ const sqlType = fieldTypeToOdbc(def);
399
496
  let sql = `ALTER TABLE "${table}" ADD COLUMN "${colName}" ${sqlType}`;
400
497
  if (def.default !== undefined && def.default !== "now") {
401
498
  sql += ` DEFAULT ${sqlDefault(def.default)}`;
@@ -422,11 +519,12 @@ export class OdbcAdapter implements DatabaseAdapter {
422
519
  // Helpers
423
520
  // ---------------------------------------------------------------------------
424
521
 
425
- function fieldTypeToOdbc(type: string): string {
426
- switch (type) {
522
+ function fieldTypeToOdbc(def: FieldDefinition): string {
523
+ switch (def.type) {
427
524
  case "integer": return "INTEGER";
428
525
  case "number":
429
526
  case "numeric": return "DOUBLE PRECISION";
527
+ case "decimal": return `DECIMAL(${def.precision ?? 10},${def.scale ?? 2})`;
430
528
  case "boolean": return "SMALLINT";
431
529
  case "datetime": return "TIMESTAMP";
432
530
  case "text": return "CLOB";
@@ -90,6 +90,22 @@ export class PostgresAdapter implements DatabaseAdapter {
90
90
  constructor(private config: PostgresConfig | string) {}
91
91
 
92
92
  /** Connect to PostgreSQL. Must be called before using the adapter. */
93
+ /** ADR-0044 required adapter capability. */
94
+ getDatabaseType(): string {
95
+ return 'postgres';
96
+ }
97
+
98
+ /** ADR-0044: readable/writable native boolean. */
99
+ autocommit = true;
100
+
101
+ /**
102
+ * ADR-0044 / DBA-P02: every built-in adapter can guarantee an atomic
103
+ * multi-row batch by default. A test-only deployment representing one
104
+ * that cannot sets this false so executeMany rejects BEFORE the first
105
+ * write rather than risking partial durability.
106
+ */
107
+ supportsAtomicBatch = true;
108
+
93
109
  async connect(): Promise<void> {
94
110
  const pgModule = requirePg();
95
111
  const Client = pgModule.Client ?? (pgModule as any).default?.Client;
@@ -559,6 +575,8 @@ function fieldTypeToPostgres(def: FieldDefinition): string {
559
575
  case "number":
560
576
  case "numeric":
561
577
  return "DOUBLE PRECISION";
578
+ case "decimal":
579
+ return `DECIMAL(${def.precision ?? 10},${def.scale ?? 2})`;
562
580
  case "boolean":
563
581
  return "BOOLEAN";
564
582
  case "datetime":
@@ -100,6 +100,32 @@ export class SQLiteAdapter implements DatabaseAdapter {
100
100
  private db: DatabaseSync;
101
101
  private _lastInsertId: number | bigint | null = null;
102
102
 
103
+ /** ADR-0044: readable/writable native boolean. */
104
+ autocommit = true;
105
+
106
+ /**
107
+ * ADR-0044 / DBA-P02: every built-in adapter can guarantee an atomic
108
+ * multi-row batch by default. A test-only deployment representing one that
109
+ * cannot (a standalone MongoDB without a replica set is the motivating real
110
+ * case) sets this false so executeMany rejects BEFORE the first write.
111
+ */
112
+ supportsAtomicBatch = true;
113
+
114
+ /** ADR-0044 required adapter capability. */
115
+ getDatabaseType(): string {
116
+ return "sqlite";
117
+ }
118
+
119
+ /**
120
+ * ADR-0044 canonical lifecycle name. A genuine no-op: `node:sqlite` opens
121
+ * the file synchronously in the constructor (see the timeout note below),
122
+ * so by the time a caller could reach connect() the adapter is already
123
+ * connected — repeated calls open no additional physical connection.
124
+ */
125
+ connect(): void {
126
+ // Already connected by the constructor.
127
+ }
128
+
103
129
  /**
104
130
  * TINA4_DATABASE_CONNECT_TIMEOUT DOES NOT APPLY HERE, deliberately.
105
131
  *
@@ -128,12 +154,48 @@ export class SQLiteAdapter implements DatabaseAdapter {
128
154
  return result;
129
155
  }
130
156
 
131
- executeMany(sql: string, paramsList: unknown[][]): { totalAffected: number; lastId?: number | bigint } {
157
+ executeMany(sql: string, paramsList: unknown[][]): DatabaseResult {
158
+ // ADR-0044 (DBA-B01): empty input is a successful no-op — it opens no
159
+ // transaction and performs no write.
160
+ if (paramsList.length === 0) {
161
+ return { success: true, affectedRows: 0 };
162
+ }
163
+
164
+ // ADR-0044 (DBA-B05): a ragged parameter set must fail BEFORE any durable
165
+ // partial success — checked against the FIRST row's length generically
166
+ // (no per-dialect placeholder parsing needed).
167
+ const expected = paramsList[0].length;
168
+ for (const params of paramsList) {
169
+ if (params.length !== expected) {
170
+ throw new Error(
171
+ `executeMany binding count mismatch - expected ${expected} parameters, got ${params.length}`,
172
+ );
173
+ }
174
+ }
175
+
176
+ // ADR-0044 (DBA-P02): reject an unsupported multi-row batch before the
177
+ // first write rather than risking partial durability.
178
+ if (!this.supportsAtomicBatch && paramsList.length > 1) {
179
+ throw new Error(
180
+ `provider "sqlite" cannot guarantee an atomic batch write on this deployment ` +
181
+ `(required deployment capability: a transaction-capable configuration) - ` +
182
+ `rejected before the first write rather than risking partial durability`,
183
+ );
184
+ }
185
+
132
186
  const stmt = this.db.prepare(sql);
133
187
  let totalAffected = 0;
134
188
  let lastId: number | bigint | undefined;
135
189
 
136
- this.db.exec("BEGIN TRANSACTION");
190
+ // Own the transaction only when not ALREADY inside one — Database#execute
191
+ // Many (ADR-0044) already brackets ONE call to this method in its own
192
+ // start/commit/rollback when the caller is standalone, so this must join
193
+ // rather than double-BEGIN when called from there. Still safe to call
194
+ // directly/standalone (a caller bypassing the facade): startTransaction()/
195
+ // commit()/rollback() already guard on _inTransaction, exactly mirroring
196
+ // the facade's own owns-guard.
197
+ const owns = !this._inTransaction;
198
+ if (owns) this.startTransaction();
137
199
  try {
138
200
  for (const params of paramsList) {
139
201
  const result = stmt.run(...toSqlParams(params));
@@ -143,13 +205,13 @@ export class SQLiteAdapter implements DatabaseAdapter {
143
205
  this._lastInsertId = result.lastInsertRowid;
144
206
  }
145
207
  }
146
- this.db.exec("COMMIT");
208
+ if (owns) this.commit();
147
209
  } catch (e) {
148
- this.db.exec("ROLLBACK");
210
+ if (owns) this.rollback();
149
211
  throw e;
150
212
  }
151
213
 
152
- return { totalAffected, lastId: lastId };
214
+ return { success: true, affectedRows: totalAffected, lastId };
153
215
  }
154
216
 
155
217
  query<T = Record<string, unknown>>(sql: string, params?: unknown[]): T[] {
@@ -181,7 +243,7 @@ export class SQLiteAdapter implements DatabaseAdapter {
181
243
  const sql = buildInsert(ANSI_DIALECT, table, keys);
182
244
  const paramsList = data.map((row) => keys.map((k) => row[k]));
183
245
  const result = this.executeMany(sql, paramsList);
184
- return { success: true, affectedRows: result.totalAffected, lastId: result.lastId };
246
+ return { success: true, affectedRows: result.affectedRows, lastId: result.lastId };
185
247
  }
186
248
 
187
249
  const keys = Object.keys(data);
@@ -305,16 +367,32 @@ export class SQLiteAdapter implements DatabaseAdapter {
305
367
  const rows = this.db.prepare(pragma).all() as Array<{
306
368
  name: string; type: string; notnull: number; dflt_value: unknown; pk: number;
307
369
  }>;
308
- return rows.map((r) => ({
370
+ return rows.map((r) => {
309
371
  // PRAGMA table_info reports `pk` as the 1-BASED POSITION within the primary
310
372
  // key, not a boolean: a composite key gives pk=1, pk=2, ... Testing `=== 1`
311
373
  // reported only the first column of a composite key.
312
- name: r.name, type: r.type, nullable: r.notnull === 0, default: r.dflt_value, primaryKey: Number(r.pk) > 0,
313
- }));
374
+ const pk = Number(r.pk);
375
+ return {
376
+ name: r.name, type: r.type, nullable: r.notnull === 0, default: r.dflt_value, primaryKey: pk > 0,
377
+ // ADR-0044 amendment (Feature 5 Decision 7): null for a non-key
378
+ // column; for a composite key this IS the declared PRIMARY KEY (...)
379
+ // order, not table-column order.
380
+ primaryKeyPosition: pk > 0 ? pk : null,
381
+ };
382
+ });
314
383
  }
315
384
 
316
385
  lastInsertId(): number | bigint | null { return this._lastInsertId; }
317
- close(): void { this.db.close(); }
386
+ private _closed = false;
387
+
388
+ /** ADR-0044 (DBA-L02): idempotent — node:sqlite's DatabaseSync.close()
389
+ * throws when called on an already-closed database, so a second close()
390
+ * must not reach it. */
391
+ close(): void {
392
+ if (this._closed) return;
393
+ this.db.close();
394
+ this._closed = true;
395
+ }
318
396
 
319
397
  /**
320
398
  * Atomically increment and return the next value of a tina4_sequences row.
@@ -397,7 +475,7 @@ export class SQLiteAdapter implements DatabaseAdapter {
397
475
  const pkCols = Object.entries(columns).filter(([, d]) => d.primaryKey).map(([c]) => c);
398
476
  const composite = pkCols.length > 1;
399
477
  for (const [colName, def] of Object.entries(columns)) {
400
- const sqlType = fieldTypeToSQLite(def.type);
478
+ const sqlType = fieldTypeToSQLite(def);
401
479
  const parts = [`"${colName}" ${sqlType}`];
402
480
  if (def.primaryKey && !composite) parts.push("PRIMARY KEY");
403
481
  if (def.autoIncrement) parts.push("AUTOINCREMENT");
@@ -419,7 +497,7 @@ export class SQLiteAdapter implements DatabaseAdapter {
419
497
  }
420
498
 
421
499
  addColumn(table: string, colName: string, def: FieldDefinition): void {
422
- const sqlType = fieldTypeToSQLite(def.type);
500
+ const sqlType = fieldTypeToSQLite(def);
423
501
  let sql = `ALTER TABLE "${table}" ADD COLUMN "${colName}" ${sqlType}`;
424
502
  if (def.default !== undefined && def.default !== "now") sql += ` DEFAULT ${sqlDefault(def.default)}`;
425
503
  else if (def.default === "now") sql += " DEFAULT CURRENT_TIMESTAMP";
@@ -427,10 +505,11 @@ export class SQLiteAdapter implements DatabaseAdapter {
427
505
  }
428
506
  }
429
507
 
430
- function fieldTypeToSQLite(type: string): string {
431
- switch (type) {
508
+ function fieldTypeToSQLite(def: FieldDefinition): string {
509
+ switch (def.type) {
432
510
  case "integer": return "INTEGER";
433
511
  case "number": case "numeric": return "REAL";
512
+ case "decimal": return `DECIMAL(${def.precision ?? 10},${def.scale ?? 2})`;
434
513
  case "boolean": return "INTEGER";
435
514
  case "datetime": return "TEXT";
436
515
  case "text": return "TEXT";