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 CHANGED
@@ -1,10 +1,10 @@
1
- # CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.52)
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.27 — The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tina4-nodejs",
3
- "version": "3.13.52",
3
+ "version": "3.13.53",
4
4
  "type": "module",
5
5
  "description": "Tina4 for Node.js/TypeScript - 54 built-in features, zero dependencies",
6
6
  "keywords": [
@@ -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
- if (def.default !== undefined && def.default !== "now") {
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
- if (def.default !== undefined && def.default !== "now") {
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
- if (def.default !== undefined && def.default !== "now") {
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
- if (def.default !== undefined && def.default !== "now") {
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
- if (def.default !== undefined && def.default !== "now") {
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
- if (def.default !== undefined && def.default !== "now") parts.push(`DEFAULT ${sqlDefault(def.default)}`);
370
- if (def.default === "now") parts.push("DEFAULT CURRENT_TIMESTAMP");
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
- this[name] = typeof def.default === "function"
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
- this[jsProp] = value;
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
  }