@objectstack/driver-sql 9.11.0 → 10.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
@@ -49,6 +49,20 @@ var SqlDriver = class {
49
49
  this.numericFields = {};
50
50
  this.dateFields = {};
51
51
  this.datetimeFields = {};
52
+ /**
53
+ * Federation read path (ADR-0015). For external objects whose physical
54
+ * remote table differs from the object name, these map between the two so
55
+ * {@link getBuilder} targets the remote table while the coercion maps above
56
+ * stay keyed by OBJECT name (matching formatInput/formatOutput). Empty for
57
+ * managed objects, so the managed query path is unchanged.
58
+ */
59
+ this.physicalTableByObject = {};
60
+ this.physicalSchemaByObject = {};
61
+ this.objectByPhysicalTable = {};
62
+ /** External columnMap (ADR-0015): logical field -> physical remote column (for WHERE/ORDER BY/writes). */
63
+ this.fieldColumnByObject = {};
64
+ /** External columnMap inverse: physical remote column -> logical field (for read output remap). */
65
+ this.columnFieldByObject = {};
52
66
  this.tablesWithTimestamps = /* @__PURE__ */ new Set();
53
67
  /**
54
68
  * Autonumber field configs per table, captured during initObjects.
@@ -273,6 +287,13 @@ var SqlDriver = class {
273
287
  // ===================================
274
288
  async connect() {
275
289
  await this.ensureDatabaseExists();
290
+ if (this.isSqlite) {
291
+ try {
292
+ await this.knex.raw("PRAGMA auto_vacuum = INCREMENTAL");
293
+ } catch (e) {
294
+ this.logger.warn("Failed to set PRAGMA auto_vacuum=INCREMENTAL", e);
295
+ }
296
+ }
276
297
  }
277
298
  async checkHealth() {
278
299
  try {
@@ -298,7 +319,7 @@ var SqlDriver = class {
298
319
  if (query.orderBy && Array.isArray(query.orderBy)) {
299
320
  for (const item of query.orderBy) {
300
321
  if (item.field) {
301
- b.orderBy(this.mapSortField(item.field), item.order || "asc");
322
+ b.orderBy(this.remoteColumn(object, item.field, this.mapSortField(item.field)), item.order || "asc");
302
323
  }
303
324
  }
304
325
  }
@@ -375,7 +396,7 @@ var SqlDriver = class {
375
396
  this.injectTenantOnInsert(object, toInsert, options);
376
397
  await this.fillAutoNumberFields(object, toInsert, options);
377
398
  const builder = this.getBuilder(object, options);
378
- const formatted = this.formatInput(object, toInsert);
399
+ const formatted = this.applyWriteColumnMap(object, this.formatInput(object, toInsert));
379
400
  const result = await builder.insert(formatted).returning("*");
380
401
  return this.formatOutput(object, result[0]);
381
402
  }
@@ -572,8 +593,8 @@ var SqlDriver = class {
572
593
  * tenant scopes it for isolation.
573
594
  */
574
595
  async fillAutoNumberFields(object, row, options) {
575
- const tableName = StorageNameMapping.resolveTableName({ name: object });
576
- const cfgs = this.autoNumberFields[tableName] || this.autoNumberFields[object];
596
+ const tableName = this.physicalTableByObject[object] ?? StorageNameMapping.resolveTableName({ name: object });
597
+ const cfgs = this.autoNumberFields[object] || this.autoNumberFields[tableName];
577
598
  if (!cfgs || cfgs.length === 0) return;
578
599
  const parentTrx = options?.transaction;
579
600
  const timezone = options?.timezone;
@@ -607,7 +628,7 @@ var SqlDriver = class {
607
628
  this.auditMissingTenant(object, "update", options);
608
629
  const builder = this.getBuilder(object, options).where("id", id);
609
630
  this.applyTenantScope(builder, object, options);
610
- const formatted = this.formatInput(object, data);
631
+ const formatted = this.applyWriteColumnMap(object, this.formatInput(object, data));
611
632
  if (this.tablesWithTimestamps.has(object)) {
612
633
  if (this.isSqlite) {
613
634
  const now = /* @__PURE__ */ new Date();
@@ -633,7 +654,7 @@ var SqlDriver = class {
633
654
  this.auditMissingTenant(object, "upsert", options);
634
655
  this.injectTenantOnInsert(object, toUpsert, options);
635
656
  await this.fillAutoNumberFields(object, toUpsert, options);
636
- const formatted = this.formatInput(object, toUpsert);
657
+ const formatted = this.applyWriteColumnMap(object, this.formatInput(object, toUpsert));
637
658
  const mergeKeys = conflictKeys && conflictKeys.length > 0 ? conflictKeys : ["id"];
638
659
  const builder = this.getBuilder(object, options);
639
660
  await builder.insert(formatted).onConflict(mergeKeys).merge();
@@ -916,6 +937,89 @@ var SqlDriver = class {
916
937
  /**
917
938
  * Batch-initialise tables from an array of object definitions.
918
939
  */
940
+ /**
941
+ * DDL-free metadata registration for a federated (external) object — the
942
+ * read-path counterpart to {@link initObjects} (ADR-0015 federation).
943
+ *
944
+ * `initObjects` is gated by `assertSchemaMutable` and therefore throws for
945
+ * any non-`managed` driver, which left external objects with NO read-coercion
946
+ * metadata and the query path resolving to a table named after the object
947
+ * instead of its remote table. This populates the same coercion maps (keyed
948
+ * by OBJECT name, matching formatInput/formatOutput/coerceFilterValue) and
949
+ * records the physical remote table (`external.remoteName`, optionally
950
+ * `external.remoteSchema`) so {@link getBuilder} targets it — WITHOUT running
951
+ * any DDL (createTable/alterTable/columnInfo). Keep the field-classification
952
+ * below in sync with initObjects() if the field-type -> storage mapping changes.
953
+ */
954
+ registerExternalObject(schema) {
955
+ const key = schema.name;
956
+ const remoteName = schema.external?.remoteName || schema.name;
957
+ const remoteSchema = schema.external?.remoteSchema;
958
+ this.physicalTableByObject[key] = remoteName;
959
+ this.objectByPhysicalTable[remoteName] = key;
960
+ if (remoteSchema) {
961
+ if (this.isSqlite) {
962
+ this.logger.warn(
963
+ `[sql-driver] external object "${key}" declares remoteSchema="${remoteSchema}" but SQLite has no schema namespace; ignoring (treating "${remoteName}" as a bare table).`
964
+ );
965
+ } else {
966
+ this.physicalSchemaByObject[key] = remoteSchema;
967
+ }
968
+ }
969
+ const columnMap = schema.external?.columnMap;
970
+ if (columnMap && typeof columnMap === "object" && Object.keys(columnMap).length > 0) {
971
+ const fieldToCol = {};
972
+ const colToField = {};
973
+ for (const [remoteCol, localField] of Object.entries(columnMap)) {
974
+ if (typeof localField === "string" && localField) {
975
+ fieldToCol[localField] = remoteCol;
976
+ colToField[remoteCol] = localField;
977
+ }
978
+ }
979
+ this.fieldColumnByObject[key] = fieldToCol;
980
+ this.columnFieldByObject[key] = colToField;
981
+ }
982
+ const jsonCols = [];
983
+ const booleanCols = [];
984
+ const numericCols = [];
985
+ const dateCols = [];
986
+ const datetimeCols = [];
987
+ const autoNumberCols = [];
988
+ const tenancyDecl = schema?.tenancy;
989
+ let tenantField = null;
990
+ if (tenancyDecl && tenancyDecl.enabled !== false && tenancyDecl.tenantField) {
991
+ const declared = String(tenancyDecl.tenantField);
992
+ if (schema.fields && Object.prototype.hasOwnProperty.call(schema.fields, declared)) {
993
+ tenantField = declared;
994
+ }
995
+ }
996
+ if (!tenantField) {
997
+ const hasOrgField = !!(schema.fields && Object.prototype.hasOwnProperty.call(schema.fields, "organization_id"));
998
+ tenantField = hasOrgField ? "organization_id" : null;
999
+ }
1000
+ if (schema.fields) {
1001
+ for (const [name, field] of Object.entries(schema.fields)) {
1002
+ const type = field.type || "string";
1003
+ if (this.isJsonField(type, field)) jsonCols.push(name);
1004
+ if (type === "boolean" || type === "toggle") booleanCols.push(name);
1005
+ if (NUMERIC_SCALAR_TYPES.has(type) && !field.multiple) numericCols.push(name);
1006
+ if (type === "date") dateCols.push(name);
1007
+ if (type === "datetime") datetimeCols.push(name);
1008
+ if (type === "auto_number" || type === "autonumber") {
1009
+ const rawFmt = typeof field.autonumberFormat === "string" && field.autonumberFormat ? field.autonumberFormat : typeof field.format === "string" && field.format ? field.format : "";
1010
+ const fmt = rawFmt || "{0000}";
1011
+ autoNumberCols.push({ name, format: fmt, tokens: parseAutonumberFormat(fmt), tenantField });
1012
+ }
1013
+ }
1014
+ }
1015
+ this.jsonFields[key] = jsonCols;
1016
+ this.booleanFields[key] = booleanCols;
1017
+ this.numericFields[key] = numericCols;
1018
+ this.autoNumberFields[key] = autoNumberCols;
1019
+ this.tenantFieldByTable[key] = tenantField;
1020
+ if (dateCols.length) this.dateFields[key] = new Set(dateCols);
1021
+ if (datetimeCols.length) this.datetimeFields[key] = new Set(datetimeCols);
1022
+ }
919
1023
  async initObjects(objects) {
920
1024
  var _a, _b;
921
1025
  this.assertSchemaMutable("initObjects");
@@ -1157,7 +1261,12 @@ var SqlDriver = class {
1157
1261
  return this.knex;
1158
1262
  }
1159
1263
  getBuilder(object, options) {
1160
- let builder = this.knex(object);
1264
+ const physical = this.physicalTableByObject[object] ?? object;
1265
+ let builder = this.knex(physical);
1266
+ const remoteSchema = this.physicalSchemaByObject[object];
1267
+ if (remoteSchema) {
1268
+ builder = builder.withSchema(remoteSchema);
1269
+ }
1161
1270
  if (options?.transaction) {
1162
1271
  builder = builder.transacting(options.transaction);
1163
1272
  }
@@ -1256,6 +1365,20 @@ var SqlDriver = class {
1256
1365
  if (typeof t === "string") return t;
1257
1366
  return null;
1258
1367
  }
1368
+ /**
1369
+ * Coercion-map key for a builder. Coercion maps (date/datetime) are keyed by
1370
+ * OBJECT name, but after the federation change {@link getBuilder} targets the
1371
+ * physical remote table, so a builder reports the remote name. Map it back to
1372
+ * the object name for external objects; identity for managed ones (no reverse
1373
+ * entry). Note datetime coercion is a SQLite-only concern (see
1374
+ * coerceFilterValue), and SQLite external tables are bare-named, so this is
1375
+ * exact where it matters.
1376
+ */
1377
+ coercionKey(builder) {
1378
+ const physical = this.tableNameForBuilder(builder);
1379
+ if (physical == null) return null;
1380
+ return this.objectByPhysicalTable[physical] ?? physical;
1381
+ }
1259
1382
  /**
1260
1383
  * Collapse a `Field.date` value to a timezone-naive `YYYY-MM-DD`
1261
1384
  * calendar-day string (ADR-0053 Phase 1). A `Date` collapses to its UTC
@@ -1349,7 +1472,7 @@ var SqlDriver = class {
1349
1472
  }
1350
1473
  applyFilters(builder, filters) {
1351
1474
  if (!filters) return;
1352
- const table = this.tableNameForBuilder(builder);
1475
+ const table = this.coercionKey(builder);
1353
1476
  if (!Array.isArray(filters) && typeof filters === "object") {
1354
1477
  const hasMongoOperators = Object.keys(filters).some(
1355
1478
  (k) => k.startsWith("$") || typeof filters[k] === "object" && filters[k] !== null && Object.keys(filters[k]).some((op) => op.startsWith("$"))
@@ -1360,7 +1483,7 @@ var SqlDriver = class {
1360
1483
  }
1361
1484
  for (const [key, value] of Object.entries(filters)) {
1362
1485
  if (["limit", "offset", "fields", "orderBy"].includes(key)) continue;
1363
- builder.where(key, this.coerceFilterValue(table, key, value));
1486
+ builder.where(this.remoteColumn(table, key, key), this.coerceFilterValue(table, key, value));
1364
1487
  }
1365
1488
  return;
1366
1489
  }
@@ -1376,8 +1499,9 @@ var SqlDriver = class {
1376
1499
  const [fieldRaw, op, value] = item;
1377
1500
  const isCriterion = typeof fieldRaw === "string" && typeof op === "string";
1378
1501
  if (isCriterion) {
1379
- const field = this.mapSortField(fieldRaw);
1380
- const coerced = this.coerceFilterValue(table, field, value);
1502
+ const localField = this.mapSortField(fieldRaw);
1503
+ const field = this.remoteColumn(table, fieldRaw, localField);
1504
+ const coerced = this.coerceFilterValue(table, localField, value);
1381
1505
  const apply = (b) => {
1382
1506
  const method = nextJoin === "or" ? "orWhere" : "where";
1383
1507
  const methodIn = nextJoin === "or" ? "orWhereIn" : "whereIn";
@@ -1429,7 +1553,7 @@ var SqlDriver = class {
1429
1553
  }
1430
1554
  applyFilterCondition(builder, condition, logicalOp = "and", tableHint) {
1431
1555
  if (!condition || typeof condition !== "object") return;
1432
- const table = tableHint ?? this.tableNameForBuilder(builder);
1556
+ const table = tableHint ?? this.coercionKey(builder);
1433
1557
  for (const [key, value] of Object.entries(condition)) {
1434
1558
  if (key === "$and" && Array.isArray(value)) {
1435
1559
  builder.where((qb) => {
@@ -1449,10 +1573,11 @@ var SqlDriver = class {
1449
1573
  }
1450
1574
  });
1451
1575
  } else if (typeof value === "object" && value !== null && !Array.isArray(value)) {
1452
- const field = this.mapSortField(key);
1576
+ const localField = this.mapSortField(key);
1577
+ const field = this.remoteColumn(table, key, localField);
1453
1578
  for (const [op, opValue] of Object.entries(value)) {
1454
1579
  const method = logicalOp === "or" ? "orWhere" : "where";
1455
- const coerced = this.coerceFilterValue(table, field, opValue);
1580
+ const coerced = this.coerceFilterValue(table, localField, opValue);
1456
1581
  switch (op) {
1457
1582
  case "$eq":
1458
1583
  builder[method](field, coerced);
@@ -1490,9 +1615,10 @@ var SqlDriver = class {
1490
1615
  }
1491
1616
  }
1492
1617
  } else {
1493
- const field = this.mapSortField(key);
1618
+ const localField = this.mapSortField(key);
1619
+ const field = this.remoteColumn(table, key, localField);
1494
1620
  const method = logicalOp === "or" ? "orWhere" : "where";
1495
- builder[method](field, this.coerceFilterValue(table, field, value));
1621
+ builder[method](field, this.coerceFilterValue(table, localField, value));
1496
1622
  }
1497
1623
  }
1498
1624
  }
@@ -1502,6 +1628,28 @@ var SqlDriver = class {
1502
1628
  if (field === "updatedAt") return "updated_at";
1503
1629
  return field;
1504
1630
  }
1631
+ /**
1632
+ * Physical column for a logical field on an external object that declares an
1633
+ * `external.columnMap` (ADR-0015). Returns `fallback` (the caller's existing
1634
+ * per-site resolution) when the object has no columnMap, so managed objects
1635
+ * and external objects without a columnMap are byte-for-byte unchanged.
1636
+ */
1637
+ remoteColumn(object, field, fallback) {
1638
+ const m = object ? this.fieldColumnByObject[object] : void 0;
1639
+ return m && m[field] || fallback;
1640
+ }
1641
+ /**
1642
+ * Remap a write payload's logical field keys to physical remote columns for an
1643
+ * external object with a columnMap. No-op otherwise. Applied AFTER formatInput
1644
+ * (whose value coercion is keyed by logical field name).
1645
+ */
1646
+ applyWriteColumnMap(object, data) {
1647
+ const m = this.fieldColumnByObject[object];
1648
+ if (!m || !data || typeof data !== "object") return data;
1649
+ const out = {};
1650
+ for (const [k, v] of Object.entries(data)) out[m[k] ?? k] = v;
1651
+ return out;
1652
+ }
1505
1653
  mapAggregateFunc(func) {
1506
1654
  switch (func) {
1507
1655
  case "count":
@@ -1758,6 +1906,15 @@ var SqlDriver = class {
1758
1906
  }
1759
1907
  formatOutput(object, data) {
1760
1908
  if (!data) return data;
1909
+ const colToField = this.columnFieldByObject[object];
1910
+ if (colToField && typeof data === "object") {
1911
+ for (const [remoteCol, localField] of Object.entries(colToField)) {
1912
+ if (remoteCol !== localField && Object.prototype.hasOwnProperty.call(data, remoteCol)) {
1913
+ data[localField] = data[remoteCol];
1914
+ delete data[remoteCol];
1915
+ }
1916
+ }
1917
+ }
1761
1918
  if (this.isSqlite) {
1762
1919
  const jsonFields = this.jsonFields[object];
1763
1920
  if (jsonFields && jsonFields.length > 0) {