@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.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  // src/sql-driver.ts
2
+ import { parseAutonumberFormat, renderAutonumber, missingFieldValues } from "@objectstack/spec/data";
2
3
  import { StorageNameMapping } from "@objectstack/spec/system";
3
4
  import { ExternalSchemaModeViolationError } from "@objectstack/spec/shared";
4
5
  import knex from "knex";
@@ -48,6 +49,20 @@ var SqlDriver = class {
48
49
  this.numericFields = {};
49
50
  this.dateFields = {};
50
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 = {};
51
66
  this.tablesWithTimestamps = /* @__PURE__ */ new Set();
52
67
  /**
53
68
  * Autonumber field configs per table, captured during initObjects.
@@ -66,6 +81,14 @@ var SqlDriver = class {
66
81
  this.autoNumberFields = {};
67
82
  /** Whether the sequences table has been ensured this process. */
68
83
  this.sequencesTableReady = false;
84
+ /**
85
+ * Whether `_objectstack_sequences` is the current `key_hash`-keyed shape.
86
+ * Set on a fresh create or a successful in-place migration. If a legacy table
87
+ * could NOT be migrated, this stays false: fixed-prefix sequences (empty
88
+ * scope) keep working via the legacy `(object, tenant_id, field)` key, while a
89
+ * per-scope write raises an actionable error rather than corrupting counters.
90
+ */
91
+ this.sequencesHasKeyHash = false;
69
92
  /** In-flight ensure promise; deduplicates concurrent first calls. */
70
93
  this.sequencesTableEnsurePromise = null;
71
94
  /**
@@ -264,6 +287,13 @@ var SqlDriver = class {
264
287
  // ===================================
265
288
  async connect() {
266
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
+ }
267
297
  }
268
298
  async checkHealth() {
269
299
  try {
@@ -289,7 +319,7 @@ var SqlDriver = class {
289
319
  if (query.orderBy && Array.isArray(query.orderBy)) {
290
320
  for (const item of query.orderBy) {
291
321
  if (item.field) {
292
- b.orderBy(this.mapSortField(item.field), item.order || "asc");
322
+ b.orderBy(this.remoteColumn(object, item.field, this.mapSortField(item.field)), item.order || "asc");
293
323
  }
294
324
  }
295
325
  }
@@ -366,13 +396,22 @@ var SqlDriver = class {
366
396
  this.injectTenantOnInsert(object, toInsert, options);
367
397
  await this.fillAutoNumberFields(object, toInsert, options);
368
398
  const builder = this.getBuilder(object, options);
369
- const formatted = this.formatInput(object, toInsert);
399
+ const formatted = this.applyWriteColumnMap(object, this.formatInput(object, toInsert));
370
400
  const result = await builder.insert(formatted).returning("*");
371
401
  return this.formatOutput(object, result[0]);
372
402
  }
373
403
  /**
374
404
  * Ensure the sequence-counter table exists. Idempotent and cheap after
375
405
  * the first call (cached via `sequencesTableReady`).
406
+ *
407
+ * The row key is `key_hash` — a SHA-256 of `(object, tenant_id, field, scope)`
408
+ * where `scope` is the rendered autonumber prefix (date/field tokens before
409
+ * the `{0000}` slot), so a new day/group/parent starts a fresh counter. A
410
+ * single 64-char hashed primary key (rather than the four raw columns, which
411
+ * blow past MySQL's 3072-byte index limit under utf8mb4 and bound how long a
412
+ * `{field}` scope may be) keys every dialect uniformly and lets `scope` be a
413
+ * generous non-indexed column. Fixed-prefix formats use the empty scope and
414
+ * keep their single global counter (backward compatible).
376
415
  */
377
416
  async ensureSequencesTable() {
378
417
  if (this.sequencesTableReady) return;
@@ -384,18 +423,15 @@ var SqlDriver = class {
384
423
  const exists = await this.knex.schema.hasTable(SEQUENCES_TABLE);
385
424
  if (!exists) {
386
425
  try {
387
- await this.knex.schema.createTable(SEQUENCES_TABLE, (t) => {
388
- t.string("object").notNullable();
389
- t.string("tenant_id").notNullable();
390
- t.string("field").notNullable();
391
- t.bigInteger("last_value").notNullable().defaultTo(0);
392
- t.timestamp("updated_at").defaultTo(this.knex.fn.now());
393
- t.primary(["object", "tenant_id", "field"]);
394
- });
426
+ await this.createSequencesTable(SEQUENCES_TABLE);
427
+ this.sequencesHasKeyHash = true;
395
428
  } catch (err) {
396
429
  const stillMissing = !await this.knex.schema.hasTable(SEQUENCES_TABLE);
397
430
  if (stillMissing) throw err;
431
+ await this.ensureSequencesKeyHashShape();
398
432
  }
433
+ } else {
434
+ await this.ensureSequencesKeyHashShape();
399
435
  }
400
436
  this.sequencesTableReady = true;
401
437
  })();
@@ -405,6 +441,71 @@ var SqlDriver = class {
405
441
  this.sequencesTableEnsurePromise = null;
406
442
  }
407
443
  }
444
+ /** SHA-256 of the composite counter key — the table's single-column PK. */
445
+ sequenceKeyHash(object, tenantId, field, scope) {
446
+ return createHash("sha256").update(`${object}${tenantId}${field}${scope}`).digest("hex");
447
+ }
448
+ /** Create the current `key_hash`-keyed sequences table shape. */
449
+ async createSequencesTable(table) {
450
+ await this.knex.schema.createTable(table, (t) => {
451
+ t.string("key_hash", 64).notNullable().primary();
452
+ t.string("object").notNullable();
453
+ t.string("tenant_id").notNullable();
454
+ t.string("field").notNullable();
455
+ t.string("scope", 1024).notNullable().defaultTo("");
456
+ t.bigInteger("last_value").notNullable().defaultTo(0);
457
+ t.timestamp("updated_at").defaultTo(this.knex.fn.now());
458
+ });
459
+ }
460
+ /**
461
+ * Migrate a pre-existing `_objectstack_sequences` table to the current
462
+ * `key_hash`-keyed shape. Handles both the original 3-column table (no
463
+ * `scope`) and an interim 4-column `(object, tenant_id, field, scope)` table:
464
+ * every legacy row is read, its `key_hash` computed in app code (no portable
465
+ * SQL hash exists), and re-inserted into a freshly built table that then
466
+ * replaces the original. Idempotent — a no-op once `key_hash` is present.
467
+ *
468
+ * If the rebuild fails, `sequencesHasKeyHash` stays false: fixed-prefix
469
+ * sequences keep working via the legacy key and per-scope writes error
470
+ * actionably (see getNextSequenceValue), rather than corrupting data.
471
+ */
472
+ async ensureSequencesKeyHashShape() {
473
+ if (await this.knex.schema.hasColumn(SEQUENCES_TABLE, "key_hash")) {
474
+ this.sequencesHasKeyHash = true;
475
+ return;
476
+ }
477
+ const hasScope = await this.knex.schema.hasColumn(SEQUENCES_TABLE, "scope");
478
+ const TMP = `${SEQUENCES_TABLE}__rebuild`;
479
+ try {
480
+ const rows = await this.knex(SEQUENCES_TABLE).select("*");
481
+ await this.knex.schema.dropTableIfExists(TMP);
482
+ await this.createSequencesTable(TMP);
483
+ const migrated = rows.map((r) => {
484
+ const scope = hasScope && r.scope != null ? String(r.scope) : "";
485
+ return {
486
+ key_hash: this.sequenceKeyHash(String(r.object), String(r.tenant_id), String(r.field), scope),
487
+ object: r.object,
488
+ tenant_id: r.tenant_id,
489
+ field: r.field,
490
+ scope,
491
+ last_value: r.last_value ?? 0,
492
+ updated_at: r.updated_at ?? this.knex.fn.now()
493
+ };
494
+ });
495
+ if (migrated.length > 0) await this.knex(TMP).insert(migrated);
496
+ await this.knex.schema.dropTable(SEQUENCES_TABLE);
497
+ await this.knex.schema.renameTable(TMP, SEQUENCES_TABLE);
498
+ this.sequencesHasKeyHash = true;
499
+ } catch (err) {
500
+ this.sequencesHasKeyHash = false;
501
+ await this.knex.schema.dropTableIfExists(TMP).catch(() => {
502
+ });
503
+ this.logger.warn(
504
+ `[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.`,
505
+ { error: String(err) }
506
+ );
507
+ }
508
+ }
408
509
  /**
409
510
  * Bootstrap helper: scan the data table for the highest numeric suffix
410
511
  * matching `prefix` (optionally scoped to a tenant). Used the first time
@@ -442,10 +543,16 @@ var SqlDriver = class {
442
543
  * Gaps are tolerated by design — a rolled-back insert "burns" a number,
443
544
  * matching standard sequence semantics.
444
545
  */
445
- async getNextSequenceValue(object, tableName, field, prefix, tenantField, tenantId, parentTrx) {
546
+ async getNextSequenceValue(object, tableName, field, prefix, tenantField, tenantId, parentTrx, scope = "") {
446
547
  await this.ensureSequencesTable();
447
548
  const resolvedTenantId = tenantField && tenantId ? String(tenantId) : GLOBAL_TENANT;
448
- const key = { object: tableName, tenant_id: resolvedTenantId, field };
549
+ if (scope !== "" && !this.sequencesHasKeyHash) {
550
+ throw new Error(
551
+ `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.`
552
+ );
553
+ }
554
+ const key = this.sequencesHasKeyHash ? { key_hash: this.sequenceKeyHash(tableName, resolvedTenantId, field, scope) } : { object: tableName, tenant_id: resolvedTenantId, field };
555
+ const insertRow = this.sequencesHasKeyHash ? { ...key, object: tableName, tenant_id: resolvedTenantId, field, scope } : { ...key };
449
556
  const runner = parentTrx ?? this.knex;
450
557
  return runner.transaction(async (trx) => {
451
558
  let existing;
@@ -465,7 +572,7 @@ var SqlDriver = class {
465
572
  );
466
573
  const initial = seedMax + 1;
467
574
  try {
468
- await trx(SEQUENCES_TABLE).insert({ ...key, last_value: initial });
575
+ await trx(SEQUENCES_TABLE).insert({ ...insertRow, last_value: initial });
469
576
  return initial;
470
577
  } catch (err) {
471
578
  existing = await trx(SEQUENCES_TABLE).where(key).forUpdate().first();
@@ -478,38 +585,50 @@ var SqlDriver = class {
478
585
  });
479
586
  }
480
587
  /**
481
- * For each `auto_number` field on the object that the caller did not
482
- * provide a value for, reserve the next sequence value scoped to the
483
- * record's tenant (or globally if the object has no tenant field) and
484
- * render `prefix + zero-padded(value)`.
588
+ * For each `auto_number` field the caller left empty, render the format and
589
+ * reserve the next counter value. The counter is scoped to the rendered
590
+ * prefix (date tokens like `{YYYYMMDD}` in the request's business timezone,
591
+ * plus `{field}` interpolation from the row), so it resets per period/group;
592
+ * the full rendered prefix bootstraps the counter from existing data, and the
593
+ * tenant scopes it for isolation.
485
594
  */
486
595
  async fillAutoNumberFields(object, row, options) {
487
- const tableName = StorageNameMapping.resolveTableName({ name: object });
488
- 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];
489
598
  if (!cfgs || cfgs.length === 0) return;
490
599
  const parentTrx = options?.transaction;
600
+ const timezone = options?.timezone;
601
+ const now = /* @__PURE__ */ new Date();
491
602
  for (const cfg of cfgs) {
492
603
  if (row[cfg.name] !== void 0 && row[cfg.name] !== null && row[cfg.name] !== "") continue;
604
+ const missing = missingFieldValues(cfg.tokens, row);
605
+ if (missing.length > 0) {
606
+ throw new Error(
607
+ `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.`
608
+ );
609
+ }
493
610
  const rowTenant = cfg.tenantField ? row[cfg.tenantField] : void 0;
494
611
  const optTenant = options?.tenantId;
495
612
  const tenantId = rowTenant != null && rowTenant !== "" ? String(rowTenant) : optTenant != null && optTenant !== "" ? String(optTenant) : null;
613
+ const probe = renderAutonumber({ tokens: cfg.tokens, seq: 0, record: row, now, timezone });
496
614
  const next = await this.getNextSequenceValue(
497
615
  object,
498
616
  tableName,
499
617
  cfg.name,
500
- cfg.prefix,
618
+ probe.prefix,
501
619
  cfg.tenantField,
502
620
  tenantId,
503
- parentTrx
621
+ parentTrx,
622
+ probe.scope
504
623
  );
505
- row[cfg.name] = `${cfg.prefix}${String(next).padStart(cfg.padWidth, "0")}`;
624
+ row[cfg.name] = renderAutonumber({ tokens: cfg.tokens, seq: next, record: row, now, timezone }).value;
506
625
  }
507
626
  }
508
627
  async update(object, id, data, options) {
509
628
  this.auditMissingTenant(object, "update", options);
510
629
  const builder = this.getBuilder(object, options).where("id", id);
511
630
  this.applyTenantScope(builder, object, options);
512
- const formatted = this.formatInput(object, data);
631
+ const formatted = this.applyWriteColumnMap(object, this.formatInput(object, data));
513
632
  if (this.tablesWithTimestamps.has(object)) {
514
633
  if (this.isSqlite) {
515
634
  const now = /* @__PURE__ */ new Date();
@@ -535,7 +654,7 @@ var SqlDriver = class {
535
654
  this.auditMissingTenant(object, "upsert", options);
536
655
  this.injectTenantOnInsert(object, toUpsert, options);
537
656
  await this.fillAutoNumberFields(object, toUpsert, options);
538
- const formatted = this.formatInput(object, toUpsert);
657
+ const formatted = this.applyWriteColumnMap(object, this.formatInput(object, toUpsert));
539
658
  const mergeKeys = conflictKeys && conflictKeys.length > 0 ? conflictKeys : ["id"];
540
659
  const builder = this.getBuilder(object, options);
541
660
  await builder.insert(formatted).onConflict(mergeKeys).merge();
@@ -818,6 +937,89 @@ var SqlDriver = class {
818
937
  /**
819
938
  * Batch-initialise tables from an array of object definitions.
820
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
+ }
821
1023
  async initObjects(objects) {
822
1024
  var _a, _b;
823
1025
  this.assertSchemaMutable("initObjects");
@@ -861,10 +1063,8 @@ var SqlDriver = class {
861
1063
  if (type === "auto_number" || type === "autonumber") {
862
1064
  const rawFmt = typeof field.autonumberFormat === "string" && field.autonumberFormat ? field.autonumberFormat : typeof field.format === "string" && field.format ? field.format : "";
863
1065
  const fmt = rawFmt || "{0000}";
864
- const m = fmt.match(/\{(0+)\}/);
865
- const padWidth = m ? m[1].length : 4;
866
- const prefix = m ? fmt.slice(0, m.index ?? 0) : fmt;
867
- autoNumberCols.push({ name, format: fmt, prefix, padWidth, tenantField });
1066
+ const tokens = parseAutonumberFormat(fmt);
1067
+ autoNumberCols.push({ name, format: fmt, tokens, tenantField });
868
1068
  }
869
1069
  }
870
1070
  }
@@ -1061,7 +1261,12 @@ var SqlDriver = class {
1061
1261
  return this.knex;
1062
1262
  }
1063
1263
  getBuilder(object, options) {
1064
- 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
+ }
1065
1270
  if (options?.transaction) {
1066
1271
  builder = builder.transacting(options.transaction);
1067
1272
  }
@@ -1160,6 +1365,20 @@ var SqlDriver = class {
1160
1365
  if (typeof t === "string") return t;
1161
1366
  return null;
1162
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
+ }
1163
1382
  /**
1164
1383
  * Collapse a `Field.date` value to a timezone-naive `YYYY-MM-DD`
1165
1384
  * calendar-day string (ADR-0053 Phase 1). A `Date` collapses to its UTC
@@ -1253,7 +1472,7 @@ var SqlDriver = class {
1253
1472
  }
1254
1473
  applyFilters(builder, filters) {
1255
1474
  if (!filters) return;
1256
- const table = this.tableNameForBuilder(builder);
1475
+ const table = this.coercionKey(builder);
1257
1476
  if (!Array.isArray(filters) && typeof filters === "object") {
1258
1477
  const hasMongoOperators = Object.keys(filters).some(
1259
1478
  (k) => k.startsWith("$") || typeof filters[k] === "object" && filters[k] !== null && Object.keys(filters[k]).some((op) => op.startsWith("$"))
@@ -1264,7 +1483,7 @@ var SqlDriver = class {
1264
1483
  }
1265
1484
  for (const [key, value] of Object.entries(filters)) {
1266
1485
  if (["limit", "offset", "fields", "orderBy"].includes(key)) continue;
1267
- builder.where(key, this.coerceFilterValue(table, key, value));
1486
+ builder.where(this.remoteColumn(table, key, key), this.coerceFilterValue(table, key, value));
1268
1487
  }
1269
1488
  return;
1270
1489
  }
@@ -1280,8 +1499,9 @@ var SqlDriver = class {
1280
1499
  const [fieldRaw, op, value] = item;
1281
1500
  const isCriterion = typeof fieldRaw === "string" && typeof op === "string";
1282
1501
  if (isCriterion) {
1283
- const field = this.mapSortField(fieldRaw);
1284
- 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);
1285
1505
  const apply = (b) => {
1286
1506
  const method = nextJoin === "or" ? "orWhere" : "where";
1287
1507
  const methodIn = nextJoin === "or" ? "orWhereIn" : "whereIn";
@@ -1333,7 +1553,7 @@ var SqlDriver = class {
1333
1553
  }
1334
1554
  applyFilterCondition(builder, condition, logicalOp = "and", tableHint) {
1335
1555
  if (!condition || typeof condition !== "object") return;
1336
- const table = tableHint ?? this.tableNameForBuilder(builder);
1556
+ const table = tableHint ?? this.coercionKey(builder);
1337
1557
  for (const [key, value] of Object.entries(condition)) {
1338
1558
  if (key === "$and" && Array.isArray(value)) {
1339
1559
  builder.where((qb) => {
@@ -1353,10 +1573,11 @@ var SqlDriver = class {
1353
1573
  }
1354
1574
  });
1355
1575
  } else if (typeof value === "object" && value !== null && !Array.isArray(value)) {
1356
- const field = this.mapSortField(key);
1576
+ const localField = this.mapSortField(key);
1577
+ const field = this.remoteColumn(table, key, localField);
1357
1578
  for (const [op, opValue] of Object.entries(value)) {
1358
1579
  const method = logicalOp === "or" ? "orWhere" : "where";
1359
- const coerced = this.coerceFilterValue(table, field, opValue);
1580
+ const coerced = this.coerceFilterValue(table, localField, opValue);
1360
1581
  switch (op) {
1361
1582
  case "$eq":
1362
1583
  builder[method](field, coerced);
@@ -1394,9 +1615,10 @@ var SqlDriver = class {
1394
1615
  }
1395
1616
  }
1396
1617
  } else {
1397
- const field = this.mapSortField(key);
1618
+ const localField = this.mapSortField(key);
1619
+ const field = this.remoteColumn(table, key, localField);
1398
1620
  const method = logicalOp === "or" ? "orWhere" : "where";
1399
- builder[method](field, this.coerceFilterValue(table, field, value));
1621
+ builder[method](field, this.coerceFilterValue(table, localField, value));
1400
1622
  }
1401
1623
  }
1402
1624
  }
@@ -1406,6 +1628,28 @@ var SqlDriver = class {
1406
1628
  if (field === "updatedAt") return "updated_at";
1407
1629
  return field;
1408
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
+ }
1409
1653
  mapAggregateFunc(func) {
1410
1654
  switch (func) {
1411
1655
  case "count":
@@ -1662,6 +1906,15 @@ var SqlDriver = class {
1662
1906
  }
1663
1907
  formatOutput(object, data) {
1664
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
+ }
1665
1918
  if (this.isSqlite) {
1666
1919
  const jsonFields = this.jsonFields[object];
1667
1920
  if (jsonFields && jsonFields.length > 0) {