tina4-nodejs 3.13.38 → 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 +16 -3
- package/README.md +6 -6
- package/package.json +1 -1
- package/packages/core/src/api.ts +64 -1
- package/packages/core/src/devAdmin.ts +36 -19
- package/packages/core/src/index.ts +8 -3
- package/packages/core/src/logger.ts +84 -27
- package/packages/core/src/mcp.ts +25 -8
- package/packages/core/src/metrics.ts +330 -70
- package/packages/core/src/middleware.ts +1 -1
- package/packages/core/src/queueBackends/kafkaBackend.ts +97 -0
- package/packages/core/src/router.ts +54 -6
- package/packages/core/src/server.ts +113 -20
- package/packages/core/src/types.ts +17 -2
- package/packages/core/src/websocket.ts +419 -9
- package/packages/core/src/websocketConnection.ts +6 -0
- package/packages/orm/src/baseModel.ts +167 -22
- package/packages/orm/src/index.ts +4 -0
- package/packages/orm/src/migration.ts +149 -22
- package/packages/orm/src/queryBuilder.ts +14 -2
- package/packages/orm/src/types.ts +7 -0
- package/packages/orm/src/validation.ts +14 -0
|
@@ -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
|
*/
|
|
@@ -1249,9 +1388,15 @@ export class BaseModel {
|
|
|
1249
1388
|
const base = r.model.toLowerCase();
|
|
1250
1389
|
const related = BaseModel._modelRegistry[r.model];
|
|
1251
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();
|
|
1252
1396
|
return (
|
|
1253
1397
|
base === want ||
|
|
1254
1398
|
base + "s" === want ||
|
|
1399
|
+
(rel !== undefined && rel === want) ||
|
|
1255
1400
|
(table !== undefined && table === want)
|
|
1256
1401
|
);
|
|
1257
1402
|
};
|
|
@@ -39,6 +39,10 @@ export {
|
|
|
39
39
|
createMigration,
|
|
40
40
|
status,
|
|
41
41
|
Migration,
|
|
42
|
+
splitStatements,
|
|
43
|
+
normalizeQuotes,
|
|
44
|
+
sortMigrationFiles,
|
|
45
|
+
shouldSkipCreateTable,
|
|
42
46
|
} from "./migration.js";
|
|
43
47
|
export type { MigrationResult, MigrationStatus } from "./migration.js";
|
|
44
48
|
export { AutoCrud, generateCrudRoutes } from "./autoCrud.js";
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
|
+
import { Log } from "@tina4/core";
|
|
3
4
|
import type { FieldDefinition, DatabaseAdapter } from "./types.js";
|
|
4
5
|
import type { SQLiteAdapter } from "./adapters/sqlite.js";
|
|
5
6
|
import type { DiscoveredModel } from "./model.js";
|
|
@@ -76,6 +77,55 @@ async function shouldSkipForFirebird(
|
|
|
76
77
|
return null;
|
|
77
78
|
}
|
|
78
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Match CREATE TABLE <name> — name may be quoted ("x"), bracketed ([x] MSSQL),
|
|
82
|
+
* or bare. Captures the table name.
|
|
83
|
+
*/
|
|
84
|
+
const CREATE_TABLE_RE =
|
|
85
|
+
/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:"([^"]+)"|\[([^\]]+)\]|(\w+))/i;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Identify the database engine via the adapter's class name.
|
|
89
|
+
* (constructor.name is the engine discriminator used throughout this package.)
|
|
90
|
+
*/
|
|
91
|
+
function engineOf(db: DatabaseAdapter): "firebird" | "mssql" | "other" {
|
|
92
|
+
const name = db.constructor.name;
|
|
93
|
+
if (name === "FirebirdAdapter") return "firebird";
|
|
94
|
+
if (name === "MssqlAdapter") return "mssql";
|
|
95
|
+
return "other";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Make CREATE TABLE idempotent on engines lacking IF NOT EXISTS.
|
|
100
|
+
*
|
|
101
|
+
* Firebird and MSSQL do not support `CREATE TABLE IF NOT EXISTS`, so a raw
|
|
102
|
+
* CREATE in a re-run migration raises "object already exists". When the target
|
|
103
|
+
* table already exists on those engines, return a skip reason so the statement
|
|
104
|
+
* is skipped (mirrors the Firebird ALTER-TABLE-ADD idempotency guard).
|
|
105
|
+
* SQLite/MySQL/PostgreSQL support IF NOT EXISTS and are left to the engine.
|
|
106
|
+
* Only a genuine already-exists is skipped — every other error still raises.
|
|
107
|
+
*/
|
|
108
|
+
export async function shouldSkipCreateTable(
|
|
109
|
+
db: DatabaseAdapter,
|
|
110
|
+
stmt: string,
|
|
111
|
+
): Promise<string | null> {
|
|
112
|
+
const engine = engineOf(db);
|
|
113
|
+
if (engine !== "firebird" && engine !== "mssql") return null;
|
|
114
|
+
|
|
115
|
+
const m = stmt.match(CREATE_TABLE_RE);
|
|
116
|
+
if (!m) return null;
|
|
117
|
+
|
|
118
|
+
const table = m[1] ?? m[2] ?? m[3];
|
|
119
|
+
try {
|
|
120
|
+
if (await adapterTableExists(db, table)) {
|
|
121
|
+
return `Table ${table} already exists, skipping CREATE TABLE`;
|
|
122
|
+
}
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
79
129
|
/**
|
|
80
130
|
* Sync model definitions to the database (create tables, add columns).
|
|
81
131
|
*/
|
|
@@ -411,13 +461,44 @@ export interface MigrationStatus {
|
|
|
411
461
|
pending: string[];
|
|
412
462
|
}
|
|
413
463
|
|
|
464
|
+
/**
|
|
465
|
+
* Smart/curly quotes — editors, word processors, docs and chat apps silently
|
|
466
|
+
* convert a straight " to “ ” and a straight ' to ‘ ’ (plus primes ′ ″). Those
|
|
467
|
+
* characters are NOT valid SQL string/identifier delimiters, so a pasted-in
|
|
468
|
+
* migration fails to run ("syntax error near …"). Map them back to straight
|
|
469
|
+
* ASCII quotes. (Real string CONTENTS are unaffected by intent — we only swap
|
|
470
|
+
* the lookalike code points for their ASCII equivalents.)
|
|
471
|
+
*/
|
|
472
|
+
const SMART_QUOTES: Record<string, string> = {
|
|
473
|
+
// Double-quote lookalikes → straight " (U+0022)
|
|
474
|
+
"“": '"', "”": '"', "„": '"', "‟": '"', "″": '"',
|
|
475
|
+
// Single-quote / apostrophe lookalikes → straight ' (U+0027)
|
|
476
|
+
"‘": "'", "’": "'", "‚": "'", "‛": "'", "′": "'",
|
|
477
|
+
};
|
|
478
|
+
const SMART_QUOTE_RE = new RegExp(`[${Object.keys(SMART_QUOTES).join("")}]`, "g");
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Replace smart/curly quotes with straight ASCII quotes so migration SQL
|
|
482
|
+
* authored or pasted from an editor/doc actually runs (those code points are
|
|
483
|
+
* not valid SQL delimiters). Already-straight quotes and ordinary string
|
|
484
|
+
* content are returned byte-for-byte unchanged.
|
|
485
|
+
*/
|
|
486
|
+
export function normalizeQuotes(sql: string): string {
|
|
487
|
+
return sql.replace(SMART_QUOTE_RE, (ch) => SMART_QUOTES[ch]);
|
|
488
|
+
}
|
|
489
|
+
|
|
414
490
|
/**
|
|
415
491
|
* Split SQL text into individual statements on the given delimiter.
|
|
416
492
|
*
|
|
417
493
|
* Strips line comments (`-- ...`) and block comments, handles stored
|
|
418
494
|
* procedure blocks delimited by `$$` or `//`.
|
|
419
495
|
*/
|
|
420
|
-
function splitStatements(sql: string, delimiter = ";"): string[] {
|
|
496
|
+
export function splitStatements(sql: string, delimiter = ";"): string[] {
|
|
497
|
+
// Normalize smart/curly quotes to straight ASCII first, so SQL pasted from
|
|
498
|
+
// an editor/doc (which converts " → “ ” and ' → ‘ ’) actually runs. Mirrors
|
|
499
|
+
// Python's _split_statements applying _normalize_quotes as its first line.
|
|
500
|
+
sql = normalizeQuotes(sql);
|
|
501
|
+
|
|
421
502
|
// Extract blocks delimited by $$ or // first, replacing with placeholders
|
|
422
503
|
const blocks: string[] = [];
|
|
423
504
|
const saveBlock = (_match: string, _p1: string): string => {
|
|
@@ -426,7 +507,12 @@ function splitStatements(sql: string, delimiter = ";"): string[] {
|
|
|
426
507
|
};
|
|
427
508
|
|
|
428
509
|
let processed = sql.replace(/\$\$([\s\S]*?)\$\$/g, saveBlock);
|
|
429
|
-
|
|
510
|
+
// The `//` delimiters must NOT be preceded by a colon, so a URL scheme
|
|
511
|
+
// (`https://…`) or other `://` literal inside a migration is never captured
|
|
512
|
+
// as an opaque stored-proc block (it would otherwise swallow everything
|
|
513
|
+
// between two `//` occurrences and skip statement splitting/cleaning).
|
|
514
|
+
// Negative lookbehind `(?<!:)` mirrors Python's runner.
|
|
515
|
+
processed = processed.replace(/(?<!:)\/\/([\s\S]*?)(?<!:)\/\//g, saveBlock);
|
|
430
516
|
|
|
431
517
|
// Remove block comments (/* ... */)
|
|
432
518
|
const clean = processed.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
@@ -458,25 +544,43 @@ function splitStatements(sql: string, delimiter = ";"): string[] {
|
|
|
458
544
|
* - Sequential: 000001_name.sql, 000002_name.sql
|
|
459
545
|
* - Timestamp: 20240315120000_name.sql (YYYYMMDDHHMMSS)
|
|
460
546
|
*
|
|
461
|
-
*
|
|
462
|
-
*
|
|
547
|
+
* Numeric-aware: a file with a leading numeric/timestamp prefix sorts first by
|
|
548
|
+
* that number (so `9_*` applies before `10_*` — a plain lexical sort misorders
|
|
549
|
+
* unpadded prefixes because "10" < "9"). Files with NO numeric prefix sort
|
|
550
|
+
* AFTER the numbered ones, then lexically. Mirrors Python's `_migration_sort_key`.
|
|
463
551
|
*/
|
|
464
|
-
function sortMigrationFiles(files: string[]): string[] {
|
|
552
|
+
export function sortMigrationFiles(files: string[]): string[] {
|
|
553
|
+
// Returns a tuple-like key: [group, numeric, name].
|
|
554
|
+
// group 0 = has numeric prefix (sorts first); group 1 = no prefix (sorts after).
|
|
555
|
+
const key = (name: string): [number, bigint, string] => {
|
|
556
|
+
const m = name.match(/^(\d+)/);
|
|
557
|
+
return m ? [0, BigInt(m[1]), name] : [1, 0n, name];
|
|
558
|
+
};
|
|
465
559
|
return [...files].sort((a, b) => {
|
|
466
|
-
const
|
|
467
|
-
const
|
|
468
|
-
if (
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
if (aNum < bNum) return -1;
|
|
473
|
-
if (aNum > bNum) return 1;
|
|
474
|
-
return a.localeCompare(b);
|
|
475
|
-
}
|
|
476
|
-
return a.localeCompare(b);
|
|
560
|
+
const [ag, an, anm] = key(a);
|
|
561
|
+
const [bg, bn, bnm] = key(b);
|
|
562
|
+
if (ag !== bg) return ag - bg;
|
|
563
|
+
if (an < bn) return -1;
|
|
564
|
+
if (an > bn) return 1;
|
|
565
|
+
return anm.localeCompare(bnm);
|
|
477
566
|
});
|
|
478
567
|
}
|
|
479
568
|
|
|
569
|
+
/**
|
|
570
|
+
* Warn (once) about migration filenames without a recognized NNNNNN_/timestamp
|
|
571
|
+
* prefix — their ordering relative to numbered migrations is undefined, a silent
|
|
572
|
+
* out-of-order-apply footgun. Mirrors Python's runner.
|
|
573
|
+
*/
|
|
574
|
+
function warnUnprefixedMigrations(files: string[]): void {
|
|
575
|
+
const unprefixed = files.filter((f) => !/^\d+[_-]/.test(f));
|
|
576
|
+
if (unprefixed.length > 0) {
|
|
577
|
+
Log.warning(
|
|
578
|
+
"Migration file(s) without a numeric/timestamp prefix may apply out of order: " +
|
|
579
|
+
unprefixed.join(", "),
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
480
584
|
/**
|
|
481
585
|
* Run all pending SQL-file migrations.
|
|
482
586
|
*
|
|
@@ -533,7 +637,19 @@ export async function migrate(
|
|
|
533
637
|
batch INTEGER NOT NULL DEFAULT 1,
|
|
534
638
|
applied_at TEXT NOT NULL
|
|
535
639
|
)`);
|
|
640
|
+
} else if (engineOf(db) === "mssql") {
|
|
641
|
+
// MSSQL has no AUTOINCREMENT / IF NOT EXISTS — route the bootstrap
|
|
642
|
+
// through the adapter's engine-aware createTable (IDENTITY(1,1) etc.),
|
|
643
|
+
// exactly like ensureMigrationTable does. Emitting raw
|
|
644
|
+
// AUTOINCREMENT/IF NOT EXISTS here is invalid on SQL Server.
|
|
645
|
+
await adapterCreateTable(db, MIGRATION_TABLE, {
|
|
646
|
+
id: { type: "integer", primaryKey: true, autoIncrement: true },
|
|
647
|
+
name: { type: "string", required: true },
|
|
648
|
+
batch: { type: "integer", required: true },
|
|
649
|
+
applied_at: { type: "datetime", default: "now" },
|
|
650
|
+
});
|
|
536
651
|
} else {
|
|
652
|
+
// SQLite / MySQL — both support IF NOT EXISTS + AUTOINCREMENT/AUTO_INCREMENT.
|
|
537
653
|
await adapterExecute(db, `CREATE TABLE IF NOT EXISTS "${MIGRATION_TABLE}" (
|
|
538
654
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
539
655
|
name TEXT NOT NULL,
|
|
@@ -553,13 +669,17 @@ export async function migrate(
|
|
|
553
669
|
}
|
|
554
670
|
}
|
|
555
671
|
|
|
556
|
-
// Collect .sql files (exclude .down.sql), sorted
|
|
672
|
+
// Collect .sql files (exclude .down.sql), numeric-aware sorted (9_ before 10_).
|
|
557
673
|
const files = sortMigrationFiles(
|
|
558
674
|
readdirSync(dir).filter((f) => f.endsWith(".sql") && !f.endsWith(".down.sql")),
|
|
559
675
|
);
|
|
560
676
|
|
|
561
677
|
if (files.length === 0) return result;
|
|
562
678
|
|
|
679
|
+
// Warn about files without a recognized numeric/timestamp prefix — their
|
|
680
|
+
// ordering relative to numbered migrations is undefined.
|
|
681
|
+
warnUnprefixedMigrations(files);
|
|
682
|
+
|
|
563
683
|
// Determine the batch number for this run
|
|
564
684
|
let currentBatch = 1;
|
|
565
685
|
try {
|
|
@@ -621,10 +741,12 @@ export async function migrate(
|
|
|
621
741
|
await adapterStartTransaction(db);
|
|
622
742
|
|
|
623
743
|
for (const stmt of statements) {
|
|
624
|
-
//
|
|
625
|
-
// Pre-check the
|
|
626
|
-
//
|
|
627
|
-
|
|
744
|
+
// Idempotency on engines lacking IF NOT EXISTS: Firebird ALTER-TABLE-ADD,
|
|
745
|
+
// and CREATE TABLE on Firebird/MSSQL. Pre-check the catalogue so a genuine
|
|
746
|
+
// already-exists is skipped instead of raising — every other error still
|
|
747
|
+
// raises (rolls the file back).
|
|
748
|
+
const skipReason =
|
|
749
|
+
(await shouldSkipForFirebird(db, stmt)) ?? (await shouldSkipCreateTable(db, stmt));
|
|
628
750
|
if (skipReason) {
|
|
629
751
|
console.log(` Migration ${file}: ${skipReason}`);
|
|
630
752
|
continue;
|
|
@@ -671,7 +793,12 @@ export async function migrate(
|
|
|
671
793
|
const msg = err instanceof Error ? err.message : String(err);
|
|
672
794
|
console.error(` Migration failed: ${file} — ${msg}`);
|
|
673
795
|
result.failed.push(file);
|
|
674
|
-
//
|
|
796
|
+
// STOP at the first failure (parity with Python/PHP/Ruby). The file rolled
|
|
797
|
+
// back; later migrations must NOT be applied on top of a missing earlier
|
|
798
|
+
// one (a missing column / table would cascade silent corruption). Already-
|
|
799
|
+
// applied files stay applied — fix the bad file and re-run.
|
|
800
|
+
Log.error(`Migration stopped at first failure: ${file} — ${msg}`);
|
|
801
|
+
break;
|
|
675
802
|
}
|
|
676
803
|
}
|
|
677
804
|
|
|
@@ -428,8 +428,20 @@ export class QueryBuilder {
|
|
|
428
428
|
return [{ [field]: { [mongoOp]: val } }, paramIndex + 1];
|
|
429
429
|
}
|
|
430
430
|
|
|
431
|
-
//
|
|
432
|
-
|
|
431
|
+
// Canonical #5: no silent $where fallback. Previously an unparseable
|
|
432
|
+
// condition was wrapped as `{ $where: <raw condition string> }` — a raw-JS
|
|
433
|
+
// sink that is both injection-shaped (the WHERE string runs as JavaScript
|
|
434
|
+
// on the MongoDB server) and silently different semantics from the SQL the
|
|
435
|
+
// caller wrote. Fail loud instead: name the clause so the caller fixes it
|
|
436
|
+
// rather than shipping a surprise $where.
|
|
437
|
+
throw new Error(
|
|
438
|
+
`QueryBuilder.toMongo(): cannot translate WHERE clause to a MongoDB ` +
|
|
439
|
+
`filter: "${cond}". Supported forms: "<field> <op> ?" ` +
|
|
440
|
+
`(=, !=, <>, >, >=, <, <=), "<field> LIKE ?", ` +
|
|
441
|
+
`"<field> [NOT] IN (?)", "<field> IS [NOT] NULL". Rewrite the ` +
|
|
442
|
+
`condition in one of those forms (toMongo() will not silently emit a ` +
|
|
443
|
+
`raw $where JavaScript expression).`,
|
|
444
|
+
);
|
|
433
445
|
}
|
|
434
446
|
|
|
435
447
|
/**
|
|
@@ -20,6 +20,13 @@ export interface FieldDefinition {
|
|
|
20
20
|
export interface RelationshipDefinition {
|
|
21
21
|
model: string;
|
|
22
22
|
foreignKey: string;
|
|
23
|
+
/**
|
|
24
|
+
* The relationship accessor/include name on the OWNING model. For an
|
|
25
|
+
* FK-auto-wired has-many this is the declaring class name lowercased + "s"
|
|
26
|
+
* (Python master rule) or the `relatedName` override. Used by eager-load
|
|
27
|
+
* include resolution so an `include: ["posts"]` matches the wired relation.
|
|
28
|
+
*/
|
|
29
|
+
relatedName?: string;
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
export interface ModelDefinition {
|
|
@@ -86,6 +86,20 @@ export function validate(
|
|
|
86
86
|
errors.push({ field: name, message: "must be a valid date/time" });
|
|
87
87
|
}
|
|
88
88
|
break;
|
|
89
|
+
|
|
90
|
+
case "foreignKey": {
|
|
91
|
+
// Outlier D: previously there was no foreignKey case, so ANY value
|
|
92
|
+
// passed validation silently. A foreign key references another model's
|
|
93
|
+
// primary key — by default an auto-increment integer — so validate it
|
|
94
|
+
// as an integer (a numeric string like "12" is coerced and accepted).
|
|
95
|
+
// This catches the common bug of assigning a whole object / array /
|
|
96
|
+
// non-numeric string to an *_id column before it reaches the driver.
|
|
97
|
+
const fkNum = typeof value === "string" ? Number(value) : value;
|
|
98
|
+
if (typeof fkNum !== "number" || isNaN(fkNum) || !Number.isInteger(fkNum)) {
|
|
99
|
+
errors.push({ field: name, message: "must be a valid foreign key (integer)" });
|
|
100
|
+
}
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
89
103
|
}
|
|
90
104
|
}
|
|
91
105
|
|