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
|
@@ -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
|
|
17
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
126
|
+
try {
|
|
127
|
+
const row: Record<string, unknown> = {};
|
|
39
128
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
69
|
-
*
|
|
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
|
|
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 -
|
|
74
|
-
* @param seed -
|
|
75
|
-
* @
|
|
76
|
-
*
|
|
77
|
-
* @
|
|
78
|
-
*
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
|