tina4-nodejs 3.13.37 → 3.13.39
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 +65 -20
- package/README.md +6 -6
- 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/src/api.ts +64 -1
- package/packages/core/src/auth.ts +112 -2
- package/packages/core/src/cache.ts +2 -2
- package/packages/core/src/devAdmin.ts +66 -44
- 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 +21 -10
- package/packages/core/src/logger.ts +85 -28
- package/packages/core/src/mcp.test.ts +1 -1
- package/packages/core/src/mcp.ts +25 -8
- package/packages/core/src/messenger.ts +111 -11
- package/packages/core/src/metrics.ts +557 -98
- package/packages/core/src/middleware.ts +130 -40
- package/packages/core/src/plan.ts +1 -1
- package/packages/core/src/queue.ts +1 -1
- package/packages/core/src/queueBackends/kafkaBackend.ts +98 -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 +56 -8
- package/packages/core/src/server.ts +138 -23
- 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/types.ts +17 -2
- package/packages/core/src/websocket.ts +666 -42
- package/packages/core/src/websocketBackplane.ts +210 -10
- package/packages/core/src/websocketConnection.ts +6 -0
- 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 +175 -25
- package/packages/orm/src/cachedDatabase.ts +15 -6
- package/packages/orm/src/database.ts +257 -55
- package/packages/orm/src/index.ts +6 -1
- package/packages/orm/src/migration.ts +151 -24
- package/packages/orm/src/queryBuilder.ts +14 -2
- package/packages/orm/src/seeder.ts +443 -65
- package/packages/orm/src/types.ts +7 -0
- package/packages/orm/src/validation.ts +14 -0
- package/packages/swagger/src/ui.ts +1 -1
|
@@ -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
|
|
@@ -95,6 +95,16 @@ export class BaseModel {
|
|
|
95
95
|
/** Relationship cache for lazy loading */
|
|
96
96
|
private _relCache: Record<string, unknown> = {};
|
|
97
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Cause of the most recent failed save(). null when the last save()
|
|
100
|
+
* succeeded. Mirrors db.getError() so a caller that checks
|
|
101
|
+
* `if (!(await model.save()))` can still recover the real cause via
|
|
102
|
+
* `model.getError()` / `model.lastError` — the failure never vanishes
|
|
103
|
+
* silently. Set by save() (validation message or driver error), cleared
|
|
104
|
+
* to null on a successful save.
|
|
105
|
+
*/
|
|
106
|
+
lastError: string | null = null;
|
|
107
|
+
|
|
98
108
|
constructor(data?: Record<string, unknown> | string) {
|
|
99
109
|
// Accept a JSON object string (parity with Python/PHP/Ruby):
|
|
100
110
|
// new Widget('{"id":1,"name":"alpha"}')
|
|
@@ -110,6 +120,21 @@ export class BaseModel {
|
|
|
110
120
|
`Map over the list to build many records.`,
|
|
111
121
|
);
|
|
112
122
|
}
|
|
123
|
+
const ModelClass0 = this.constructor as typeof BaseModel;
|
|
124
|
+
// Set defaults from field definitions BEFORE populating from data.
|
|
125
|
+
// Outlier A (mirrors Python issue #50.1): a callable default is resolved
|
|
126
|
+
// to its called value PER INSTANCE, so per-row defaults (e.g.
|
|
127
|
+
// `default: () => new Date()`) actually differ and a function never
|
|
128
|
+
// reaches the driver. Static defaults are assigned verbatim. Data passed
|
|
129
|
+
// to the constructor overrides any default below.
|
|
130
|
+
const fields0 = ModelClass0.fields ?? {};
|
|
131
|
+
for (const [name, def] of Object.entries(fields0)) {
|
|
132
|
+
if (def.default === undefined) continue;
|
|
133
|
+
this[name] = typeof def.default === "function"
|
|
134
|
+
? (def.default as () => unknown)()
|
|
135
|
+
: def.default;
|
|
136
|
+
}
|
|
137
|
+
|
|
113
138
|
if (data) {
|
|
114
139
|
const ModelClass = this.constructor as typeof BaseModel;
|
|
115
140
|
// If autoMap is on, auto-generate fieldMapping from camelCase fields
|
|
@@ -188,8 +213,12 @@ export class BaseModel {
|
|
|
188
213
|
this.belongsTo.push({ model: def.references, foreignKey: key });
|
|
189
214
|
}
|
|
190
215
|
|
|
191
|
-
// Register hasMany on the referenced model via the module-level registry
|
|
192
|
-
|
|
216
|
+
// Register hasMany on the referenced model via the module-level registry.
|
|
217
|
+
// Outlier F: the has-many key defaults to the DECLARING class name
|
|
218
|
+
// lowercased + "s" (Python master: `name.lower() + "s"`), e.g. a Post
|
|
219
|
+
// with author_id → Author.posts. The relatedName override wins. The old
|
|
220
|
+
// default was the table name, which drifted from the documented rule.
|
|
221
|
+
const hasManyKey = def.relatedName ?? (this.name.toLowerCase() + "s");
|
|
193
222
|
const existing = _fkRegistry.get(def.references) ?? [];
|
|
194
223
|
if (!existing.find((r) => r.foreignKey === key && r.declaringModel === this.name)) {
|
|
195
224
|
existing.push({ foreignKey: key, declaringModel: this.name, hasManyKey });
|
|
@@ -207,7 +236,10 @@ export class BaseModel {
|
|
|
207
236
|
for (const entry of entries) {
|
|
208
237
|
this.hasMany = this.hasMany ?? [];
|
|
209
238
|
if (!this.hasMany.find((r) => r.foreignKey === entry.foreignKey && r.model === entry.declaringModel)) {
|
|
210
|
-
|
|
239
|
+
// Outlier F: carry the derived has-many key (declaring class lowercased
|
|
240
|
+
// + "s", or the relatedName override) onto the relationship so an
|
|
241
|
+
// include: ["posts"] resolves to it — not the related table name.
|
|
242
|
+
this.hasMany.push({ model: entry.declaringModel, foreignKey: entry.foreignKey, relatedName: entry.hasManyKey });
|
|
211
243
|
}
|
|
212
244
|
}
|
|
213
245
|
}
|
|
@@ -300,38 +332,83 @@ export class BaseModel {
|
|
|
300
332
|
/**
|
|
301
333
|
* Create a new instance from data, save it, and return the saved instance.
|
|
302
334
|
*
|
|
335
|
+
* Canonical #3: if the underlying save() fails (validation errors or a
|
|
336
|
+
* driver error), create() returns `false` — it does NOT hand back a
|
|
337
|
+
* possibly-unsaved instance, so a failed insert can never masquerade as a
|
|
338
|
+
* success. The failure cause is logged and available on the (discarded)
|
|
339
|
+
* instance's getError() via the same path save() uses.
|
|
340
|
+
*
|
|
303
341
|
* Usage:
|
|
304
342
|
* const user = User.create({ name: "Alice", email: "alice@example.com" });
|
|
343
|
+
* if (!(await User.create({ name: null }))) { ... } // save() failed -> false
|
|
305
344
|
*/
|
|
306
345
|
static async create<T extends BaseModel>(
|
|
307
346
|
this: new (data?: Record<string, unknown>) => T,
|
|
308
347
|
data: Record<string, unknown> = {},
|
|
309
|
-
): Promise<T> {
|
|
348
|
+
): Promise<T | false> {
|
|
310
349
|
const instance = new this(data) as T;
|
|
311
|
-
await instance.save()
|
|
350
|
+
if ((await instance.save()) === false) {
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
312
353
|
return instance;
|
|
313
354
|
}
|
|
314
355
|
|
|
315
356
|
/**
|
|
316
|
-
* Find
|
|
357
|
+
* Find record(s) by primary key, filter object, or all.
|
|
317
358
|
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
321
|
-
*
|
|
322
|
-
*
|
|
359
|
+
* Outlier C — overloaded on the first argument (parity with
|
|
360
|
+
* Python/PHP/Ruby):
|
|
361
|
+
* - number | string (scalar PK) → single instance (or null), like
|
|
362
|
+
* findById(pk). `include` is accepted as the 2nd argument in this form.
|
|
363
|
+
* - object (filter) → array of instances (AND-ed conditions).
|
|
364
|
+
* - omitted → array of all records.
|
|
323
365
|
*
|
|
324
|
-
*
|
|
366
|
+
* Usage:
|
|
367
|
+
* User.find(1) → User | null (PK lookup)
|
|
368
|
+
* User.find(1, ["posts"]) → User | null (PK lookup + eager)
|
|
369
|
+
* User.find({ name: "Alice" }) → [User, ...]
|
|
370
|
+
* User.find({ age: 18 }, 10) → [User, ...] (limit 10)
|
|
371
|
+
* User.find({}, 100, 0, "name ASC") → [User, ...] (with orderBy)
|
|
372
|
+
* User.find() → all records
|
|
325
373
|
*/
|
|
374
|
+
// Scalar PK → single instance | null.
|
|
375
|
+
static async find<T extends BaseModel>(
|
|
376
|
+
this: new (data?: Record<string, unknown>) => T,
|
|
377
|
+
pk: number | string,
|
|
378
|
+
include?: string[],
|
|
379
|
+
): Promise<T | null>;
|
|
380
|
+
// Filter object / all → array.
|
|
326
381
|
static async find<T extends BaseModel>(
|
|
327
382
|
this: new (data?: Record<string, unknown>) => T,
|
|
328
383
|
filter?: Record<string, unknown>,
|
|
329
|
-
limit
|
|
384
|
+
limit?: number,
|
|
385
|
+
offset?: number,
|
|
386
|
+
orderBy?: string,
|
|
387
|
+
include?: string[],
|
|
388
|
+
): Promise<T[]>;
|
|
389
|
+
static async find<T extends BaseModel>(
|
|
390
|
+
this: new (data?: Record<string, unknown>) => T,
|
|
391
|
+
filter?: Record<string, unknown> | number | string,
|
|
392
|
+
limit: number | string[] = 100,
|
|
330
393
|
offset = 0,
|
|
331
394
|
orderBy?: string,
|
|
332
395
|
include?: string[],
|
|
333
|
-
): Promise<T[]> {
|
|
396
|
+
): Promise<T[] | T | null> {
|
|
334
397
|
const ModelClass = this as unknown as typeof BaseModel & (new (data?: Record<string, unknown>) => T);
|
|
398
|
+
|
|
399
|
+
// Scalar PK lookup routes to findById. A number or a string (but NOT a
|
|
400
|
+
// boolean, and NOT an object) is a primary-key value — Active Record
|
|
401
|
+
// convention (Django Model.objects.get(pk), SQLAlchemy session.get(M, id),
|
|
402
|
+
// Ruby Model.find(1)). In the scalar form the 2nd arg is `include`.
|
|
403
|
+
if (typeof filter === "number" || typeof filter === "string") {
|
|
404
|
+
const inc = Array.isArray(limit) ? limit : undefined;
|
|
405
|
+
return (ModelClass.findById as (id: unknown, include?: string[]) => Promise<T | null>).call(
|
|
406
|
+
ModelClass, filter, inc,
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Array form — coerce `limit` back to a number for the list path.
|
|
411
|
+
const lim = typeof limit === "number" ? limit : 100;
|
|
335
412
|
const db = ModelClass.getDb();
|
|
336
413
|
const conditions: string[] = [];
|
|
337
414
|
const params: unknown[] = [];
|
|
@@ -356,7 +433,7 @@ export class BaseModel {
|
|
|
356
433
|
sql += ` ORDER BY ${orderBy}`;
|
|
357
434
|
}
|
|
358
435
|
|
|
359
|
-
const rows = await adapterFetch(db, sql, params,
|
|
436
|
+
const rows = await adapterFetch(db, sql, params, lim, offset);
|
|
360
437
|
const data = (rows as any)?.data ?? rows;
|
|
361
438
|
const instances = (Array.isArray(data) ? data : []).map((row: Record<string, unknown>) => {
|
|
362
439
|
const inst = new this(row) as T;
|
|
@@ -391,11 +468,16 @@ export class BaseModel {
|
|
|
391
468
|
|
|
392
469
|
let sql: string;
|
|
393
470
|
if (filter === undefined || filter === null) {
|
|
394
|
-
// No args — use PK already set
|
|
395
|
-
|
|
396
|
-
|
|
471
|
+
// No args — use the PK value already set. Outlier B: resolve the REAL
|
|
472
|
+
// primary key via getPkField()/getPkColumn() (a model has no
|
|
473
|
+
// `primaryKey` static — the old code referenced a non-existent field, so
|
|
474
|
+
// it always queried `WHERE undefined = ?` and never loaded). Use the JS
|
|
475
|
+
// property for the value and the DB column for the WHERE clause.
|
|
476
|
+
const pkProp = ModelClass.getPkField();
|
|
477
|
+
const pkCol = ModelClass.getPkColumn();
|
|
478
|
+
const pkValue = (this as any)[pkProp];
|
|
397
479
|
if (pkValue === undefined || pkValue === null) return false;
|
|
398
|
-
sql = `SELECT * FROM ${table} WHERE ${
|
|
480
|
+
sql = `SELECT * FROM "${table}" WHERE "${pkCol}" = ?`;
|
|
399
481
|
params = [pkValue];
|
|
400
482
|
} else {
|
|
401
483
|
sql = `SELECT * FROM ${table} WHERE ${filter}`;
|
|
@@ -492,11 +574,40 @@ export class BaseModel {
|
|
|
492
574
|
}
|
|
493
575
|
|
|
494
576
|
/**
|
|
495
|
-
* Save this instance (insert or update).
|
|
496
|
-
*
|
|
577
|
+
* Save this instance (insert or update). Returns this on success (fluent
|
|
578
|
+
* self), false on failure.
|
|
579
|
+
*
|
|
580
|
+
* Fails loud, never silent (the same principle db.execute() follows by
|
|
581
|
+
* raising). On ANY failure path save() returns `false` — keeping the
|
|
582
|
+
* contract callers rely on (`if (!(await model.save())) ...`) — but it also
|
|
583
|
+
* (a) logs the real cause via Log.error with model/table context and
|
|
584
|
+
* (b) records the cause on `this.lastError` so a caller can recover it after
|
|
585
|
+
* the fact via getError() / lastError. It never throws and never changes the
|
|
586
|
+
* `this | false` return shape.
|
|
587
|
+
*
|
|
588
|
+
* Two distinct failure paths, both loud:
|
|
589
|
+
* - Validation (canonical #2): validate() runs FIRST. If it returns errors,
|
|
590
|
+
* save() logs them, records them on lastError, and returns false WITHOUT
|
|
591
|
+
* touching the database — an invalid model never reaches the driver.
|
|
592
|
+
* - Database: a driver error (NOT NULL, duplicate PK, missing table, ...) is
|
|
593
|
+
* rolled back, logged with the underlying cause, recorded on lastError,
|
|
594
|
+
* and returns false — the cause is no longer swallowed silently.
|
|
497
595
|
*/
|
|
498
596
|
async save(): Promise<this | false> {
|
|
499
597
|
const ModelClass = this.constructor as typeof BaseModel;
|
|
598
|
+
|
|
599
|
+
// ── Canonical #2: validate() is enforced. An invalid model never reaches
|
|
600
|
+
// the driver — fail loud (log + lastError), return false. ──
|
|
601
|
+
const errors = this.validate();
|
|
602
|
+
if (errors.length > 0) {
|
|
603
|
+
this.lastError = errors.join("; ");
|
|
604
|
+
Log.error(
|
|
605
|
+
`${ModelClass.name}.save() refused: validation failed for table ` +
|
|
606
|
+
`'${ModelClass.tableName}' — ${this.lastError}`,
|
|
607
|
+
);
|
|
608
|
+
return false;
|
|
609
|
+
}
|
|
610
|
+
|
|
500
611
|
const db = ModelClass.getDb();
|
|
501
612
|
const pk = ModelClass.getPkField();
|
|
502
613
|
const pkCol = ModelClass.getPkColumn();
|
|
@@ -585,14 +696,42 @@ export class BaseModel {
|
|
|
585
696
|
}
|
|
586
697
|
}
|
|
587
698
|
await adapterCommit(db);
|
|
588
|
-
} catch (e) {
|
|
699
|
+
} catch (e: any) {
|
|
589
700
|
await adapterRollback(db);
|
|
701
|
+
// ── Canonical #1: fail loud, never silent. Keep the false return
|
|
702
|
+
// contract, but capture the REAL cause (prefer the adapter's
|
|
703
|
+
// getError()/getLastError() when present, falling back to the exception
|
|
704
|
+
// text) on this.lastError so it survives, and log it with model/table
|
|
705
|
+
// context. ──
|
|
706
|
+
const adapterErr =
|
|
707
|
+
typeof (db as any).getError === "function" ? (db as any).getError() :
|
|
708
|
+
typeof (db as any).getLastError === "function" ? (db as any).getLastError() :
|
|
709
|
+
null;
|
|
710
|
+
this.lastError = adapterErr || e?.message || String(e);
|
|
711
|
+
Log.error(
|
|
712
|
+
`${ModelClass.name}.save() failed for table ` +
|
|
713
|
+
`'${ModelClass.tableName}': ${this.lastError}`,
|
|
714
|
+
);
|
|
590
715
|
return false;
|
|
591
716
|
}
|
|
717
|
+
// Success — clear any previously-recorded error.
|
|
718
|
+
this.lastError = null;
|
|
592
719
|
(this as any)._exists = true;
|
|
593
720
|
return this;
|
|
594
721
|
}
|
|
595
722
|
|
|
723
|
+
/**
|
|
724
|
+
* Return the cause of the most recent failed save(), or null.
|
|
725
|
+
*
|
|
726
|
+
* Mirrors db.getError(). After save() returns false — whether from
|
|
727
|
+
* validation or a driver error — the real cause is retrievable here (and on
|
|
728
|
+
* this.lastError) so a caller using the `if (!(await model.save()))`
|
|
729
|
+
* contract can still surface it. Cleared to null on a successful save.
|
|
730
|
+
*/
|
|
731
|
+
getError(): string | null {
|
|
732
|
+
return this.lastError;
|
|
733
|
+
}
|
|
734
|
+
|
|
596
735
|
/**
|
|
597
736
|
* Delete this instance. Uses soft delete if configured.
|
|
598
737
|
*/
|
|
@@ -1086,7 +1225,10 @@ export class BaseModel {
|
|
|
1086
1225
|
|
|
1087
1226
|
const related = new relatedClass(rows[0] as Record<string, unknown>) as R;
|
|
1088
1227
|
const relKey = relatedClass.tableName.toLowerCase();
|
|
1089
|
-
|
|
1228
|
+
// Write through the BaseModel index signature: a generic `T` cannot be
|
|
1229
|
+
// indexed for writing (TS2862), but `T extends BaseModel` guarantees `this`
|
|
1230
|
+
// is a BaseModel, whose `[key: string]: unknown` signature is writable.
|
|
1231
|
+
(this as BaseModel)[relKey] = related;
|
|
1090
1232
|
return related;
|
|
1091
1233
|
}
|
|
1092
1234
|
|
|
@@ -1118,7 +1260,8 @@ export class BaseModel {
|
|
|
1118
1260
|
const rows = await adapterQuery(db, sql, [pkValue]);
|
|
1119
1261
|
const related = rows.map((row) => new relatedClass(row as Record<string, unknown>) as R);
|
|
1120
1262
|
const relKey = relatedClass.tableName.toLowerCase();
|
|
1121
|
-
|
|
1263
|
+
// See hasOne: write through BaseModel's writable index signature (TS2862).
|
|
1264
|
+
(this as BaseModel)[relKey] = related;
|
|
1122
1265
|
return related;
|
|
1123
1266
|
}
|
|
1124
1267
|
|
|
@@ -1153,7 +1296,8 @@ export class BaseModel {
|
|
|
1153
1296
|
|
|
1154
1297
|
const related = new relatedClass(rows[0] as Record<string, unknown>) as R;
|
|
1155
1298
|
const relKey = relatedClass.tableName.toLowerCase();
|
|
1156
|
-
|
|
1299
|
+
// See hasOne: write through BaseModel's writable index signature (TS2862).
|
|
1300
|
+
(this as BaseModel)[relKey] = related;
|
|
1157
1301
|
return related;
|
|
1158
1302
|
}
|
|
1159
1303
|
|
|
@@ -1244,9 +1388,15 @@ export class BaseModel {
|
|
|
1244
1388
|
const base = r.model.toLowerCase();
|
|
1245
1389
|
const related = BaseModel._modelRegistry[r.model];
|
|
1246
1390
|
const table = related?.tableName?.toLowerCase();
|
|
1391
|
+
// Outlier F: an FK-auto-wired has-many carries its derived key
|
|
1392
|
+
// (declaring class lowercased + "s", or relatedName) — match it so
|
|
1393
|
+
// include: ["posts"] resolves to the wired relation regardless of the
|
|
1394
|
+
// related table name.
|
|
1395
|
+
const rel = r.relatedName?.toLowerCase();
|
|
1247
1396
|
return (
|
|
1248
1397
|
base === want ||
|
|
1249
1398
|
base + "s" === want ||
|
|
1399
|
+
(rel !== undefined && rel === want) ||
|
|
1250
1400
|
(table !== undefined && table === want)
|
|
1251
1401
|
);
|
|
1252
1402
|
};
|
|
@@ -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) {
|