@objectstack/driver-sql 10.2.0 → 11.0.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
@@ -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);
@@ -1047,6 +1108,37 @@ var SqlDriver = class {
1047
1108
  this.assertSchemaMutable("dropTable");
1048
1109
  await this.knex.schema.dropTableIfExists(object);
1049
1110
  }
1111
+ /**
1112
+ * Resolve the per-table tenant-isolation column for a schema, honoring an
1113
+ * explicit tenancy opt-out. Single source of truth for both {@link initObjects}
1114
+ * and {@link registerExternalObject} (they previously inlined this logic and
1115
+ * drifted).
1116
+ *
1117
+ * Precedence:
1118
+ * 1. `tenancy.enabled === false` → `null` (NO driver-level org scope), even
1119
+ * when the object carries an `organization_id` column. Platform-global
1120
+ * objects (e.g. `sys_license`) keep an optional, often-NULL org FK but must
1121
+ * NOT be tenant-scoped: otherwise an authenticated caller's active-org
1122
+ * `DriverOptions.tenantId` injects `WHERE organization_id = <org>` and every
1123
+ * NULL-org / cross-org row silently disappears (the platform admin then
1124
+ * reads zero licenses while an unscoped/anonymous read still sees them).
1125
+ * The declarative branch below already respected `enabled !== false`; the
1126
+ * implicit `organization_id` fallback did not — this closes that gap.
1127
+ * 2. Declared `tenancy.tenantField` (when that field exists on the object).
1128
+ * 3. Implicit `organization_id` column detection (legacy objects whose
1129
+ * multi-tenant column was injected by the kernel without a spec migration).
1130
+ */
1131
+ computeTenantField(schema) {
1132
+ const tenancyDecl = schema?.tenancy;
1133
+ if (tenancyDecl?.enabled === false) return null;
1134
+ const fields = schema?.fields;
1135
+ if (tenancyDecl?.tenantField) {
1136
+ const declared = String(tenancyDecl.tenantField);
1137
+ if (fields && Object.prototype.hasOwnProperty.call(fields, declared)) return declared;
1138
+ }
1139
+ if (fields && Object.prototype.hasOwnProperty.call(fields, "organization_id")) return "organization_id";
1140
+ return null;
1141
+ }
1050
1142
  /**
1051
1143
  * Batch-initialise tables from an array of object definitions.
1052
1144
  */
@@ -1097,19 +1189,9 @@ var SqlDriver = class {
1097
1189
  const numericCols = [];
1098
1190
  const dateCols = [];
1099
1191
  const datetimeCols = [];
1192
+ const timeCols = [];
1100
1193
  const autoNumberCols = [];
1101
- const tenancyDecl = schema?.tenancy;
1102
- let tenantField = null;
1103
- if (tenancyDecl && tenancyDecl.enabled !== false && tenancyDecl.tenantField) {
1104
- const declared = String(tenancyDecl.tenantField);
1105
- if (schema.fields && Object.prototype.hasOwnProperty.call(schema.fields, declared)) {
1106
- tenantField = declared;
1107
- }
1108
- }
1109
- if (!tenantField) {
1110
- const hasOrgField = !!(schema.fields && Object.prototype.hasOwnProperty.call(schema.fields, "organization_id"));
1111
- tenantField = hasOrgField ? "organization_id" : null;
1112
- }
1194
+ const tenantField = this.computeTenantField(schema);
1113
1195
  if (schema.fields) {
1114
1196
  for (const [name, field] of Object.entries(schema.fields)) {
1115
1197
  const type = field.type || "string";
@@ -1118,6 +1200,7 @@ var SqlDriver = class {
1118
1200
  if (NUMERIC_SCALAR_TYPES.has(type) && !field.multiple) numericCols.push(name);
1119
1201
  if (type === "date") dateCols.push(name);
1120
1202
  if (type === "datetime") datetimeCols.push(name);
1203
+ if (type === "time") timeCols.push(name);
1121
1204
  if (type === "auto_number" || type === "autonumber") {
1122
1205
  const rawFmt = typeof field.autonumberFormat === "string" && field.autonumberFormat ? field.autonumberFormat : typeof field.format === "string" && field.format ? field.format : "";
1123
1206
  const fmt = rawFmt || "{0000}";
@@ -1132,9 +1215,10 @@ var SqlDriver = class {
1132
1215
  this.tenantFieldByTable[key] = tenantField;
1133
1216
  if (dateCols.length) this.dateFields[key] = new Set(dateCols);
1134
1217
  if (datetimeCols.length) this.datetimeFields[key] = new Set(datetimeCols);
1218
+ if (timeCols.length) this.timeFields[key] = new Set(timeCols);
1135
1219
  }
1136
1220
  async initObjects(objects) {
1137
- var _a, _b;
1221
+ var _a, _b, _c;
1138
1222
  this.assertSchemaMutable("initObjects");
1139
1223
  await this.ensureDatabaseExists();
1140
1224
  for (const obj of objects) {
@@ -1147,18 +1231,7 @@ var SqlDriver = class {
1147
1231
  const booleanCols = [];
1148
1232
  const numericCols = [];
1149
1233
  const autoNumberCols = [];
1150
- const tenancyDecl = obj?.tenancy;
1151
- let tenantField = null;
1152
- if (tenancyDecl && tenancyDecl.enabled !== false && tenancyDecl.tenantField) {
1153
- const declared = String(tenancyDecl.tenantField);
1154
- if (obj.fields && Object.prototype.hasOwnProperty.call(obj.fields, declared)) {
1155
- tenantField = declared;
1156
- }
1157
- }
1158
- if (!tenantField) {
1159
- const hasOrgField = !!(obj.fields && Object.prototype.hasOwnProperty.call(obj.fields, "organization_id"));
1160
- tenantField = hasOrgField ? "organization_id" : null;
1161
- }
1234
+ const tenantField = this.computeTenantField(obj);
1162
1235
  if (obj.fields) {
1163
1236
  for (const [name, field] of Object.entries(obj.fields)) {
1164
1237
  const type = field.type || "string";
@@ -1177,6 +1250,9 @@ var SqlDriver = class {
1177
1250
  if (type === "datetime") {
1178
1251
  ((_b = this.datetimeFields)[tableName] ?? (_b[tableName] = /* @__PURE__ */ new Set())).add(name);
1179
1252
  }
1253
+ if (type === "time") {
1254
+ ((_c = this.timeFields)[tableName] ?? (_c[tableName] = /* @__PURE__ */ new Set())).add(name);
1255
+ }
1180
1256
  if (type === "auto_number" || type === "autonumber") {
1181
1257
  const rawFmt = typeof field.autonumberFormat === "string" && field.autonumberFormat ? field.autonumberFormat : typeof field.format === "string" && field.format ? field.format : "";
1182
1258
  const fmt = rawFmt || "{0000}";
@@ -1662,8 +1738,7 @@ var SqlDriver = class {
1662
1738
  }
1663
1739
  isMultiTenantMode() {
1664
1740
  if (this._multiTenantMode === void 0) {
1665
- const raw = process.env.OS_MULTI_ORG_ENABLED ?? process.env.OS_MULTI_TENANT ?? "false";
1666
- this._multiTenantMode = String(raw).toLowerCase() !== "false";
1741
+ this._multiTenantMode = resolveMultiOrgEnabled();
1667
1742
  }
1668
1743
  return this._multiTenantMode;
1669
1744
  }
@@ -1779,6 +1854,32 @@ var SqlDriver = class {
1779
1854
  }
1780
1855
  return value;
1781
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
+ }
1782
1883
  /**
1783
1884
  * Normalise a filter value for a single column so the comparison the
1784
1885
  * driver sends to SQLite matches the on-disk representation.
@@ -2063,6 +2164,41 @@ var SqlDriver = class {
2063
2164
  return sql;
2064
2165
  }
2065
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
+ }
2066
2202
  createColumn(table, name, field) {
2067
2203
  if (field.multiple) {
2068
2204
  table.json(name);
@@ -2120,7 +2256,12 @@ var SqlDriver = class {
2120
2256
  case "time":
2121
2257
  col = table.time(name);
2122
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.
2123
2263
  case "lookup":
2264
+ case "user":
2124
2265
  col = table.string(name);
2125
2266
  if (field.reference_to) {
2126
2267
  table.foreign(name).references("id").inTable(field.reference_to);
@@ -2142,12 +2283,12 @@ var SqlDriver = class {
2142
2283
  if (col) {
2143
2284
  if (field.unique) col.unique();
2144
2285
  if (field.required) col.notNullable();
2145
- if ((type === "datetime" || type === "date" || type === "time") && typeof field.defaultValue === "string" && /^now\(\)$/i.test(field.defaultValue.trim())) {
2146
- col.defaultTo(this.knex.fn.now());
2286
+ if ((type === "datetime" || type === "date" || type === "time") && isNowDefaultValue(field.defaultValue)) {
2287
+ col.defaultTo(this.nowColumnDefault(type));
2147
2288
  } else if (field.defaultValue !== void 0 && field.defaultValue !== null) {
2148
2289
  const dv = field.defaultValue;
2149
- if (typeof dv === "string" && /^now\(\)$/i.test(dv.trim())) {
2150
- col.defaultTo(this.knex.fn.now());
2290
+ if (isNowDefaultValue(dv)) {
2291
+ col.defaultTo(this.nowColumnDefault(type));
2151
2292
  } else if (typeof dv !== "object") {
2152
2293
  col.defaultTo(dv);
2153
2294
  }
@@ -2321,6 +2462,17 @@ var SqlDriver = class {
2321
2462
  }
2322
2463
  }
2323
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
+ }
2324
2476
  }
2325
2477
  const dateFields = this.dateFields[object];
2326
2478
  if (dateFields && dateFields.size > 0) {
@@ -2331,6 +2483,15 @@ var SqlDriver = class {
2331
2483
  if (normalized !== v) data[field] = normalized;
2332
2484
  }
2333
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
+ }
2334
2495
  return data;
2335
2496
  }
2336
2497
  // ── Introspection internals ─────────────────────────────────────────────────