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.
- package/CLAUDE.md +60 -25
- package/package.json +1 -2
- package/packages/cli/dist/bin.js +20620 -18995
- 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 +20459 -18815
- package/packages/core/public/js/tina4-dev-admin.min.js +23 -19
- package/packages/core/src/ai.ts +28 -12
- 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 +31 -13
- package/packages/frond/src/engine.ts +39 -7
- package/packages/orm/dist/index.js +10879 -9258
- 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/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 +10 -0
- 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
|
@@ -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
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
210
|
-
|
|
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
|
|
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:
|
|
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(
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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.
|
|
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:
|
|
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
|
|
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
|
|
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(
|
|
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[][]):
|
|
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
|
-
|
|
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.
|
|
208
|
+
if (owns) this.commit();
|
|
147
209
|
} catch (e) {
|
|
148
|
-
this.
|
|
210
|
+
if (owns) this.rollback();
|
|
149
211
|
throw e;
|
|
150
212
|
}
|
|
151
213
|
|
|
152
|
-
return {
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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";
|