@objectstack/driver-sql 10.3.0 → 11.1.0

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/dist/index.js CHANGED
@@ -43,6 +43,7 @@ module.exports = __toCommonJS(index_exports);
43
43
  var import_data = require("@objectstack/spec/data");
44
44
  var import_system = require("@objectstack/spec/system");
45
45
  var import_shared = require("@objectstack/spec/shared");
46
+ var import_types = require("@objectstack/types");
46
47
 
47
48
  // src/schema-drift.ts
48
49
  var BUILTIN_COLUMNS = /* @__PURE__ */ new Set(["id", "created_at", "updated_at"]);
@@ -182,6 +183,39 @@ var NUMERIC_SCALAR_TYPES = /* @__PURE__ */ new Set([
182
183
  "slider",
183
184
  "progress"
184
185
  ]);
186
+ var AUDIT_TIMESTAMP_COLUMNS = ["created_at", "updated_at"];
187
+ function repairNaiveUtcAuditTimestamp(value) {
188
+ if (typeof value !== "string") return value;
189
+ const s = value.trim();
190
+ if (s === "") return value;
191
+ if (/[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return value;
192
+ const m = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2}(?:\.\d+)?)$/.exec(s);
193
+ if (!m) return value;
194
+ const d = /* @__PURE__ */ new Date(`${m[1]}T${m[2]}Z`);
195
+ return Number.isNaN(d.getTime()) ? value : d.toISOString();
196
+ }
197
+ function isNowDefaultValue(v) {
198
+ return typeof v === "string" && /^now\(\)$/i.test(v.trim());
199
+ }
200
+ function normalizeSqliteDatetimeOutput(value) {
201
+ if (value == null) return value;
202
+ if (typeof value === "number") {
203
+ if (!Number.isFinite(value)) return value;
204
+ const d = new Date(value);
205
+ return Number.isNaN(d.getTime()) ? value : d.toISOString();
206
+ }
207
+ if (value instanceof Date) {
208
+ return Number.isNaN(value.getTime()) ? value : value.toISOString();
209
+ }
210
+ if (typeof value !== "string") return value;
211
+ const s = value.trim();
212
+ if (s === "") return value;
213
+ if (/^-?\d+$/.test(s)) {
214
+ const d = new Date(Number(s));
215
+ return Number.isNaN(d.getTime()) ? value : d.toISOString();
216
+ }
217
+ return repairNaiveUtcAuditTimestamp(s);
218
+ }
185
219
  var SqlDriver = class {
186
220
  constructor(config) {
187
221
  // IDataDriver metadata
@@ -192,6 +226,7 @@ var SqlDriver = class {
192
226
  this.numericFields = {};
193
227
  this.dateFields = {};
194
228
  this.datetimeFields = {};
229
+ this.timeFields = {};
195
230
  /**
196
231
  * Federation read path (ADR-0015). For external objects whose physical
197
232
  * remote table differs from the object name, these map between the two so
@@ -461,7 +496,7 @@ var SqlDriver = class {
461
496
  await this.knex.destroy();
462
497
  }
463
498
  // ===================================
464
- // CRUD — DriverInterface core
499
+ // CRUD — IDataDriver core
465
500
  // ===================================
466
501
  async find(object, query, options) {
467
502
  const buildBase = () => {
@@ -551,6 +586,7 @@ var SqlDriver = class {
551
586
  await this.fillAutoNumberFields(object, toInsert, options);
552
587
  const builder = this.getBuilder(object, options);
553
588
  const formatted = this.applyWriteColumnMap(object, this.formatInput(object, toInsert));
589
+ this.stampInsertTimestamps(object, formatted);
554
590
  const result = await builder.insert(formatted).returning("*");
555
591
  return this.formatOutput(object, result[0]);
556
592
  }
@@ -778,18 +814,39 @@ var SqlDriver = class {
778
814
  row[cfg.name] = (0, import_data.renderAutonumber)({ tokens: cfg.tokens, seq: next, record: row, now, timezone }).value;
779
815
  }
780
816
  }
817
+ /**
818
+ * Stamp the builtin audit timestamps to one canonical ISO-8601-with-`Z`
819
+ * instant on the SQLite write paths (`create`/`bulkCreate`/`upsert`), so
820
+ * INSERT and UPDATE agree on a single zone-explicit format.
821
+ *
822
+ * Without this, an insert that omits `created_at`/`updated_at` falls back to
823
+ * the column's `CURRENT_TIMESTAMP` default, which on SQLite renders a
824
+ * zone-NAIVE, space-separated `'YYYY-MM-DD HH:MM:SS'` (no millis, no zone) —
825
+ * the same ambiguity the old UPDATE stamp had. Stamping app-side (rather than
826
+ * changing the column default) fixes this for EXISTING tenant databases
827
+ * immediately, since their tables keep the legacy default. Legacy/raw rows
828
+ * still written zone-naive are repaired on read by
829
+ * `repairNaiveUtcAuditTimestamp`.
830
+ *
831
+ * Only fills a slot the caller left empty — an explicit value (a seed fixture,
832
+ * the sys_metadata writer, a service outbox) is preserved. No-op for
833
+ * timestamp-less objects and for Postgres/MySQL, whose native `now()` column
834
+ * default already stores a zone-aware TIMESTAMP.
835
+ */
836
+ stampInsertTimestamps(object, formatted) {
837
+ if (!this.isSqlite || !this.tablesWithTimestamps.has(object)) return;
838
+ const iso = (/* @__PURE__ */ new Date()).toISOString();
839
+ for (const col of AUDIT_TIMESTAMP_COLUMNS) {
840
+ if (formatted[col] === void 0 || formatted[col] === null) formatted[col] = iso;
841
+ }
842
+ }
781
843
  async update(object, id, data, options) {
782
844
  this.auditMissingTenant(object, "update", options);
783
845
  const builder = this.getBuilder(object, options).where("id", id);
784
846
  this.applyTenantScope(builder, object, options);
785
847
  const formatted = this.applyWriteColumnMap(object, this.formatInput(object, data));
786
848
  if (this.tablesWithTimestamps.has(object)) {
787
- if (this.isSqlite) {
788
- const now = /* @__PURE__ */ new Date();
789
- formatted.updated_at = now.toISOString().replace("T", " ").replace("Z", "");
790
- } else {
791
- formatted.updated_at = this.knex.fn.now();
792
- }
849
+ formatted.updated_at = this.isSqlite ? (/* @__PURE__ */ new Date()).toISOString() : this.knex.fn.now();
793
850
  }
794
851
  await builder.update(formatted);
795
852
  const readback = this.getBuilder(object, options).where("id", id);
@@ -809,9 +866,12 @@ var SqlDriver = class {
809
866
  this.injectTenantOnInsert(object, toUpsert, options);
810
867
  await this.fillAutoNumberFields(object, toUpsert, options);
811
868
  const formatted = this.applyWriteColumnMap(object, this.formatInput(object, toUpsert));
869
+ this.stampInsertTimestamps(object, formatted);
812
870
  const mergeKeys = conflictKeys && conflictKeys.length > 0 ? conflictKeys : ["id"];
813
871
  const builder = this.getBuilder(object, options);
814
- await builder.insert(formatted).onConflict(mergeKeys).merge();
872
+ const mergeColumns = Object.keys(formatted).filter((c) => c !== "created_at");
873
+ const insertion = builder.insert(formatted).onConflict(mergeKeys);
874
+ await (mergeColumns.length > 0 ? insertion.merge(mergeColumns) : insertion.merge());
815
875
  const readback = this.getBuilder(object, options).where("id", toUpsert.id);
816
876
  this.applyTenantScope(readback, object, options);
817
877
  const result = await readback.first();
@@ -833,6 +893,7 @@ var SqlDriver = class {
833
893
  if (row && typeof row === "object") {
834
894
  this.injectTenantOnInsert(object, row, options);
835
895
  await this.fillAutoNumberFields(object, row, options);
896
+ this.stampInsertTimestamps(object, row);
836
897
  }
837
898
  }
838
899
  const builder = this.getBuilder(object, options);
@@ -1169,6 +1230,7 @@ var SqlDriver = class {
1169
1230
  const numericCols = [];
1170
1231
  const dateCols = [];
1171
1232
  const datetimeCols = [];
1233
+ const timeCols = [];
1172
1234
  const autoNumberCols = [];
1173
1235
  const tenantField = this.computeTenantField(schema);
1174
1236
  if (schema.fields) {
@@ -1179,6 +1241,7 @@ var SqlDriver = class {
1179
1241
  if (NUMERIC_SCALAR_TYPES.has(type) && !field.multiple) numericCols.push(name);
1180
1242
  if (type === "date") dateCols.push(name);
1181
1243
  if (type === "datetime") datetimeCols.push(name);
1244
+ if (type === "time") timeCols.push(name);
1182
1245
  if (type === "auto_number" || type === "autonumber") {
1183
1246
  const rawFmt = typeof field.autonumberFormat === "string" && field.autonumberFormat ? field.autonumberFormat : typeof field.format === "string" && field.format ? field.format : "";
1184
1247
  const fmt = rawFmt || "{0000}";
@@ -1193,9 +1256,10 @@ var SqlDriver = class {
1193
1256
  this.tenantFieldByTable[key] = tenantField;
1194
1257
  if (dateCols.length) this.dateFields[key] = new Set(dateCols);
1195
1258
  if (datetimeCols.length) this.datetimeFields[key] = new Set(datetimeCols);
1259
+ if (timeCols.length) this.timeFields[key] = new Set(timeCols);
1196
1260
  }
1197
1261
  async initObjects(objects) {
1198
- var _a, _b;
1262
+ var _a, _b, _c;
1199
1263
  this.assertSchemaMutable("initObjects");
1200
1264
  await this.ensureDatabaseExists();
1201
1265
  for (const obj of objects) {
@@ -1227,6 +1291,9 @@ var SqlDriver = class {
1227
1291
  if (type === "datetime") {
1228
1292
  ((_b = this.datetimeFields)[tableName] ?? (_b[tableName] = /* @__PURE__ */ new Set())).add(name);
1229
1293
  }
1294
+ if (type === "time") {
1295
+ ((_c = this.timeFields)[tableName] ?? (_c[tableName] = /* @__PURE__ */ new Set())).add(name);
1296
+ }
1230
1297
  if (type === "auto_number" || type === "autonumber") {
1231
1298
  const rawFmt = typeof field.autonumberFormat === "string" && field.autonumberFormat ? field.autonumberFormat : typeof field.format === "string" && field.format ? field.format : "";
1232
1299
  const fmt = rawFmt || "{0000}";
@@ -1712,8 +1779,7 @@ var SqlDriver = class {
1712
1779
  }
1713
1780
  isMultiTenantMode() {
1714
1781
  if (this._multiTenantMode === void 0) {
1715
- const raw = process.env.OS_MULTI_ORG_ENABLED ?? process.env.OS_MULTI_TENANT ?? "false";
1716
- this._multiTenantMode = String(raw).toLowerCase() !== "false";
1782
+ this._multiTenantMode = (0, import_types.resolveMultiOrgEnabled)();
1717
1783
  }
1718
1784
  return this._multiTenantMode;
1719
1785
  }
@@ -1829,6 +1895,32 @@ var SqlDriver = class {
1829
1895
  }
1830
1896
  return value;
1831
1897
  }
1898
+ /**
1899
+ * Read-side repair for a `Field.time` value to its wall-clock time-of-day
1900
+ * (`Field.time` is a tz-naive time-of-day, not an instant — #2004). This is a
1901
+ * deliberately NARROW, read-only normalization (no write/filter counterpart):
1902
+ * it only strips a leading `YYYY-MM-DD` date — exactly what a legacy
1903
+ * `defaultValue: 'NOW()'` column took when the default was still the full
1904
+ * `CURRENT_TIMESTAMP` (or a full ISO datetime that leaked into the column) —
1905
+ * and any trailing zone, leaving the time portion. A value that is ALREADY a
1906
+ * bare time-of-day (`HH:MM[:SS[.fff]]`, with or without `Z`/offset) is returned
1907
+ * untouched, so the common case never changes and no write/read asymmetry is
1908
+ * introduced. A `Date`/epoch-ms (defensive — a Date bound to a time column)
1909
+ * maps to its UTC time-of-day. `null`/unrecognised shapes pass through.
1910
+ */
1911
+ toTimeOnly(value) {
1912
+ if (value == null) return value;
1913
+ if (value instanceof Date) {
1914
+ return Number.isNaN(value.getTime()) ? value : value.toISOString().slice(11, 19);
1915
+ }
1916
+ if (typeof value === "number" && Number.isFinite(value)) {
1917
+ const d = new Date(value);
1918
+ return Number.isNaN(d.getTime()) ? value : d.toISOString().slice(11, 19);
1919
+ }
1920
+ if (typeof value !== "string") return value;
1921
+ const m = /^\d{4}-\d{2}-\d{2}[ T](\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?)(?:[Zz]|[+-]\d{2}:?\d{2})?$/.exec(value.trim());
1922
+ return m ? m[1] : value;
1923
+ }
1832
1924
  /**
1833
1925
  * Normalise a filter value for a single column so the comparison the
1834
1926
  * driver sends to SQLite matches the on-disk representation.
@@ -2113,6 +2205,41 @@ var SqlDriver = class {
2113
2205
  return sql;
2114
2206
  }
2115
2207
  // ── Column creation helper ──────────────────────────────────────────────────
2208
+ /**
2209
+ * The driver-native column DEFAULT for a `defaultValue: 'NOW()'` field.
2210
+ *
2211
+ * Postgres/MySQL use native `now()` — a real zone-aware TIMESTAMP that never
2212
+ * had the ambiguity below. SQLite has no timestamp type and `knex.fn.now()`
2213
+ * compiles to `CURRENT_TIMESTAMP`, which renders a timezone-NAIVE,
2214
+ * space-separated `'YYYY-MM-DD HH:MM:SS'` (no millis, no zone). `Date.parse`
2215
+ * reads such a zone-less string as LOCAL time, so a stored UTC wall-clock
2216
+ * shifts by the host offset on a non-UTC runtime — the same class of bug
2217
+ * ADR-0074 fixed for the builtin audit columns. Emit a canonical instead:
2218
+ * - datetime → ISO-8601 with explicit `Z` (`2026-06-26T10:34:13.891Z`),
2219
+ * matching `new Date().toISOString()` and the value
2220
+ * `formatInput`'s `NOW()` safety-net writes;
2221
+ * - date → `YYYY-MM-DD` UTC calendar day (matches `toDateOnly`, so the
2222
+ * stored default already equals what an explicit write stores);
2223
+ * - time → `HH:MM:SS.fff` UTC time-of-day (not a full timestamp).
2224
+ *
2225
+ * NOTE: a DDL default only governs NEWLY-created columns. An existing column
2226
+ * keeps its legacy `CURRENT_TIMESTAMP` default and still emits naive text on a
2227
+ * defaulted insert; `formatOutput` repairs those to canonical on read
2228
+ * (`normalizeSqliteDatetimeOutput` for datetime, `toDateOnly` for date), so
2229
+ * reads are uniform without a schema migration.
2230
+ */
2231
+ nowColumnDefault(type) {
2232
+ if (!this.isSqlite) return this.knex.fn.now();
2233
+ switch (type) {
2234
+ case "date":
2235
+ return this.knex.raw("(strftime('%Y-%m-%d', 'now'))");
2236
+ case "time":
2237
+ return this.knex.raw("(strftime('%H:%M:%f', 'now'))");
2238
+ // datetime (and any non-temporal field that opts into NOW()): canonical instant.
2239
+ default:
2240
+ return this.knex.raw("(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))");
2241
+ }
2242
+ }
2116
2243
  createColumn(table, name, field) {
2117
2244
  if (field.multiple) {
2118
2245
  table.json(name);
@@ -2170,7 +2297,12 @@ var SqlDriver = class {
2170
2297
  case "time":
2171
2298
  col = table.time(name);
2172
2299
  break;
2300
+ // `user` is a lookup specialized to sys_user (ADR: lookup → sys_user). Same
2301
+ // physical storage as any lookup: a string column holding the related row id
2302
+ // (multiple ⇒ JSON, handled at the top of createColumn). No bespoke storage
2303
+ // primitive — it shares this exact DDL path so reads/$expand/FK stay uniform.
2173
2304
  case "lookup":
2305
+ case "user":
2174
2306
  col = table.string(name);
2175
2307
  if (field.reference_to) {
2176
2308
  table.foreign(name).references("id").inTable(field.reference_to);
@@ -2192,12 +2324,12 @@ var SqlDriver = class {
2192
2324
  if (col) {
2193
2325
  if (field.unique) col.unique();
2194
2326
  if (field.required) col.notNullable();
2195
- if ((type === "datetime" || type === "date" || type === "time") && typeof field.defaultValue === "string" && /^now\(\)$/i.test(field.defaultValue.trim())) {
2196
- col.defaultTo(this.knex.fn.now());
2327
+ if ((type === "datetime" || type === "date" || type === "time") && isNowDefaultValue(field.defaultValue)) {
2328
+ col.defaultTo(this.nowColumnDefault(type));
2197
2329
  } else if (field.defaultValue !== void 0 && field.defaultValue !== null) {
2198
2330
  const dv = field.defaultValue;
2199
- if (typeof dv === "string" && /^now\(\)$/i.test(dv.trim())) {
2200
- col.defaultTo(this.knex.fn.now());
2331
+ if (isNowDefaultValue(dv)) {
2332
+ col.defaultTo(this.nowColumnDefault(type));
2201
2333
  } else if (typeof dv !== "object") {
2202
2334
  col.defaultTo(dv);
2203
2335
  }
@@ -2371,6 +2503,17 @@ var SqlDriver = class {
2371
2503
  }
2372
2504
  }
2373
2505
  }
2506
+ for (const col of AUDIT_TIMESTAMP_COLUMNS) {
2507
+ if (data[col] !== void 0) data[col] = repairNaiveUtcAuditTimestamp(data[col]);
2508
+ }
2509
+ const datetimeFields = this.datetimeFields[object];
2510
+ if (datetimeFields && datetimeFields.size > 0) {
2511
+ for (const field of datetimeFields) {
2512
+ if (data[field] !== void 0) {
2513
+ data[field] = normalizeSqliteDatetimeOutput(data[field]);
2514
+ }
2515
+ }
2516
+ }
2374
2517
  }
2375
2518
  const dateFields = this.dateFields[object];
2376
2519
  if (dateFields && dateFields.size > 0) {
@@ -2381,6 +2524,15 @@ var SqlDriver = class {
2381
2524
  if (normalized !== v) data[field] = normalized;
2382
2525
  }
2383
2526
  }
2527
+ const timeFields = this.timeFields[object];
2528
+ if (timeFields && timeFields.size > 0) {
2529
+ for (const field of timeFields) {
2530
+ const v = data[field];
2531
+ if (v == null) continue;
2532
+ const normalized = this.toTimeOnly(v);
2533
+ if (normalized !== v) data[field] = normalized;
2534
+ }
2535
+ }
2384
2536
  return data;
2385
2537
  }
2386
2538
  // ── Introspection internals ─────────────────────────────────────────────────