tina4-nodejs 3.13.52 → 3.13.53
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/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.53)
|
|
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.53 — 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.
|
|
@@ -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
|
}
|