@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.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  import { parseAutonumberFormat, renderAutonumber, missingFieldValues } from "@objectstack/spec/data";
3
3
  import { StorageNameMapping } from "@objectstack/spec/system";
4
4
  import { ExternalSchemaModeViolationError } from "@objectstack/spec/shared";
5
+ import { resolveMultiOrgEnabled } from "@objectstack/types";
5
6
 
6
7
  // src/schema-drift.ts
7
8
  var BUILTIN_COLUMNS = /* @__PURE__ */ new Set(["id", "created_at", "updated_at"]);
@@ -141,6 +142,39 @@ var NUMERIC_SCALAR_TYPES = /* @__PURE__ */ new Set([
141
142
  "slider",
142
143
  "progress"
143
144
  ]);
145
+ var AUDIT_TIMESTAMP_COLUMNS = ["created_at", "updated_at"];
146
+ function repairNaiveUtcAuditTimestamp(value) {
147
+ if (typeof value !== "string") return value;
148
+ const s = value.trim();
149
+ if (s === "") return value;
150
+ if (/[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return value;
151
+ const m = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2}(?:\.\d+)?)$/.exec(s);
152
+ if (!m) return value;
153
+ const d = /* @__PURE__ */ new Date(`${m[1]}T${m[2]}Z`);
154
+ return Number.isNaN(d.getTime()) ? value : d.toISOString();
155
+ }
156
+ function isNowDefaultValue(v) {
157
+ return typeof v === "string" && /^now\(\)$/i.test(v.trim());
158
+ }
159
+ function normalizeSqliteDatetimeOutput(value) {
160
+ if (value == null) return value;
161
+ if (typeof value === "number") {
162
+ if (!Number.isFinite(value)) return value;
163
+ const d = new Date(value);
164
+ return Number.isNaN(d.getTime()) ? value : d.toISOString();
165
+ }
166
+ if (value instanceof Date) {
167
+ return Number.isNaN(value.getTime()) ? value : value.toISOString();
168
+ }
169
+ if (typeof value !== "string") return value;
170
+ const s = value.trim();
171
+ if (s === "") return value;
172
+ if (/^-?\d+$/.test(s)) {
173
+ const d = new Date(Number(s));
174
+ return Number.isNaN(d.getTime()) ? value : d.toISOString();
175
+ }
176
+ return repairNaiveUtcAuditTimestamp(s);
177
+ }
144
178
  var SqlDriver = class {
145
179
  constructor(config) {
146
180
  // IDataDriver metadata
@@ -151,6 +185,7 @@ var SqlDriver = class {
151
185
  this.numericFields = {};
152
186
  this.dateFields = {};
153
187
  this.datetimeFields = {};
188
+ this.timeFields = {};
154
189
  /**
155
190
  * Federation read path (ADR-0015). For external objects whose physical
156
191
  * remote table differs from the object name, these map between the two so
@@ -420,7 +455,7 @@ var SqlDriver = class {
420
455
  await this.knex.destroy();
421
456
  }
422
457
  // ===================================
423
- // CRUD — DriverInterface core
458
+ // CRUD — IDataDriver core
424
459
  // ===================================
425
460
  async find(object, query, options) {
426
461
  const buildBase = () => {
@@ -510,6 +545,7 @@ var SqlDriver = class {
510
545
  await this.fillAutoNumberFields(object, toInsert, options);
511
546
  const builder = this.getBuilder(object, options);
512
547
  const formatted = this.applyWriteColumnMap(object, this.formatInput(object, toInsert));
548
+ this.stampInsertTimestamps(object, formatted);
513
549
  const result = await builder.insert(formatted).returning("*");
514
550
  return this.formatOutput(object, result[0]);
515
551
  }
@@ -737,18 +773,39 @@ var SqlDriver = class {
737
773
  row[cfg.name] = renderAutonumber({ tokens: cfg.tokens, seq: next, record: row, now, timezone }).value;
738
774
  }
739
775
  }
776
+ /**
777
+ * Stamp the builtin audit timestamps to one canonical ISO-8601-with-`Z`
778
+ * instant on the SQLite write paths (`create`/`bulkCreate`/`upsert`), so
779
+ * INSERT and UPDATE agree on a single zone-explicit format.
780
+ *
781
+ * Without this, an insert that omits `created_at`/`updated_at` falls back to
782
+ * the column's `CURRENT_TIMESTAMP` default, which on SQLite renders a
783
+ * zone-NAIVE, space-separated `'YYYY-MM-DD HH:MM:SS'` (no millis, no zone) —
784
+ * the same ambiguity the old UPDATE stamp had. Stamping app-side (rather than
785
+ * changing the column default) fixes this for EXISTING tenant databases
786
+ * immediately, since their tables keep the legacy default. Legacy/raw rows
787
+ * still written zone-naive are repaired on read by
788
+ * `repairNaiveUtcAuditTimestamp`.
789
+ *
790
+ * Only fills a slot the caller left empty — an explicit value (a seed fixture,
791
+ * the sys_metadata writer, a service outbox) is preserved. No-op for
792
+ * timestamp-less objects and for Postgres/MySQL, whose native `now()` column
793
+ * default already stores a zone-aware TIMESTAMP.
794
+ */
795
+ stampInsertTimestamps(object, formatted) {
796
+ if (!this.isSqlite || !this.tablesWithTimestamps.has(object)) return;
797
+ const iso = (/* @__PURE__ */ new Date()).toISOString();
798
+ for (const col of AUDIT_TIMESTAMP_COLUMNS) {
799
+ if (formatted[col] === void 0 || formatted[col] === null) formatted[col] = iso;
800
+ }
801
+ }
740
802
  async update(object, id, data, options) {
741
803
  this.auditMissingTenant(object, "update", options);
742
804
  const builder = this.getBuilder(object, options).where("id", id);
743
805
  this.applyTenantScope(builder, object, options);
744
806
  const formatted = this.applyWriteColumnMap(object, this.formatInput(object, data));
745
807
  if (this.tablesWithTimestamps.has(object)) {
746
- if (this.isSqlite) {
747
- const now = /* @__PURE__ */ new Date();
748
- formatted.updated_at = now.toISOString().replace("T", " ").replace("Z", "");
749
- } else {
750
- formatted.updated_at = this.knex.fn.now();
751
- }
808
+ formatted.updated_at = this.isSqlite ? (/* @__PURE__ */ new Date()).toISOString() : this.knex.fn.now();
752
809
  }
753
810
  await builder.update(formatted);
754
811
  const readback = this.getBuilder(object, options).where("id", id);
@@ -768,9 +825,12 @@ var SqlDriver = class {
768
825
  this.injectTenantOnInsert(object, toUpsert, options);
769
826
  await this.fillAutoNumberFields(object, toUpsert, options);
770
827
  const formatted = this.applyWriteColumnMap(object, this.formatInput(object, toUpsert));
828
+ this.stampInsertTimestamps(object, formatted);
771
829
  const mergeKeys = conflictKeys && conflictKeys.length > 0 ? conflictKeys : ["id"];
772
830
  const builder = this.getBuilder(object, options);
773
- await builder.insert(formatted).onConflict(mergeKeys).merge();
831
+ const mergeColumns = Object.keys(formatted).filter((c) => c !== "created_at");
832
+ const insertion = builder.insert(formatted).onConflict(mergeKeys);
833
+ await (mergeColumns.length > 0 ? insertion.merge(mergeColumns) : insertion.merge());
774
834
  const readback = this.getBuilder(object, options).where("id", toUpsert.id);
775
835
  this.applyTenantScope(readback, object, options);
776
836
  const result = await readback.first();
@@ -792,6 +852,7 @@ var SqlDriver = class {
792
852
  if (row && typeof row === "object") {
793
853
  this.injectTenantOnInsert(object, row, options);
794
854
  await this.fillAutoNumberFields(object, row, options);
855
+ this.stampInsertTimestamps(object, row);
795
856
  }
796
857
  }
797
858
  const builder = this.getBuilder(object, options);
@@ -1128,6 +1189,7 @@ var SqlDriver = class {
1128
1189
  const numericCols = [];
1129
1190
  const dateCols = [];
1130
1191
  const datetimeCols = [];
1192
+ const timeCols = [];
1131
1193
  const autoNumberCols = [];
1132
1194
  const tenantField = this.computeTenantField(schema);
1133
1195
  if (schema.fields) {
@@ -1138,6 +1200,7 @@ var SqlDriver = class {
1138
1200
  if (NUMERIC_SCALAR_TYPES.has(type) && !field.multiple) numericCols.push(name);
1139
1201
  if (type === "date") dateCols.push(name);
1140
1202
  if (type === "datetime") datetimeCols.push(name);
1203
+ if (type === "time") timeCols.push(name);
1141
1204
  if (type === "auto_number" || type === "autonumber") {
1142
1205
  const rawFmt = typeof field.autonumberFormat === "string" && field.autonumberFormat ? field.autonumberFormat : typeof field.format === "string" && field.format ? field.format : "";
1143
1206
  const fmt = rawFmt || "{0000}";
@@ -1152,9 +1215,10 @@ var SqlDriver = class {
1152
1215
  this.tenantFieldByTable[key] = tenantField;
1153
1216
  if (dateCols.length) this.dateFields[key] = new Set(dateCols);
1154
1217
  if (datetimeCols.length) this.datetimeFields[key] = new Set(datetimeCols);
1218
+ if (timeCols.length) this.timeFields[key] = new Set(timeCols);
1155
1219
  }
1156
1220
  async initObjects(objects) {
1157
- var _a, _b;
1221
+ var _a, _b, _c;
1158
1222
  this.assertSchemaMutable("initObjects");
1159
1223
  await this.ensureDatabaseExists();
1160
1224
  for (const obj of objects) {
@@ -1186,6 +1250,9 @@ var SqlDriver = class {
1186
1250
  if (type === "datetime") {
1187
1251
  ((_b = this.datetimeFields)[tableName] ?? (_b[tableName] = /* @__PURE__ */ new Set())).add(name);
1188
1252
  }
1253
+ if (type === "time") {
1254
+ ((_c = this.timeFields)[tableName] ?? (_c[tableName] = /* @__PURE__ */ new Set())).add(name);
1255
+ }
1189
1256
  if (type === "auto_number" || type === "autonumber") {
1190
1257
  const rawFmt = typeof field.autonumberFormat === "string" && field.autonumberFormat ? field.autonumberFormat : typeof field.format === "string" && field.format ? field.format : "";
1191
1258
  const fmt = rawFmt || "{0000}";
@@ -1671,8 +1738,7 @@ var SqlDriver = class {
1671
1738
  }
1672
1739
  isMultiTenantMode() {
1673
1740
  if (this._multiTenantMode === void 0) {
1674
- const raw = process.env.OS_MULTI_ORG_ENABLED ?? process.env.OS_MULTI_TENANT ?? "false";
1675
- this._multiTenantMode = String(raw).toLowerCase() !== "false";
1741
+ this._multiTenantMode = resolveMultiOrgEnabled();
1676
1742
  }
1677
1743
  return this._multiTenantMode;
1678
1744
  }
@@ -1788,6 +1854,32 @@ var SqlDriver = class {
1788
1854
  }
1789
1855
  return value;
1790
1856
  }
1857
+ /**
1858
+ * Read-side repair for a `Field.time` value to its wall-clock time-of-day
1859
+ * (`Field.time` is a tz-naive time-of-day, not an instant — #2004). This is a
1860
+ * deliberately NARROW, read-only normalization (no write/filter counterpart):
1861
+ * it only strips a leading `YYYY-MM-DD` date — exactly what a legacy
1862
+ * `defaultValue: 'NOW()'` column took when the default was still the full
1863
+ * `CURRENT_TIMESTAMP` (or a full ISO datetime that leaked into the column) —
1864
+ * and any trailing zone, leaving the time portion. A value that is ALREADY a
1865
+ * bare time-of-day (`HH:MM[:SS[.fff]]`, with or without `Z`/offset) is returned
1866
+ * untouched, so the common case never changes and no write/read asymmetry is
1867
+ * introduced. A `Date`/epoch-ms (defensive — a Date bound to a time column)
1868
+ * maps to its UTC time-of-day. `null`/unrecognised shapes pass through.
1869
+ */
1870
+ toTimeOnly(value) {
1871
+ if (value == null) return value;
1872
+ if (value instanceof Date) {
1873
+ return Number.isNaN(value.getTime()) ? value : value.toISOString().slice(11, 19);
1874
+ }
1875
+ if (typeof value === "number" && Number.isFinite(value)) {
1876
+ const d = new Date(value);
1877
+ return Number.isNaN(d.getTime()) ? value : d.toISOString().slice(11, 19);
1878
+ }
1879
+ if (typeof value !== "string") return value;
1880
+ const m = /^\d{4}-\d{2}-\d{2}[ T](\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?)(?:[Zz]|[+-]\d{2}:?\d{2})?$/.exec(value.trim());
1881
+ return m ? m[1] : value;
1882
+ }
1791
1883
  /**
1792
1884
  * Normalise a filter value for a single column so the comparison the
1793
1885
  * driver sends to SQLite matches the on-disk representation.
@@ -2072,6 +2164,41 @@ var SqlDriver = class {
2072
2164
  return sql;
2073
2165
  }
2074
2166
  // ── Column creation helper ──────────────────────────────────────────────────
2167
+ /**
2168
+ * The driver-native column DEFAULT for a `defaultValue: 'NOW()'` field.
2169
+ *
2170
+ * Postgres/MySQL use native `now()` — a real zone-aware TIMESTAMP that never
2171
+ * had the ambiguity below. SQLite has no timestamp type and `knex.fn.now()`
2172
+ * compiles to `CURRENT_TIMESTAMP`, which renders a timezone-NAIVE,
2173
+ * space-separated `'YYYY-MM-DD HH:MM:SS'` (no millis, no zone). `Date.parse`
2174
+ * reads such a zone-less string as LOCAL time, so a stored UTC wall-clock
2175
+ * shifts by the host offset on a non-UTC runtime — the same class of bug
2176
+ * ADR-0074 fixed for the builtin audit columns. Emit a canonical instead:
2177
+ * - datetime → ISO-8601 with explicit `Z` (`2026-06-26T10:34:13.891Z`),
2178
+ * matching `new Date().toISOString()` and the value
2179
+ * `formatInput`'s `NOW()` safety-net writes;
2180
+ * - date → `YYYY-MM-DD` UTC calendar day (matches `toDateOnly`, so the
2181
+ * stored default already equals what an explicit write stores);
2182
+ * - time → `HH:MM:SS.fff` UTC time-of-day (not a full timestamp).
2183
+ *
2184
+ * NOTE: a DDL default only governs NEWLY-created columns. An existing column
2185
+ * keeps its legacy `CURRENT_TIMESTAMP` default and still emits naive text on a
2186
+ * defaulted insert; `formatOutput` repairs those to canonical on read
2187
+ * (`normalizeSqliteDatetimeOutput` for datetime, `toDateOnly` for date), so
2188
+ * reads are uniform without a schema migration.
2189
+ */
2190
+ nowColumnDefault(type) {
2191
+ if (!this.isSqlite) return this.knex.fn.now();
2192
+ switch (type) {
2193
+ case "date":
2194
+ return this.knex.raw("(strftime('%Y-%m-%d', 'now'))");
2195
+ case "time":
2196
+ return this.knex.raw("(strftime('%H:%M:%f', 'now'))");
2197
+ // datetime (and any non-temporal field that opts into NOW()): canonical instant.
2198
+ default:
2199
+ return this.knex.raw("(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))");
2200
+ }
2201
+ }
2075
2202
  createColumn(table, name, field) {
2076
2203
  if (field.multiple) {
2077
2204
  table.json(name);
@@ -2129,7 +2256,12 @@ var SqlDriver = class {
2129
2256
  case "time":
2130
2257
  col = table.time(name);
2131
2258
  break;
2259
+ // `user` is a lookup specialized to sys_user (ADR: lookup → sys_user). Same
2260
+ // physical storage as any lookup: a string column holding the related row id
2261
+ // (multiple ⇒ JSON, handled at the top of createColumn). No bespoke storage
2262
+ // primitive — it shares this exact DDL path so reads/$expand/FK stay uniform.
2132
2263
  case "lookup":
2264
+ case "user":
2133
2265
  col = table.string(name);
2134
2266
  if (field.reference_to) {
2135
2267
  table.foreign(name).references("id").inTable(field.reference_to);
@@ -2151,12 +2283,12 @@ var SqlDriver = class {
2151
2283
  if (col) {
2152
2284
  if (field.unique) col.unique();
2153
2285
  if (field.required) col.notNullable();
2154
- if ((type === "datetime" || type === "date" || type === "time") && typeof field.defaultValue === "string" && /^now\(\)$/i.test(field.defaultValue.trim())) {
2155
- col.defaultTo(this.knex.fn.now());
2286
+ if ((type === "datetime" || type === "date" || type === "time") && isNowDefaultValue(field.defaultValue)) {
2287
+ col.defaultTo(this.nowColumnDefault(type));
2156
2288
  } else if (field.defaultValue !== void 0 && field.defaultValue !== null) {
2157
2289
  const dv = field.defaultValue;
2158
- if (typeof dv === "string" && /^now\(\)$/i.test(dv.trim())) {
2159
- col.defaultTo(this.knex.fn.now());
2290
+ if (isNowDefaultValue(dv)) {
2291
+ col.defaultTo(this.nowColumnDefault(type));
2160
2292
  } else if (typeof dv !== "object") {
2161
2293
  col.defaultTo(dv);
2162
2294
  }
@@ -2330,6 +2462,17 @@ var SqlDriver = class {
2330
2462
  }
2331
2463
  }
2332
2464
  }
2465
+ for (const col of AUDIT_TIMESTAMP_COLUMNS) {
2466
+ if (data[col] !== void 0) data[col] = repairNaiveUtcAuditTimestamp(data[col]);
2467
+ }
2468
+ const datetimeFields = this.datetimeFields[object];
2469
+ if (datetimeFields && datetimeFields.size > 0) {
2470
+ for (const field of datetimeFields) {
2471
+ if (data[field] !== void 0) {
2472
+ data[field] = normalizeSqliteDatetimeOutput(data[field]);
2473
+ }
2474
+ }
2475
+ }
2333
2476
  }
2334
2477
  const dateFields = this.dateFields[object];
2335
2478
  if (dateFields && dateFields.size > 0) {
@@ -2340,6 +2483,15 @@ var SqlDriver = class {
2340
2483
  if (normalized !== v) data[field] = normalized;
2341
2484
  }
2342
2485
  }
2486
+ const timeFields = this.timeFields[object];
2487
+ if (timeFields && timeFields.size > 0) {
2488
+ for (const field of timeFields) {
2489
+ const v = data[field];
2490
+ if (v == null) continue;
2491
+ const normalized = this.toTimeOnly(v);
2492
+ if (normalized !== v) data[field] = normalized;
2493
+ }
2494
+ }
2343
2495
  return data;
2344
2496
  }
2345
2497
  // ── Introspection internals ─────────────────────────────────────────────────