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.
Files changed (56) hide show
  1. package/CLAUDE.md +65 -20
  2. package/README.md +6 -6
  3. package/package.json +5 -3
  4. package/packages/cli/src/bin.ts +7 -0
  5. package/packages/cli/src/commands/init.ts +1 -0
  6. package/packages/cli/src/commands/metrics.ts +154 -0
  7. package/packages/cli/src/commands/routes.ts +3 -3
  8. package/packages/core/src/api.ts +64 -1
  9. package/packages/core/src/auth.ts +112 -2
  10. package/packages/core/src/cache.ts +2 -2
  11. package/packages/core/src/devAdmin.ts +66 -44
  12. package/packages/core/src/devMailbox.ts +4 -0
  13. package/packages/core/src/dotenv.ts +13 -4
  14. package/packages/core/src/events.ts +86 -4
  15. package/packages/core/src/graphql.ts +182 -128
  16. package/packages/core/src/htmlElement.ts +62 -3
  17. package/packages/core/src/index.ts +21 -10
  18. package/packages/core/src/logger.ts +85 -28
  19. package/packages/core/src/mcp.test.ts +1 -1
  20. package/packages/core/src/mcp.ts +25 -8
  21. package/packages/core/src/messenger.ts +111 -11
  22. package/packages/core/src/metrics.ts +557 -98
  23. package/packages/core/src/middleware.ts +130 -40
  24. package/packages/core/src/plan.ts +1 -1
  25. package/packages/core/src/queue.ts +1 -1
  26. package/packages/core/src/queueBackends/kafkaBackend.ts +98 -1
  27. package/packages/core/src/queueBackends/mongoBackend.ts +1 -1
  28. package/packages/core/src/queueBackends/rabbitmqBackend.ts +1 -1
  29. package/packages/core/src/rateLimiter.ts +1 -1
  30. package/packages/core/src/response.ts +90 -6
  31. package/packages/core/src/router.ts +56 -8
  32. package/packages/core/src/server.ts +138 -23
  33. package/packages/core/src/session.ts +130 -18
  34. package/packages/core/src/sessionHandlers/databaseHandler.ts +10 -0
  35. package/packages/core/src/sessionHandlers/mongoHandler.ts +21 -4
  36. package/packages/core/src/sessionHandlers/redisHandler.ts +28 -7
  37. package/packages/core/src/sessionHandlers/valkeyHandler.ts +27 -8
  38. package/packages/core/src/testClient.ts +1 -1
  39. package/packages/core/src/types.ts +17 -2
  40. package/packages/core/src/websocket.ts +666 -42
  41. package/packages/core/src/websocketBackplane.ts +210 -10
  42. package/packages/core/src/websocketConnection.ts +6 -0
  43. package/packages/core/src/wsdl.ts +55 -21
  44. package/packages/orm/src/adapters/pg-types.d.ts +60 -0
  45. package/packages/orm/src/adapters/postgres.ts +26 -4
  46. package/packages/orm/src/adapters/sqlite.ts +112 -13
  47. package/packages/orm/src/baseModel.ts +175 -25
  48. package/packages/orm/src/cachedDatabase.ts +15 -6
  49. package/packages/orm/src/database.ts +257 -55
  50. package/packages/orm/src/index.ts +6 -1
  51. package/packages/orm/src/migration.ts +151 -24
  52. package/packages/orm/src/queryBuilder.ts +14 -2
  53. package/packages/orm/src/seeder.ts +443 -65
  54. package/packages/orm/src/types.ts +7 -0
  55. package/packages/orm/src/validation.ts +14 -0
  56. package/packages/swagger/src/ui.ts +1 -1
@@ -1,27 +1,104 @@
1
1
  // Tina4 Seeder — seed database tables and ORM models with fake data.
2
2
  // Zero external dependencies.
3
+ //
4
+ // SEEDING FULL OVERHAUL (P1-P4) — unified, visible-but-resilient error
5
+ // handling. Mirrors the Python master (tina4_python/seeder/__init__.py):
6
+ // - P1: each row is wrapped; a row failure is LOGGED (index + cause) and
7
+ // skipped, incrementing `failed` — never silent (PHP/Ruby's old bug),
8
+ // never fragile (the old Node crash now that adapterExecute() raises).
9
+ // `strict: true` re-raises on the FIRST failure instead of skipping.
10
+ // - P2: `clear: true` truncates the target before seeding (idempotent re-runs).
11
+ // - P3: `seed` seeds the FakeData RNG for reproducible runs.
12
+ // - P4: seedModels() topo-sorts by the ORM ForeignKeyField dependency graph
13
+ // (parents before children, reverse-order clear), resolves FK values to
14
+ // real parent PKs, and warns on clear type mismatches.
3
15
 
4
16
  import { FakeData } from "./fakeData.js";
5
- import { adapterExecute } from "./database.js";
17
+ import { adapterExecute, adapterFetch } from "./database.js";
18
+ import { Log } from "@tina4/core";
6
19
  import type { DatabaseAdapter, FieldDefinition } from "./types.js";
7
20
 
21
+ /**
22
+ * Result of a seed run — `{ seeded, failed, errors }`.
23
+ *
24
+ * `errors` is a list of `{ row, message }` describing every skipped row
25
+ * (`row` is the 0-based index). Mirrors the Python `SeedSummary`; Node tests
26
+ * compare `.seeded` / `.failed` rather than the bare integer.
27
+ */
28
+ export interface SeedSummary {
29
+ seeded: number;
30
+ failed: number;
31
+ errors: Array<{ row: number; message: string }>;
32
+ }
33
+
34
+ /** Options shared by seedTable / seedOrm / seedModels. */
35
+ export interface SeedOptions {
36
+ /** Static values applied to every row (overrides generated values). */
37
+ overrides?: Record<string, unknown>;
38
+ /** Delete every existing row in the target before seeding (P2). */
39
+ clear?: boolean;
40
+ /** PRNG seed for reproducible FakeData output (P3). */
41
+ seed?: number;
42
+ /** Re-raise on the first failed row instead of skipping it (P1). */
43
+ strict?: boolean;
44
+ }
45
+
46
+ /**
47
+ * Normalise the legacy positional `overrides` argument and the new options
48
+ * object into a single SeedOptions. Backward compatible: callers passing a
49
+ * plain overrides object as the 5th positional arg still work.
50
+ */
51
+ function normaliseOptions(
52
+ overrides?: Record<string, unknown>,
53
+ opts?: SeedOptions,
54
+ ): Required<Pick<SeedOptions, "clear" | "strict">> & { overrides?: Record<string, unknown>; seed?: number } {
55
+ const merged: SeedOptions = { ...(opts ?? {}) };
56
+ // The new `opts.overrides` takes precedence if both are supplied.
57
+ const effectiveOverrides = merged.overrides ?? overrides;
58
+ return {
59
+ overrides: effectiveOverrides,
60
+ clear: merged.clear ?? false,
61
+ seed: merged.seed,
62
+ strict: merged.strict ?? false,
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Delete every row in `table`. Tolerant — logs and continues on error so the
68
+ * summary itself never crashes (mirrors Python `_clear_table`).
69
+ */
70
+ async function clearTable(db: DatabaseAdapter, tableName: string): Promise<void> {
71
+ try {
72
+ await adapterExecute(db, `DELETE FROM "${tableName}"`);
73
+ } catch (e) {
74
+ Log.warning(`Seeder: could not clear '${tableName}': ${(e as Error).message}`);
75
+ }
76
+ }
77
+
8
78
  /**
9
79
  * Seed a database table with fake data using raw SQL inserts.
10
80
  *
81
+ * Visible-but-resilient (P1): each row is wrapped. On a row failure the cause
82
+ * is logged (with the row index) and the row is skipped — unless `strict: true`,
83
+ * in which case the first failure RE-RAISES. At the end a one-line summary is
84
+ * logged ("seeded N, M failed").
85
+ *
11
86
  * @param db - A DatabaseAdapter instance
12
87
  * @param tableName - The table to insert into
13
88
  * @param count - Number of rows to insert (default 10)
14
- * @param fieldMap - Dict of column_name -> callable that generates a value.
15
- * If not provided, no rows are inserted.
16
- * @param overrides - Static values applied to every row (overrides fieldMap)
17
- * @returns Number of rows inserted
89
+ * @param fieldMap - Dict of column_name -> callable that generates a value
90
+ * (or a static value). If not provided, no rows are inserted.
91
+ * @param overrides - (legacy positional) Static values applied to every row.
92
+ * Prefer `opts.overrides`.
93
+ * @param opts - Seed options: `{ overrides, clear, seed, strict }`.
94
+ * @returns A SeedSummary `{ seeded, failed, errors }`.
18
95
  *
19
96
  * @example
20
97
  * const fake = new FakeData();
21
98
  * await seedTable(db, "users", 50, {
22
99
  * name: () => fake.name(),
23
100
  * email: () => fake.email(),
24
- * });
101
+ * }, undefined, { clear: true });
25
102
  */
26
103
  export async function seedTable(
27
104
  db: DatabaseAdapter,
@@ -29,93 +106,394 @@ export async function seedTable(
29
106
  count = 10,
30
107
  fieldMap?: Record<string, (() => unknown) | unknown>,
31
108
  overrides?: Record<string, unknown>,
32
- ): Promise<number> {
109
+ opts?: SeedOptions,
110
+ ): Promise<SeedSummary> {
111
+ const { overrides: effectiveOverrides, clear, strict } = normaliseOptions(overrides, opts);
112
+
33
113
  if (!fieldMap || Object.keys(fieldMap).length === 0) {
34
- return 0;
114
+ return { seeded: 0, failed: 0, errors: [] };
115
+ }
116
+
117
+ if (clear) {
118
+ await clearTable(db, tableName);
35
119
  }
36
120
 
121
+ let seeded = 0;
122
+ let failed = 0;
123
+ const errors: Array<{ row: number; message: string }> = [];
124
+
37
125
  for (let i = 0; i < count; i++) {
38
- const row: Record<string, unknown> = {};
126
+ try {
127
+ const row: Record<string, unknown> = {};
39
128
 
40
- // Generate values from fieldMap
41
- for (const [col, generator] of Object.entries(fieldMap)) {
42
- row[col] = typeof generator === "function" ? (generator as () => unknown)() : generator;
43
- }
129
+ // Generate values from fieldMap
130
+ for (const [col, generator] of Object.entries(fieldMap)) {
131
+ row[col] = typeof generator === "function" ? (generator as () => unknown)() : generator;
132
+ }
44
133
 
45
- // Apply static overrides
46
- if (overrides) {
47
- for (const [col, value] of Object.entries(overrides)) {
48
- row[col] = value;
134
+ // Apply static overrides
135
+ if (effectiveOverrides) {
136
+ for (const [col, value] of Object.entries(effectiveOverrides)) {
137
+ row[col] = value;
138
+ }
49
139
  }
140
+
141
+ // Build INSERT SQL
142
+ const columns = Object.keys(row);
143
+ const colList = columns.map((c) => `"${c}"`).join(", ");
144
+ const placeholders = columns.map(() => "?").join(", ");
145
+ const values = columns.map((c) => row[c]);
146
+
147
+ // adapterExecute() RAISES on a constraint/SQL error since v3.13.x — the
148
+ // try/except is what turns that into a counted, logged, skipped failure.
149
+ await adapterExecute(
150
+ db,
151
+ `INSERT INTO "${tableName}" (${colList}) VALUES (${placeholders})`,
152
+ values,
153
+ );
154
+ seeded++;
155
+ } catch (e) {
156
+ const message = (e as Error).message ?? String(e);
157
+ if (strict) {
158
+ Log.error(`Seeder: row ${i} failed seeding '${tableName}' (strict): ${message}`);
159
+ throw e;
160
+ }
161
+ failed++;
162
+ errors.push({ row: i, message });
163
+ Log.warning(`Seeder: row ${i} failed seeding '${tableName}', skipped: ${message}`);
50
164
  }
165
+ }
51
166
 
52
- // Build INSERT SQL
53
- const columns = Object.keys(row);
54
- const colList = columns.map((c) => `"${c}"`).join(", ");
55
- const placeholders = columns.map(() => "?").join(", ");
56
- const values = columns.map((c) => row[c]);
167
+ Log.info(`Seeder: '${tableName}' seeded ${seeded}, ${failed} failed`);
168
+ return { seeded, failed, errors };
169
+ }
57
170
 
58
- await adapterExecute(db,
59
- `INSERT INTO "${tableName}" (${colList}) VALUES (${placeholders})`,
60
- values,
61
- );
171
+ /** A model-like shape the seeder can drive (real BaseModel subclass or mock). */
172
+ interface SeedableModel {
173
+ tableName: string;
174
+ fields: Record<string, FieldDefinition>;
175
+ _db?: string;
176
+ getDb?: () => DatabaseAdapter;
177
+ name?: string;
178
+ }
179
+
180
+ /**
181
+ * Resolve the DatabaseAdapter for a model — try its own getDb(), then fall
182
+ * back to the bound adapters (default or named via `_db`).
183
+ */
184
+ async function resolveModelDb(ormClass: SeedableModel): Promise<DatabaseAdapter> {
185
+ if (typeof ormClass.getDb === "function") {
186
+ return ormClass.getDb();
62
187
  }
188
+ const { getAdapter, getNamedAdapter } = await import("./database.js");
189
+ return ormClass._db ? getNamedAdapter(ormClass._db) : getAdapter();
190
+ }
63
191
 
64
- return count;
192
+ /**
193
+ * P4c — when a generated value's JS type clearly mismatches the target column's
194
+ * field type, LOG a warning (never hard-fail). Mirrors Python `_validate_types`.
195
+ */
196
+ function validateTypes(
197
+ fields: Record<string, FieldDefinition>,
198
+ attrs: Record<string, unknown>,
199
+ modelName: string,
200
+ ): void {
201
+ for (const [name, value] of Object.entries(attrs)) {
202
+ if (value === null || value === undefined) continue;
203
+ const field = fields[name];
204
+ if (!field) continue;
205
+ const t = field.type;
206
+ let expected: string | null = null;
207
+ if (t === "integer" || t === "foreignKey") expected = "number";
208
+ else if (t === "number" || t === "numeric") expected = "number";
209
+ else if (t === "boolean") expected = "boolean";
210
+ if (expected === null) continue;
211
+ // booleans are acceptable in numeric columns; don't warn on that.
212
+ if (expected === "number" && typeof value === "boolean") continue;
213
+ if (typeof value !== expected) {
214
+ Log.warning(
215
+ `Seeder: ${modelName}.${name} expected ${expected} but generated ` +
216
+ `${typeof value} (${JSON.stringify(value)}) — inserting anyway`,
217
+ );
218
+ }
219
+ }
65
220
  }
66
221
 
67
222
  /**
68
- * Seed an ORM model class with fake data, auto-generating values
69
- * based on field definitions.
223
+ * P4a for each foreignKey field on the model, fetch the existing primary-key
224
+ * values of the referenced table so seeded child rows reference a REAL parent
225
+ * (parents are seeded first by seedModels's topo order). Returns
226
+ * `{ fkFieldName: [pkValue, ...] }`; columns with no resolvable / empty parent
227
+ * are omitted (the generic generator then fills them, and the row may fail
228
+ * loudly — never silently). Mirrors Python `_foreign_key_pools`.
229
+ */
230
+ async function foreignKeyPools(
231
+ db: DatabaseAdapter,
232
+ fields: Record<string, FieldDefinition>,
233
+ resolveModel: (name: string) => SeedableModel | null,
234
+ ): Promise<Record<string, unknown[]>> {
235
+ const pools: Record<string, unknown[]> = {};
236
+ for (const [name, def] of Object.entries(fields)) {
237
+ if (def.type !== "foreignKey" || !def.references) continue;
238
+ try {
239
+ const target = resolveModel(def.references);
240
+ if (!target) continue;
241
+ const pk = pkFieldOf(target.fields);
242
+ const rows = await adapterFetch<Record<string, unknown>>(
243
+ db,
244
+ `SELECT "${pk}" FROM "${target.tableName}"`,
245
+ );
246
+ const values = rows
247
+ .map((r) => r[pk])
248
+ .filter((v) => v !== null && v !== undefined);
249
+ if (values.length > 0) {
250
+ pools[name] = values;
251
+ }
252
+ } catch (e) {
253
+ Log.warning(`Seeder: could not resolve FK pool for ${name}: ${(e as Error).message}`);
254
+ }
255
+ }
256
+ return pools;
257
+ }
258
+
259
+ /** The primary-key field name from a fields map (defaults to "id"). */
260
+ function pkFieldOf(fields: Record<string, FieldDefinition>): string {
261
+ for (const [name, def] of Object.entries(fields)) {
262
+ if (def.primaryKey) return name;
263
+ }
264
+ return "id";
265
+ }
266
+
267
+ /**
268
+ * Seed an ORM model class with auto-generated fake data, based on field
269
+ * definitions. Visible-but-resilient (P1): each row is wrapped, failures are
270
+ * logged + counted + skipped (or re-raised under `strict`).
70
271
  *
71
- * @param ormClass - A model class with static `tableName`, `fields`, and optionally `_db`
272
+ * @param ormClass - A model with static `tableName`, `fields`, and optionally
273
+ * `getDb`/`_db`.
72
274
  * @param count - Number of rows to insert (default 10)
73
- * @param overrides - Static values applied to every row (override auto-generated)
74
- * @param seed - Optional PRNG seed for deterministic output
75
- * @returns Number of rows inserted
76
- *
77
- * @example
78
- * import User from "./src/models/User.js";
79
- * await seedOrm(User, 100, { role: "user" });
275
+ * @param overrides - (legacy positional) Static field overrides.
276
+ * @param seed - (legacy positional) PRNG seed for deterministic output.
277
+ * @param opts - Seed options `{ overrides, clear, seed, strict }`. Options
278
+ * supersede the legacy positional args when both are supplied.
279
+ * @param fkPools - (internal) pre-resolved FK value pools from seedModels.
280
+ * @returns A SeedSummary `{ seeded, failed, errors }`.
80
281
  */
81
282
  export async function seedOrm(
82
- ormClass: {
83
- tableName: string;
84
- fields: Record<string, FieldDefinition>;
85
- _db?: string;
86
- getDb?: () => DatabaseAdapter;
87
- },
283
+ ormClass: SeedableModel,
88
284
  count = 10,
89
285
  overrides?: Record<string, unknown>,
90
286
  seed?: number,
91
- ): Promise<number> {
92
- const fake = new FakeData(seed);
93
- const fields = ormClass.fields;
287
+ opts?: SeedOptions,
288
+ fkPools?: Record<string, unknown[]>,
289
+ ): Promise<SeedSummary> {
290
+ const merged: SeedOptions = { ...(opts ?? {}) };
291
+ const effectiveOverrides = merged.overrides ?? overrides;
292
+ const effectiveSeed = merged.seed ?? seed;
293
+ const clear = merged.clear ?? false;
294
+ const strict = merged.strict ?? false;
295
+
296
+ const fake = new FakeData(effectiveSeed);
297
+ const fields = ormClass.fields ?? {};
298
+ const modelName = ormClass.name ?? ormClass.tableName;
299
+
300
+ if (Object.keys(fields).length === 0) {
301
+ Log.error(`Seeder: No fields found on ${modelName}`);
302
+ return { seeded: 0, failed: 0, errors: [] };
303
+ }
304
+
305
+ const db = await resolveModelDb(ormClass);
306
+
307
+ if (clear) {
308
+ await clearTable(db, ormClass.tableName);
309
+ }
310
+
311
+ // Auto-increment primary keys are never generated.
312
+ const autoPk = new Set(
313
+ Object.entries(fields)
314
+ .filter(([, def]) => def.primaryKey && def.autoIncrement)
315
+ .map(([name]) => name),
316
+ );
317
+
318
+ // Resolve FK value pools when not supplied by seedModels (so a standalone
319
+ // seedOrm of a child whose parents already exist still references real PKs).
320
+ let pools = fkPools;
321
+ if (pools === undefined) {
322
+ const { getRegisteredModel } = await import("./baseModel.js").then((m) => ({
323
+ getRegisteredModel: (name: string): SeedableModel | null =>
324
+ (m.BaseModel as any)._modelRegistry?.[name] ?? null,
325
+ }));
326
+ pools = await foreignKeyPools(db, fields, getRegisteredModel);
327
+ }
328
+
329
+ let seeded = 0;
330
+ let failed = 0;
331
+ const errors: Array<{ row: number; message: string }> = [];
332
+
333
+ for (let i = 0; i < count; i++) {
334
+ try {
335
+ const attrs: Record<string, unknown> = {};
336
+ for (const [name, def] of Object.entries(fields)) {
337
+ if (autoPk.has(name)) continue;
338
+ if (effectiveOverrides && name in effectiveOverrides) {
339
+ const val = effectiveOverrides[name];
340
+ attrs[name] = typeof val === "function" ? (val as (f: FakeData) => unknown)(fake) : val;
341
+ } else if (pools[name] && pools[name].length > 0) {
342
+ attrs[name] = fake.choice(pools[name]);
343
+ } else {
344
+ attrs[name] = fake.forField(def, name);
345
+ }
346
+ }
347
+ validateTypes(fields, attrs, modelName);
94
348
 
95
- // Build a fieldMap from the model's field definitions
96
- const fieldMap: Record<string, () => unknown> = {};
349
+ const columns = Object.keys(attrs);
350
+ const colList = columns.map((c) => `"${c}"`).join(", ");
351
+ const placeholders = columns.map(() => "?").join(", ");
352
+ const values = columns.map((c) => attrs[c]);
97
353
 
98
- for (const [colName, fieldDef] of Object.entries(fields)) {
99
- // Skip auto-increment primary keys
100
- if (fieldDef.primaryKey && fieldDef.autoIncrement) {
101
- continue;
354
+ await adapterExecute(
355
+ db,
356
+ `INSERT INTO "${ormClass.tableName}" (${colList}) VALUES (${placeholders})`,
357
+ values,
358
+ );
359
+ seeded++;
360
+ } catch (e) {
361
+ const message = (e as Error).message ?? String(e);
362
+ if (strict) {
363
+ Log.error(`Seeder: row ${i} failed seeding ${modelName} (strict): ${message}`);
364
+ throw e;
365
+ }
366
+ failed++;
367
+ errors.push({ row: i, message });
368
+ Log.warning(`Seeder: row ${i} failed seeding ${modelName}, skipped: ${message}`);
102
369
  }
103
- // Skip fields that have an override
104
- if (overrides && colName in overrides) {
105
- continue;
370
+ }
371
+
372
+ Log.info(`Seeder: ${modelName} — seeded ${seeded}, ${failed} failed`);
373
+ return { seeded, failed, errors };
374
+ }
375
+
376
+ /**
377
+ * Topologically sort ORM models so parents (referenced tables) come before
378
+ * children (tables with a foreignKey pointing at them). Uses the ORM's existing
379
+ * `references` metadata. Models not in the input list are ignored as
380
+ * dependencies (you only seed what you pass). Cycles / unresolved deps fall
381
+ * back to the caller's declared order so nothing is dropped. Mirrors Python
382
+ * `_topo_sort_models`.
383
+ */
384
+ function topoSortModels(ormClasses: SeedableModel[]): SeedableModel[] {
385
+ const inSet: SeedableModel[] = [];
386
+ for (const m of ormClasses) {
387
+ if (!inSet.includes(m)) inSet.push(m);
388
+ }
389
+ const nameToModel = new Map<string, SeedableModel>();
390
+ for (const m of inSet) {
391
+ nameToModel.set(m.name ?? m.tableName, m);
392
+ }
393
+
394
+ const depsOf = (model: SeedableModel): Set<SeedableModel> => {
395
+ const deps = new Set<SeedableModel>();
396
+ for (const def of Object.values(model.fields ?? {})) {
397
+ if (def.type === "foreignKey" && def.references) {
398
+ const target = nameToModel.get(def.references);
399
+ if (target && target !== model) deps.add(target);
400
+ }
401
+ }
402
+ return deps;
403
+ };
404
+
405
+ const depsMap = new Map<SeedableModel, Set<SeedableModel>>();
406
+ for (const m of inSet) depsMap.set(m, depsOf(m));
407
+
408
+ const ordered: SeedableModel[] = [];
409
+ const placed = new Set<SeedableModel>();
410
+ let remaining = [...inSet];
411
+ let progressed = true;
412
+ while (remaining.length > 0 && progressed) {
413
+ progressed = false;
414
+ const still: SeedableModel[] = [];
415
+ for (const model of remaining) {
416
+ const deps = depsMap.get(model)!;
417
+ let allPlaced = true;
418
+ for (const d of deps) {
419
+ if (!placed.has(d)) { allPlaced = false; break; }
420
+ }
421
+ if (allPlaced) {
422
+ ordered.push(model);
423
+ placed.add(model);
424
+ progressed = true;
425
+ } else {
426
+ still.push(model);
427
+ }
106
428
  }
107
- fieldMap[colName] = () => fake.forField(fieldDef, colName);
429
+ remaining = still;
108
430
  }
431
+ // Cycle / unresolved deps — append in declared order so we never drop a model.
432
+ ordered.push(...remaining);
433
+ return ordered;
434
+ }
109
435
 
110
- // Get the database adapter — try the model's own getDb, then fall back to import
111
- let db: DatabaseAdapter;
112
- if (typeof (ormClass as any).getDb === "function") {
113
- db = (ormClass as any).getDb();
114
- } else {
115
- // Dynamic import to avoid circular dependency issues
116
- const { getAdapter, getNamedAdapter } = await import("./database.js");
117
- db = ormClass._db ? getNamedAdapter(ormClass._db) : getAdapter();
436
+ /**
437
+ * Batch-seed several ORM models, ordering by their foreignKey dependency graph
438
+ * (P4a). Parent tables seed before children (topological sort); when
439
+ * `clear: true` the clear runs in REVERSE order so children are removed before
440
+ * parents — no FK violations regardless of the order the caller lists models.
441
+ * FK columns are resolved to real parent PKs so child rows reference an
442
+ * existing parent. Mirrors Python `seed_models`.
443
+ *
444
+ * @param ormClasses - List of model classes to seed.
445
+ * @param count - Rows per model (default 10).
446
+ * @param opts - Seed options. `overrides` may be a flat dict applied to every
447
+ * model, or a Map/record keyed by model to apply per-model overrides.
448
+ * @returns `{ [modelName]: SeedSummary }` for each model seeded.
449
+ */
450
+ export async function seedModels(
451
+ ormClasses: SeedableModel[],
452
+ count = 10,
453
+ opts?: SeedOptions & { overrides?: Record<string, unknown> | Map<SeedableModel, Record<string, unknown>> },
454
+ ): Promise<Record<string, SeedSummary>> {
455
+ const clear = opts?.clear ?? false;
456
+ const seed = opts?.seed;
457
+ const strict = opts?.strict ?? false;
458
+ const overrides = opts?.overrides;
459
+
460
+ const ordered = topoSortModels(ormClasses);
461
+
462
+ // Build a name→model resolver covering the input set so FK pools resolve
463
+ // even when the referenced model isn't in BaseModel's global registry.
464
+ const nameToModel = new Map<string, SeedableModel>();
465
+ for (const m of ordered) nameToModel.set(m.name ?? m.tableName, m);
466
+ const resolveModel = (name: string): SeedableModel | null => nameToModel.get(name) ?? null;
467
+
468
+ if (clear) {
469
+ for (let i = ordered.length - 1; i >= 0; i--) {
470
+ const model = ordered[i];
471
+ const db = await resolveModelDb(model);
472
+ await clearTable(db, model.tableName);
473
+ }
118
474
  }
119
475
 
120
- return seedTable(db, ormClass.tableName, count, fieldMap, overrides);
476
+ const results: Record<string, SeedSummary> = {};
477
+ for (const model of ordered) {
478
+ const db = await resolveModelDb(model);
479
+ const pools = await foreignKeyPools(db, model.fields ?? {}, resolveModel);
480
+
481
+ let modelOverrides: Record<string, unknown> | undefined;
482
+ if (overrides instanceof Map) {
483
+ modelOverrides = overrides.get(model);
484
+ } else {
485
+ modelOverrides = overrides as Record<string, unknown> | undefined;
486
+ }
487
+
488
+ const summary = await seedOrm(
489
+ model,
490
+ count,
491
+ undefined,
492
+ undefined,
493
+ { overrides: modelOverrides, clear: false, seed, strict },
494
+ pools,
495
+ );
496
+ results[model.name ?? model.tableName] = summary;
497
+ }
498
+ return results;
121
499
  }
@@ -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
 
@@ -44,7 +44,7 @@ export function swaggerEnabled(): boolean {
44
44
  }
45
45
 
46
46
  export function createSwaggerRoutes(
47
- getSpec: () => Record<string, unknown>
47
+ getSpec: () => unknown
48
48
  ): RouteDefinition[] {
49
49
  return [
50
50
  {