tina4-nodejs 3.13.52 → 3.13.54
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 +2 -2
- package/package.json +1 -1
- package/packages/orm/src/adapters/firebird.ts +6 -2
- package/packages/orm/src/adapters/mssql.ts +6 -2
- package/packages/orm/src/adapters/mysql.ts +7 -2
- package/packages/orm/src/adapters/odbc.ts +5 -2
- package/packages/orm/src/adapters/postgres.ts +6 -2
- package/packages/orm/src/adapters/sqlite.ts +5 -2
- package/packages/orm/src/baseModel.ts +49 -5
- package/packages/orm/src/index.ts +1 -0
- package/packages/orm/src/migration.ts +46 -6
- package/packages/orm/src/types.ts +1 -1
- package/packages/orm/src/validation.ts +9 -0
- package/packages/swagger/src/generator.ts +5 -0
package/CLAUDE.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
# CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.
|
|
1
|
+
# CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.54)
|
|
2
2
|
|
|
3
3
|
> This file helps AI assistants (Claude, Copilot, Cursor, etc.) understand and work on this codebase effectively.
|
|
4
4
|
|
|
5
5
|
## What This Project Is
|
|
6
6
|
|
|
7
|
-
Tina4 for Node.js/TypeScript v3.13.
|
|
7
|
+
Tina4 for Node.js/TypeScript v3.13.54 — The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
|
|
8
8
|
|
|
9
9
|
The philosophy: zero ceremony, batteries included, file system as source of truth.
|
|
10
10
|
|
package/package.json
CHANGED
|
@@ -486,10 +486,12 @@ export class FirebirdAdapter implements DatabaseAdapter {
|
|
|
486
486
|
|
|
487
487
|
if (def.primaryKey && !def.autoIncrement) parts.push("PRIMARY KEY");
|
|
488
488
|
if (def.required && !def.primaryKey) parts.push("NOT NULL");
|
|
489
|
-
|
|
489
|
+
// A json column carries no DDL DEFAULT (parity with the Python master): an
|
|
490
|
+
// object/array default is applied per instance, not a portable SQL literal.
|
|
491
|
+
if (def.type !== "json" && def.default !== undefined && def.default !== "now") {
|
|
490
492
|
parts.push(`DEFAULT ${sqlDefault(def.default)}`);
|
|
491
493
|
}
|
|
492
|
-
if (def.default === "now") {
|
|
494
|
+
if (def.type !== "json" && def.default === "now") {
|
|
493
495
|
parts.push("DEFAULT CURRENT_TIMESTAMP");
|
|
494
496
|
}
|
|
495
497
|
|
|
@@ -548,6 +550,8 @@ function fieldTypeToFirebird(def: FieldDefinition): string {
|
|
|
548
550
|
return "TIMESTAMP";
|
|
549
551
|
case "text":
|
|
550
552
|
return "BLOB SUB_TYPE TEXT";
|
|
553
|
+
case "json":
|
|
554
|
+
return "BLOB SUB_TYPE TEXT"; // Firebird has no TEXT/JSON type; store JSON in a text BLOB
|
|
551
555
|
case "string":
|
|
552
556
|
return def.maxLength ? `VARCHAR(${def.maxLength})` : "VARCHAR(255)";
|
|
553
557
|
default:
|
|
@@ -467,10 +467,12 @@ export class MssqlAdapter implements DatabaseAdapter {
|
|
|
467
467
|
|
|
468
468
|
if (def.primaryKey && !def.autoIncrement) parts.push("PRIMARY KEY");
|
|
469
469
|
if (def.required && !def.primaryKey) parts.push("NOT NULL");
|
|
470
|
-
|
|
470
|
+
// A json column carries no DDL DEFAULT (parity with the Python master): an
|
|
471
|
+
// object/array default is applied per instance, not a portable SQL literal.
|
|
472
|
+
if (def.type !== "json" && def.default !== undefined && def.default !== "now") {
|
|
471
473
|
parts.push(`DEFAULT ${sqlDefault(def.default)}`);
|
|
472
474
|
}
|
|
473
|
-
if (def.default === "now") {
|
|
475
|
+
if (def.type !== "json" && def.default === "now") {
|
|
474
476
|
parts.push("DEFAULT GETDATE()");
|
|
475
477
|
}
|
|
476
478
|
|
|
@@ -499,6 +501,8 @@ function fieldTypeToMssql(def: FieldDefinition): string {
|
|
|
499
501
|
return "DATETIME";
|
|
500
502
|
case "text":
|
|
501
503
|
return "NTEXT";
|
|
504
|
+
case "json":
|
|
505
|
+
return "NVARCHAR(MAX)"; // MSSQL stores JSON text; its JSON functions read NVARCHAR
|
|
502
506
|
case "string":
|
|
503
507
|
return def.maxLength ? `NVARCHAR(${def.maxLength})` : "NVARCHAR(255)";
|
|
504
508
|
default:
|
|
@@ -364,10 +364,13 @@ export class MysqlAdapter implements DatabaseAdapter {
|
|
|
364
364
|
|
|
365
365
|
if (def.primaryKey && !def.autoIncrement) parts.push("PRIMARY KEY");
|
|
366
366
|
if (def.required && !def.primaryKey) parts.push("NOT NULL");
|
|
367
|
-
|
|
367
|
+
// A json column carries no DDL DEFAULT (parity with the Python master; and
|
|
368
|
+
// MySQL's native JSON type rejects a literal DEFAULT anyway). The instance
|
|
369
|
+
// still gets its object/array default at construction.
|
|
370
|
+
if (def.type !== "json" && def.default !== undefined && def.default !== "now") {
|
|
368
371
|
parts.push(`DEFAULT ${sqlDefault(def.default)}`);
|
|
369
372
|
}
|
|
370
|
-
if (def.default === "now") {
|
|
373
|
+
if (def.type !== "json" && def.default === "now") {
|
|
371
374
|
parts.push("DEFAULT CURRENT_TIMESTAMP");
|
|
372
375
|
}
|
|
373
376
|
|
|
@@ -395,6 +398,8 @@ function fieldTypeToMysql(def: FieldDefinition): string {
|
|
|
395
398
|
return "DATETIME";
|
|
396
399
|
case "text":
|
|
397
400
|
return "TEXT";
|
|
401
|
+
case "json":
|
|
402
|
+
return "JSON";
|
|
398
403
|
case "string":
|
|
399
404
|
return def.maxLength ? `VARCHAR(${def.maxLength})` : "VARCHAR(255)";
|
|
400
405
|
default:
|
|
@@ -353,10 +353,12 @@ export class OdbcAdapter implements DatabaseAdapter {
|
|
|
353
353
|
if (def.primaryKey) parts.push("PRIMARY KEY");
|
|
354
354
|
if (def.autoIncrement) parts.push("GENERATED ALWAYS AS IDENTITY"); // ANSI SQL
|
|
355
355
|
if (def.required && !def.primaryKey) parts.push("NOT NULL");
|
|
356
|
-
|
|
356
|
+
// A json column carries no DDL DEFAULT (parity with the Python master): an
|
|
357
|
+
// object/array default is applied per instance, not a portable SQL literal.
|
|
358
|
+
if (def.type !== "json" && def.default !== undefined && def.default !== "now") {
|
|
357
359
|
parts.push(`DEFAULT ${sqlDefault(def.default)}`);
|
|
358
360
|
}
|
|
359
|
-
if (def.default === "now") parts.push("DEFAULT CURRENT_TIMESTAMP");
|
|
361
|
+
if (def.type !== "json" && def.default === "now") parts.push("DEFAULT CURRENT_TIMESTAMP");
|
|
360
362
|
colDefs.push(parts.join(" "));
|
|
361
363
|
}
|
|
362
364
|
await this.connection.query(`CREATE TABLE IF NOT EXISTS "${name}" (${colDefs.join(", ")})`);
|
|
@@ -405,6 +407,7 @@ function fieldTypeToOdbc(type: string): string {
|
|
|
405
407
|
case "boolean": return "SMALLINT";
|
|
406
408
|
case "datetime": return "TIMESTAMP";
|
|
407
409
|
case "text": return "CLOB";
|
|
410
|
+
case "json": return "CLOB"; // store JSON text in a CLOB
|
|
408
411
|
case "string":
|
|
409
412
|
default: return "VARCHAR(255)";
|
|
410
413
|
}
|
|
@@ -423,10 +423,12 @@ export class PostgresAdapter implements DatabaseAdapter {
|
|
|
423
423
|
|
|
424
424
|
if (def.primaryKey && !def.autoIncrement) parts.push("PRIMARY KEY");
|
|
425
425
|
if (def.required && !def.primaryKey) parts.push("NOT NULL");
|
|
426
|
-
|
|
426
|
+
// A json column carries no DDL DEFAULT (parity with the Python master): an
|
|
427
|
+
// object/array default is applied per instance, not a portable SQL literal.
|
|
428
|
+
if (def.type !== "json" && def.default !== undefined && def.default !== "now") {
|
|
427
429
|
parts.push(`DEFAULT ${sqlDefault(def.default)}`);
|
|
428
430
|
}
|
|
429
|
-
if (def.default === "now") {
|
|
431
|
+
if (def.type !== "json" && def.default === "now") {
|
|
430
432
|
parts.push("DEFAULT CURRENT_TIMESTAMP");
|
|
431
433
|
}
|
|
432
434
|
|
|
@@ -460,6 +462,8 @@ function fieldTypeToPostgres(def: FieldDefinition): string {
|
|
|
460
462
|
return "TIMESTAMP";
|
|
461
463
|
case "text":
|
|
462
464
|
return "TEXT";
|
|
465
|
+
case "json":
|
|
466
|
+
return "JSONB";
|
|
463
467
|
case "string":
|
|
464
468
|
return def.maxLength ? `VARCHAR(${def.maxLength})` : "VARCHAR(255)";
|
|
465
469
|
default:
|
|
@@ -366,8 +366,10 @@ export class SQLiteAdapter implements DatabaseAdapter {
|
|
|
366
366
|
if (def.primaryKey) parts.push("PRIMARY KEY");
|
|
367
367
|
if (def.autoIncrement) parts.push("AUTOINCREMENT");
|
|
368
368
|
if (def.required && !def.primaryKey) parts.push("NOT NULL");
|
|
369
|
-
|
|
370
|
-
|
|
369
|
+
// A json column carries no DDL DEFAULT (parity with the Python master): an
|
|
370
|
+
// object/array default is applied per instance, not a portable SQL literal.
|
|
371
|
+
if (def.type !== "json" && def.default !== undefined && def.default !== "now") parts.push(`DEFAULT ${sqlDefault(def.default)}`);
|
|
372
|
+
if (def.type !== "json" && def.default === "now") parts.push("DEFAULT CURRENT_TIMESTAMP");
|
|
371
373
|
colDefs.push(parts.join(" "));
|
|
372
374
|
}
|
|
373
375
|
this.db.exec(`CREATE TABLE IF NOT EXISTS "${name}" (${colDefs.join(", ")})`);
|
|
@@ -393,6 +395,7 @@ function fieldTypeToSQLite(type: string): string {
|
|
|
393
395
|
case "boolean": return "INTEGER";
|
|
394
396
|
case "datetime": return "TEXT";
|
|
395
397
|
case "text": return "TEXT";
|
|
398
|
+
case "json": return "TEXT"; // no native JSON type; queryable via json1
|
|
396
399
|
case "string": default: return "TEXT";
|
|
397
400
|
}
|
|
398
401
|
}
|
|
@@ -26,6 +26,41 @@ export function camelToSnake(name: string): string {
|
|
|
26
26
|
return name.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Convert an in-memory field value to its database representation.
|
|
31
|
+
* A "json" field serialises its object/array to a JSON string for the driver
|
|
32
|
+
* (parity with the Python master's JSONField.to_db). A value that can't be
|
|
33
|
+
* serialised (e.g. a circular reference or a BigInt) throws — save() builds
|
|
34
|
+
* the row inside its try/catch, so it fails loud (rolls back, returns false,
|
|
35
|
+
* records the cause). null/undefined and an already-serialised string pass
|
|
36
|
+
* through untouched. Every other field type is returned as-is.
|
|
37
|
+
*/
|
|
38
|
+
export function toDbFieldValue(def: FieldDefinition | undefined, value: unknown): unknown {
|
|
39
|
+
if (def?.type === "json" && value !== null && value !== undefined && typeof value !== "string") {
|
|
40
|
+
return JSON.stringify(value);
|
|
41
|
+
}
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Convert a database value to its in-memory representation for a field.
|
|
47
|
+
* A "json" column comes back from the driver as a JSON string (SQLite TEXT,
|
|
48
|
+
* MySQL JSON, PostgreSQL JSONB via the text protocol, MSSQL NVARCHAR); decode
|
|
49
|
+
* it to the object/array the property expects (parity with the Python master's
|
|
50
|
+
* JSONField parse-on-read). A value already an object/array is left untouched;
|
|
51
|
+
* null stays null; a non-decodable string keeps its raw form.
|
|
52
|
+
*/
|
|
53
|
+
export function fromDbFieldValue(def: FieldDefinition | undefined, value: unknown): unknown {
|
|
54
|
+
if (def?.type === "json" && typeof value === "string") {
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(value);
|
|
57
|
+
} catch {
|
|
58
|
+
return value; // leave the raw string in place
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
|
|
29
64
|
/**
|
|
30
65
|
* Check whether TINA4_ORM_PLURAL_TABLE_NAMES is enabled in .env.
|
|
31
66
|
* When true, hasMany relationship keys get an "s" suffix (e.g. "posts" instead of "post").
|
|
@@ -130,9 +165,15 @@ export class BaseModel {
|
|
|
130
165
|
const fields0 = ModelClass0.fields ?? {};
|
|
131
166
|
for (const [name, def] of Object.entries(fields0)) {
|
|
132
167
|
if (def.default === undefined) continue;
|
|
133
|
-
|
|
168
|
+
let dv = typeof def.default === "function"
|
|
134
169
|
? (def.default as () => unknown)()
|
|
135
170
|
: def.default;
|
|
171
|
+
// Deep-clone a mutable object/array default so two instances never alias
|
|
172
|
+
// the same object (e.g. a json field `default: {}` — mutating a.meta must
|
|
173
|
+
// not leak into b.meta). Parity with the Python master's per-instance
|
|
174
|
+
// deepcopy and Ruby's Marshal round-trip.
|
|
175
|
+
if (dv !== null && typeof dv === "object") dv = structuredClone(dv);
|
|
176
|
+
this[name] = dv;
|
|
136
177
|
}
|
|
137
178
|
|
|
138
179
|
if (data) {
|
|
@@ -153,7 +194,10 @@ export class BaseModel {
|
|
|
153
194
|
for (const [key, value] of Object.entries(data)) {
|
|
154
195
|
// Lowercase the DB column key so UPPERCASE columns (Firebird/Oracle) match the mapping
|
|
155
196
|
const jsProp = reverseMapping[key] ?? reverseMapping[key.toLowerCase()] ?? key;
|
|
156
|
-
|
|
197
|
+
// A json column arrives as a JSON string from the driver (or as a raw
|
|
198
|
+
// string a caller passed); decode it to the object/array the property
|
|
199
|
+
// holds. A value already an object is left as-is.
|
|
200
|
+
this[jsProp] = fromDbFieldValue(fields0[jsProp], value);
|
|
157
201
|
}
|
|
158
202
|
}
|
|
159
203
|
}
|
|
@@ -176,7 +220,7 @@ export class BaseModel {
|
|
|
176
220
|
for (const key of Object.keys(ModelClass.fields)) {
|
|
177
221
|
if (this[key] !== undefined) {
|
|
178
222
|
const dbCol = ModelClass.getDbColumn(key);
|
|
179
|
-
result[dbCol] = this[key];
|
|
223
|
+
result[dbCol] = toDbFieldValue(ModelClass.fields[key], this[key]);
|
|
180
224
|
}
|
|
181
225
|
}
|
|
182
226
|
return result;
|
|
@@ -649,7 +693,7 @@ export class BaseModel {
|
|
|
649
693
|
if (updateFields.length === 0) { await adapterCommit(db); return this; }
|
|
650
694
|
|
|
651
695
|
const setClause = updateFields.map(([k]) => `"${ModelClass.getDbColumn(k)}" = ?`).join(", ");
|
|
652
|
-
const values = [...updateFields.map(([k]) => this[k]), pkValue];
|
|
696
|
+
const values = [...updateFields.map(([k, def]) => toDbFieldValue(def, this[k])), pkValue];
|
|
653
697
|
|
|
654
698
|
await adapterExecute(db, `UPDATE "${ModelClass.tableName}" SET ${setClause} WHERE "${pkCol}" = ?`, values);
|
|
655
699
|
} else {
|
|
@@ -660,7 +704,7 @@ export class BaseModel {
|
|
|
660
704
|
|
|
661
705
|
const columns = insertFields.map(([k]) => `"${ModelClass.getDbColumn(k)}"`).join(", ");
|
|
662
706
|
const placeholders = insertFields.map(() => "?").join(", ");
|
|
663
|
-
const values = insertFields.map(([k]) => this[k]);
|
|
707
|
+
const values = insertFields.map(([k, def]) => toDbFieldValue(def, this[k]));
|
|
664
708
|
|
|
665
709
|
// For auto-increment PKs on engines that need it (PostgreSQL),
|
|
666
710
|
// RETURNING the PK column lets us read the engine-assigned id back.
|
|
@@ -568,6 +568,27 @@ export function normalizeQuotes(sql: string): string {
|
|
|
568
568
|
return sql.replace(SMART_QUOTE_RE, (ch) => SMART_QUOTES[ch]);
|
|
569
569
|
}
|
|
570
570
|
|
|
571
|
+
const SET_TERM_RE = /^SET\s+TERM\s+(\S+)$/i;
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Return the new terminator from a `SET TERM <new> <current>` directive.
|
|
575
|
+
*
|
|
576
|
+
* `SET TERM` is a script-level directive (recognised by isql and other
|
|
577
|
+
* InterBase/Firebird tooling, not run by the engine) that changes the
|
|
578
|
+
* terminator separating statements. Recognising it lets a statement whose own
|
|
579
|
+
* body contains the default `;` terminator — a trigger, stored procedure or
|
|
580
|
+
* `EXECUTE BLOCK` — be kept intact rather than split on those inner `;`. The
|
|
581
|
+
* terminator may be more than one character (e.g. `!!`).
|
|
582
|
+
*
|
|
583
|
+
* @param statement A single, already-trimmed statement.
|
|
584
|
+
* @returns The new terminator, or `null` when `statement` is not a `SET TERM`
|
|
585
|
+
* directive.
|
|
586
|
+
*/
|
|
587
|
+
export function parseSetTerm(statement: string): string | null {
|
|
588
|
+
const m = statement.trim().match(SET_TERM_RE);
|
|
589
|
+
return m ? m[1] : null;
|
|
590
|
+
}
|
|
591
|
+
|
|
571
592
|
/**
|
|
572
593
|
* Split SQL text into individual statements with a single-pass, quote- and
|
|
573
594
|
* comment-aware scanner. The split decision is made character by character so
|
|
@@ -586,7 +607,11 @@ export function normalizeQuotes(sql: string): string {
|
|
|
586
607
|
* - `'…'` single-quoted strings and `"…"` double-quoted identifiers are copied
|
|
587
608
|
* verbatim, honouring the SQL doubled-quote escape (`''` / `""`); a `;`, `--`
|
|
588
609
|
* or `/*` inside a literal is data, not a delimiter or comment.
|
|
589
|
-
*
|
|
610
|
+
* - A `SET TERM <new> <current>` directive switches the active terminator and is
|
|
611
|
+
* consumed (never emitted), so a statement whose own body contains the default
|
|
612
|
+
* terminator — a Firebird trigger, stored procedure or `EXECUTE BLOCK` —
|
|
613
|
+
* survives as one. Multi-character terminators (e.g. `!!`) are supported.
|
|
614
|
+
* Mirrors the tina4-python `_split_statements` / tina4-php / tina4-ruby scanner (parity).
|
|
590
615
|
*/
|
|
591
616
|
export function splitStatements(sql: string, delimiter = ";"): string[] {
|
|
592
617
|
// Normalize smart/curly quotes to straight ASCII first, so SQL pasted from
|
|
@@ -596,7 +621,10 @@ export function splitStatements(sql: string, delimiter = ";"): string[] {
|
|
|
596
621
|
const statements: string[] = [];
|
|
597
622
|
let current = "";
|
|
598
623
|
const n = sql.length;
|
|
599
|
-
|
|
624
|
+
// Active statement terminator. Starts as the delimiter argument; a SET TERM
|
|
625
|
+
// directive can switch it mid-script (dlen recomputed) so a statement whose
|
|
626
|
+
// own body contains the default terminator stays intact.
|
|
627
|
+
let dlen = delimiter.length;
|
|
600
628
|
let i = 0;
|
|
601
629
|
let inDollarBlock = false;
|
|
602
630
|
let inSlashBlock = false;
|
|
@@ -685,12 +713,22 @@ export function splitStatements(sql: string, delimiter = ";"): string[] {
|
|
|
685
713
|
continue;
|
|
686
714
|
}
|
|
687
715
|
|
|
688
|
-
// Statement delimiter — only reached outside blocks/comments/strings.
|
|
716
|
+
// Statement delimiter — only reached outside blocks/comments/strings. A
|
|
717
|
+
// SET TERM directive switches the active terminator and is consumed (never
|
|
718
|
+
// emitted); any other completed statement is collected.
|
|
689
719
|
if (dlen > 0 && sql.startsWith(delimiter, i)) {
|
|
720
|
+
i += dlen;
|
|
690
721
|
const stmt = current.trim();
|
|
691
|
-
if (stmt) statements.push(stmt);
|
|
692
722
|
current = "";
|
|
693
|
-
|
|
723
|
+
if (stmt) {
|
|
724
|
+
const newTerm = parseSetTerm(stmt);
|
|
725
|
+
if (newTerm !== null) {
|
|
726
|
+
delimiter = newTerm;
|
|
727
|
+
dlen = delimiter.length;
|
|
728
|
+
} else {
|
|
729
|
+
statements.push(stmt);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
694
732
|
continue;
|
|
695
733
|
}
|
|
696
734
|
|
|
@@ -698,8 +736,10 @@ export function splitStatements(sql: string, delimiter = ";"): string[] {
|
|
|
698
736
|
i += 1;
|
|
699
737
|
}
|
|
700
738
|
|
|
739
|
+
// Trailing statement (may not end with a delimiter). A trailing SET TERM
|
|
740
|
+
// directive is a no-op — consume it, don't emit it.
|
|
701
741
|
const stmt = current.trim();
|
|
702
|
-
if (stmt) statements.push(stmt);
|
|
742
|
+
if (stmt && parseSetTerm(stmt) === null) statements.push(stmt);
|
|
703
743
|
return statements;
|
|
704
744
|
}
|
|
705
745
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type FieldType = "string" | "integer" | "number" | "numeric" | "boolean" | "datetime" | "text" | "foreignKey";
|
|
1
|
+
export type FieldType = "string" | "integer" | "number" | "numeric" | "boolean" | "datetime" | "text" | "json" | "foreignKey";
|
|
2
2
|
|
|
3
3
|
export interface FieldDefinition {
|
|
4
4
|
type: FieldType;
|
|
@@ -87,6 +87,15 @@ export function validate(
|
|
|
87
87
|
}
|
|
88
88
|
break;
|
|
89
89
|
|
|
90
|
+
case "json":
|
|
91
|
+
// A JSON column holds an object/array (or a pre-serialised JSON
|
|
92
|
+
// string); reject a bare scalar. A value that can't be JSON-encoded
|
|
93
|
+
// (a circular reference, a BigInt) fails loud at save time.
|
|
94
|
+
if (value !== null && typeof value !== "object" && typeof value !== "string") {
|
|
95
|
+
errors.push({ field: name, message: "must be a JSON object or array" });
|
|
96
|
+
}
|
|
97
|
+
break;
|
|
98
|
+
|
|
90
99
|
case "foreignKey": {
|
|
91
100
|
// Outlier D: previously there was no foreignKey case, so ANY value
|
|
92
101
|
// passed validation silently. A foreign key references another model's
|
|
@@ -446,6 +446,11 @@ function fieldToSchemaProperty(def: FieldDefinition): Record<string, unknown> {
|
|
|
446
446
|
// and produced an empty {} schema (audit P2).
|
|
447
447
|
prop.type = "integer";
|
|
448
448
|
break;
|
|
449
|
+
case "json":
|
|
450
|
+
// A JSON document column — an object or array. OpenAPI 3.0 can't express
|
|
451
|
+
// "object OR array" in one type, so advertise the common object shape.
|
|
452
|
+
prop.type = "object";
|
|
453
|
+
break;
|
|
449
454
|
default:
|
|
450
455
|
prop.type = "string";
|
|
451
456
|
}
|