tina4-nodejs 3.13.36 → 3.13.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +51 -19
- package/package.json +5 -3
- package/packages/cli/src/bin.ts +7 -0
- package/packages/cli/src/commands/init.ts +1 -0
- package/packages/cli/src/commands/metrics.ts +154 -0
- package/packages/cli/src/commands/routes.ts +3 -3
- package/packages/core/public/js/tina4-dev-admin.js +212 -212
- package/packages/core/public/js/tina4-dev-admin.min.js +212 -212
- package/packages/core/src/auth.ts +112 -2
- package/packages/core/src/cache.ts +2 -2
- package/packages/core/src/devAdmin.ts +75 -26
- package/packages/core/src/devMailbox.ts +4 -0
- package/packages/core/src/dotenv.ts +13 -4
- package/packages/core/src/events.ts +86 -4
- package/packages/core/src/graphql.ts +182 -128
- package/packages/core/src/htmlElement.ts +62 -3
- package/packages/core/src/index.ts +14 -8
- package/packages/core/src/logger.ts +1 -1
- package/packages/core/src/mcp.test.ts +1 -1
- package/packages/core/src/messenger.ts +111 -11
- package/packages/core/src/metrics.ts +232 -33
- package/packages/core/src/middleware.ts +129 -39
- package/packages/core/src/plan.ts +1 -1
- package/packages/core/src/queue.ts +1 -1
- package/packages/core/src/queueBackends/kafkaBackend.ts +1 -1
- package/packages/core/src/queueBackends/mongoBackend.ts +1 -1
- package/packages/core/src/queueBackends/rabbitmqBackend.ts +1 -1
- package/packages/core/src/rateLimiter.ts +1 -1
- package/packages/core/src/response.ts +90 -6
- package/packages/core/src/router.ts +2 -2
- package/packages/core/src/server.ts +26 -4
- package/packages/core/src/session.ts +130 -18
- package/packages/core/src/sessionHandlers/databaseHandler.ts +10 -0
- package/packages/core/src/sessionHandlers/mongoHandler.ts +21 -4
- package/packages/core/src/sessionHandlers/redisHandler.ts +28 -7
- package/packages/core/src/sessionHandlers/valkeyHandler.ts +27 -8
- package/packages/core/src/testClient.ts +1 -1
- package/packages/core/src/websocket.ts +247 -33
- package/packages/core/src/websocketBackplane.ts +210 -10
- package/packages/core/src/wsdl.ts +55 -21
- package/packages/orm/src/adapters/pg-types.d.ts +60 -0
- package/packages/orm/src/adapters/postgres.ts +26 -4
- package/packages/orm/src/adapters/sqlite.ts +112 -13
- package/packages/orm/src/baseModel.ts +8 -3
- package/packages/orm/src/cachedDatabase.ts +15 -6
- package/packages/orm/src/database.ts +257 -55
- package/packages/orm/src/index.ts +2 -1
- package/packages/orm/src/migration.ts +2 -2
- package/packages/orm/src/seeder.ts +443 -65
- package/packages/swagger/src/ui.ts +1 -1
|
@@ -19,6 +19,9 @@
|
|
|
19
19
|
* }
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
+
import { Log } from "./logger.js";
|
|
23
|
+
import { isDebugMode } from "./errorOverlay.js";
|
|
24
|
+
|
|
22
25
|
// ── Types ────────────────────────────────────────────────────
|
|
23
26
|
|
|
24
27
|
export interface WSDLOperationMeta {
|
|
@@ -261,13 +264,27 @@ export abstract class WSDLService {
|
|
|
261
264
|
private convertValue(value: string, typeName: string): unknown {
|
|
262
265
|
switch (typeName) {
|
|
263
266
|
case "int":
|
|
264
|
-
case "integer":
|
|
265
|
-
|
|
267
|
+
case "integer": {
|
|
268
|
+
// Match Python's int(value) — a non-numeric value RAISES rather than
|
|
269
|
+
// silently yielding NaN. The thrown Error is caught in handle() and
|
|
270
|
+
// becomes a Server fault.
|
|
271
|
+
const n = parseInt(value, 10);
|
|
272
|
+
if (Number.isNaN(n)) {
|
|
273
|
+
throw new Error(`invalid integer value: ${JSON.stringify(value)}`);
|
|
274
|
+
}
|
|
275
|
+
return n;
|
|
276
|
+
}
|
|
266
277
|
case "float":
|
|
267
278
|
case "double":
|
|
268
279
|
case "number":
|
|
269
|
-
case "numeric":
|
|
270
|
-
|
|
280
|
+
case "numeric": {
|
|
281
|
+
// Match Python's float(value) — non-numeric raises (→ Server fault).
|
|
282
|
+
const f = parseFloat(value);
|
|
283
|
+
if (Number.isNaN(f)) {
|
|
284
|
+
throw new Error(`invalid numeric value: ${JSON.stringify(value)}`);
|
|
285
|
+
}
|
|
286
|
+
return f;
|
|
287
|
+
}
|
|
271
288
|
case "bool":
|
|
272
289
|
case "boolean":
|
|
273
290
|
return ["true", "1", "yes"].includes(value.toLowerCase());
|
|
@@ -389,6 +406,16 @@ export abstract class WSDLService {
|
|
|
389
406
|
async handle(soapXml: string = ""): Promise<string> {
|
|
390
407
|
const ops = this.discoverOperations();
|
|
391
408
|
|
|
409
|
+
// SOAP 1.1 (§3) forbids a Document Type Declaration in a SOAP message.
|
|
410
|
+
// Rejecting any DOCTYPE/DTD up front — BEFORE the body is parsed — also
|
|
411
|
+
// closes the XML entity-expansion (billion-laughs) and external-entity
|
|
412
|
+
// (XXE) attack surface regardless of parser internals. The operation
|
|
413
|
+
// never runs. (Node's parser is hand-rolled and already immune; this is
|
|
414
|
+
// defence in depth + consistent fault behaviour across all 4 frameworks.)
|
|
415
|
+
if (/<!DOCTYPE/i.test(soapXml)) {
|
|
416
|
+
return this.soapFault("Client", "DOCTYPE declarations are not allowed in SOAP messages");
|
|
417
|
+
}
|
|
418
|
+
|
|
392
419
|
// Parse SOAP body
|
|
393
420
|
const body = extractSoapBody(soapXml);
|
|
394
421
|
if (!body) {
|
|
@@ -413,33 +440,40 @@ export abstract class WSDLService {
|
|
|
413
440
|
return this.soapFault("Client", `Operation not implemented: ${opName}`);
|
|
414
441
|
}
|
|
415
442
|
|
|
416
|
-
// Extract parameters from the operation element
|
|
417
|
-
const children = extractChildren(operation.content);
|
|
418
|
-
const params: unknown[] = [];
|
|
419
|
-
|
|
420
|
-
if (opMeta.input) {
|
|
421
|
-
for (const [paramName, paramType] of Object.entries(opMeta.input)) {
|
|
422
|
-
const child = children.find((c) => c.name === paramName);
|
|
423
|
-
if (child) {
|
|
424
|
-
params.push(this.convertValue(child.value, paramType));
|
|
425
|
-
} else {
|
|
426
|
-
params.push(null);
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
|
|
431
443
|
// Lifecycle hook: before invocation
|
|
432
444
|
this.onRequest(soapXml);
|
|
433
445
|
|
|
434
|
-
// Invoke the method
|
|
446
|
+
// Invoke the method. Parameter conversion runs INSIDE the try so a
|
|
447
|
+
// non-numeric value for an int/float param (convertValue throws, matching
|
|
448
|
+
// Python's int()/float() raise) becomes a Server fault — not a silent NaN.
|
|
435
449
|
try {
|
|
450
|
+
// Extract parameters from the operation element
|
|
451
|
+
const children = extractChildren(operation.content);
|
|
452
|
+
const params: unknown[] = [];
|
|
453
|
+
|
|
454
|
+
if (opMeta.input) {
|
|
455
|
+
for (const [paramName, paramType] of Object.entries(opMeta.input)) {
|
|
456
|
+
const child = children.find((c) => c.name === paramName);
|
|
457
|
+
if (child) {
|
|
458
|
+
params.push(this.convertValue(child.value, paramType));
|
|
459
|
+
} else {
|
|
460
|
+
params.push(null);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
436
465
|
const rawResult = await (method as (...args: unknown[]) => Promise<unknown>).call(this, ...params);
|
|
437
466
|
// Lifecycle hook: after invocation — allow result transformation
|
|
438
467
|
const result = this.onResult(rawResult as Record<string, unknown>);
|
|
439
468
|
return this.soapResponse(opName, result);
|
|
440
469
|
} catch (err) {
|
|
470
|
+
// Log the real cause, but only leak the detail to the client in debug
|
|
471
|
+
// mode — a resolver exception can carry internal state (DB credentials,
|
|
472
|
+
// file paths) that must not reach a SOAP client.
|
|
441
473
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
442
|
-
|
|
474
|
+
Log.error(`WSDL operation '${opName}' failed: ${errMsg}`);
|
|
475
|
+
const detail = isDebugMode() ? errMsg : "Internal server error";
|
|
476
|
+
return this.soapFault("Server", detail);
|
|
443
477
|
}
|
|
444
478
|
}
|
|
445
479
|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal ambient type surface for the optional `pg` (node-postgres) peer
|
|
3
|
+
* dependency. `@types/pg` is intentionally NOT a dependency (pg is an optional
|
|
4
|
+
* peer loaded lazily via createRequire), so without this declaration the
|
|
5
|
+
* `typeof import("pg")` references in postgres.ts resolve to an *implicit* any
|
|
6
|
+
* (TS7016) and collapse `this.client` to `never`.
|
|
7
|
+
*
|
|
8
|
+
* This declares only the subset of the pg API that the PostgresAdapter uses:
|
|
9
|
+
* the `Client` class (connect/query/end) and the global `types.setTypeParser`
|
|
10
|
+
* registry. It carries no runtime weight — purely a compile-time contract that
|
|
11
|
+
* matches node-postgres' real shape.
|
|
12
|
+
*/
|
|
13
|
+
declare module "pg" {
|
|
14
|
+
export interface QueryResultRow {
|
|
15
|
+
[column: string]: unknown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface QueryResult<R extends QueryResultRow = QueryResultRow> {
|
|
19
|
+
rows: R[];
|
|
20
|
+
rowCount: number | null;
|
|
21
|
+
command: string;
|
|
22
|
+
oid: number;
|
|
23
|
+
fields: unknown[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ClientConfig {
|
|
27
|
+
host?: string;
|
|
28
|
+
port?: number;
|
|
29
|
+
user?: string;
|
|
30
|
+
password?: string;
|
|
31
|
+
database?: string;
|
|
32
|
+
connectionString?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class Client {
|
|
36
|
+
constructor(config?: ClientConfig | string);
|
|
37
|
+
connect(): Promise<void>;
|
|
38
|
+
query<R extends QueryResultRow = QueryResultRow>(
|
|
39
|
+
queryText: string,
|
|
40
|
+
values?: unknown[],
|
|
41
|
+
): Promise<QueryResult<R>>;
|
|
42
|
+
end(): Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class Pool {
|
|
46
|
+
constructor(config?: ClientConfig | string);
|
|
47
|
+
connect(): Promise<Client>;
|
|
48
|
+
query<R extends QueryResultRow = QueryResultRow>(
|
|
49
|
+
queryText: string,
|
|
50
|
+
values?: unknown[],
|
|
51
|
+
): Promise<QueryResult<R>>;
|
|
52
|
+
end(): Promise<void>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface TypeParsers {
|
|
56
|
+
setTypeParser(oid: number, parseFn: (value: string) => unknown): void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const types: TypeParsers;
|
|
60
|
+
}
|
|
@@ -86,7 +86,13 @@ export class PostgresAdapter implements DatabaseAdapter {
|
|
|
86
86
|
await this.client!.connect();
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
|
|
89
|
+
// NOTE: a plain runtime guard, not an `asserts this is ...` predicate. A
|
|
90
|
+
// type-narrowing predicate that re-declares the private `client` member
|
|
91
|
+
// intersects the class with a structural literal and collapses `this` to
|
|
92
|
+
// `never` (TS treats the private brand as unsatisfiable). The non-null
|
|
93
|
+
// `this.client!` assertions at the (already-guarded) call sites carry the
|
|
94
|
+
// narrowing instead, so behaviour is unchanged.
|
|
95
|
+
private ensureConnected(): void {
|
|
90
96
|
if (!this.client) {
|
|
91
97
|
throw new Error("PostgreSQL adapter not connected. Call connect() first.");
|
|
92
98
|
}
|
|
@@ -107,6 +113,22 @@ export class PostgresAdapter implements DatabaseAdapter {
|
|
|
107
113
|
});
|
|
108
114
|
}
|
|
109
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Normalise an `id` column value (typed `unknown` because pg row values are
|
|
118
|
+
* `unknown`) into the `number | bigint | null` shape `_lastInsertId` /
|
|
119
|
+
* `DatabaseResult.lastInsertId` expect. At runtime PG returns numeric PKs as
|
|
120
|
+
* number/bigint (the int8/numeric type parsers above coerce them to Number);
|
|
121
|
+
* a numeric string is coerced, anything else (null/undefined/non-numeric)
|
|
122
|
+
* becomes null.
|
|
123
|
+
*/
|
|
124
|
+
private normalizeId(value: unknown): number | bigint | null {
|
|
125
|
+
if (typeof value === "number" || typeof value === "bigint") return value;
|
|
126
|
+
if (typeof value === "string" && value.trim() !== "" && !Number.isNaN(Number(value))) {
|
|
127
|
+
return Number(value);
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
110
132
|
execute(sql: string, params?: unknown[]): unknown {
|
|
111
133
|
this.ensureConnected();
|
|
112
134
|
const convertedSql = this.convertPlaceholders(sql);
|
|
@@ -139,7 +161,7 @@ export class PostgresAdapter implements DatabaseAdapter {
|
|
|
139
161
|
const convertedSql = this.convertPlaceholders(sql);
|
|
140
162
|
const result = await this.client!.query(convertedSql, params);
|
|
141
163
|
if (result.rows?.[0]?.id !== undefined) {
|
|
142
|
-
this._lastInsertId = result.rows[0].id;
|
|
164
|
+
this._lastInsertId = this.normalizeId(result.rows[0].id);
|
|
143
165
|
}
|
|
144
166
|
return result;
|
|
145
167
|
}
|
|
@@ -194,12 +216,12 @@ export class PostgresAdapter implements DatabaseAdapter {
|
|
|
194
216
|
try {
|
|
195
217
|
const result = await this.client!.query(sql, values);
|
|
196
218
|
const insertedRow = result.rows[0];
|
|
197
|
-
const id = insertedRow?.id
|
|
219
|
+
const id = this.normalizeId(insertedRow?.id);
|
|
198
220
|
if (id !== null) this._lastInsertId = id;
|
|
199
221
|
return {
|
|
200
222
|
success: true,
|
|
201
223
|
rowsAffected: result.rowCount ?? 1,
|
|
202
|
-
lastInsertId: id,
|
|
224
|
+
lastInsertId: id ?? undefined,
|
|
203
225
|
};
|
|
204
226
|
} catch (e) {
|
|
205
227
|
return { success: false, rowsAffected: 0, error: (e as Error).message };
|
|
@@ -9,6 +9,45 @@ function isIdentifier(str: string): boolean {
|
|
|
9
9
|
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(str);
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* The value shape `node:sqlite` binds as a statement parameter. Mirrors the
|
|
14
|
+
* module-internal `SQLInputValue` type (which is not exported from
|
|
15
|
+
* `node:sqlite`, so it cannot be imported). The DatabaseAdapter interface
|
|
16
|
+
* carries params as `unknown[]`; this narrows them to the bindable shape at the
|
|
17
|
+
* `.run()`/`.all()`/`.get()` call sites without an `any` cast.
|
|
18
|
+
*/
|
|
19
|
+
type SqlParam = null | number | bigint | string | NodeJS.ArrayBufferView;
|
|
20
|
+
|
|
21
|
+
/** Narrow adapter-level `unknown[]` params to node:sqlite's bindable type. */
|
|
22
|
+
function toSqlParams(params: readonly unknown[]): SqlParam[] {
|
|
23
|
+
return params as SqlParam[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Whether the linked SQLite library is at least the given version.
|
|
28
|
+
*
|
|
29
|
+
* Used to gate `UPDATE ... RETURNING` (SQLite >= 3.35) in the atomic
|
|
30
|
+
* sequence path. Memoised — the version never changes at runtime.
|
|
31
|
+
*/
|
|
32
|
+
let _sqliteVersionInfo: [number, number, number] | null = null;
|
|
33
|
+
function sqliteVersionAtLeast(major: number, minor: number, patch: number): boolean {
|
|
34
|
+
if (_sqliteVersionInfo === null) {
|
|
35
|
+
try {
|
|
36
|
+
const probe = new DatabaseSync(":memory:");
|
|
37
|
+
const row = probe.prepare("SELECT sqlite_version() AS v").get() as { v?: string };
|
|
38
|
+
probe.close();
|
|
39
|
+
const parts = String(row?.v ?? "0.0.0").split(".").map((n) => parseInt(n, 10) || 0);
|
|
40
|
+
_sqliteVersionInfo = [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
|
|
41
|
+
} catch {
|
|
42
|
+
_sqliteVersionInfo = [0, 0, 0];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const [ma, mi, pa] = _sqliteVersionInfo;
|
|
46
|
+
if (ma !== major) return ma > major;
|
|
47
|
+
if (mi !== minor) return mi > minor;
|
|
48
|
+
return pa >= patch;
|
|
49
|
+
}
|
|
50
|
+
|
|
12
51
|
/**
|
|
13
52
|
* Resolve a SQLite path argument against the project root (cwd).
|
|
14
53
|
*
|
|
@@ -55,7 +94,7 @@ export class SQLiteAdapter implements DatabaseAdapter {
|
|
|
55
94
|
|
|
56
95
|
execute(sql: string, params?: unknown[]): unknown {
|
|
57
96
|
const stmt = this.db.prepare(sql);
|
|
58
|
-
const result = params ? stmt.run(...params) : stmt.run();
|
|
97
|
+
const result = params ? stmt.run(...toSqlParams(params)) : stmt.run();
|
|
59
98
|
if (result && typeof result === "object" && "lastInsertRowid" in result) {
|
|
60
99
|
this._lastInsertId = result.lastInsertRowid as number | bigint;
|
|
61
100
|
}
|
|
@@ -70,8 +109,8 @@ export class SQLiteAdapter implements DatabaseAdapter {
|
|
|
70
109
|
this.db.exec("BEGIN TRANSACTION");
|
|
71
110
|
try {
|
|
72
111
|
for (const params of paramsList) {
|
|
73
|
-
const result = stmt.run(...params);
|
|
74
|
-
totalAffected += result.changes;
|
|
112
|
+
const result = stmt.run(...toSqlParams(params));
|
|
113
|
+
totalAffected += Number(result.changes);
|
|
75
114
|
if (result.lastInsertRowid) {
|
|
76
115
|
lastId = result.lastInsertRowid;
|
|
77
116
|
this._lastInsertId = result.lastInsertRowid;
|
|
@@ -88,7 +127,7 @@ export class SQLiteAdapter implements DatabaseAdapter {
|
|
|
88
127
|
|
|
89
128
|
query<T = Record<string, unknown>>(sql: string, params?: unknown[]): T[] {
|
|
90
129
|
const stmt = this.db.prepare(sql);
|
|
91
|
-
return (params ? stmt.all(...params) : stmt.all()) as T[];
|
|
130
|
+
return (params ? stmt.all(...toSqlParams(params)) : stmt.all()) as T[];
|
|
92
131
|
}
|
|
93
132
|
|
|
94
133
|
fetch<T = Record<string, unknown>>(sql: string, params?: unknown[], limit?: number, skip?: number): T[] {
|
|
@@ -108,7 +147,7 @@ export class SQLiteAdapter implements DatabaseAdapter {
|
|
|
108
147
|
|
|
109
148
|
fetchOne<T = Record<string, unknown>>(sql: string, params?: unknown[]): T | null {
|
|
110
149
|
const stmt = this.db.prepare(sql);
|
|
111
|
-
const row = params ? stmt.get(...params) : stmt.get();
|
|
150
|
+
const row = params ? stmt.get(...toSqlParams(params)) : stmt.get();
|
|
112
151
|
return (row as T) ?? null;
|
|
113
152
|
}
|
|
114
153
|
|
|
@@ -129,9 +168,9 @@ export class SQLiteAdapter implements DatabaseAdapter {
|
|
|
129
168
|
const values = Object.values(data);
|
|
130
169
|
|
|
131
170
|
try {
|
|
132
|
-
const result = this.db.prepare(sql).run(...values);
|
|
171
|
+
const result = this.db.prepare(sql).run(...toSqlParams(values));
|
|
133
172
|
this._lastInsertId = result.lastInsertRowid;
|
|
134
|
-
return { success: true, rowsAffected: result.changes, lastInsertId: result.lastInsertRowid };
|
|
173
|
+
return { success: true, rowsAffected: Number(result.changes), lastInsertId: result.lastInsertRowid };
|
|
135
174
|
} catch (e) {
|
|
136
175
|
return { success: false, rowsAffected: 0, error: (e as Error).message };
|
|
137
176
|
}
|
|
@@ -144,8 +183,8 @@ export class SQLiteAdapter implements DatabaseAdapter {
|
|
|
144
183
|
const values = [...Object.values(data), ...Object.values(filter)];
|
|
145
184
|
|
|
146
185
|
try {
|
|
147
|
-
const result = this.db.prepare(sql).run(...values);
|
|
148
|
-
return { success: true, rowsAffected: result.changes };
|
|
186
|
+
const result = this.db.prepare(sql).run(...toSqlParams(values));
|
|
187
|
+
return { success: true, rowsAffected: Number(result.changes) };
|
|
149
188
|
} catch (e) {
|
|
150
189
|
return { success: false, rowsAffected: 0, error: (e as Error).message };
|
|
151
190
|
}
|
|
@@ -164,8 +203,8 @@ export class SQLiteAdapter implements DatabaseAdapter {
|
|
|
164
203
|
if (typeof filter === "string") {
|
|
165
204
|
const sql = filter ? `DELETE FROM "${table}" WHERE ${filter}` : `DELETE FROM "${table}"`;
|
|
166
205
|
try {
|
|
167
|
-
const result = this.db.prepare(sql).run(...(params ?? []));
|
|
168
|
-
return { success: true, rowsAffected: result.changes };
|
|
206
|
+
const result = this.db.prepare(sql).run(...toSqlParams(params ?? []));
|
|
207
|
+
return { success: true, rowsAffected: Number(result.changes) };
|
|
169
208
|
} catch (e) {
|
|
170
209
|
return { success: false, rowsAffected: 0, error: (e as Error).message };
|
|
171
210
|
}
|
|
@@ -176,8 +215,8 @@ export class SQLiteAdapter implements DatabaseAdapter {
|
|
|
176
215
|
const values = Object.values(filter);
|
|
177
216
|
|
|
178
217
|
try {
|
|
179
|
-
const result = this.db.prepare(sql).run(...values);
|
|
180
|
-
return { success: true, rowsAffected: result.changes };
|
|
218
|
+
const result = this.db.prepare(sql).run(...toSqlParams(values));
|
|
219
|
+
return { success: true, rowsAffected: Number(result.changes) };
|
|
181
220
|
} catch (e) {
|
|
182
221
|
return { success: false, rowsAffected: 0, error: (e as Error).message };
|
|
183
222
|
}
|
|
@@ -233,6 +272,66 @@ export class SQLiteAdapter implements DatabaseAdapter {
|
|
|
233
272
|
lastInsertId(): number | bigint | null { return this._lastInsertId; }
|
|
234
273
|
close(): void { this.db.close(); }
|
|
235
274
|
|
|
275
|
+
/**
|
|
276
|
+
* Atomically increment and return the next value of a tina4_sequences row.
|
|
277
|
+
*
|
|
278
|
+
* DB-contract B (no duplicate primary keys under concurrency): the old
|
|
279
|
+
* read-increment-read path in Database.sequenceNext() yields at every `await`
|
|
280
|
+
* between the read and the write, so two concurrent async callers can read the
|
|
281
|
+
* same `current_value` and return the same id. This method runs the WHOLE
|
|
282
|
+
* operation — ensure-table, seed-if-absent, and the increment-and-return — as
|
|
283
|
+
* ONE synchronous burst on the single shared `node:sqlite` connection. Because
|
|
284
|
+
* `node:sqlite` is synchronous and JavaScript is single-threaded, no other
|
|
285
|
+
* async task can interleave between the statements (there is no `await`
|
|
286
|
+
* inside), so the increment is atomic and every caller gets a distinct id.
|
|
287
|
+
* This is the Node analog of the Python master holding SQLiteAdapter._write_lock
|
|
288
|
+
* across the whole op.
|
|
289
|
+
*
|
|
290
|
+
* On SQLite >= 3.35 a single `UPDATE ... SET current_value = current_value + 1
|
|
291
|
+
* ... RETURNING current_value` is itself atomic and returns the new value in
|
|
292
|
+
* one statement (read via prepare().all() — stmt.run() does not surface
|
|
293
|
+
* RETURNING rows). Older SQLite does `UPDATE ... + 1` then `SELECT`, still
|
|
294
|
+
* race-safe because both run in the same synchronous burst.
|
|
295
|
+
*
|
|
296
|
+
* @throws if the sequence row vanishes mid-increment (never silently returns 1).
|
|
297
|
+
*/
|
|
298
|
+
sequenceNextSqlite(seqName: string, seedValue: number): number {
|
|
299
|
+
// Ensure the sequence table exists (idempotent).
|
|
300
|
+
this.db.exec(
|
|
301
|
+
"CREATE TABLE IF NOT EXISTS tina4_sequences (" +
|
|
302
|
+
"seq_name VARCHAR(200) NOT NULL PRIMARY KEY, " +
|
|
303
|
+
"current_value INTEGER NOT NULL DEFAULT 0)",
|
|
304
|
+
);
|
|
305
|
+
// Race-safe seed: INSERT OR IGNORE is a no-op if the row already exists, so
|
|
306
|
+
// there is never a read-then-insert gap.
|
|
307
|
+
this.db.prepare(
|
|
308
|
+
"INSERT OR IGNORE INTO tina4_sequences (seq_name, current_value) VALUES (?, ?)",
|
|
309
|
+
).run(seqName, seedValue);
|
|
310
|
+
|
|
311
|
+
const supportsReturning = sqliteVersionAtLeast(3, 35, 0);
|
|
312
|
+
let row: { current_value?: number | bigint } | undefined;
|
|
313
|
+
if (supportsReturning) {
|
|
314
|
+
// One atomic increment-and-return.
|
|
315
|
+
row = this.db.prepare(
|
|
316
|
+
"UPDATE tina4_sequences SET current_value = current_value + 1 " +
|
|
317
|
+
"WHERE seq_name = ? RETURNING current_value",
|
|
318
|
+
).get(seqName) as { current_value?: number | bigint } | undefined;
|
|
319
|
+
} else {
|
|
320
|
+
// Older SQLite (< 3.35, no RETURNING): increment then read. Still
|
|
321
|
+
// race-safe because both run in the same synchronous burst (no await).
|
|
322
|
+
this.db.prepare(
|
|
323
|
+
"UPDATE tina4_sequences SET current_value = current_value + 1 WHERE seq_name = ?",
|
|
324
|
+
).run(seqName);
|
|
325
|
+
row = this.db.prepare(
|
|
326
|
+
"SELECT current_value FROM tina4_sequences WHERE seq_name = ?",
|
|
327
|
+
).get(seqName) as { current_value?: number | bigint } | undefined;
|
|
328
|
+
}
|
|
329
|
+
if (!row || row.current_value == null) {
|
|
330
|
+
throw new Error(`getNextId: sequence row '${seqName}' vanished mid-increment`);
|
|
331
|
+
}
|
|
332
|
+
return Number(row.current_value);
|
|
333
|
+
}
|
|
334
|
+
|
|
236
335
|
tableExists(name: string): boolean {
|
|
237
336
|
// v3.13.14 (#48): a SQLite "schema" is an ATTACH alias ("extra.widget").
|
|
238
337
|
// Query that database's own sqlite_master when the prefix is a plain
|
|
@@ -1086,7 +1086,10 @@ export class BaseModel {
|
|
|
1086
1086
|
|
|
1087
1087
|
const related = new relatedClass(rows[0] as Record<string, unknown>) as R;
|
|
1088
1088
|
const relKey = relatedClass.tableName.toLowerCase();
|
|
1089
|
-
|
|
1089
|
+
// Write through the BaseModel index signature: a generic `T` cannot be
|
|
1090
|
+
// indexed for writing (TS2862), but `T extends BaseModel` guarantees `this`
|
|
1091
|
+
// is a BaseModel, whose `[key: string]: unknown` signature is writable.
|
|
1092
|
+
(this as BaseModel)[relKey] = related;
|
|
1090
1093
|
return related;
|
|
1091
1094
|
}
|
|
1092
1095
|
|
|
@@ -1118,7 +1121,8 @@ export class BaseModel {
|
|
|
1118
1121
|
const rows = await adapterQuery(db, sql, [pkValue]);
|
|
1119
1122
|
const related = rows.map((row) => new relatedClass(row as Record<string, unknown>) as R);
|
|
1120
1123
|
const relKey = relatedClass.tableName.toLowerCase();
|
|
1121
|
-
|
|
1124
|
+
// See hasOne: write through BaseModel's writable index signature (TS2862).
|
|
1125
|
+
(this as BaseModel)[relKey] = related;
|
|
1122
1126
|
return related;
|
|
1123
1127
|
}
|
|
1124
1128
|
|
|
@@ -1153,7 +1157,8 @@ export class BaseModel {
|
|
|
1153
1157
|
|
|
1154
1158
|
const related = new relatedClass(rows[0] as Record<string, unknown>) as R;
|
|
1155
1159
|
const relKey = relatedClass.tableName.toLowerCase();
|
|
1156
|
-
|
|
1160
|
+
// See hasOne: write through BaseModel's writable index signature (TS2862).
|
|
1161
|
+
(this as BaseModel)[relKey] = related;
|
|
1157
1162
|
return related;
|
|
1158
1163
|
}
|
|
1159
1164
|
|
|
@@ -7,11 +7,14 @@
|
|
|
7
7
|
*
|
|
8
8
|
* One store, two layers (mirrors the Python master — tina4_python/database/connection.py):
|
|
9
9
|
*
|
|
10
|
-
* • request-scoped (DEFAULT
|
|
10
|
+
* • request-scoped (DEFAULT OFF, opt-in TINA4_AUTO_CACHING=true) — dedupes
|
|
11
11
|
* identical SELECTs to protect the DB from rapid repeat reads. Cleared at the
|
|
12
12
|
* START of every HTTP request (via Database.resetRequestCaches()) AND on any
|
|
13
13
|
* write, with a short safety TTL (TINA4_AUTO_CACHING_TTL, default 5s) for
|
|
14
|
-
* non-request contexts (scripts/workers).
|
|
14
|
+
* non-request contexts (scripts/workers). Default OFF because a request-scoped
|
|
15
|
+
* cache defaulting ON is a footgun — a read-after-write in one request (e.g.
|
|
16
|
+
* SELECT MAX(id) then INSERT) returns a cached pre-write value. Opt in for
|
|
17
|
+
* read-heavy endpoints.
|
|
15
18
|
* • persistent (opt-in, TINA4_DB_CACHE=true) — cross-request TTL cache that is
|
|
16
19
|
* NOT cleared per request; entries expire by TINA4_DB_CACHE_TTL (default 30s).
|
|
17
20
|
*
|
|
@@ -45,7 +48,7 @@ function isTruthy(val: string | undefined): boolean {
|
|
|
45
48
|
export interface CachedAdapterOptions {
|
|
46
49
|
/** Force-enable the persistent (cross-request) layer. Defaults to TINA4_DB_CACHE. */
|
|
47
50
|
persistent?: boolean;
|
|
48
|
-
/** Force-enable the request-scoped layer. Defaults to TINA4_AUTO_CACHING (default
|
|
51
|
+
/** Force-enable the request-scoped layer. Defaults to TINA4_AUTO_CACHING (default false / opt-in). */
|
|
49
52
|
requestScoped?: boolean;
|
|
50
53
|
/** Override the effective TTL (seconds). Defaults to the mode-appropriate env var. */
|
|
51
54
|
ttl?: number;
|
|
@@ -65,7 +68,7 @@ export class CachedDatabaseAdapter implements DatabaseAdapter {
|
|
|
65
68
|
private cache: QueryCache;
|
|
66
69
|
/** Persistent (cross-request) layer — TINA4_DB_CACHE. */
|
|
67
70
|
private cachePersistent: boolean;
|
|
68
|
-
/** Request-scoped layer — TINA4_AUTO_CACHING (default
|
|
71
|
+
/** Request-scoped layer — TINA4_AUTO_CACHING (default OFF / opt-in). */
|
|
69
72
|
private cacheRequestScoped: boolean;
|
|
70
73
|
private enabled: boolean;
|
|
71
74
|
private ttl: number;
|
|
@@ -94,8 +97,14 @@ export class CachedDatabaseAdapter implements DatabaseAdapter {
|
|
|
94
97
|
constructor(adapter: DatabaseAdapter, options: CachedAdapterOptions = {}) {
|
|
95
98
|
this.adapter = adapter;
|
|
96
99
|
this.cachePersistent = options.persistent ?? isTruthy(process.env.TINA4_DB_CACHE);
|
|
97
|
-
|
|
98
|
-
|
|
100
|
+
// Request-scoped cache defaults to OFF (opt-in). A request-scoped cache
|
|
101
|
+
// defaulting ON is a footgun: a `SELECT MAX(id)` (or generator read) right
|
|
102
|
+
// before an INSERT in the same request returns a cached pre-write value →
|
|
103
|
+
// duplicate primary keys; any read-after-write in one request shows stale
|
|
104
|
+
// state. So the DEFAULT is OFF; opt in for read-heavy endpoints with
|
|
105
|
+
// TINA4_AUTO_CACHING=true. Mirrors the Python master
|
|
106
|
+
// (tina4_python/database/connection.py: default literal "false").
|
|
107
|
+
this.cacheRequestScoped = options.requestScoped ?? isTruthy(process.env.TINA4_AUTO_CACHING);
|
|
99
108
|
this.enabled = this.cachePersistent || this.cacheRequestScoped;
|
|
100
109
|
|
|
101
110
|
if (options.ttl !== undefined) {
|