@objectstack/driver-sql 9.10.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.js CHANGED
@@ -36,6 +36,7 @@ __export(index_exports, {
36
36
  module.exports = __toCommonJS(index_exports);
37
37
 
38
38
  // src/sql-driver.ts
39
+ var import_data = require("@objectstack/spec/data");
39
40
  var import_system = require("@objectstack/spec/system");
40
41
  var import_shared = require("@objectstack/spec/shared");
41
42
  var import_knex = __toESM(require("knex"));
@@ -85,6 +86,20 @@ var SqlDriver = class {
85
86
  this.numericFields = {};
86
87
  this.dateFields = {};
87
88
  this.datetimeFields = {};
89
+ /**
90
+ * Federation read path (ADR-0015). For external objects whose physical
91
+ * remote table differs from the object name, these map between the two so
92
+ * {@link getBuilder} targets the remote table while the coercion maps above
93
+ * stay keyed by OBJECT name (matching formatInput/formatOutput). Empty for
94
+ * managed objects, so the managed query path is unchanged.
95
+ */
96
+ this.physicalTableByObject = {};
97
+ this.physicalSchemaByObject = {};
98
+ this.objectByPhysicalTable = {};
99
+ /** External columnMap (ADR-0015): logical field -> physical remote column (for WHERE/ORDER BY/writes). */
100
+ this.fieldColumnByObject = {};
101
+ /** External columnMap inverse: physical remote column -> logical field (for read output remap). */
102
+ this.columnFieldByObject = {};
88
103
  this.tablesWithTimestamps = /* @__PURE__ */ new Set();
89
104
  /**
90
105
  * Autonumber field configs per table, captured during initObjects.
@@ -103,6 +118,14 @@ var SqlDriver = class {
103
118
  this.autoNumberFields = {};
104
119
  /** Whether the sequences table has been ensured this process. */
105
120
  this.sequencesTableReady = false;
121
+ /**
122
+ * Whether `_objectstack_sequences` is the current `key_hash`-keyed shape.
123
+ * Set on a fresh create or a successful in-place migration. If a legacy table
124
+ * could NOT be migrated, this stays false: fixed-prefix sequences (empty
125
+ * scope) keep working via the legacy `(object, tenant_id, field)` key, while a
126
+ * per-scope write raises an actionable error rather than corrupting counters.
127
+ */
128
+ this.sequencesHasKeyHash = false;
106
129
  /** In-flight ensure promise; deduplicates concurrent first calls. */
107
130
  this.sequencesTableEnsurePromise = null;
108
131
  /**
@@ -301,6 +324,13 @@ var SqlDriver = class {
301
324
  // ===================================
302
325
  async connect() {
303
326
  await this.ensureDatabaseExists();
327
+ if (this.isSqlite) {
328
+ try {
329
+ await this.knex.raw("PRAGMA auto_vacuum = INCREMENTAL");
330
+ } catch (e) {
331
+ this.logger.warn("Failed to set PRAGMA auto_vacuum=INCREMENTAL", e);
332
+ }
333
+ }
304
334
  }
305
335
  async checkHealth() {
306
336
  try {
@@ -326,7 +356,7 @@ var SqlDriver = class {
326
356
  if (query.orderBy && Array.isArray(query.orderBy)) {
327
357
  for (const item of query.orderBy) {
328
358
  if (item.field) {
329
- b.orderBy(this.mapSortField(item.field), item.order || "asc");
359
+ b.orderBy(this.remoteColumn(object, item.field, this.mapSortField(item.field)), item.order || "asc");
330
360
  }
331
361
  }
332
362
  }
@@ -403,13 +433,22 @@ var SqlDriver = class {
403
433
  this.injectTenantOnInsert(object, toInsert, options);
404
434
  await this.fillAutoNumberFields(object, toInsert, options);
405
435
  const builder = this.getBuilder(object, options);
406
- const formatted = this.formatInput(object, toInsert);
436
+ const formatted = this.applyWriteColumnMap(object, this.formatInput(object, toInsert));
407
437
  const result = await builder.insert(formatted).returning("*");
408
438
  return this.formatOutput(object, result[0]);
409
439
  }
410
440
  /**
411
441
  * Ensure the sequence-counter table exists. Idempotent and cheap after
412
442
  * the first call (cached via `sequencesTableReady`).
443
+ *
444
+ * The row key is `key_hash` — a SHA-256 of `(object, tenant_id, field, scope)`
445
+ * where `scope` is the rendered autonumber prefix (date/field tokens before
446
+ * the `{0000}` slot), so a new day/group/parent starts a fresh counter. A
447
+ * single 64-char hashed primary key (rather than the four raw columns, which
448
+ * blow past MySQL's 3072-byte index limit under utf8mb4 and bound how long a
449
+ * `{field}` scope may be) keys every dialect uniformly and lets `scope` be a
450
+ * generous non-indexed column. Fixed-prefix formats use the empty scope and
451
+ * keep their single global counter (backward compatible).
413
452
  */
414
453
  async ensureSequencesTable() {
415
454
  if (this.sequencesTableReady) return;
@@ -421,18 +460,15 @@ var SqlDriver = class {
421
460
  const exists = await this.knex.schema.hasTable(SEQUENCES_TABLE);
422
461
  if (!exists) {
423
462
  try {
424
- await this.knex.schema.createTable(SEQUENCES_TABLE, (t) => {
425
- t.string("object").notNullable();
426
- t.string("tenant_id").notNullable();
427
- t.string("field").notNullable();
428
- t.bigInteger("last_value").notNullable().defaultTo(0);
429
- t.timestamp("updated_at").defaultTo(this.knex.fn.now());
430
- t.primary(["object", "tenant_id", "field"]);
431
- });
463
+ await this.createSequencesTable(SEQUENCES_TABLE);
464
+ this.sequencesHasKeyHash = true;
432
465
  } catch (err) {
433
466
  const stillMissing = !await this.knex.schema.hasTable(SEQUENCES_TABLE);
434
467
  if (stillMissing) throw err;
468
+ await this.ensureSequencesKeyHashShape();
435
469
  }
470
+ } else {
471
+ await this.ensureSequencesKeyHashShape();
436
472
  }
437
473
  this.sequencesTableReady = true;
438
474
  })();
@@ -442,6 +478,71 @@ var SqlDriver = class {
442
478
  this.sequencesTableEnsurePromise = null;
443
479
  }
444
480
  }
481
+ /** SHA-256 of the composite counter key — the table's single-column PK. */
482
+ sequenceKeyHash(object, tenantId, field, scope) {
483
+ return (0, import_node_crypto.createHash)("sha256").update(`${object}${tenantId}${field}${scope}`).digest("hex");
484
+ }
485
+ /** Create the current `key_hash`-keyed sequences table shape. */
486
+ async createSequencesTable(table) {
487
+ await this.knex.schema.createTable(table, (t) => {
488
+ t.string("key_hash", 64).notNullable().primary();
489
+ t.string("object").notNullable();
490
+ t.string("tenant_id").notNullable();
491
+ t.string("field").notNullable();
492
+ t.string("scope", 1024).notNullable().defaultTo("");
493
+ t.bigInteger("last_value").notNullable().defaultTo(0);
494
+ t.timestamp("updated_at").defaultTo(this.knex.fn.now());
495
+ });
496
+ }
497
+ /**
498
+ * Migrate a pre-existing `_objectstack_sequences` table to the current
499
+ * `key_hash`-keyed shape. Handles both the original 3-column table (no
500
+ * `scope`) and an interim 4-column `(object, tenant_id, field, scope)` table:
501
+ * every legacy row is read, its `key_hash` computed in app code (no portable
502
+ * SQL hash exists), and re-inserted into a freshly built table that then
503
+ * replaces the original. Idempotent — a no-op once `key_hash` is present.
504
+ *
505
+ * If the rebuild fails, `sequencesHasKeyHash` stays false: fixed-prefix
506
+ * sequences keep working via the legacy key and per-scope writes error
507
+ * actionably (see getNextSequenceValue), rather than corrupting data.
508
+ */
509
+ async ensureSequencesKeyHashShape() {
510
+ if (await this.knex.schema.hasColumn(SEQUENCES_TABLE, "key_hash")) {
511
+ this.sequencesHasKeyHash = true;
512
+ return;
513
+ }
514
+ const hasScope = await this.knex.schema.hasColumn(SEQUENCES_TABLE, "scope");
515
+ const TMP = `${SEQUENCES_TABLE}__rebuild`;
516
+ try {
517
+ const rows = await this.knex(SEQUENCES_TABLE).select("*");
518
+ await this.knex.schema.dropTableIfExists(TMP);
519
+ await this.createSequencesTable(TMP);
520
+ const migrated = rows.map((r) => {
521
+ const scope = hasScope && r.scope != null ? String(r.scope) : "";
522
+ return {
523
+ key_hash: this.sequenceKeyHash(String(r.object), String(r.tenant_id), String(r.field), scope),
524
+ object: r.object,
525
+ tenant_id: r.tenant_id,
526
+ field: r.field,
527
+ scope,
528
+ last_value: r.last_value ?? 0,
529
+ updated_at: r.updated_at ?? this.knex.fn.now()
530
+ };
531
+ });
532
+ if (migrated.length > 0) await this.knex(TMP).insert(migrated);
533
+ await this.knex.schema.dropTable(SEQUENCES_TABLE);
534
+ await this.knex.schema.renameTable(TMP, SEQUENCES_TABLE);
535
+ this.sequencesHasKeyHash = true;
536
+ } catch (err) {
537
+ this.sequencesHasKeyHash = false;
538
+ await this.knex.schema.dropTableIfExists(TMP).catch(() => {
539
+ });
540
+ this.logger.warn(
541
+ `[autonumber] Failed to migrate ${SEQUENCES_TABLE} to the key_hash shape. Fixed-prefix autonumbers keep working; date/{field}/per-parent formats will error until the table is migrated.`,
542
+ { error: String(err) }
543
+ );
544
+ }
545
+ }
445
546
  /**
446
547
  * Bootstrap helper: scan the data table for the highest numeric suffix
447
548
  * matching `prefix` (optionally scoped to a tenant). Used the first time
@@ -479,10 +580,16 @@ var SqlDriver = class {
479
580
  * Gaps are tolerated by design — a rolled-back insert "burns" a number,
480
581
  * matching standard sequence semantics.
481
582
  */
482
- async getNextSequenceValue(object, tableName, field, prefix, tenantField, tenantId, parentTrx) {
583
+ async getNextSequenceValue(object, tableName, field, prefix, tenantField, tenantId, parentTrx, scope = "") {
483
584
  await this.ensureSequencesTable();
484
585
  const resolvedTenantId = tenantField && tenantId ? String(tenantId) : GLOBAL_TENANT;
485
- const key = { object: tableName, tenant_id: resolvedTenantId, field };
586
+ if (scope !== "" && !this.sequencesHasKeyHash) {
587
+ throw new Error(
588
+ `Cannot generate a per-scope autonumber for "${object}.${field}": the ${SEQUENCES_TABLE} table is still the legacy shape. Migrate it to the key_hash shape before using date/{field}/per-parent formats.`
589
+ );
590
+ }
591
+ const key = this.sequencesHasKeyHash ? { key_hash: this.sequenceKeyHash(tableName, resolvedTenantId, field, scope) } : { object: tableName, tenant_id: resolvedTenantId, field };
592
+ const insertRow = this.sequencesHasKeyHash ? { ...key, object: tableName, tenant_id: resolvedTenantId, field, scope } : { ...key };
486
593
  const runner = parentTrx ?? this.knex;
487
594
  return runner.transaction(async (trx) => {
488
595
  let existing;
@@ -502,7 +609,7 @@ var SqlDriver = class {
502
609
  );
503
610
  const initial = seedMax + 1;
504
611
  try {
505
- await trx(SEQUENCES_TABLE).insert({ ...key, last_value: initial });
612
+ await trx(SEQUENCES_TABLE).insert({ ...insertRow, last_value: initial });
506
613
  return initial;
507
614
  } catch (err) {
508
615
  existing = await trx(SEQUENCES_TABLE).where(key).forUpdate().first();
@@ -515,38 +622,50 @@ var SqlDriver = class {
515
622
  });
516
623
  }
517
624
  /**
518
- * For each `auto_number` field on the object that the caller did not
519
- * provide a value for, reserve the next sequence value scoped to the
520
- * record's tenant (or globally if the object has no tenant field) and
521
- * render `prefix + zero-padded(value)`.
625
+ * For each `auto_number` field the caller left empty, render the format and
626
+ * reserve the next counter value. The counter is scoped to the rendered
627
+ * prefix (date tokens like `{YYYYMMDD}` in the request's business timezone,
628
+ * plus `{field}` interpolation from the row), so it resets per period/group;
629
+ * the full rendered prefix bootstraps the counter from existing data, and the
630
+ * tenant scopes it for isolation.
522
631
  */
523
632
  async fillAutoNumberFields(object, row, options) {
524
- const tableName = import_system.StorageNameMapping.resolveTableName({ name: object });
525
- const cfgs = this.autoNumberFields[tableName] || this.autoNumberFields[object];
633
+ const tableName = this.physicalTableByObject[object] ?? import_system.StorageNameMapping.resolveTableName({ name: object });
634
+ const cfgs = this.autoNumberFields[object] || this.autoNumberFields[tableName];
526
635
  if (!cfgs || cfgs.length === 0) return;
527
636
  const parentTrx = options?.transaction;
637
+ const timezone = options?.timezone;
638
+ const now = /* @__PURE__ */ new Date();
528
639
  for (const cfg of cfgs) {
529
640
  if (row[cfg.name] !== void 0 && row[cfg.name] !== null && row[cfg.name] !== "") continue;
641
+ const missing = (0, import_data.missingFieldValues)(cfg.tokens, row);
642
+ if (missing.length > 0) {
643
+ throw new Error(
644
+ `Cannot generate autonumber "${object}.${cfg.name}" (format "${cfg.format}"): referenced field(s) [${missing.join(", ")}] are empty on the record. Fields interpolated into an autonumber format must be set before the record is created.`
645
+ );
646
+ }
530
647
  const rowTenant = cfg.tenantField ? row[cfg.tenantField] : void 0;
531
648
  const optTenant = options?.tenantId;
532
649
  const tenantId = rowTenant != null && rowTenant !== "" ? String(rowTenant) : optTenant != null && optTenant !== "" ? String(optTenant) : null;
650
+ const probe = (0, import_data.renderAutonumber)({ tokens: cfg.tokens, seq: 0, record: row, now, timezone });
533
651
  const next = await this.getNextSequenceValue(
534
652
  object,
535
653
  tableName,
536
654
  cfg.name,
537
- cfg.prefix,
655
+ probe.prefix,
538
656
  cfg.tenantField,
539
657
  tenantId,
540
- parentTrx
658
+ parentTrx,
659
+ probe.scope
541
660
  );
542
- row[cfg.name] = `${cfg.prefix}${String(next).padStart(cfg.padWidth, "0")}`;
661
+ row[cfg.name] = (0, import_data.renderAutonumber)({ tokens: cfg.tokens, seq: next, record: row, now, timezone }).value;
543
662
  }
544
663
  }
545
664
  async update(object, id, data, options) {
546
665
  this.auditMissingTenant(object, "update", options);
547
666
  const builder = this.getBuilder(object, options).where("id", id);
548
667
  this.applyTenantScope(builder, object, options);
549
- const formatted = this.formatInput(object, data);
668
+ const formatted = this.applyWriteColumnMap(object, this.formatInput(object, data));
550
669
  if (this.tablesWithTimestamps.has(object)) {
551
670
  if (this.isSqlite) {
552
671
  const now = /* @__PURE__ */ new Date();
@@ -572,7 +691,7 @@ var SqlDriver = class {
572
691
  this.auditMissingTenant(object, "upsert", options);
573
692
  this.injectTenantOnInsert(object, toUpsert, options);
574
693
  await this.fillAutoNumberFields(object, toUpsert, options);
575
- const formatted = this.formatInput(object, toUpsert);
694
+ const formatted = this.applyWriteColumnMap(object, this.formatInput(object, toUpsert));
576
695
  const mergeKeys = conflictKeys && conflictKeys.length > 0 ? conflictKeys : ["id"];
577
696
  const builder = this.getBuilder(object, options);
578
697
  await builder.insert(formatted).onConflict(mergeKeys).merge();
@@ -855,6 +974,89 @@ var SqlDriver = class {
855
974
  /**
856
975
  * Batch-initialise tables from an array of object definitions.
857
976
  */
977
+ /**
978
+ * DDL-free metadata registration for a federated (external) object — the
979
+ * read-path counterpart to {@link initObjects} (ADR-0015 federation).
980
+ *
981
+ * `initObjects` is gated by `assertSchemaMutable` and therefore throws for
982
+ * any non-`managed` driver, which left external objects with NO read-coercion
983
+ * metadata and the query path resolving to a table named after the object
984
+ * instead of its remote table. This populates the same coercion maps (keyed
985
+ * by OBJECT name, matching formatInput/formatOutput/coerceFilterValue) and
986
+ * records the physical remote table (`external.remoteName`, optionally
987
+ * `external.remoteSchema`) so {@link getBuilder} targets it — WITHOUT running
988
+ * any DDL (createTable/alterTable/columnInfo). Keep the field-classification
989
+ * below in sync with initObjects() if the field-type -> storage mapping changes.
990
+ */
991
+ registerExternalObject(schema) {
992
+ const key = schema.name;
993
+ const remoteName = schema.external?.remoteName || schema.name;
994
+ const remoteSchema = schema.external?.remoteSchema;
995
+ this.physicalTableByObject[key] = remoteName;
996
+ this.objectByPhysicalTable[remoteName] = key;
997
+ if (remoteSchema) {
998
+ if (this.isSqlite) {
999
+ this.logger.warn(
1000
+ `[sql-driver] external object "${key}" declares remoteSchema="${remoteSchema}" but SQLite has no schema namespace; ignoring (treating "${remoteName}" as a bare table).`
1001
+ );
1002
+ } else {
1003
+ this.physicalSchemaByObject[key] = remoteSchema;
1004
+ }
1005
+ }
1006
+ const columnMap = schema.external?.columnMap;
1007
+ if (columnMap && typeof columnMap === "object" && Object.keys(columnMap).length > 0) {
1008
+ const fieldToCol = {};
1009
+ const colToField = {};
1010
+ for (const [remoteCol, localField] of Object.entries(columnMap)) {
1011
+ if (typeof localField === "string" && localField) {
1012
+ fieldToCol[localField] = remoteCol;
1013
+ colToField[remoteCol] = localField;
1014
+ }
1015
+ }
1016
+ this.fieldColumnByObject[key] = fieldToCol;
1017
+ this.columnFieldByObject[key] = colToField;
1018
+ }
1019
+ const jsonCols = [];
1020
+ const booleanCols = [];
1021
+ const numericCols = [];
1022
+ const dateCols = [];
1023
+ const datetimeCols = [];
1024
+ const autoNumberCols = [];
1025
+ const tenancyDecl = schema?.tenancy;
1026
+ let tenantField = null;
1027
+ if (tenancyDecl && tenancyDecl.enabled !== false && tenancyDecl.tenantField) {
1028
+ const declared = String(tenancyDecl.tenantField);
1029
+ if (schema.fields && Object.prototype.hasOwnProperty.call(schema.fields, declared)) {
1030
+ tenantField = declared;
1031
+ }
1032
+ }
1033
+ if (!tenantField) {
1034
+ const hasOrgField = !!(schema.fields && Object.prototype.hasOwnProperty.call(schema.fields, "organization_id"));
1035
+ tenantField = hasOrgField ? "organization_id" : null;
1036
+ }
1037
+ if (schema.fields) {
1038
+ for (const [name, field] of Object.entries(schema.fields)) {
1039
+ const type = field.type || "string";
1040
+ if (this.isJsonField(type, field)) jsonCols.push(name);
1041
+ if (type === "boolean" || type === "toggle") booleanCols.push(name);
1042
+ if (NUMERIC_SCALAR_TYPES.has(type) && !field.multiple) numericCols.push(name);
1043
+ if (type === "date") dateCols.push(name);
1044
+ if (type === "datetime") datetimeCols.push(name);
1045
+ if (type === "auto_number" || type === "autonumber") {
1046
+ const rawFmt = typeof field.autonumberFormat === "string" && field.autonumberFormat ? field.autonumberFormat : typeof field.format === "string" && field.format ? field.format : "";
1047
+ const fmt = rawFmt || "{0000}";
1048
+ autoNumberCols.push({ name, format: fmt, tokens: (0, import_data.parseAutonumberFormat)(fmt), tenantField });
1049
+ }
1050
+ }
1051
+ }
1052
+ this.jsonFields[key] = jsonCols;
1053
+ this.booleanFields[key] = booleanCols;
1054
+ this.numericFields[key] = numericCols;
1055
+ this.autoNumberFields[key] = autoNumberCols;
1056
+ this.tenantFieldByTable[key] = tenantField;
1057
+ if (dateCols.length) this.dateFields[key] = new Set(dateCols);
1058
+ if (datetimeCols.length) this.datetimeFields[key] = new Set(datetimeCols);
1059
+ }
858
1060
  async initObjects(objects) {
859
1061
  var _a, _b;
860
1062
  this.assertSchemaMutable("initObjects");
@@ -898,10 +1100,8 @@ var SqlDriver = class {
898
1100
  if (type === "auto_number" || type === "autonumber") {
899
1101
  const rawFmt = typeof field.autonumberFormat === "string" && field.autonumberFormat ? field.autonumberFormat : typeof field.format === "string" && field.format ? field.format : "";
900
1102
  const fmt = rawFmt || "{0000}";
901
- const m = fmt.match(/\{(0+)\}/);
902
- const padWidth = m ? m[1].length : 4;
903
- const prefix = m ? fmt.slice(0, m.index ?? 0) : fmt;
904
- autoNumberCols.push({ name, format: fmt, prefix, padWidth, tenantField });
1103
+ const tokens = (0, import_data.parseAutonumberFormat)(fmt);
1104
+ autoNumberCols.push({ name, format: fmt, tokens, tenantField });
905
1105
  }
906
1106
  }
907
1107
  }
@@ -1098,7 +1298,12 @@ var SqlDriver = class {
1098
1298
  return this.knex;
1099
1299
  }
1100
1300
  getBuilder(object, options) {
1101
- let builder = this.knex(object);
1301
+ const physical = this.physicalTableByObject[object] ?? object;
1302
+ let builder = this.knex(physical);
1303
+ const remoteSchema = this.physicalSchemaByObject[object];
1304
+ if (remoteSchema) {
1305
+ builder = builder.withSchema(remoteSchema);
1306
+ }
1102
1307
  if (options?.transaction) {
1103
1308
  builder = builder.transacting(options.transaction);
1104
1309
  }
@@ -1197,6 +1402,20 @@ var SqlDriver = class {
1197
1402
  if (typeof t === "string") return t;
1198
1403
  return null;
1199
1404
  }
1405
+ /**
1406
+ * Coercion-map key for a builder. Coercion maps (date/datetime) are keyed by
1407
+ * OBJECT name, but after the federation change {@link getBuilder} targets the
1408
+ * physical remote table, so a builder reports the remote name. Map it back to
1409
+ * the object name for external objects; identity for managed ones (no reverse
1410
+ * entry). Note datetime coercion is a SQLite-only concern (see
1411
+ * coerceFilterValue), and SQLite external tables are bare-named, so this is
1412
+ * exact where it matters.
1413
+ */
1414
+ coercionKey(builder) {
1415
+ const physical = this.tableNameForBuilder(builder);
1416
+ if (physical == null) return null;
1417
+ return this.objectByPhysicalTable[physical] ?? physical;
1418
+ }
1200
1419
  /**
1201
1420
  * Collapse a `Field.date` value to a timezone-naive `YYYY-MM-DD`
1202
1421
  * calendar-day string (ADR-0053 Phase 1). A `Date` collapses to its UTC
@@ -1290,7 +1509,7 @@ var SqlDriver = class {
1290
1509
  }
1291
1510
  applyFilters(builder, filters) {
1292
1511
  if (!filters) return;
1293
- const table = this.tableNameForBuilder(builder);
1512
+ const table = this.coercionKey(builder);
1294
1513
  if (!Array.isArray(filters) && typeof filters === "object") {
1295
1514
  const hasMongoOperators = Object.keys(filters).some(
1296
1515
  (k) => k.startsWith("$") || typeof filters[k] === "object" && filters[k] !== null && Object.keys(filters[k]).some((op) => op.startsWith("$"))
@@ -1301,7 +1520,7 @@ var SqlDriver = class {
1301
1520
  }
1302
1521
  for (const [key, value] of Object.entries(filters)) {
1303
1522
  if (["limit", "offset", "fields", "orderBy"].includes(key)) continue;
1304
- builder.where(key, this.coerceFilterValue(table, key, value));
1523
+ builder.where(this.remoteColumn(table, key, key), this.coerceFilterValue(table, key, value));
1305
1524
  }
1306
1525
  return;
1307
1526
  }
@@ -1317,8 +1536,9 @@ var SqlDriver = class {
1317
1536
  const [fieldRaw, op, value] = item;
1318
1537
  const isCriterion = typeof fieldRaw === "string" && typeof op === "string";
1319
1538
  if (isCriterion) {
1320
- const field = this.mapSortField(fieldRaw);
1321
- const coerced = this.coerceFilterValue(table, field, value);
1539
+ const localField = this.mapSortField(fieldRaw);
1540
+ const field = this.remoteColumn(table, fieldRaw, localField);
1541
+ const coerced = this.coerceFilterValue(table, localField, value);
1322
1542
  const apply = (b) => {
1323
1543
  const method = nextJoin === "or" ? "orWhere" : "where";
1324
1544
  const methodIn = nextJoin === "or" ? "orWhereIn" : "whereIn";
@@ -1370,7 +1590,7 @@ var SqlDriver = class {
1370
1590
  }
1371
1591
  applyFilterCondition(builder, condition, logicalOp = "and", tableHint) {
1372
1592
  if (!condition || typeof condition !== "object") return;
1373
- const table = tableHint ?? this.tableNameForBuilder(builder);
1593
+ const table = tableHint ?? this.coercionKey(builder);
1374
1594
  for (const [key, value] of Object.entries(condition)) {
1375
1595
  if (key === "$and" && Array.isArray(value)) {
1376
1596
  builder.where((qb) => {
@@ -1390,10 +1610,11 @@ var SqlDriver = class {
1390
1610
  }
1391
1611
  });
1392
1612
  } else if (typeof value === "object" && value !== null && !Array.isArray(value)) {
1393
- const field = this.mapSortField(key);
1613
+ const localField = this.mapSortField(key);
1614
+ const field = this.remoteColumn(table, key, localField);
1394
1615
  for (const [op, opValue] of Object.entries(value)) {
1395
1616
  const method = logicalOp === "or" ? "orWhere" : "where";
1396
- const coerced = this.coerceFilterValue(table, field, opValue);
1617
+ const coerced = this.coerceFilterValue(table, localField, opValue);
1397
1618
  switch (op) {
1398
1619
  case "$eq":
1399
1620
  builder[method](field, coerced);
@@ -1431,9 +1652,10 @@ var SqlDriver = class {
1431
1652
  }
1432
1653
  }
1433
1654
  } else {
1434
- const field = this.mapSortField(key);
1655
+ const localField = this.mapSortField(key);
1656
+ const field = this.remoteColumn(table, key, localField);
1435
1657
  const method = logicalOp === "or" ? "orWhere" : "where";
1436
- builder[method](field, this.coerceFilterValue(table, field, value));
1658
+ builder[method](field, this.coerceFilterValue(table, localField, value));
1437
1659
  }
1438
1660
  }
1439
1661
  }
@@ -1443,6 +1665,28 @@ var SqlDriver = class {
1443
1665
  if (field === "updatedAt") return "updated_at";
1444
1666
  return field;
1445
1667
  }
1668
+ /**
1669
+ * Physical column for a logical field on an external object that declares an
1670
+ * `external.columnMap` (ADR-0015). Returns `fallback` (the caller's existing
1671
+ * per-site resolution) when the object has no columnMap, so managed objects
1672
+ * and external objects without a columnMap are byte-for-byte unchanged.
1673
+ */
1674
+ remoteColumn(object, field, fallback) {
1675
+ const m = object ? this.fieldColumnByObject[object] : void 0;
1676
+ return m && m[field] || fallback;
1677
+ }
1678
+ /**
1679
+ * Remap a write payload's logical field keys to physical remote columns for an
1680
+ * external object with a columnMap. No-op otherwise. Applied AFTER formatInput
1681
+ * (whose value coercion is keyed by logical field name).
1682
+ */
1683
+ applyWriteColumnMap(object, data) {
1684
+ const m = this.fieldColumnByObject[object];
1685
+ if (!m || !data || typeof data !== "object") return data;
1686
+ const out = {};
1687
+ for (const [k, v] of Object.entries(data)) out[m[k] ?? k] = v;
1688
+ return out;
1689
+ }
1446
1690
  mapAggregateFunc(func) {
1447
1691
  switch (func) {
1448
1692
  case "count":
@@ -1699,6 +1943,15 @@ var SqlDriver = class {
1699
1943
  }
1700
1944
  formatOutput(object, data) {
1701
1945
  if (!data) return data;
1946
+ const colToField = this.columnFieldByObject[object];
1947
+ if (colToField && typeof data === "object") {
1948
+ for (const [remoteCol, localField] of Object.entries(colToField)) {
1949
+ if (remoteCol !== localField && Object.prototype.hasOwnProperty.call(data, remoteCol)) {
1950
+ data[localField] = data[remoteCol];
1951
+ delete data[remoteCol];
1952
+ }
1953
+ }
1954
+ }
1702
1955
  if (this.isSqlite) {
1703
1956
  const jsonFields = this.jsonFields[object];
1704
1957
  if (jsonFields && jsonFields.length > 0) {