@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.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
@@ -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);
@@ -1088,6 +1149,37 @@ var SqlDriver = class {
1088
1149
  this.assertSchemaMutable("dropTable");
1089
1150
  await this.knex.schema.dropTableIfExists(object);
1090
1151
  }
1152
+ /**
1153
+ * Resolve the per-table tenant-isolation column for a schema, honoring an
1154
+ * explicit tenancy opt-out. Single source of truth for both {@link initObjects}
1155
+ * and {@link registerExternalObject} (they previously inlined this logic and
1156
+ * drifted).
1157
+ *
1158
+ * Precedence:
1159
+ * 1. `tenancy.enabled === false` → `null` (NO driver-level org scope), even
1160
+ * when the object carries an `organization_id` column. Platform-global
1161
+ * objects (e.g. `sys_license`) keep an optional, often-NULL org FK but must
1162
+ * NOT be tenant-scoped: otherwise an authenticated caller's active-org
1163
+ * `DriverOptions.tenantId` injects `WHERE organization_id = <org>` and every
1164
+ * NULL-org / cross-org row silently disappears (the platform admin then
1165
+ * reads zero licenses while an unscoped/anonymous read still sees them).
1166
+ * The declarative branch below already respected `enabled !== false`; the
1167
+ * implicit `organization_id` fallback did not — this closes that gap.
1168
+ * 2. Declared `tenancy.tenantField` (when that field exists on the object).
1169
+ * 3. Implicit `organization_id` column detection (legacy objects whose
1170
+ * multi-tenant column was injected by the kernel without a spec migration).
1171
+ */
1172
+ computeTenantField(schema) {
1173
+ const tenancyDecl = schema?.tenancy;
1174
+ if (tenancyDecl?.enabled === false) return null;
1175
+ const fields = schema?.fields;
1176
+ if (tenancyDecl?.tenantField) {
1177
+ const declared = String(tenancyDecl.tenantField);
1178
+ if (fields && Object.prototype.hasOwnProperty.call(fields, declared)) return declared;
1179
+ }
1180
+ if (fields && Object.prototype.hasOwnProperty.call(fields, "organization_id")) return "organization_id";
1181
+ return null;
1182
+ }
1091
1183
  /**
1092
1184
  * Batch-initialise tables from an array of object definitions.
1093
1185
  */
@@ -1138,19 +1230,9 @@ var SqlDriver = class {
1138
1230
  const numericCols = [];
1139
1231
  const dateCols = [];
1140
1232
  const datetimeCols = [];
1233
+ const timeCols = [];
1141
1234
  const autoNumberCols = [];
1142
- const tenancyDecl = schema?.tenancy;
1143
- let tenantField = null;
1144
- if (tenancyDecl && tenancyDecl.enabled !== false && tenancyDecl.tenantField) {
1145
- const declared = String(tenancyDecl.tenantField);
1146
- if (schema.fields && Object.prototype.hasOwnProperty.call(schema.fields, declared)) {
1147
- tenantField = declared;
1148
- }
1149
- }
1150
- if (!tenantField) {
1151
- const hasOrgField = !!(schema.fields && Object.prototype.hasOwnProperty.call(schema.fields, "organization_id"));
1152
- tenantField = hasOrgField ? "organization_id" : null;
1153
- }
1235
+ const tenantField = this.computeTenantField(schema);
1154
1236
  if (schema.fields) {
1155
1237
  for (const [name, field] of Object.entries(schema.fields)) {
1156
1238
  const type = field.type || "string";
@@ -1159,6 +1241,7 @@ var SqlDriver = class {
1159
1241
  if (NUMERIC_SCALAR_TYPES.has(type) && !field.multiple) numericCols.push(name);
1160
1242
  if (type === "date") dateCols.push(name);
1161
1243
  if (type === "datetime") datetimeCols.push(name);
1244
+ if (type === "time") timeCols.push(name);
1162
1245
  if (type === "auto_number" || type === "autonumber") {
1163
1246
  const rawFmt = typeof field.autonumberFormat === "string" && field.autonumberFormat ? field.autonumberFormat : typeof field.format === "string" && field.format ? field.format : "";
1164
1247
  const fmt = rawFmt || "{0000}";
@@ -1173,9 +1256,10 @@ var SqlDriver = class {
1173
1256
  this.tenantFieldByTable[key] = tenantField;
1174
1257
  if (dateCols.length) this.dateFields[key] = new Set(dateCols);
1175
1258
  if (datetimeCols.length) this.datetimeFields[key] = new Set(datetimeCols);
1259
+ if (timeCols.length) this.timeFields[key] = new Set(timeCols);
1176
1260
  }
1177
1261
  async initObjects(objects) {
1178
- var _a, _b;
1262
+ var _a, _b, _c;
1179
1263
  this.assertSchemaMutable("initObjects");
1180
1264
  await this.ensureDatabaseExists();
1181
1265
  for (const obj of objects) {
@@ -1188,18 +1272,7 @@ var SqlDriver = class {
1188
1272
  const booleanCols = [];
1189
1273
  const numericCols = [];
1190
1274
  const autoNumberCols = [];
1191
- const tenancyDecl = obj?.tenancy;
1192
- let tenantField = null;
1193
- if (tenancyDecl && tenancyDecl.enabled !== false && tenancyDecl.tenantField) {
1194
- const declared = String(tenancyDecl.tenantField);
1195
- if (obj.fields && Object.prototype.hasOwnProperty.call(obj.fields, declared)) {
1196
- tenantField = declared;
1197
- }
1198
- }
1199
- if (!tenantField) {
1200
- const hasOrgField = !!(obj.fields && Object.prototype.hasOwnProperty.call(obj.fields, "organization_id"));
1201
- tenantField = hasOrgField ? "organization_id" : null;
1202
- }
1275
+ const tenantField = this.computeTenantField(obj);
1203
1276
  if (obj.fields) {
1204
1277
  for (const [name, field] of Object.entries(obj.fields)) {
1205
1278
  const type = field.type || "string";
@@ -1218,6 +1291,9 @@ var SqlDriver = class {
1218
1291
  if (type === "datetime") {
1219
1292
  ((_b = this.datetimeFields)[tableName] ?? (_b[tableName] = /* @__PURE__ */ new Set())).add(name);
1220
1293
  }
1294
+ if (type === "time") {
1295
+ ((_c = this.timeFields)[tableName] ?? (_c[tableName] = /* @__PURE__ */ new Set())).add(name);
1296
+ }
1221
1297
  if (type === "auto_number" || type === "autonumber") {
1222
1298
  const rawFmt = typeof field.autonumberFormat === "string" && field.autonumberFormat ? field.autonumberFormat : typeof field.format === "string" && field.format ? field.format : "";
1223
1299
  const fmt = rawFmt || "{0000}";
@@ -1703,8 +1779,7 @@ var SqlDriver = class {
1703
1779
  }
1704
1780
  isMultiTenantMode() {
1705
1781
  if (this._multiTenantMode === void 0) {
1706
- const raw = process.env.OS_MULTI_ORG_ENABLED ?? process.env.OS_MULTI_TENANT ?? "false";
1707
- this._multiTenantMode = String(raw).toLowerCase() !== "false";
1782
+ this._multiTenantMode = (0, import_types.resolveMultiOrgEnabled)();
1708
1783
  }
1709
1784
  return this._multiTenantMode;
1710
1785
  }
@@ -1820,6 +1895,32 @@ var SqlDriver = class {
1820
1895
  }
1821
1896
  return value;
1822
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
+ }
1823
1924
  /**
1824
1925
  * Normalise a filter value for a single column so the comparison the
1825
1926
  * driver sends to SQLite matches the on-disk representation.
@@ -2104,6 +2205,41 @@ var SqlDriver = class {
2104
2205
  return sql;
2105
2206
  }
2106
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
+ }
2107
2243
  createColumn(table, name, field) {
2108
2244
  if (field.multiple) {
2109
2245
  table.json(name);
@@ -2161,7 +2297,12 @@ var SqlDriver = class {
2161
2297
  case "time":
2162
2298
  col = table.time(name);
2163
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.
2164
2304
  case "lookup":
2305
+ case "user":
2165
2306
  col = table.string(name);
2166
2307
  if (field.reference_to) {
2167
2308
  table.foreign(name).references("id").inTable(field.reference_to);
@@ -2183,12 +2324,12 @@ var SqlDriver = class {
2183
2324
  if (col) {
2184
2325
  if (field.unique) col.unique();
2185
2326
  if (field.required) col.notNullable();
2186
- if ((type === "datetime" || type === "date" || type === "time") && typeof field.defaultValue === "string" && /^now\(\)$/i.test(field.defaultValue.trim())) {
2187
- col.defaultTo(this.knex.fn.now());
2327
+ if ((type === "datetime" || type === "date" || type === "time") && isNowDefaultValue(field.defaultValue)) {
2328
+ col.defaultTo(this.nowColumnDefault(type));
2188
2329
  } else if (field.defaultValue !== void 0 && field.defaultValue !== null) {
2189
2330
  const dv = field.defaultValue;
2190
- if (typeof dv === "string" && /^now\(\)$/i.test(dv.trim())) {
2191
- col.defaultTo(this.knex.fn.now());
2331
+ if (isNowDefaultValue(dv)) {
2332
+ col.defaultTo(this.nowColumnDefault(type));
2192
2333
  } else if (typeof dv !== "object") {
2193
2334
  col.defaultTo(dv);
2194
2335
  }
@@ -2362,6 +2503,17 @@ var SqlDriver = class {
2362
2503
  }
2363
2504
  }
2364
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
+ }
2365
2517
  }
2366
2518
  const dateFields = this.dateFields[object];
2367
2519
  if (dateFields && dateFields.size > 0) {
@@ -2372,6 +2524,15 @@ var SqlDriver = class {
2372
2524
  if (normalized !== v) data[field] = normalized;
2373
2525
  }
2374
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
+ }
2375
2536
  return data;
2376
2537
  }
2377
2538
  // ── Introspection internals ─────────────────────────────────────────────────