@objectstack/driver-sql 10.3.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/LICENSE +202 -93
- package/dist/index.d.mts +59 -0
- package/dist/index.d.ts +59 -0
- package/dist/index.js +166 -14
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +166 -14
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -3
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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") &&
|
|
2155
|
-
col.defaultTo(this.
|
|
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 (
|
|
2159
|
-
col.defaultTo(this.
|
|
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 ─────────────────────────────────────────────────
|