@objectstack/driver-sql 9.11.0 → 10.2.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
@@ -30,8 +30,12 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
+ BUILTIN_COLUMNS: () => BUILTIN_COLUMNS,
33
34
  SqlDriver: () => SqlDriver,
34
- default: () => index_default
35
+ default: () => index_default,
36
+ diffManagedTable: () => diffManagedTable,
37
+ driftKey: () => driftKey,
38
+ fieldHasColumn: () => fieldHasColumn
35
39
  });
36
40
  module.exports = __toCommonJS(index_exports);
37
41
 
@@ -39,6 +43,108 @@ module.exports = __toCommonJS(index_exports);
39
43
  var import_data = require("@objectstack/spec/data");
40
44
  var import_system = require("@objectstack/spec/system");
41
45
  var import_shared = require("@objectstack/spec/shared");
46
+
47
+ // src/schema-drift.ts
48
+ var BUILTIN_COLUMNS = /* @__PURE__ */ new Set(["id", "created_at", "updated_at"]);
49
+ function fieldHasColumn(field) {
50
+ if (field?.multiple) return true;
51
+ return (field?.type ?? "string") !== "formula";
52
+ }
53
+ function enforcesVarcharLength(dialect) {
54
+ return dialect === "postgres" || dialect === "mysql";
55
+ }
56
+ function diffManagedTable(args) {
57
+ const { table, fields, columns, dialect } = args;
58
+ const out = [];
59
+ const columnsByName = new Map(columns.map((c) => [c.name, c]));
60
+ const expectedColumns = /* @__PURE__ */ new Set();
61
+ for (const [fieldName, field] of Object.entries(fields ?? {})) {
62
+ if (BUILTIN_COLUMNS.has(fieldName)) continue;
63
+ if (!fieldHasColumn(field)) continue;
64
+ expectedColumns.add(fieldName);
65
+ const col = columnsByName.get(fieldName);
66
+ if (!col) continue;
67
+ const expectNullable = field.required !== true;
68
+ if (expectNullable && !col.nullable) {
69
+ out.push({
70
+ kind: "nullability_mismatch",
71
+ remoteName: table,
72
+ table,
73
+ column: fieldName,
74
+ expected: "NULL",
75
+ actual: "NOT NULL",
76
+ severity: "warning",
77
+ category: "safe",
78
+ op: { type: "relax_not_null", table, column: fieldName },
79
+ message: `${table}.${fieldName}: metadata is optional but the column is NOT NULL \u2014 writes that omit it fail. Run "os migrate" to relax it.`
80
+ });
81
+ } else if (!expectNullable && col.nullable) {
82
+ out.push({
83
+ kind: "nullability_mismatch",
84
+ remoteName: table,
85
+ table,
86
+ column: fieldName,
87
+ expected: "NOT NULL",
88
+ actual: "NULL",
89
+ severity: "error",
90
+ category: "destructive",
91
+ op: { type: "tighten_not_null", table, column: fieldName },
92
+ message: `${table}.${fieldName}: metadata is required but the column is nullable \u2014 existing nulls must be backfilled. Run "os migrate apply --allow-destructive".`
93
+ });
94
+ }
95
+ if (enforcesVarcharLength(dialect) && typeof field.maxLength === "number" && typeof col.maxLength === "number" && field.maxLength !== col.maxLength) {
96
+ if (field.maxLength > col.maxLength) {
97
+ out.push({
98
+ kind: "type_mismatch",
99
+ remoteName: table,
100
+ table,
101
+ column: fieldName,
102
+ expected: `varchar(${field.maxLength})`,
103
+ actual: `varchar(${col.maxLength})`,
104
+ severity: "warning",
105
+ category: "safe",
106
+ op: { type: "widen_varchar", table, column: fieldName, to: field.maxLength, from: col.maxLength },
107
+ message: `${table}.${fieldName}: metadata allows ${field.maxLength} chars but the column caps at ${col.maxLength} \u2014 widen via "os migrate".`
108
+ });
109
+ } else {
110
+ out.push({
111
+ kind: "type_mismatch",
112
+ remoteName: table,
113
+ table,
114
+ column: fieldName,
115
+ expected: `varchar(${field.maxLength})`,
116
+ actual: `varchar(${col.maxLength})`,
117
+ severity: "error",
118
+ category: "destructive",
119
+ op: { type: "narrow_varchar", table, column: fieldName, to: field.maxLength, from: col.maxLength },
120
+ message: `${table}.${fieldName}: metadata caps at ${field.maxLength} chars but the column allows ${col.maxLength} \u2014 narrowing may truncate. "os migrate apply --allow-destructive".`
121
+ });
122
+ }
123
+ }
124
+ }
125
+ for (const col of columns) {
126
+ if (BUILTIN_COLUMNS.has(col.name)) continue;
127
+ if (expectedColumns.has(col.name)) continue;
128
+ out.push({
129
+ kind: "unmapped_column",
130
+ remoteName: table,
131
+ table,
132
+ column: col.name,
133
+ expected: "(absent)",
134
+ actual: col.type,
135
+ severity: "warning",
136
+ category: "destructive",
137
+ op: { type: "drop_column", table, column: col.name },
138
+ message: `${table}.${col.name}: column exists in the database but not in metadata (orphaned) \u2014 "os migrate apply --allow-destructive" to drop it.`
139
+ });
140
+ }
141
+ return out;
142
+ }
143
+ function driftKey(d) {
144
+ return `${d.table}.${d.column ?? ""}:${d.kind}`;
145
+ }
146
+
147
+ // src/sql-driver.ts
42
148
  var import_knex = __toESM(require("knex"));
43
149
  var import_nanoid = require("nanoid");
44
150
  var import_node_crypto = require("crypto");
@@ -86,6 +192,20 @@ var SqlDriver = class {
86
192
  this.numericFields = {};
87
193
  this.dateFields = {};
88
194
  this.datetimeFields = {};
195
+ /**
196
+ * Federation read path (ADR-0015). For external objects whose physical
197
+ * remote table differs from the object name, these map between the two so
198
+ * {@link getBuilder} targets the remote table while the coercion maps above
199
+ * stay keyed by OBJECT name (matching formatInput/formatOutput). Empty for
200
+ * managed objects, so the managed query path is unchanged.
201
+ */
202
+ this.physicalTableByObject = {};
203
+ this.physicalSchemaByObject = {};
204
+ this.objectByPhysicalTable = {};
205
+ /** External columnMap (ADR-0015): logical field -> physical remote column (for WHERE/ORDER BY/writes). */
206
+ this.fieldColumnByObject = {};
207
+ /** External columnMap inverse: physical remote column -> logical field (for read output remap). */
208
+ this.columnFieldByObject = {};
89
209
  this.tablesWithTimestamps = /* @__PURE__ */ new Set();
90
210
  /**
91
211
  * Autonumber field configs per table, captured during initObjects.
@@ -138,8 +258,19 @@ var SqlDriver = class {
138
258
  this.logger = {
139
259
  warn: (msg, meta) => console.warn(msg, meta ?? "")
140
260
  };
141
- const { schemaMode, ...knexConfig } = config;
261
+ /**
262
+ * Metadata field defs for every table this driver manages, captured during
263
+ * `initObjects` (tableName → fields). The source of truth that
264
+ * {@link detectManagedDrift} diffs the physical schema against.
265
+ */
266
+ this.managedObjectFields = /* @__PURE__ */ new Map();
267
+ /** Declared indexes per managed table (tableName → indexes[]), captured in `initObjects`. Used to recreate indexes after a SQLite table rebuild. */
268
+ this.managedObjectIndexes = /* @__PURE__ */ new Map();
269
+ /** De-dup set for boot-time drift warnings (keyed by {@link driftKey}). */
270
+ this.driftWarned = /* @__PURE__ */ new Set();
271
+ const { schemaMode, autoMigrate, ...knexConfig } = config;
142
272
  this.schemaMode = schemaMode ?? "managed";
273
+ this.autoMigrate = autoMigrate ?? "off";
143
274
  this.config = knexConfig;
144
275
  this.knex = (0, import_knex.default)(knexConfig);
145
276
  }
@@ -310,6 +441,13 @@ var SqlDriver = class {
310
441
  // ===================================
311
442
  async connect() {
312
443
  await this.ensureDatabaseExists();
444
+ if (this.isSqlite) {
445
+ try {
446
+ await this.knex.raw("PRAGMA auto_vacuum = INCREMENTAL");
447
+ } catch (e) {
448
+ this.logger.warn("Failed to set PRAGMA auto_vacuum=INCREMENTAL", e);
449
+ }
450
+ }
313
451
  }
314
452
  async checkHealth() {
315
453
  try {
@@ -335,7 +473,7 @@ var SqlDriver = class {
335
473
  if (query.orderBy && Array.isArray(query.orderBy)) {
336
474
  for (const item of query.orderBy) {
337
475
  if (item.field) {
338
- b.orderBy(this.mapSortField(item.field), item.order || "asc");
476
+ b.orderBy(this.remoteColumn(object, item.field, this.mapSortField(item.field)), item.order || "asc");
339
477
  }
340
478
  }
341
479
  }
@@ -412,7 +550,7 @@ var SqlDriver = class {
412
550
  this.injectTenantOnInsert(object, toInsert, options);
413
551
  await this.fillAutoNumberFields(object, toInsert, options);
414
552
  const builder = this.getBuilder(object, options);
415
- const formatted = this.formatInput(object, toInsert);
553
+ const formatted = this.applyWriteColumnMap(object, this.formatInput(object, toInsert));
416
554
  const result = await builder.insert(formatted).returning("*");
417
555
  return this.formatOutput(object, result[0]);
418
556
  }
@@ -609,8 +747,8 @@ var SqlDriver = class {
609
747
  * tenant scopes it for isolation.
610
748
  */
611
749
  async fillAutoNumberFields(object, row, options) {
612
- const tableName = import_system.StorageNameMapping.resolveTableName({ name: object });
613
- const cfgs = this.autoNumberFields[tableName] || this.autoNumberFields[object];
750
+ const tableName = this.physicalTableByObject[object] ?? import_system.StorageNameMapping.resolveTableName({ name: object });
751
+ const cfgs = this.autoNumberFields[object] || this.autoNumberFields[tableName];
614
752
  if (!cfgs || cfgs.length === 0) return;
615
753
  const parentTrx = options?.transaction;
616
754
  const timezone = options?.timezone;
@@ -644,7 +782,7 @@ var SqlDriver = class {
644
782
  this.auditMissingTenant(object, "update", options);
645
783
  const builder = this.getBuilder(object, options).where("id", id);
646
784
  this.applyTenantScope(builder, object, options);
647
- const formatted = this.formatInput(object, data);
785
+ const formatted = this.applyWriteColumnMap(object, this.formatInput(object, data));
648
786
  if (this.tablesWithTimestamps.has(object)) {
649
787
  if (this.isSqlite) {
650
788
  const now = /* @__PURE__ */ new Date();
@@ -670,7 +808,7 @@ var SqlDriver = class {
670
808
  this.auditMissingTenant(object, "upsert", options);
671
809
  this.injectTenantOnInsert(object, toUpsert, options);
672
810
  await this.fillAutoNumberFields(object, toUpsert, options);
673
- const formatted = this.formatInput(object, toUpsert);
811
+ const formatted = this.applyWriteColumnMap(object, this.formatInput(object, toUpsert));
674
812
  const mergeKeys = conflictKeys && conflictKeys.length > 0 ? conflictKeys : ["id"];
675
813
  const builder = this.getBuilder(object, options);
676
814
  await builder.insert(formatted).onConflict(mergeKeys).merge();
@@ -953,12 +1091,99 @@ var SqlDriver = class {
953
1091
  /**
954
1092
  * Batch-initialise tables from an array of object definitions.
955
1093
  */
1094
+ /**
1095
+ * DDL-free metadata registration for a federated (external) object — the
1096
+ * read-path counterpart to {@link initObjects} (ADR-0015 federation).
1097
+ *
1098
+ * `initObjects` is gated by `assertSchemaMutable` and therefore throws for
1099
+ * any non-`managed` driver, which left external objects with NO read-coercion
1100
+ * metadata and the query path resolving to a table named after the object
1101
+ * instead of its remote table. This populates the same coercion maps (keyed
1102
+ * by OBJECT name, matching formatInput/formatOutput/coerceFilterValue) and
1103
+ * records the physical remote table (`external.remoteName`, optionally
1104
+ * `external.remoteSchema`) so {@link getBuilder} targets it — WITHOUT running
1105
+ * any DDL (createTable/alterTable/columnInfo). Keep the field-classification
1106
+ * below in sync with initObjects() if the field-type -> storage mapping changes.
1107
+ */
1108
+ registerExternalObject(schema) {
1109
+ const key = schema.name;
1110
+ const remoteName = schema.external?.remoteName || schema.name;
1111
+ const remoteSchema = schema.external?.remoteSchema;
1112
+ this.physicalTableByObject[key] = remoteName;
1113
+ this.objectByPhysicalTable[remoteName] = key;
1114
+ if (remoteSchema) {
1115
+ if (this.isSqlite) {
1116
+ this.logger.warn(
1117
+ `[sql-driver] external object "${key}" declares remoteSchema="${remoteSchema}" but SQLite has no schema namespace; ignoring (treating "${remoteName}" as a bare table).`
1118
+ );
1119
+ } else {
1120
+ this.physicalSchemaByObject[key] = remoteSchema;
1121
+ }
1122
+ }
1123
+ const columnMap = schema.external?.columnMap;
1124
+ if (columnMap && typeof columnMap === "object" && Object.keys(columnMap).length > 0) {
1125
+ const fieldToCol = {};
1126
+ const colToField = {};
1127
+ for (const [remoteCol, localField] of Object.entries(columnMap)) {
1128
+ if (typeof localField === "string" && localField) {
1129
+ fieldToCol[localField] = remoteCol;
1130
+ colToField[remoteCol] = localField;
1131
+ }
1132
+ }
1133
+ this.fieldColumnByObject[key] = fieldToCol;
1134
+ this.columnFieldByObject[key] = colToField;
1135
+ }
1136
+ const jsonCols = [];
1137
+ const booleanCols = [];
1138
+ const numericCols = [];
1139
+ const dateCols = [];
1140
+ const datetimeCols = [];
1141
+ 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
+ }
1154
+ if (schema.fields) {
1155
+ for (const [name, field] of Object.entries(schema.fields)) {
1156
+ const type = field.type || "string";
1157
+ if (this.isJsonField(type, field)) jsonCols.push(name);
1158
+ if (type === "boolean" || type === "toggle") booleanCols.push(name);
1159
+ if (NUMERIC_SCALAR_TYPES.has(type) && !field.multiple) numericCols.push(name);
1160
+ if (type === "date") dateCols.push(name);
1161
+ if (type === "datetime") datetimeCols.push(name);
1162
+ if (type === "auto_number" || type === "autonumber") {
1163
+ const rawFmt = typeof field.autonumberFormat === "string" && field.autonumberFormat ? field.autonumberFormat : typeof field.format === "string" && field.format ? field.format : "";
1164
+ const fmt = rawFmt || "{0000}";
1165
+ autoNumberCols.push({ name, format: fmt, tokens: (0, import_data.parseAutonumberFormat)(fmt), tenantField });
1166
+ }
1167
+ }
1168
+ }
1169
+ this.jsonFields[key] = jsonCols;
1170
+ this.booleanFields[key] = booleanCols;
1171
+ this.numericFields[key] = numericCols;
1172
+ this.autoNumberFields[key] = autoNumberCols;
1173
+ this.tenantFieldByTable[key] = tenantField;
1174
+ if (dateCols.length) this.dateFields[key] = new Set(dateCols);
1175
+ if (datetimeCols.length) this.datetimeFields[key] = new Set(datetimeCols);
1176
+ }
956
1177
  async initObjects(objects) {
957
1178
  var _a, _b;
958
1179
  this.assertSchemaMutable("initObjects");
959
1180
  await this.ensureDatabaseExists();
960
1181
  for (const obj of objects) {
961
1182
  const tableName = import_system.StorageNameMapping.resolveTableName(obj);
1183
+ this.managedObjectFields.set(tableName, obj.fields ?? {});
1184
+ if (Array.isArray(obj.indexes)) {
1185
+ this.managedObjectIndexes.set(tableName, obj.indexes);
1186
+ }
962
1187
  const jsonCols = [];
963
1188
  const booleanCols = [];
964
1189
  const numericCols = [];
@@ -1051,8 +1276,267 @@ var SqlDriver = class {
1051
1276
  const physicalColumns = new Set(Object.keys(colInfo));
1052
1277
  await this.syncDeclaredIndexes(tableName, declaredIndexes, physicalColumns);
1053
1278
  }
1279
+ if (exists) {
1280
+ await this.reconcileAndWarnDrift(tableName, obj.fields ?? {});
1281
+ }
1054
1282
  }
1055
1283
  }
1284
+ // ── Managed-schema drift & reconcile (#2186) ───────────────────────────────
1285
+ /** Canonical dialect name for the drift differ. */
1286
+ get dialectName() {
1287
+ if (this.isSqlite) return "sqlite";
1288
+ if (this.isPostgres) return "postgres";
1289
+ if (this.isMysql) return "mysql";
1290
+ return "unknown";
1291
+ }
1292
+ /** True only when running under `NODE_ENV=production` — auto-DDL is force-disabled there. */
1293
+ isProductionEnv() {
1294
+ try {
1295
+ return (process.env.NODE_ENV ?? "").toLowerCase() === "production";
1296
+ } catch {
1297
+ return false;
1298
+ }
1299
+ }
1300
+ /** Diff one table's metadata fields against its physical columns. */
1301
+ async detectTableDrift(tableName, fields) {
1302
+ const cols = await this.introspectColumns(tableName);
1303
+ const physical = cols.map((c) => ({
1304
+ name: c.name,
1305
+ type: c.type,
1306
+ nullable: c.nullable,
1307
+ maxLength: c.maxLength
1308
+ }));
1309
+ return diffManagedTable({ table: tableName, fields, columns: physical, dialect: this.dialectName });
1310
+ }
1311
+ /**
1312
+ * Detect every managed-schema divergence between metadata and the physical
1313
+ * database. Metadata is the source of truth. Returns one entry per drift,
1314
+ * sorted by table then column. Used by `os migrate` (P3) and tests.
1315
+ *
1316
+ * @param objects optional explicit object list; defaults to whatever
1317
+ * `initObjects` last synced (captured in {@link managedObjectFields}).
1318
+ */
1319
+ async detectManagedDrift(objects) {
1320
+ const tables = /* @__PURE__ */ new Map();
1321
+ if (objects) {
1322
+ for (const o of objects) tables.set(import_system.StorageNameMapping.resolveTableName(o), o.fields ?? {});
1323
+ } else {
1324
+ for (const [t, f] of this.managedObjectFields) tables.set(t, f);
1325
+ }
1326
+ const out = [];
1327
+ for (const [tableName, fields] of tables) {
1328
+ if (!await this.knex.schema.hasTable(tableName)) continue;
1329
+ out.push(...await this.detectTableDrift(tableName, fields));
1330
+ }
1331
+ out.sort((a, b) => a.table === b.table ? (a.column ?? "").localeCompare(b.column ?? "") : a.table.localeCompare(b.table));
1332
+ return out;
1333
+ }
1334
+ /**
1335
+ * Boot-time per-table drift handling (P1 + P2): detect divergence, in dev
1336
+ * auto-reconcile the *safe* (loosening) subset when `autoMigrate==='safe'`,
1337
+ * then WARN once per remaining divergence with an actionable hint.
1338
+ */
1339
+ async reconcileAndWarnDrift(tableName, fields) {
1340
+ let drift;
1341
+ try {
1342
+ drift = await this.detectTableDrift(tableName, fields);
1343
+ } catch (e) {
1344
+ this.logger.warn(`[schema-drift] could not introspect '${tableName}' for drift detection`, e?.message ?? e);
1345
+ return;
1346
+ }
1347
+ if (drift.length === 0) return;
1348
+ const autoOn = this.autoMigrate === "safe" && this.schemaMode === "managed";
1349
+ if (autoOn && this.isProductionEnv()) {
1350
+ this.logger.warn(
1351
+ `[schema-drift] autoMigrate='safe' is ignored under NODE_ENV=production \u2014 schema is never auto-altered in production. Run 'os migrate' deliberately.`
1352
+ );
1353
+ } else if (autoOn) {
1354
+ const safe = drift.filter((d) => d.category === "safe");
1355
+ if (safe.length > 0) {
1356
+ try {
1357
+ const { applied } = await this.applyMigrationEntries(safe, { allowDestructive: false });
1358
+ for (const d of applied) {
1359
+ (this.logger.info ?? this.logger.warn)(`[schema-drift] auto-reconciled ${d.op.type} on ${d.table}.${d.column}`);
1360
+ }
1361
+ drift = await this.detectTableDrift(tableName, fields);
1362
+ } catch (e) {
1363
+ this.logger.warn(`[schema-drift] dev auto-reconcile failed for '${tableName}' \u2014 falling back to warning`, e?.message ?? e);
1364
+ }
1365
+ }
1366
+ }
1367
+ for (const d of drift) {
1368
+ const k = driftKey(d);
1369
+ if (this.driftWarned.has(k)) continue;
1370
+ this.driftWarned.add(k);
1371
+ this.logger.warn(`[schema-drift] ${d.message}`);
1372
+ }
1373
+ }
1374
+ /**
1375
+ * Apply a set of drift entries to the physical schema. Destructive entries
1376
+ * are skipped unless `allowDestructive` is set. Postgres/MySQL alter columns
1377
+ * in place; SQLite (which cannot alter constraints in place) rebuilds each
1378
+ * affected table (copy → swap) applying only the requested edits.
1379
+ *
1380
+ * @returns the entries actually applied and those skipped (e.g. destructive
1381
+ * without `allowDestructive`, or unsupported on the dialect).
1382
+ */
1383
+ async applyMigrationEntries(entries, opts = {}) {
1384
+ this.assertSchemaMutable("reconcileManagedSchema");
1385
+ const allowDestructive = opts.allowDestructive === true;
1386
+ const applied = [];
1387
+ const skipped = [];
1388
+ const candidates = entries.filter((d) => {
1389
+ if (d.category === "destructive" && !allowDestructive) {
1390
+ skipped.push(d);
1391
+ return false;
1392
+ }
1393
+ return true;
1394
+ });
1395
+ if (candidates.length === 0) return { applied, skipped };
1396
+ const byTable = /* @__PURE__ */ new Map();
1397
+ for (const d of candidates) {
1398
+ (byTable.get(d.table) ?? byTable.set(d.table, []).get(d.table)).push(d);
1399
+ }
1400
+ for (const [table, ents] of byTable) {
1401
+ try {
1402
+ if (this.isSqlite) {
1403
+ await this.rebuildSqliteTablePatched(table, ents);
1404
+ applied.push(...ents);
1405
+ } else {
1406
+ for (const d of ents) {
1407
+ const ok = await this.applyDriftOpInPlace(d.op);
1408
+ (ok ? applied : skipped).push(d);
1409
+ }
1410
+ }
1411
+ } catch (e) {
1412
+ this.logger.warn(`[schema-drift] failed to reconcile '${table}'`, e?.message ?? e);
1413
+ for (const d of ents) if (!applied.includes(d)) skipped.push(d);
1414
+ }
1415
+ }
1416
+ return { applied, skipped };
1417
+ }
1418
+ /** Apply a single drift op in place (Postgres / MySQL). Returns false if unsupported. */
1419
+ async applyDriftOpInPlace(op) {
1420
+ const { table, column } = op;
1421
+ if (this.isPostgres) {
1422
+ switch (op.type) {
1423
+ case "relax_not_null":
1424
+ await this.knex.raw("ALTER TABLE ?? ALTER COLUMN ?? DROP NOT NULL", [table, column]);
1425
+ return true;
1426
+ case "tighten_not_null":
1427
+ await this.knex.raw("ALTER TABLE ?? ALTER COLUMN ?? SET NOT NULL", [table, column]);
1428
+ return true;
1429
+ case "widen_varchar":
1430
+ case "narrow_varchar":
1431
+ await this.knex.raw(`ALTER TABLE ?? ALTER COLUMN ?? TYPE varchar(${op.to})`, [table, column]);
1432
+ return true;
1433
+ case "drop_column":
1434
+ await this.knex.raw("ALTER TABLE ?? DROP COLUMN ??", [table, column]);
1435
+ return true;
1436
+ }
1437
+ }
1438
+ if (this.isMysql) {
1439
+ const info = await this.knex(table).columnInfo();
1440
+ const ci = info?.[column];
1441
+ const colType = ci?.type ? /char/i.test(ci.type) && ci.maxLength ? `${ci.type}(${ci.maxLength})` : ci.type : void 0;
1442
+ switch (op.type) {
1443
+ case "relax_not_null":
1444
+ if (!colType) return false;
1445
+ await this.knex.raw(`ALTER TABLE ?? MODIFY ?? ${colType} NULL`, [table, column]);
1446
+ return true;
1447
+ case "tighten_not_null":
1448
+ if (!colType) return false;
1449
+ await this.knex.raw(`ALTER TABLE ?? MODIFY ?? ${colType} NOT NULL`, [table, column]);
1450
+ return true;
1451
+ case "widen_varchar":
1452
+ case "narrow_varchar":
1453
+ await this.knex.raw(`ALTER TABLE ?? MODIFY ?? varchar(${op.to})`, [table, column]);
1454
+ return true;
1455
+ case "drop_column":
1456
+ await this.knex.raw("ALTER TABLE ?? DROP COLUMN ??", [table, column]);
1457
+ return true;
1458
+ }
1459
+ }
1460
+ this.logger.warn(`[schema-drift] ${op.type} on ${table}.${column} is unsupported on dialect '${this.dialectName}' \u2014 skipped`);
1461
+ return false;
1462
+ }
1463
+ /**
1464
+ * Rebuild a SQLite table applying a set of column edits (relax/tighten NOT
1465
+ * NULL, drop column), preserving all other columns and their data. Follows
1466
+ * the official SQLite procedure: create patched table → copy → drop → rename.
1467
+ * varchar widen/narrow are no-ops on SQLite (dynamic typing) and ignored.
1468
+ *
1469
+ * Unique field-level constraints and declared indexes are recreated from
1470
+ * metadata afterwards (the source of truth). DB-level foreign keys declared
1471
+ * by `lookup` fields are not re-added (ObjectStack enforces relationships at
1472
+ * the application layer, not via SQLite FK constraints).
1473
+ */
1474
+ async rebuildSqliteTablePatched(table, ents) {
1475
+ const relax = /* @__PURE__ */ new Set();
1476
+ const tighten = /* @__PURE__ */ new Set();
1477
+ const drop = /* @__PURE__ */ new Set();
1478
+ for (const e of ents) {
1479
+ if (e.op.type === "relax_not_null") relax.add(e.op.column);
1480
+ else if (e.op.type === "tighten_not_null") tighten.add(e.op.column);
1481
+ else if (e.op.type === "drop_column") drop.add(e.op.column);
1482
+ }
1483
+ const physical = await this.introspectColumns(table);
1484
+ const kept = physical.filter((c) => !drop.has(c.name));
1485
+ const keptNames = kept.map((c) => c.name);
1486
+ const fields = this.managedObjectFields.get(table) ?? {};
1487
+ const tmp = `__os_mig_${table}`;
1488
+ await this.knex.raw("PRAGMA foreign_keys = OFF");
1489
+ try {
1490
+ await this.knex.transaction(async (trx) => {
1491
+ await trx.schema.dropTableIfExists(tmp);
1492
+ await trx.schema.createTable(tmp, (t) => {
1493
+ for (const c of kept) {
1494
+ const col = this.buildRebuiltColumn(t, c);
1495
+ if (!col) continue;
1496
+ const nullable = relax.has(c.name) ? true : tighten.has(c.name) ? false : c.nullable;
1497
+ if (!nullable && c.name !== "id") col.notNullable();
1498
+ if (c.name === "created_at" || c.name === "updated_at") col.defaultTo(this.knex.fn.now());
1499
+ }
1500
+ });
1501
+ const colList = keptNames.map((n) => `"${n}"`).join(", ");
1502
+ await trx.raw(`INSERT INTO "${tmp}" (${colList}) SELECT ${colList} FROM "${table}"`);
1503
+ await trx.schema.dropTable(table);
1504
+ await trx.schema.renameTable(tmp, table);
1505
+ });
1506
+ } finally {
1507
+ await this.knex.raw("PRAGMA foreign_keys = ON");
1508
+ }
1509
+ try {
1510
+ const keptSet = new Set(keptNames);
1511
+ for (const [name, field] of Object.entries(fields)) {
1512
+ if (field?.unique && keptSet.has(name)) {
1513
+ const idx = `uniq_${table}_${name}`;
1514
+ await this.knex.raw("CREATE UNIQUE INDEX IF NOT EXISTS ?? ON ?? (??)", [idx, table, name]);
1515
+ }
1516
+ }
1517
+ const declared = this.managedObjectIndexes.get(table);
1518
+ if (Array.isArray(declared) && declared.length > 0) {
1519
+ await this.syncDeclaredIndexes(table, declared, keptSet);
1520
+ }
1521
+ } catch (e) {
1522
+ this.logger.warn(`[schema-drift] could not fully recreate indexes for '${table}' after rebuild`, e?.message ?? e);
1523
+ }
1524
+ }
1525
+ /** Map an introspected SQLite column to a knex builder for the rebuilt table. */
1526
+ buildRebuiltColumn(t, c) {
1527
+ if (c.name === "id") return t.string("id").primary();
1528
+ const ty = (c.type || "text").toLowerCase();
1529
+ if (ty.includes("int")) return t.integer(c.name);
1530
+ if (/(real|floa|doub|num|dec)/.test(ty)) return t.float(c.name);
1531
+ if (ty.includes("bool")) return t.boolean(c.name);
1532
+ if (ty.includes("datetime") || ty.includes("timestamp")) return t.timestamp(c.name);
1533
+ if (ty === "date") return t.date(c.name);
1534
+ if (ty === "time") return t.time(c.name);
1535
+ if (ty.includes("json")) return t.json(c.name);
1536
+ if (ty.includes("blob") || ty.includes("binary")) return t.binary(c.name);
1537
+ if (ty.includes("text")) return t.text(c.name);
1538
+ return t.string(c.name);
1539
+ }
1056
1540
  /**
1057
1541
  * Build a deterministic index name for a declared index so repeated
1058
1542
  * `initObjects` runs converge on the same identifier (and can detect an
@@ -1194,7 +1678,12 @@ var SqlDriver = class {
1194
1678
  return this.knex;
1195
1679
  }
1196
1680
  getBuilder(object, options) {
1197
- let builder = this.knex(object);
1681
+ const physical = this.physicalTableByObject[object] ?? object;
1682
+ let builder = this.knex(physical);
1683
+ const remoteSchema = this.physicalSchemaByObject[object];
1684
+ if (remoteSchema) {
1685
+ builder = builder.withSchema(remoteSchema);
1686
+ }
1198
1687
  if (options?.transaction) {
1199
1688
  builder = builder.transacting(options.transaction);
1200
1689
  }
@@ -1293,6 +1782,20 @@ var SqlDriver = class {
1293
1782
  if (typeof t === "string") return t;
1294
1783
  return null;
1295
1784
  }
1785
+ /**
1786
+ * Coercion-map key for a builder. Coercion maps (date/datetime) are keyed by
1787
+ * OBJECT name, but after the federation change {@link getBuilder} targets the
1788
+ * physical remote table, so a builder reports the remote name. Map it back to
1789
+ * the object name for external objects; identity for managed ones (no reverse
1790
+ * entry). Note datetime coercion is a SQLite-only concern (see
1791
+ * coerceFilterValue), and SQLite external tables are bare-named, so this is
1792
+ * exact where it matters.
1793
+ */
1794
+ coercionKey(builder) {
1795
+ const physical = this.tableNameForBuilder(builder);
1796
+ if (physical == null) return null;
1797
+ return this.objectByPhysicalTable[physical] ?? physical;
1798
+ }
1296
1799
  /**
1297
1800
  * Collapse a `Field.date` value to a timezone-naive `YYYY-MM-DD`
1298
1801
  * calendar-day string (ADR-0053 Phase 1). A `Date` collapses to its UTC
@@ -1386,7 +1889,7 @@ var SqlDriver = class {
1386
1889
  }
1387
1890
  applyFilters(builder, filters) {
1388
1891
  if (!filters) return;
1389
- const table = this.tableNameForBuilder(builder);
1892
+ const table = this.coercionKey(builder);
1390
1893
  if (!Array.isArray(filters) && typeof filters === "object") {
1391
1894
  const hasMongoOperators = Object.keys(filters).some(
1392
1895
  (k) => k.startsWith("$") || typeof filters[k] === "object" && filters[k] !== null && Object.keys(filters[k]).some((op) => op.startsWith("$"))
@@ -1397,7 +1900,7 @@ var SqlDriver = class {
1397
1900
  }
1398
1901
  for (const [key, value] of Object.entries(filters)) {
1399
1902
  if (["limit", "offset", "fields", "orderBy"].includes(key)) continue;
1400
- builder.where(key, this.coerceFilterValue(table, key, value));
1903
+ builder.where(this.remoteColumn(table, key, key), this.coerceFilterValue(table, key, value));
1401
1904
  }
1402
1905
  return;
1403
1906
  }
@@ -1413,8 +1916,9 @@ var SqlDriver = class {
1413
1916
  const [fieldRaw, op, value] = item;
1414
1917
  const isCriterion = typeof fieldRaw === "string" && typeof op === "string";
1415
1918
  if (isCriterion) {
1416
- const field = this.mapSortField(fieldRaw);
1417
- const coerced = this.coerceFilterValue(table, field, value);
1919
+ const localField = this.mapSortField(fieldRaw);
1920
+ const field = this.remoteColumn(table, fieldRaw, localField);
1921
+ const coerced = this.coerceFilterValue(table, localField, value);
1418
1922
  const apply = (b) => {
1419
1923
  const method = nextJoin === "or" ? "orWhere" : "where";
1420
1924
  const methodIn = nextJoin === "or" ? "orWhereIn" : "whereIn";
@@ -1466,7 +1970,7 @@ var SqlDriver = class {
1466
1970
  }
1467
1971
  applyFilterCondition(builder, condition, logicalOp = "and", tableHint) {
1468
1972
  if (!condition || typeof condition !== "object") return;
1469
- const table = tableHint ?? this.tableNameForBuilder(builder);
1973
+ const table = tableHint ?? this.coercionKey(builder);
1470
1974
  for (const [key, value] of Object.entries(condition)) {
1471
1975
  if (key === "$and" && Array.isArray(value)) {
1472
1976
  builder.where((qb) => {
@@ -1486,10 +1990,11 @@ var SqlDriver = class {
1486
1990
  }
1487
1991
  });
1488
1992
  } else if (typeof value === "object" && value !== null && !Array.isArray(value)) {
1489
- const field = this.mapSortField(key);
1993
+ const localField = this.mapSortField(key);
1994
+ const field = this.remoteColumn(table, key, localField);
1490
1995
  for (const [op, opValue] of Object.entries(value)) {
1491
1996
  const method = logicalOp === "or" ? "orWhere" : "where";
1492
- const coerced = this.coerceFilterValue(table, field, opValue);
1997
+ const coerced = this.coerceFilterValue(table, localField, opValue);
1493
1998
  switch (op) {
1494
1999
  case "$eq":
1495
2000
  builder[method](field, coerced);
@@ -1527,9 +2032,10 @@ var SqlDriver = class {
1527
2032
  }
1528
2033
  }
1529
2034
  } else {
1530
- const field = this.mapSortField(key);
2035
+ const localField = this.mapSortField(key);
2036
+ const field = this.remoteColumn(table, key, localField);
1531
2037
  const method = logicalOp === "or" ? "orWhere" : "where";
1532
- builder[method](field, this.coerceFilterValue(table, field, value));
2038
+ builder[method](field, this.coerceFilterValue(table, localField, value));
1533
2039
  }
1534
2040
  }
1535
2041
  }
@@ -1539,6 +2045,28 @@ var SqlDriver = class {
1539
2045
  if (field === "updatedAt") return "updated_at";
1540
2046
  return field;
1541
2047
  }
2048
+ /**
2049
+ * Physical column for a logical field on an external object that declares an
2050
+ * `external.columnMap` (ADR-0015). Returns `fallback` (the caller's existing
2051
+ * per-site resolution) when the object has no columnMap, so managed objects
2052
+ * and external objects without a columnMap are byte-for-byte unchanged.
2053
+ */
2054
+ remoteColumn(object, field, fallback) {
2055
+ const m = object ? this.fieldColumnByObject[object] : void 0;
2056
+ return m && m[field] || fallback;
2057
+ }
2058
+ /**
2059
+ * Remap a write payload's logical field keys to physical remote columns for an
2060
+ * external object with a columnMap. No-op otherwise. Applied AFTER formatInput
2061
+ * (whose value coercion is keyed by logical field name).
2062
+ */
2063
+ applyWriteColumnMap(object, data) {
2064
+ const m = this.fieldColumnByObject[object];
2065
+ if (!m || !data || typeof data !== "object") return data;
2066
+ const out = {};
2067
+ for (const [k, v] of Object.entries(data)) out[m[k] ?? k] = v;
2068
+ return out;
2069
+ }
1542
2070
  mapAggregateFunc(func) {
1543
2071
  switch (func) {
1544
2072
  case "count":
@@ -1795,6 +2323,15 @@ var SqlDriver = class {
1795
2323
  }
1796
2324
  formatOutput(object, data) {
1797
2325
  if (!data) return data;
2326
+ const colToField = this.columnFieldByObject[object];
2327
+ if (colToField && typeof data === "object") {
2328
+ for (const [remoteCol, localField] of Object.entries(colToField)) {
2329
+ if (remoteCol !== localField && Object.prototype.hasOwnProperty.call(data, remoteCol)) {
2330
+ data[localField] = data[remoteCol];
2331
+ delete data[remoteCol];
2332
+ }
2333
+ }
2334
+ }
1798
2335
  if (this.isSqlite) {
1799
2336
  const jsonFields = this.jsonFields[object];
1800
2337
  if (jsonFields && jsonFields.length > 0) {
@@ -2067,6 +2604,10 @@ var index_default = {
2067
2604
  };
2068
2605
  // Annotate the CommonJS export names for ESM import in node:
2069
2606
  0 && (module.exports = {
2070
- SqlDriver
2607
+ BUILTIN_COLUMNS,
2608
+ SqlDriver,
2609
+ diffManagedTable,
2610
+ driftKey,
2611
+ fieldHasColumn
2071
2612
  });
2072
2613
  //# sourceMappingURL=index.js.map