tina4-nodejs 3.13.133 → 3.13.134

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 (50) hide show
  1. package/CLAUDE.md +3 -3
  2. package/README.md +2 -2
  3. package/package.json +1 -1
  4. package/packages/cli/dist/bin.js +3181 -3051
  5. package/packages/cli/src/commands/generate.ts +33 -22
  6. package/packages/cli/src/commands/lint.ts +77 -111
  7. package/packages/core/dist/index.js +3090 -2952
  8. package/packages/core/src/.tina4-metrics.json +15004 -0
  9. package/packages/core/src/aiClient.ts +199 -161
  10. package/packages/core/src/dispatchPipeline.ts +65 -67
  11. package/packages/core/src/docs.ts +52 -544
  12. package/packages/core/src/docsParser.ts +270 -0
  13. package/packages/core/src/docsScanner.ts +121 -0
  14. package/packages/core/src/docsSignatures.ts +165 -0
  15. package/packages/core/src/index.ts +2 -0
  16. package/packages/core/src/logger.ts +68 -82
  17. package/packages/core/src/mcp.ts +32 -60
  18. package/packages/core/src/messenger.ts +136 -157
  19. package/packages/core/src/middleware.ts +56 -60
  20. package/packages/core/src/plan.ts +78 -70
  21. package/packages/core/src/projectIndex.ts +15 -288
  22. package/packages/core/src/projectIndexExtractors.ts +126 -0
  23. package/packages/core/src/projectIndexStorage.ts +122 -0
  24. package/packages/core/src/push.ts +281 -0
  25. package/packages/core/src/server.ts +182 -183
  26. package/packages/frond/dist/index.js +607 -770
  27. package/packages/frond/src/engine.ts +670 -818
  28. package/packages/orm/dist/index.js +3100 -2965
  29. package/packages/orm/src/adapters/mongodb.ts +99 -144
  30. package/packages/orm/src/baseModel.ts +429 -515
  31. package/packages/orm/src/fakeData.ts +73 -61
  32. package/packages/orm/src/migration.ts +96 -126
  33. package/packages/orm/src/seeder.ts +6 -238
  34. package/packages/orm/src/seederTable.ts +101 -0
  35. package/packages/orm/src/seederTypes.ts +14 -0
  36. package/packages/orm/src/validation.ts +97 -80
  37. package/types/core/src/aiClient.d.ts +5 -0
  38. package/types/core/src/docsParser.d.ts +28 -0
  39. package/types/core/src/docsScanner.d.ts +1 -0
  40. package/types/core/src/docsSignatures.d.ts +11 -0
  41. package/types/core/src/index.d.ts +2 -0
  42. package/types/core/src/messenger.d.ts +8 -0
  43. package/types/core/src/projectIndexExtractors.d.ts +3 -0
  44. package/types/core/src/projectIndexStorage.d.ts +13 -0
  45. package/types/core/src/push.d.ts +45 -0
  46. package/types/frond/src/engine.d.ts +25 -0
  47. package/types/orm/src/fakeData.d.ts +3 -0
  48. package/types/orm/src/seeder.d.ts +3 -89
  49. package/types/orm/src/seederTable.d.ts +9 -0
  50. package/types/orm/src/seederTypes.d.ts +16 -0
@@ -13,6 +13,43 @@ const PRODUCT_TABLE_HINTS = [
13
13
  "sku", "listing", "stock", "ware",
14
14
  ] as const;
15
15
 
16
+ const COLUMN_HEURISTICS: Array<[RegExp, string]> = [
17
+ [/email/, "email"],
18
+ [/(phone|mobile|tel)/, "phone"],
19
+ [/^(name|full_name|fullname)$/, "name"],
20
+ [/^(first_name|firstname)$/, "firstName"],
21
+ [/^(last_name|lastname|surname)$/, "lastName"],
22
+ [/address/, "address"],
23
+ [/city/, "city"],
24
+ [/country/, "country"],
25
+ [/(zip|postal)/, "zipCode"],
26
+ [/(company|org)/, "company"],
27
+ [/(job|title|position)/, "jobTitle"],
28
+ [/(url|website|link)/, "url"],
29
+ [/(color|colour)/, "colorHex"],
30
+ [/(uuid|guid)/, "uuid"],
31
+ [/^(ip|ip_address|ipaddress)$/, "ipAddress"],
32
+ [/currency/, "currency"],
33
+ ];
34
+
35
+ const HEURISTIC_METHODS: Record<string, string> = {
36
+ email: "email",
37
+ phone: "phone",
38
+ firstName: "firstName",
39
+ lastName: "lastName",
40
+ address: "address",
41
+ city: "city",
42
+ country: "country",
43
+ zipCode: "zipCode",
44
+ company: "company",
45
+ jobTitle: "jobTitle",
46
+ url: "url",
47
+ colorHex: "colorHex",
48
+ uuid: "uuid",
49
+ ipAddress: "ipAddress",
50
+ currency: "currency",
51
+ };
52
+
16
53
  /**
17
54
  * True when the table/model name looks like a product catalogue, so a generic
18
55
  * `name` column should seed a product name, not a person name. With NO table
@@ -46,6 +83,39 @@ export class FakeData extends CoreFakeData {
46
83
  return new Date(startMs + offset * 86400000);
47
84
  }
48
85
 
86
+ private heuristicValue(key: string, table?: string): unknown {
87
+ if (key === "name") return isProductTable(table) ? this.product() : this.name();
88
+ const methodName = HEURISTIC_METHODS[key];
89
+ const method = methodName
90
+ ? (this as unknown as Record<string, unknown>)[methodName]
91
+ : undefined;
92
+ return typeof method === "function" ? method.call(this) : undefined;
93
+ }
94
+
95
+ private stringValue(fieldDef: FieldDefinition): string {
96
+ const maxLen = fieldDef.maxLength ?? 50;
97
+ const minLen = fieldDef.minLength ?? 3;
98
+ let value = this.sentence(Math.max(2, Math.ceil(maxLen / 6)));
99
+ if (value.length > maxLen) value = value.slice(0, maxLen);
100
+ if (value.length < minLen) {
101
+ while (value.length < minLen) value += " " + this.word();
102
+ value = value.slice(0, maxLen);
103
+ }
104
+ return value;
105
+ }
106
+
107
+ private typedValue(fieldDef: FieldDefinition): unknown {
108
+ if (fieldDef.type === "string") return this.stringValue(fieldDef);
109
+ if (fieldDef.type === "text") return this.paragraph(3);
110
+ if (fieldDef.type === "integer") return this.integer(fieldDef.min ?? 0, fieldDef.max ?? 10000);
111
+ if (fieldDef.type === "number" || fieldDef.type === "numeric") {
112
+ return this.numeric(fieldDef.min ?? 0, fieldDef.max ?? 10000, 2);
113
+ }
114
+ if (fieldDef.type === "boolean") return this.boolean();
115
+ if (fieldDef.type === "datetime") return this.datetime().toISOString();
116
+ return this.sentence(4);
117
+ }
118
+
49
119
  /**
50
120
  * Generate a fake value appropriate for an ORM field definition.
51
121
  * Respects min/max, minLength/maxLength, and type constraints.
@@ -71,67 +141,9 @@ export class FakeData extends CoreFakeData {
71
141
  return fieldDef.default;
72
142
  }
73
143
 
74
- // Heuristic: use column name to pick a smarter generator
75
144
  const col = (columnName ?? "").toLowerCase();
76
-
77
- if (col.includes("email")) return this.email();
78
- if (col.includes("phone") || col.includes("mobile") || col.includes("tel")) return this.phone();
79
- if (col === "name" || col === "full_name" || col === "fullname") {
80
- return isProductTable(table) ? this.product() : this.name();
81
- }
82
- if (col === "first_name" || col === "firstname") return this.firstName();
83
- if (col === "last_name" || col === "lastname" || col === "surname") return this.lastName();
84
- if (col.includes("address")) return this.address();
85
- if (col.includes("city")) return this.city();
86
- if (col.includes("country")) return this.country();
87
- if (col.includes("zip") || col.includes("postal")) return this.zipCode();
88
- if (col.includes("company") || col.includes("org")) return this.company();
89
- if (col.includes("job") || col.includes("title") || col.includes("position")) return this.jobTitle();
90
- if (col.includes("url") || col.includes("website") || col.includes("link")) return this.url();
91
- if (col.includes("color") || col.includes("colour")) return this.colorHex();
92
- if (col.includes("uuid") || col === "guid") return this.uuid();
93
- if (col === "ip" || col === "ip_address" || col === "ipaddress") return this.ipAddress();
94
- if (col.includes("currency")) return this.currency();
95
-
96
- // Fall back to type-based generation
97
- switch (fieldDef.type) {
98
- case "string": {
99
- const maxLen = fieldDef.maxLength ?? 50;
100
- const minLen = fieldDef.minLength ?? 3;
101
- // Generate a sentence and trim to fit
102
- let value = this.sentence(Math.max(2, Math.ceil(maxLen / 6)));
103
- if (value.length > maxLen) value = value.slice(0, maxLen);
104
- if (value.length < minLen) {
105
- while (value.length < minLen) value += " " + this.word();
106
- value = value.slice(0, maxLen);
107
- }
108
- return value;
109
- }
110
-
111
- case "text":
112
- return this.paragraph(3);
113
-
114
- case "integer": {
115
- const min = (fieldDef.min as number) ?? 0;
116
- const max = (fieldDef.max as number) ?? 10000;
117
- return this.integer(min, max);
118
- }
119
-
120
- case "number":
121
- case "numeric": {
122
- const min = (fieldDef.min as number) ?? 0;
123
- const max = (fieldDef.max as number) ?? 10000;
124
- return this.numeric(min, max, 2);
125
- }
126
-
127
- case "boolean":
128
- return this.boolean();
129
-
130
- case "datetime":
131
- return this.datetime().toISOString();
132
-
133
- default:
134
- return this.sentence(4);
135
- }
145
+ const heuristic = COLUMN_HEURISTICS.find(([pattern]) => pattern.test(col));
146
+ if (heuristic) return this.heuristicValue(heuristic[1], table);
147
+ return this.typedValue(fieldDef);
136
148
  }
137
149
  }
@@ -811,6 +811,85 @@ export function parseSetTerm(statement: string): string | null {
811
811
  return m ? m[1] : null;
812
812
  }
813
813
 
814
+ interface SplitState {
815
+ sql: string;
816
+ statements: string[];
817
+ current: string;
818
+ delimiter: string;
819
+ dlen: number;
820
+ i: number;
821
+ inDollarBlock: boolean;
822
+ inSlashBlock: boolean;
823
+ }
824
+
825
+ function consumeQuotedSql(state: SplitState, quote: string): void {
826
+ state.current += quote;
827
+ state.i += 1;
828
+ while (state.i < state.sql.length) {
829
+ if (state.sql[state.i] === quote && state.sql[state.i + 1] === quote) {
830
+ state.current += quote + quote;
831
+ state.i += 2;
832
+ } else {
833
+ state.current += state.sql[state.i];
834
+ state.i += 1;
835
+ if (state.sql[state.i - 1] === quote) break;
836
+ }
837
+ }
838
+ }
839
+
840
+ function consumeSpecialSqlToken(state: SplitState): boolean {
841
+ const { sql, i } = state;
842
+ const ch = sql[i];
843
+ if (!state.inSlashBlock && ch === "$" && sql[i + 1] === "$") {
844
+ state.current += "$$";
845
+ state.i += 2;
846
+ state.inDollarBlock = !state.inDollarBlock;
847
+ return true;
848
+ }
849
+ if (!state.inDollarBlock && ch === "/" && sql[i + 1] === "/" && !(i > 0 && sql[i - 1] === ":")) {
850
+ state.current += "//";
851
+ state.i += 2;
852
+ state.inSlashBlock = !state.inSlashBlock;
853
+ return true;
854
+ }
855
+ if (state.inDollarBlock || state.inSlashBlock) {
856
+ state.current += ch;
857
+ state.i += 1;
858
+ return true;
859
+ }
860
+ if (ch === "/" && sql[i + 1] === "*") {
861
+ const end = sql.indexOf("*/", i + 2);
862
+ state.i = end === -1 ? sql.length : end + 2;
863
+ return true;
864
+ }
865
+ if (ch === "-" && sql[i + 1] === "-") {
866
+ const end = sql.indexOf("\n", i + 2);
867
+ state.i = end === -1 ? sql.length : end;
868
+ return true;
869
+ }
870
+ if (ch === "'" || ch === '"') {
871
+ consumeQuotedSql(state, ch);
872
+ return true;
873
+ }
874
+ return false;
875
+ }
876
+
877
+ function consumeSqlDelimiter(state: SplitState): boolean {
878
+ if (state.dlen === 0 || !state.sql.startsWith(state.delimiter, state.i)) return false;
879
+ state.i += state.dlen;
880
+ const statement = state.current.trim();
881
+ state.current = "";
882
+ if (!statement) return true;
883
+ const newTerm = parseSetTerm(statement);
884
+ if (newTerm !== null) {
885
+ state.delimiter = newTerm;
886
+ state.dlen = newTerm.length;
887
+ } else {
888
+ state.statements.push(statement);
889
+ }
890
+ return true;
891
+ }
892
+
814
893
  /**
815
894
  * Split SQL text into individual statements with a single-pass, quote- and
816
895
  * comment-aware scanner. The split decision is made character by character so
@@ -836,133 +915,24 @@ export function parseSetTerm(statement: string): string | null {
836
915
  * Mirrors the tina4-python `_split_statements` / tina4-php / tina4-ruby scanner (parity).
837
916
  */
838
917
  export function splitStatements(sql: string, delimiter = ";"): string[] {
839
- // Normalize smart/curly quotes to straight ASCII first, so SQL pasted from
840
- // an editor/doc (which converts " → “ ” and ' → ‘ ’) actually runs.
841
- sql = normalizeQuotes(sql);
842
-
843
- const statements: string[] = [];
844
- let current = "";
845
- const n = sql.length;
846
- // Active statement terminator. Starts as the delimiter argument; a SET TERM
847
- // directive can switch it mid-script (dlen recomputed) so a statement whose
848
- // own body contains the default terminator stays intact.
849
- let dlen = delimiter.length;
850
- let i = 0;
851
- let inDollarBlock = false;
852
- let inSlashBlock = false;
853
-
854
- while (i < n) {
855
- const ch = sql[i];
856
-
857
- // $$ … $$ stored-proc block (toggle).
858
- if (!inSlashBlock && ch === "$" && i + 1 < n && sql[i + 1] === "$") {
859
- current += "$$";
860
- i += 2;
861
- inDollarBlock = !inDollarBlock;
862
- continue;
863
- }
864
-
865
- // // … // stored-proc block (toggle) — but NOT a `://` URL scheme.
866
- if (
867
- !inDollarBlock && ch === "/" && i + 1 < n && sql[i + 1] === "/" &&
868
- !(i > 0 && sql[i - 1] === ":")
869
- ) {
870
- current += "//";
871
- i += 2;
872
- inSlashBlock = !inSlashBlock;
873
- continue;
874
- }
875
-
876
- // Inside a stored-proc block: consume verbatim (inner ; never splits).
877
- if (inDollarBlock || inSlashBlock) {
878
- current += ch;
879
- i += 1;
880
- continue;
881
- }
882
-
883
- // Block comment /* … */ — stripped.
884
- if (ch === "/" && i + 1 < n && sql[i + 1] === "*") {
885
- const end = sql.indexOf("*/", i + 2);
886
- i = end !== -1 ? end + 2 : n;
887
- continue;
888
- }
889
-
890
- // Line comment -- … — stripped to end of line; the newline is left for the
891
- // next iteration so line structure (and NEXT-line boundaries) survive.
892
- if (ch === "-" && i + 1 < n && sql[i + 1] === "-") {
893
- const end = sql.indexOf("\n", i + 2);
894
- i = end !== -1 ? end : n;
895
- continue;
896
- }
897
-
898
- // Single-quoted string literal — '' escapes a quote. Copied verbatim.
899
- if (ch === "'") {
900
- current += "'";
901
- i += 1;
902
- while (i < n) {
903
- if (sql[i] === "'" && i + 1 < n && sql[i + 1] === "'") {
904
- current += "''";
905
- i += 2;
906
- } else if (sql[i] === "'") {
907
- current += "'";
908
- i += 1;
909
- break;
910
- } else {
911
- current += sql[i];
912
- i += 1;
913
- }
914
- }
915
- continue;
916
- }
917
-
918
- // Double-quoted identifier — "" escapes a quote. Same verbatim handling.
919
- if (ch === '"') {
920
- current += '"';
921
- i += 1;
922
- while (i < n) {
923
- if (sql[i] === '"' && i + 1 < n && sql[i + 1] === '"') {
924
- current += '""';
925
- i += 2;
926
- } else if (sql[i] === '"') {
927
- current += '"';
928
- i += 1;
929
- break;
930
- } else {
931
- current += sql[i];
932
- i += 1;
933
- }
934
- }
935
- continue;
936
- }
937
-
938
- // Statement delimiter — only reached outside blocks/comments/strings. A
939
- // SET TERM directive switches the active terminator and is consumed (never
940
- // emitted); any other completed statement is collected.
941
- if (dlen > 0 && sql.startsWith(delimiter, i)) {
942
- i += dlen;
943
- const stmt = current.trim();
944
- current = "";
945
- if (stmt) {
946
- const newTerm = parseSetTerm(stmt);
947
- if (newTerm !== null) {
948
- delimiter = newTerm;
949
- dlen = delimiter.length;
950
- } else {
951
- statements.push(stmt);
952
- }
953
- }
954
- continue;
955
- }
956
-
957
- current += ch;
958
- i += 1;
918
+ const state: SplitState = {
919
+ sql: normalizeQuotes(sql),
920
+ statements: [],
921
+ current: "",
922
+ delimiter,
923
+ dlen: delimiter.length,
924
+ i: 0,
925
+ inDollarBlock: false,
926
+ inSlashBlock: false,
927
+ };
928
+ while (state.i < state.sql.length) {
929
+ if (consumeSpecialSqlToken(state) || consumeSqlDelimiter(state)) continue;
930
+ state.current += state.sql[state.i];
931
+ state.i += 1;
959
932
  }
960
-
961
- // Trailing statement (may not end with a delimiter). A trailing SET TERM
962
- // directive is a no-op — consume it, don't emit it.
963
- const stmt = current.trim();
964
- if (stmt && parseSetTerm(stmt) === null) statements.push(stmt);
965
- return statements;
933
+ const trailing = state.current.trim();
934
+ if (trailing && parseSetTerm(trailing) === null) state.statements.push(trailing);
935
+ return state.statements;
966
936
  }
967
937
 
968
938
  /**
@@ -14,247 +14,15 @@
14
14
  // real parent PKs, and warns on clear type mismatches.
15
15
 
16
16
  import { FakeData } from "./fakeData.js";
17
- import { adapterExecute, adapterFetch, adapterColumns, adapterInsert } from "./database.js";
17
+ import { adapterFetch, adapterInsert } from "./database.js";
18
18
  import { Log } from "../../core/src/index.js";
19
- import type { DatabaseAdapter, FieldDefinition, FieldType } from "./types.js";
19
+ import type { DatabaseAdapter, FieldDefinition } from "./types.js";
20
+ import { clearTable } from "./seederTable.js";
21
+ import type { SeedOptions, SeedSummary } from "./seederTypes.js";
20
22
 
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
- /**
41
- * PRNG seed for reproducible FakeData output (P3). Honoured by seedOrm and
42
- * seedModels, which build and seed their own FakeData internally.
43
- *
44
- * NOT honoured by seedTable (SEED-TABLE-SEED-INERT, SEED-DEC-01, ratified
45
- * 2026-08-11 — same principle as the no-op ForeignKeyField on_delete):
46
- * seedTable has no generators of its own to seed — fieldMap callables are
47
- * opaque — so this used to be a silent no-op there. Passing it to seedTable
48
- * now THROWS instead. Build your own `new FakeData(seed)` and close over it
49
- * in fieldMap: `const fake = new FakeData(42); seedTable(db, table, count,
50
- * { name: () => fake.name() })`.
51
- */
52
- seed?: number;
53
- /** Re-raise on the first failed row instead of skipping it (P1). */
54
- strict?: boolean;
55
- }
56
-
57
- /**
58
- * Normalise the legacy positional `overrides` argument and the new options
59
- * object into a single SeedOptions. Backward compatible: callers passing a
60
- * plain overrides object as the 5th positional arg still work.
61
- */
62
- function normaliseOptions(
63
- overrides?: Record<string, unknown>,
64
- opts?: SeedOptions,
65
- ): Required<Pick<SeedOptions, "clear" | "strict">> & { overrides?: Record<string, unknown> } {
66
- const merged: SeedOptions = { ...(opts ?? {}) };
67
- // The new `opts.overrides` takes precedence if both are supplied.
68
- const effectiveOverrides = merged.overrides ?? overrides;
69
- return {
70
- overrides: effectiveOverrides,
71
- clear: merged.clear ?? false,
72
- strict: merged.strict ?? false,
73
- };
74
- }
75
-
76
- /**
77
- * Delete every row in `table`. Tolerant — logs and continues on error so the
78
- * summary itself never crashes (mirrors Python `_clear_table`).
79
- */
80
- async function clearTable(db: DatabaseAdapter, tableName: string): Promise<void> {
81
- try {
82
- await adapterExecute(db, `DELETE FROM "${tableName}"`);
83
- } catch (e) {
84
- Log.warning(`Seeder: could not clear '${tableName}': ${(e as Error).message}`);
85
- }
86
- }
87
-
88
- /**
89
- * Map a raw SQL column type string to a Tina4 FieldType so FakeData.forField()
90
- * can pick a generator. Substring match (case-insensitive) covers the engine
91
- * spellings — INTEGER/INT, REAL/FLOAT/DOUBLE/NUMERIC/DECIMAL, BOOL, DATE/TIME,
92
- * TEXT/CLOB. Anything else is a plain string.
93
- */
94
- function sqlTypeToFieldType(sqlType: string): FieldType {
95
- const t = (sqlType || "").toUpperCase();
96
- if (t.includes("INT")) return "integer";
97
- if (t.includes("BOOL")) return "boolean";
98
- if (t.includes("REAL") || t.includes("FLOA") || t.includes("DOUB") || t.includes("NUM") || t.includes("DEC")) return "number";
99
- if (t.includes("DATE") || t.includes("TIME")) return "datetime";
100
- if (t.includes("TEXT") || t.includes("CLOB")) return "text";
101
- return "string";
102
- }
23
+ export { autoFieldMap, seedTable } from "./seederTable.js";
24
+ export type { SeedOptions, SeedSummary } from "./seederTypes.js";
103
25
 
104
- /**
105
- * Introspect `table`'s columns and build a column->generator field map for
106
- * {@link seedTable}, skipping the auto-increment / `id` primary key (the engine
107
- * assigns it). Mirrors the Python master's `auto_field_map`
108
- * (tina4_python/seeder/__init__.py): the shared "seed a table I did not
109
- * hand-write generators for" helper that both the dev-admin seed endpoint and
110
- * the MCP `seed_table` dev tool use — `seedTable` itself stays explicit
111
- * (no map = no rows), and this is how a caller opts into automatic generation.
112
- *
113
- * Reuses `FakeData.forField()` (column-name + type heuristics) so the generated
114
- * data matches every other Tina4 seeding path. Returns an empty map when the
115
- * table has no seedable columns, so `seedTable` then seeds nothing rather than
116
- * crashing.
117
- *
118
- * @param db - A DatabaseAdapter instance (pass `getAdapter()`, NOT the Database
119
- * wrapper — the wrapper has no `columns()`).
120
- * @param table - The table to introspect.
121
- * @param fake - Optional shared FakeData (pass one seeded via `new FakeData(n)`
122
- * for reproducible output).
123
- * @returns `{ column -> () => value }`, ready to hand to `seedTable`.
124
- */
125
- export async function autoFieldMap(
126
- db: DatabaseAdapter,
127
- table: string,
128
- fake: FakeData = new FakeData(),
129
- ): Promise<Record<string, () => unknown>> {
130
- const columns = await adapterColumns(db, table);
131
- const fieldMap: Record<string, () => unknown> = {};
132
- for (const col of columns) {
133
- const name = col.name;
134
- const sqlType = String(col.type ?? "").toUpperCase();
135
- // Skip the auto-increment surrogate key so the INSERT omits it and the
136
- // engine assigns it. Mirrors Python: primary key AND (AUTO/SERIAL/IDENTITY
137
- // in the type OR the column is literally `id` — SQLite reports just
138
- // "INTEGER" for an AUTOINCREMENT rowid alias, so the name catch is needed).
139
- if (col.primaryKey === true &&
140
- (sqlType.includes("AUTO") || sqlType.includes("SERIAL") ||
141
- sqlType.includes("IDENTITY") || name.toLowerCase() === "id")) {
142
- continue;
143
- }
144
- const fieldType = sqlTypeToFieldType(sqlType);
145
- // Bind the type + name per column; forField applies the name heuristics
146
- // (email/phone/name/...) before falling back to the type. The TABLE name is
147
- // threaded so a generic `name` column on a product-ish table seeds a
148
- // product name, not a person name (parity with the Python auto_field_map).
149
- fieldMap[name] = () => fake.forField({ type: fieldType }, name, table);
150
- }
151
- return fieldMap;
152
- }
153
-
154
- /**
155
- * Seed a database table with fake data using raw SQL inserts.
156
- *
157
- * Visible-but-resilient (P1): each row is wrapped. On a row failure the cause
158
- * is logged (with the row index) and the row is skipped — unless `strict: true`,
159
- * in which case the first failure RE-RAISES. At the end a one-line summary is
160
- * logged ("seeded N, M failed").
161
- *
162
- * @param db - A DatabaseAdapter instance
163
- * @param tableName - The table to insert into
164
- * @param count - Number of rows to insert (default 10)
165
- * @param fieldMap - Dict of column_name -> callable that generates a value
166
- * (or a static value). If not provided, no rows are inserted.
167
- * @param overrides - (legacy positional) Static values applied to every row.
168
- * Prefer `opts.overrides`.
169
- * @param opts - Seed options: `{ overrides, clear, strict }`. `opts.seed` is
170
- * NOT honoured here (see {@link SeedOptions.seed}) and throws if supplied.
171
- * @returns A SeedSummary `{ seeded, failed, errors }`.
172
- * @throws {Error} If `opts.seed` is defined (SEED-TABLE-SEED-INERT removal).
173
- *
174
- * @example
175
- * const fake = new FakeData();
176
- * await seedTable(db, "users", 50, {
177
- * name: () => fake.name(),
178
- * email: () => fake.email(),
179
- * }, undefined, { clear: true });
180
- */
181
- export async function seedTable(
182
- db: DatabaseAdapter,
183
- tableName: string,
184
- count = 10,
185
- fieldMap?: Record<string, (() => unknown) | unknown>,
186
- overrides?: Record<string, unknown>,
187
- opts?: SeedOptions,
188
- ): Promise<SeedSummary> {
189
- if (opts?.seed !== undefined) {
190
- throw new Error(
191
- "seedTable() no longer accepts opts.seed: it has no generators of its own to seed " +
192
- "(fieldMap callables are opaque). Build a seeded FakeData yourself and close over it " +
193
- "in fieldMap, e.g. const fake = new FakeData(42); seedTable(db, table, count, " +
194
- "{ name: () => fake.name() }).",
195
- );
196
- }
197
-
198
- const { overrides: effectiveOverrides, clear, strict } = normaliseOptions(overrides, opts);
199
-
200
- if (!fieldMap || Object.keys(fieldMap).length === 0) {
201
- return { seeded: 0, failed: 0, errors: [] };
202
- }
203
-
204
- if (clear) {
205
- await clearTable(db, tableName);
206
- }
207
-
208
- let seeded = 0;
209
- let failed = 0;
210
- const errors: Array<{ row: number; message: string }> = [];
211
-
212
- for (let i = 0; i < count; i++) {
213
- try {
214
- const row: Record<string, unknown> = {};
215
-
216
- // Generate values from fieldMap
217
- for (const [col, generator] of Object.entries(fieldMap)) {
218
- row[col] = typeof generator === "function" ? (generator as () => unknown)() : generator;
219
- }
220
-
221
- // Apply static overrides
222
- if (effectiveOverrides) {
223
- for (const [col, value] of Object.entries(effectiveOverrides)) {
224
- row[col] = value;
225
- }
226
- }
227
-
228
- // Route through the adapter's OWN native insert path (feature 3's
229
- // shared buildInsert()/Dialect) instead of hand-built SQL with a fixed
230
- // quote style. A hardcoded double-quote works on PostgreSQL/MSSQL/
231
- // SQLite but breaks Firebird: an unquoted CREATE TABLE folds the name
232
- // to UPPERCASE, so a quoted lower-case INSERT target is a DIFFERENT,
233
- // unfindable identifier ("Table unknown" -204) — the SAME class of
234
- // engine-portability bug as PHP's old backtick-quoted seed_table.
235
- // adapterInsert() RAISES on a constraint/SQL error since v3.13.x — the
236
- // try/except is what turns that into a counted, logged, skipped failure.
237
- await adapterInsert(
238
- db,
239
- tableName,
240
- row,
241
- );
242
- seeded++;
243
- } catch (e) {
244
- const message = (e as Error).message ?? String(e);
245
- if (strict) {
246
- Log.error(`Seeder: row ${i} failed seeding '${tableName}' (strict): ${message}`);
247
- throw e;
248
- }
249
- failed++;
250
- errors.push({ row: i, message });
251
- Log.warning(`Seeder: row ${i} failed seeding '${tableName}', skipped: ${message}`);
252
- }
253
- }
254
-
255
- Log.info(`Seeder: '${tableName}' — seeded ${seeded}, ${failed} failed`);
256
- return { seeded, failed, errors };
257
- }
258
26
 
259
27
  /** A model-like shape the seeder can drive (real BaseModel subclass or mock). */
260
28
  interface SeedableModel {