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.
- package/CLAUDE.md +3 -3
- package/README.md +2 -2
- package/package.json +1 -1
- package/packages/cli/dist/bin.js +3181 -3051
- package/packages/cli/src/commands/generate.ts +33 -22
- package/packages/cli/src/commands/lint.ts +77 -111
- package/packages/core/dist/index.js +3090 -2952
- package/packages/core/src/.tina4-metrics.json +15004 -0
- package/packages/core/src/aiClient.ts +199 -161
- package/packages/core/src/dispatchPipeline.ts +65 -67
- package/packages/core/src/docs.ts +52 -544
- package/packages/core/src/docsParser.ts +270 -0
- package/packages/core/src/docsScanner.ts +121 -0
- package/packages/core/src/docsSignatures.ts +165 -0
- package/packages/core/src/index.ts +2 -0
- package/packages/core/src/logger.ts +68 -82
- package/packages/core/src/mcp.ts +32 -60
- package/packages/core/src/messenger.ts +136 -157
- package/packages/core/src/middleware.ts +56 -60
- package/packages/core/src/plan.ts +78 -70
- package/packages/core/src/projectIndex.ts +15 -288
- package/packages/core/src/projectIndexExtractors.ts +126 -0
- package/packages/core/src/projectIndexStorage.ts +122 -0
- package/packages/core/src/push.ts +281 -0
- package/packages/core/src/server.ts +182 -183
- package/packages/frond/dist/index.js +607 -770
- package/packages/frond/src/engine.ts +670 -818
- package/packages/orm/dist/index.js +3100 -2965
- package/packages/orm/src/adapters/mongodb.ts +99 -144
- package/packages/orm/src/baseModel.ts +429 -515
- package/packages/orm/src/fakeData.ts +73 -61
- package/packages/orm/src/migration.ts +96 -126
- package/packages/orm/src/seeder.ts +6 -238
- package/packages/orm/src/seederTable.ts +101 -0
- package/packages/orm/src/seederTypes.ts +14 -0
- package/packages/orm/src/validation.ts +97 -80
- package/types/core/src/aiClient.d.ts +5 -0
- package/types/core/src/docsParser.d.ts +28 -0
- package/types/core/src/docsScanner.d.ts +1 -0
- package/types/core/src/docsSignatures.d.ts +11 -0
- package/types/core/src/index.d.ts +2 -0
- package/types/core/src/messenger.d.ts +8 -0
- package/types/core/src/projectIndexExtractors.d.ts +3 -0
- package/types/core/src/projectIndexStorage.d.ts +13 -0
- package/types/core/src/push.d.ts +45 -0
- package/types/frond/src/engine.d.ts +25 -0
- package/types/orm/src/fakeData.d.ts +3 -0
- package/types/orm/src/seeder.d.ts +3 -89
- package/types/orm/src/seederTable.d.ts +9 -0
- 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 (
|
|
78
|
-
|
|
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
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
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
|
-
|
|
962
|
-
|
|
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 {
|
|
17
|
+
import { adapterFetch, adapterInsert } from "./database.js";
|
|
18
18
|
import { Log } from "../../core/src/index.js";
|
|
19
|
-
import type { DatabaseAdapter, FieldDefinition
|
|
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
|
-
|
|
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 {
|