@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.mjs CHANGED
@@ -2,6 +2,108 @@
2
2
  import { parseAutonumberFormat, renderAutonumber, missingFieldValues } from "@objectstack/spec/data";
3
3
  import { StorageNameMapping } from "@objectstack/spec/system";
4
4
  import { ExternalSchemaModeViolationError } from "@objectstack/spec/shared";
5
+
6
+ // src/schema-drift.ts
7
+ var BUILTIN_COLUMNS = /* @__PURE__ */ new Set(["id", "created_at", "updated_at"]);
8
+ function fieldHasColumn(field) {
9
+ if (field?.multiple) return true;
10
+ return (field?.type ?? "string") !== "formula";
11
+ }
12
+ function enforcesVarcharLength(dialect) {
13
+ return dialect === "postgres" || dialect === "mysql";
14
+ }
15
+ function diffManagedTable(args) {
16
+ const { table, fields, columns, dialect } = args;
17
+ const out = [];
18
+ const columnsByName = new Map(columns.map((c) => [c.name, c]));
19
+ const expectedColumns = /* @__PURE__ */ new Set();
20
+ for (const [fieldName, field] of Object.entries(fields ?? {})) {
21
+ if (BUILTIN_COLUMNS.has(fieldName)) continue;
22
+ if (!fieldHasColumn(field)) continue;
23
+ expectedColumns.add(fieldName);
24
+ const col = columnsByName.get(fieldName);
25
+ if (!col) continue;
26
+ const expectNullable = field.required !== true;
27
+ if (expectNullable && !col.nullable) {
28
+ out.push({
29
+ kind: "nullability_mismatch",
30
+ remoteName: table,
31
+ table,
32
+ column: fieldName,
33
+ expected: "NULL",
34
+ actual: "NOT NULL",
35
+ severity: "warning",
36
+ category: "safe",
37
+ op: { type: "relax_not_null", table, column: fieldName },
38
+ message: `${table}.${fieldName}: metadata is optional but the column is NOT NULL \u2014 writes that omit it fail. Run "os migrate" to relax it.`
39
+ });
40
+ } else if (!expectNullable && col.nullable) {
41
+ out.push({
42
+ kind: "nullability_mismatch",
43
+ remoteName: table,
44
+ table,
45
+ column: fieldName,
46
+ expected: "NOT NULL",
47
+ actual: "NULL",
48
+ severity: "error",
49
+ category: "destructive",
50
+ op: { type: "tighten_not_null", table, column: fieldName },
51
+ message: `${table}.${fieldName}: metadata is required but the column is nullable \u2014 existing nulls must be backfilled. Run "os migrate apply --allow-destructive".`
52
+ });
53
+ }
54
+ if (enforcesVarcharLength(dialect) && typeof field.maxLength === "number" && typeof col.maxLength === "number" && field.maxLength !== col.maxLength) {
55
+ if (field.maxLength > col.maxLength) {
56
+ out.push({
57
+ kind: "type_mismatch",
58
+ remoteName: table,
59
+ table,
60
+ column: fieldName,
61
+ expected: `varchar(${field.maxLength})`,
62
+ actual: `varchar(${col.maxLength})`,
63
+ severity: "warning",
64
+ category: "safe",
65
+ op: { type: "widen_varchar", table, column: fieldName, to: field.maxLength, from: col.maxLength },
66
+ message: `${table}.${fieldName}: metadata allows ${field.maxLength} chars but the column caps at ${col.maxLength} \u2014 widen via "os migrate".`
67
+ });
68
+ } else {
69
+ out.push({
70
+ kind: "type_mismatch",
71
+ remoteName: table,
72
+ table,
73
+ column: fieldName,
74
+ expected: `varchar(${field.maxLength})`,
75
+ actual: `varchar(${col.maxLength})`,
76
+ severity: "error",
77
+ category: "destructive",
78
+ op: { type: "narrow_varchar", table, column: fieldName, to: field.maxLength, from: col.maxLength },
79
+ message: `${table}.${fieldName}: metadata caps at ${field.maxLength} chars but the column allows ${col.maxLength} \u2014 narrowing may truncate. "os migrate apply --allow-destructive".`
80
+ });
81
+ }
82
+ }
83
+ }
84
+ for (const col of columns) {
85
+ if (BUILTIN_COLUMNS.has(col.name)) continue;
86
+ if (expectedColumns.has(col.name)) continue;
87
+ out.push({
88
+ kind: "unmapped_column",
89
+ remoteName: table,
90
+ table,
91
+ column: col.name,
92
+ expected: "(absent)",
93
+ actual: col.type,
94
+ severity: "warning",
95
+ category: "destructive",
96
+ op: { type: "drop_column", table, column: col.name },
97
+ message: `${table}.${col.name}: column exists in the database but not in metadata (orphaned) \u2014 "os migrate apply --allow-destructive" to drop it.`
98
+ });
99
+ }
100
+ return out;
101
+ }
102
+ function driftKey(d) {
103
+ return `${d.table}.${d.column ?? ""}:${d.kind}`;
104
+ }
105
+
106
+ // src/sql-driver.ts
5
107
  import knex from "knex";
6
108
  import { nanoid } from "nanoid";
7
109
  import { createHash } from "crypto";
@@ -49,6 +151,20 @@ var SqlDriver = class {
49
151
  this.numericFields = {};
50
152
  this.dateFields = {};
51
153
  this.datetimeFields = {};
154
+ /**
155
+ * Federation read path (ADR-0015). For external objects whose physical
156
+ * remote table differs from the object name, these map between the two so
157
+ * {@link getBuilder} targets the remote table while the coercion maps above
158
+ * stay keyed by OBJECT name (matching formatInput/formatOutput). Empty for
159
+ * managed objects, so the managed query path is unchanged.
160
+ */
161
+ this.physicalTableByObject = {};
162
+ this.physicalSchemaByObject = {};
163
+ this.objectByPhysicalTable = {};
164
+ /** External columnMap (ADR-0015): logical field -> physical remote column (for WHERE/ORDER BY/writes). */
165
+ this.fieldColumnByObject = {};
166
+ /** External columnMap inverse: physical remote column -> logical field (for read output remap). */
167
+ this.columnFieldByObject = {};
52
168
  this.tablesWithTimestamps = /* @__PURE__ */ new Set();
53
169
  /**
54
170
  * Autonumber field configs per table, captured during initObjects.
@@ -101,8 +217,19 @@ var SqlDriver = class {
101
217
  this.logger = {
102
218
  warn: (msg, meta) => console.warn(msg, meta ?? "")
103
219
  };
104
- const { schemaMode, ...knexConfig } = config;
220
+ /**
221
+ * Metadata field defs for every table this driver manages, captured during
222
+ * `initObjects` (tableName → fields). The source of truth that
223
+ * {@link detectManagedDrift} diffs the physical schema against.
224
+ */
225
+ this.managedObjectFields = /* @__PURE__ */ new Map();
226
+ /** Declared indexes per managed table (tableName → indexes[]), captured in `initObjects`. Used to recreate indexes after a SQLite table rebuild. */
227
+ this.managedObjectIndexes = /* @__PURE__ */ new Map();
228
+ /** De-dup set for boot-time drift warnings (keyed by {@link driftKey}). */
229
+ this.driftWarned = /* @__PURE__ */ new Set();
230
+ const { schemaMode, autoMigrate, ...knexConfig } = config;
105
231
  this.schemaMode = schemaMode ?? "managed";
232
+ this.autoMigrate = autoMigrate ?? "off";
106
233
  this.config = knexConfig;
107
234
  this.knex = knex(knexConfig);
108
235
  }
@@ -273,6 +400,13 @@ var SqlDriver = class {
273
400
  // ===================================
274
401
  async connect() {
275
402
  await this.ensureDatabaseExists();
403
+ if (this.isSqlite) {
404
+ try {
405
+ await this.knex.raw("PRAGMA auto_vacuum = INCREMENTAL");
406
+ } catch (e) {
407
+ this.logger.warn("Failed to set PRAGMA auto_vacuum=INCREMENTAL", e);
408
+ }
409
+ }
276
410
  }
277
411
  async checkHealth() {
278
412
  try {
@@ -298,7 +432,7 @@ var SqlDriver = class {
298
432
  if (query.orderBy && Array.isArray(query.orderBy)) {
299
433
  for (const item of query.orderBy) {
300
434
  if (item.field) {
301
- b.orderBy(this.mapSortField(item.field), item.order || "asc");
435
+ b.orderBy(this.remoteColumn(object, item.field, this.mapSortField(item.field)), item.order || "asc");
302
436
  }
303
437
  }
304
438
  }
@@ -375,7 +509,7 @@ var SqlDriver = class {
375
509
  this.injectTenantOnInsert(object, toInsert, options);
376
510
  await this.fillAutoNumberFields(object, toInsert, options);
377
511
  const builder = this.getBuilder(object, options);
378
- const formatted = this.formatInput(object, toInsert);
512
+ const formatted = this.applyWriteColumnMap(object, this.formatInput(object, toInsert));
379
513
  const result = await builder.insert(formatted).returning("*");
380
514
  return this.formatOutput(object, result[0]);
381
515
  }
@@ -572,8 +706,8 @@ var SqlDriver = class {
572
706
  * tenant scopes it for isolation.
573
707
  */
574
708
  async fillAutoNumberFields(object, row, options) {
575
- const tableName = StorageNameMapping.resolveTableName({ name: object });
576
- const cfgs = this.autoNumberFields[tableName] || this.autoNumberFields[object];
709
+ const tableName = this.physicalTableByObject[object] ?? StorageNameMapping.resolveTableName({ name: object });
710
+ const cfgs = this.autoNumberFields[object] || this.autoNumberFields[tableName];
577
711
  if (!cfgs || cfgs.length === 0) return;
578
712
  const parentTrx = options?.transaction;
579
713
  const timezone = options?.timezone;
@@ -607,7 +741,7 @@ var SqlDriver = class {
607
741
  this.auditMissingTenant(object, "update", options);
608
742
  const builder = this.getBuilder(object, options).where("id", id);
609
743
  this.applyTenantScope(builder, object, options);
610
- const formatted = this.formatInput(object, data);
744
+ const formatted = this.applyWriteColumnMap(object, this.formatInput(object, data));
611
745
  if (this.tablesWithTimestamps.has(object)) {
612
746
  if (this.isSqlite) {
613
747
  const now = /* @__PURE__ */ new Date();
@@ -633,7 +767,7 @@ var SqlDriver = class {
633
767
  this.auditMissingTenant(object, "upsert", options);
634
768
  this.injectTenantOnInsert(object, toUpsert, options);
635
769
  await this.fillAutoNumberFields(object, toUpsert, options);
636
- const formatted = this.formatInput(object, toUpsert);
770
+ const formatted = this.applyWriteColumnMap(object, this.formatInput(object, toUpsert));
637
771
  const mergeKeys = conflictKeys && conflictKeys.length > 0 ? conflictKeys : ["id"];
638
772
  const builder = this.getBuilder(object, options);
639
773
  await builder.insert(formatted).onConflict(mergeKeys).merge();
@@ -916,12 +1050,99 @@ var SqlDriver = class {
916
1050
  /**
917
1051
  * Batch-initialise tables from an array of object definitions.
918
1052
  */
1053
+ /**
1054
+ * DDL-free metadata registration for a federated (external) object — the
1055
+ * read-path counterpart to {@link initObjects} (ADR-0015 federation).
1056
+ *
1057
+ * `initObjects` is gated by `assertSchemaMutable` and therefore throws for
1058
+ * any non-`managed` driver, which left external objects with NO read-coercion
1059
+ * metadata and the query path resolving to a table named after the object
1060
+ * instead of its remote table. This populates the same coercion maps (keyed
1061
+ * by OBJECT name, matching formatInput/formatOutput/coerceFilterValue) and
1062
+ * records the physical remote table (`external.remoteName`, optionally
1063
+ * `external.remoteSchema`) so {@link getBuilder} targets it — WITHOUT running
1064
+ * any DDL (createTable/alterTable/columnInfo). Keep the field-classification
1065
+ * below in sync with initObjects() if the field-type -> storage mapping changes.
1066
+ */
1067
+ registerExternalObject(schema) {
1068
+ const key = schema.name;
1069
+ const remoteName = schema.external?.remoteName || schema.name;
1070
+ const remoteSchema = schema.external?.remoteSchema;
1071
+ this.physicalTableByObject[key] = remoteName;
1072
+ this.objectByPhysicalTable[remoteName] = key;
1073
+ if (remoteSchema) {
1074
+ if (this.isSqlite) {
1075
+ this.logger.warn(
1076
+ `[sql-driver] external object "${key}" declares remoteSchema="${remoteSchema}" but SQLite has no schema namespace; ignoring (treating "${remoteName}" as a bare table).`
1077
+ );
1078
+ } else {
1079
+ this.physicalSchemaByObject[key] = remoteSchema;
1080
+ }
1081
+ }
1082
+ const columnMap = schema.external?.columnMap;
1083
+ if (columnMap && typeof columnMap === "object" && Object.keys(columnMap).length > 0) {
1084
+ const fieldToCol = {};
1085
+ const colToField = {};
1086
+ for (const [remoteCol, localField] of Object.entries(columnMap)) {
1087
+ if (typeof localField === "string" && localField) {
1088
+ fieldToCol[localField] = remoteCol;
1089
+ colToField[remoteCol] = localField;
1090
+ }
1091
+ }
1092
+ this.fieldColumnByObject[key] = fieldToCol;
1093
+ this.columnFieldByObject[key] = colToField;
1094
+ }
1095
+ const jsonCols = [];
1096
+ const booleanCols = [];
1097
+ const numericCols = [];
1098
+ const dateCols = [];
1099
+ const datetimeCols = [];
1100
+ const autoNumberCols = [];
1101
+ const tenancyDecl = schema?.tenancy;
1102
+ let tenantField = null;
1103
+ if (tenancyDecl && tenancyDecl.enabled !== false && tenancyDecl.tenantField) {
1104
+ const declared = String(tenancyDecl.tenantField);
1105
+ if (schema.fields && Object.prototype.hasOwnProperty.call(schema.fields, declared)) {
1106
+ tenantField = declared;
1107
+ }
1108
+ }
1109
+ if (!tenantField) {
1110
+ const hasOrgField = !!(schema.fields && Object.prototype.hasOwnProperty.call(schema.fields, "organization_id"));
1111
+ tenantField = hasOrgField ? "organization_id" : null;
1112
+ }
1113
+ if (schema.fields) {
1114
+ for (const [name, field] of Object.entries(schema.fields)) {
1115
+ const type = field.type || "string";
1116
+ if (this.isJsonField(type, field)) jsonCols.push(name);
1117
+ if (type === "boolean" || type === "toggle") booleanCols.push(name);
1118
+ if (NUMERIC_SCALAR_TYPES.has(type) && !field.multiple) numericCols.push(name);
1119
+ if (type === "date") dateCols.push(name);
1120
+ if (type === "datetime") datetimeCols.push(name);
1121
+ if (type === "auto_number" || type === "autonumber") {
1122
+ const rawFmt = typeof field.autonumberFormat === "string" && field.autonumberFormat ? field.autonumberFormat : typeof field.format === "string" && field.format ? field.format : "";
1123
+ const fmt = rawFmt || "{0000}";
1124
+ autoNumberCols.push({ name, format: fmt, tokens: parseAutonumberFormat(fmt), tenantField });
1125
+ }
1126
+ }
1127
+ }
1128
+ this.jsonFields[key] = jsonCols;
1129
+ this.booleanFields[key] = booleanCols;
1130
+ this.numericFields[key] = numericCols;
1131
+ this.autoNumberFields[key] = autoNumberCols;
1132
+ this.tenantFieldByTable[key] = tenantField;
1133
+ if (dateCols.length) this.dateFields[key] = new Set(dateCols);
1134
+ if (datetimeCols.length) this.datetimeFields[key] = new Set(datetimeCols);
1135
+ }
919
1136
  async initObjects(objects) {
920
1137
  var _a, _b;
921
1138
  this.assertSchemaMutable("initObjects");
922
1139
  await this.ensureDatabaseExists();
923
1140
  for (const obj of objects) {
924
1141
  const tableName = StorageNameMapping.resolveTableName(obj);
1142
+ this.managedObjectFields.set(tableName, obj.fields ?? {});
1143
+ if (Array.isArray(obj.indexes)) {
1144
+ this.managedObjectIndexes.set(tableName, obj.indexes);
1145
+ }
925
1146
  const jsonCols = [];
926
1147
  const booleanCols = [];
927
1148
  const numericCols = [];
@@ -1014,8 +1235,267 @@ var SqlDriver = class {
1014
1235
  const physicalColumns = new Set(Object.keys(colInfo));
1015
1236
  await this.syncDeclaredIndexes(tableName, declaredIndexes, physicalColumns);
1016
1237
  }
1238
+ if (exists) {
1239
+ await this.reconcileAndWarnDrift(tableName, obj.fields ?? {});
1240
+ }
1017
1241
  }
1018
1242
  }
1243
+ // ── Managed-schema drift & reconcile (#2186) ───────────────────────────────
1244
+ /** Canonical dialect name for the drift differ. */
1245
+ get dialectName() {
1246
+ if (this.isSqlite) return "sqlite";
1247
+ if (this.isPostgres) return "postgres";
1248
+ if (this.isMysql) return "mysql";
1249
+ return "unknown";
1250
+ }
1251
+ /** True only when running under `NODE_ENV=production` — auto-DDL is force-disabled there. */
1252
+ isProductionEnv() {
1253
+ try {
1254
+ return (process.env.NODE_ENV ?? "").toLowerCase() === "production";
1255
+ } catch {
1256
+ return false;
1257
+ }
1258
+ }
1259
+ /** Diff one table's metadata fields against its physical columns. */
1260
+ async detectTableDrift(tableName, fields) {
1261
+ const cols = await this.introspectColumns(tableName);
1262
+ const physical = cols.map((c) => ({
1263
+ name: c.name,
1264
+ type: c.type,
1265
+ nullable: c.nullable,
1266
+ maxLength: c.maxLength
1267
+ }));
1268
+ return diffManagedTable({ table: tableName, fields, columns: physical, dialect: this.dialectName });
1269
+ }
1270
+ /**
1271
+ * Detect every managed-schema divergence between metadata and the physical
1272
+ * database. Metadata is the source of truth. Returns one entry per drift,
1273
+ * sorted by table then column. Used by `os migrate` (P3) and tests.
1274
+ *
1275
+ * @param objects optional explicit object list; defaults to whatever
1276
+ * `initObjects` last synced (captured in {@link managedObjectFields}).
1277
+ */
1278
+ async detectManagedDrift(objects) {
1279
+ const tables = /* @__PURE__ */ new Map();
1280
+ if (objects) {
1281
+ for (const o of objects) tables.set(StorageNameMapping.resolveTableName(o), o.fields ?? {});
1282
+ } else {
1283
+ for (const [t, f] of this.managedObjectFields) tables.set(t, f);
1284
+ }
1285
+ const out = [];
1286
+ for (const [tableName, fields] of tables) {
1287
+ if (!await this.knex.schema.hasTable(tableName)) continue;
1288
+ out.push(...await this.detectTableDrift(tableName, fields));
1289
+ }
1290
+ out.sort((a, b) => a.table === b.table ? (a.column ?? "").localeCompare(b.column ?? "") : a.table.localeCompare(b.table));
1291
+ return out;
1292
+ }
1293
+ /**
1294
+ * Boot-time per-table drift handling (P1 + P2): detect divergence, in dev
1295
+ * auto-reconcile the *safe* (loosening) subset when `autoMigrate==='safe'`,
1296
+ * then WARN once per remaining divergence with an actionable hint.
1297
+ */
1298
+ async reconcileAndWarnDrift(tableName, fields) {
1299
+ let drift;
1300
+ try {
1301
+ drift = await this.detectTableDrift(tableName, fields);
1302
+ } catch (e) {
1303
+ this.logger.warn(`[schema-drift] could not introspect '${tableName}' for drift detection`, e?.message ?? e);
1304
+ return;
1305
+ }
1306
+ if (drift.length === 0) return;
1307
+ const autoOn = this.autoMigrate === "safe" && this.schemaMode === "managed";
1308
+ if (autoOn && this.isProductionEnv()) {
1309
+ this.logger.warn(
1310
+ `[schema-drift] autoMigrate='safe' is ignored under NODE_ENV=production \u2014 schema is never auto-altered in production. Run 'os migrate' deliberately.`
1311
+ );
1312
+ } else if (autoOn) {
1313
+ const safe = drift.filter((d) => d.category === "safe");
1314
+ if (safe.length > 0) {
1315
+ try {
1316
+ const { applied } = await this.applyMigrationEntries(safe, { allowDestructive: false });
1317
+ for (const d of applied) {
1318
+ (this.logger.info ?? this.logger.warn)(`[schema-drift] auto-reconciled ${d.op.type} on ${d.table}.${d.column}`);
1319
+ }
1320
+ drift = await this.detectTableDrift(tableName, fields);
1321
+ } catch (e) {
1322
+ this.logger.warn(`[schema-drift] dev auto-reconcile failed for '${tableName}' \u2014 falling back to warning`, e?.message ?? e);
1323
+ }
1324
+ }
1325
+ }
1326
+ for (const d of drift) {
1327
+ const k = driftKey(d);
1328
+ if (this.driftWarned.has(k)) continue;
1329
+ this.driftWarned.add(k);
1330
+ this.logger.warn(`[schema-drift] ${d.message}`);
1331
+ }
1332
+ }
1333
+ /**
1334
+ * Apply a set of drift entries to the physical schema. Destructive entries
1335
+ * are skipped unless `allowDestructive` is set. Postgres/MySQL alter columns
1336
+ * in place; SQLite (which cannot alter constraints in place) rebuilds each
1337
+ * affected table (copy → swap) applying only the requested edits.
1338
+ *
1339
+ * @returns the entries actually applied and those skipped (e.g. destructive
1340
+ * without `allowDestructive`, or unsupported on the dialect).
1341
+ */
1342
+ async applyMigrationEntries(entries, opts = {}) {
1343
+ this.assertSchemaMutable("reconcileManagedSchema");
1344
+ const allowDestructive = opts.allowDestructive === true;
1345
+ const applied = [];
1346
+ const skipped = [];
1347
+ const candidates = entries.filter((d) => {
1348
+ if (d.category === "destructive" && !allowDestructive) {
1349
+ skipped.push(d);
1350
+ return false;
1351
+ }
1352
+ return true;
1353
+ });
1354
+ if (candidates.length === 0) return { applied, skipped };
1355
+ const byTable = /* @__PURE__ */ new Map();
1356
+ for (const d of candidates) {
1357
+ (byTable.get(d.table) ?? byTable.set(d.table, []).get(d.table)).push(d);
1358
+ }
1359
+ for (const [table, ents] of byTable) {
1360
+ try {
1361
+ if (this.isSqlite) {
1362
+ await this.rebuildSqliteTablePatched(table, ents);
1363
+ applied.push(...ents);
1364
+ } else {
1365
+ for (const d of ents) {
1366
+ const ok = await this.applyDriftOpInPlace(d.op);
1367
+ (ok ? applied : skipped).push(d);
1368
+ }
1369
+ }
1370
+ } catch (e) {
1371
+ this.logger.warn(`[schema-drift] failed to reconcile '${table}'`, e?.message ?? e);
1372
+ for (const d of ents) if (!applied.includes(d)) skipped.push(d);
1373
+ }
1374
+ }
1375
+ return { applied, skipped };
1376
+ }
1377
+ /** Apply a single drift op in place (Postgres / MySQL). Returns false if unsupported. */
1378
+ async applyDriftOpInPlace(op) {
1379
+ const { table, column } = op;
1380
+ if (this.isPostgres) {
1381
+ switch (op.type) {
1382
+ case "relax_not_null":
1383
+ await this.knex.raw("ALTER TABLE ?? ALTER COLUMN ?? DROP NOT NULL", [table, column]);
1384
+ return true;
1385
+ case "tighten_not_null":
1386
+ await this.knex.raw("ALTER TABLE ?? ALTER COLUMN ?? SET NOT NULL", [table, column]);
1387
+ return true;
1388
+ case "widen_varchar":
1389
+ case "narrow_varchar":
1390
+ await this.knex.raw(`ALTER TABLE ?? ALTER COLUMN ?? TYPE varchar(${op.to})`, [table, column]);
1391
+ return true;
1392
+ case "drop_column":
1393
+ await this.knex.raw("ALTER TABLE ?? DROP COLUMN ??", [table, column]);
1394
+ return true;
1395
+ }
1396
+ }
1397
+ if (this.isMysql) {
1398
+ const info = await this.knex(table).columnInfo();
1399
+ const ci = info?.[column];
1400
+ const colType = ci?.type ? /char/i.test(ci.type) && ci.maxLength ? `${ci.type}(${ci.maxLength})` : ci.type : void 0;
1401
+ switch (op.type) {
1402
+ case "relax_not_null":
1403
+ if (!colType) return false;
1404
+ await this.knex.raw(`ALTER TABLE ?? MODIFY ?? ${colType} NULL`, [table, column]);
1405
+ return true;
1406
+ case "tighten_not_null":
1407
+ if (!colType) return false;
1408
+ await this.knex.raw(`ALTER TABLE ?? MODIFY ?? ${colType} NOT NULL`, [table, column]);
1409
+ return true;
1410
+ case "widen_varchar":
1411
+ case "narrow_varchar":
1412
+ await this.knex.raw(`ALTER TABLE ?? MODIFY ?? varchar(${op.to})`, [table, column]);
1413
+ return true;
1414
+ case "drop_column":
1415
+ await this.knex.raw("ALTER TABLE ?? DROP COLUMN ??", [table, column]);
1416
+ return true;
1417
+ }
1418
+ }
1419
+ this.logger.warn(`[schema-drift] ${op.type} on ${table}.${column} is unsupported on dialect '${this.dialectName}' \u2014 skipped`);
1420
+ return false;
1421
+ }
1422
+ /**
1423
+ * Rebuild a SQLite table applying a set of column edits (relax/tighten NOT
1424
+ * NULL, drop column), preserving all other columns and their data. Follows
1425
+ * the official SQLite procedure: create patched table → copy → drop → rename.
1426
+ * varchar widen/narrow are no-ops on SQLite (dynamic typing) and ignored.
1427
+ *
1428
+ * Unique field-level constraints and declared indexes are recreated from
1429
+ * metadata afterwards (the source of truth). DB-level foreign keys declared
1430
+ * by `lookup` fields are not re-added (ObjectStack enforces relationships at
1431
+ * the application layer, not via SQLite FK constraints).
1432
+ */
1433
+ async rebuildSqliteTablePatched(table, ents) {
1434
+ const relax = /* @__PURE__ */ new Set();
1435
+ const tighten = /* @__PURE__ */ new Set();
1436
+ const drop = /* @__PURE__ */ new Set();
1437
+ for (const e of ents) {
1438
+ if (e.op.type === "relax_not_null") relax.add(e.op.column);
1439
+ else if (e.op.type === "tighten_not_null") tighten.add(e.op.column);
1440
+ else if (e.op.type === "drop_column") drop.add(e.op.column);
1441
+ }
1442
+ const physical = await this.introspectColumns(table);
1443
+ const kept = physical.filter((c) => !drop.has(c.name));
1444
+ const keptNames = kept.map((c) => c.name);
1445
+ const fields = this.managedObjectFields.get(table) ?? {};
1446
+ const tmp = `__os_mig_${table}`;
1447
+ await this.knex.raw("PRAGMA foreign_keys = OFF");
1448
+ try {
1449
+ await this.knex.transaction(async (trx) => {
1450
+ await trx.schema.dropTableIfExists(tmp);
1451
+ await trx.schema.createTable(tmp, (t) => {
1452
+ for (const c of kept) {
1453
+ const col = this.buildRebuiltColumn(t, c);
1454
+ if (!col) continue;
1455
+ const nullable = relax.has(c.name) ? true : tighten.has(c.name) ? false : c.nullable;
1456
+ if (!nullable && c.name !== "id") col.notNullable();
1457
+ if (c.name === "created_at" || c.name === "updated_at") col.defaultTo(this.knex.fn.now());
1458
+ }
1459
+ });
1460
+ const colList = keptNames.map((n) => `"${n}"`).join(", ");
1461
+ await trx.raw(`INSERT INTO "${tmp}" (${colList}) SELECT ${colList} FROM "${table}"`);
1462
+ await trx.schema.dropTable(table);
1463
+ await trx.schema.renameTable(tmp, table);
1464
+ });
1465
+ } finally {
1466
+ await this.knex.raw("PRAGMA foreign_keys = ON");
1467
+ }
1468
+ try {
1469
+ const keptSet = new Set(keptNames);
1470
+ for (const [name, field] of Object.entries(fields)) {
1471
+ if (field?.unique && keptSet.has(name)) {
1472
+ const idx = `uniq_${table}_${name}`;
1473
+ await this.knex.raw("CREATE UNIQUE INDEX IF NOT EXISTS ?? ON ?? (??)", [idx, table, name]);
1474
+ }
1475
+ }
1476
+ const declared = this.managedObjectIndexes.get(table);
1477
+ if (Array.isArray(declared) && declared.length > 0) {
1478
+ await this.syncDeclaredIndexes(table, declared, keptSet);
1479
+ }
1480
+ } catch (e) {
1481
+ this.logger.warn(`[schema-drift] could not fully recreate indexes for '${table}' after rebuild`, e?.message ?? e);
1482
+ }
1483
+ }
1484
+ /** Map an introspected SQLite column to a knex builder for the rebuilt table. */
1485
+ buildRebuiltColumn(t, c) {
1486
+ if (c.name === "id") return t.string("id").primary();
1487
+ const ty = (c.type || "text").toLowerCase();
1488
+ if (ty.includes("int")) return t.integer(c.name);
1489
+ if (/(real|floa|doub|num|dec)/.test(ty)) return t.float(c.name);
1490
+ if (ty.includes("bool")) return t.boolean(c.name);
1491
+ if (ty.includes("datetime") || ty.includes("timestamp")) return t.timestamp(c.name);
1492
+ if (ty === "date") return t.date(c.name);
1493
+ if (ty === "time") return t.time(c.name);
1494
+ if (ty.includes("json")) return t.json(c.name);
1495
+ if (ty.includes("blob") || ty.includes("binary")) return t.binary(c.name);
1496
+ if (ty.includes("text")) return t.text(c.name);
1497
+ return t.string(c.name);
1498
+ }
1019
1499
  /**
1020
1500
  * Build a deterministic index name for a declared index so repeated
1021
1501
  * `initObjects` runs converge on the same identifier (and can detect an
@@ -1157,7 +1637,12 @@ var SqlDriver = class {
1157
1637
  return this.knex;
1158
1638
  }
1159
1639
  getBuilder(object, options) {
1160
- let builder = this.knex(object);
1640
+ const physical = this.physicalTableByObject[object] ?? object;
1641
+ let builder = this.knex(physical);
1642
+ const remoteSchema = this.physicalSchemaByObject[object];
1643
+ if (remoteSchema) {
1644
+ builder = builder.withSchema(remoteSchema);
1645
+ }
1161
1646
  if (options?.transaction) {
1162
1647
  builder = builder.transacting(options.transaction);
1163
1648
  }
@@ -1256,6 +1741,20 @@ var SqlDriver = class {
1256
1741
  if (typeof t === "string") return t;
1257
1742
  return null;
1258
1743
  }
1744
+ /**
1745
+ * Coercion-map key for a builder. Coercion maps (date/datetime) are keyed by
1746
+ * OBJECT name, but after the federation change {@link getBuilder} targets the
1747
+ * physical remote table, so a builder reports the remote name. Map it back to
1748
+ * the object name for external objects; identity for managed ones (no reverse
1749
+ * entry). Note datetime coercion is a SQLite-only concern (see
1750
+ * coerceFilterValue), and SQLite external tables are bare-named, so this is
1751
+ * exact where it matters.
1752
+ */
1753
+ coercionKey(builder) {
1754
+ const physical = this.tableNameForBuilder(builder);
1755
+ if (physical == null) return null;
1756
+ return this.objectByPhysicalTable[physical] ?? physical;
1757
+ }
1259
1758
  /**
1260
1759
  * Collapse a `Field.date` value to a timezone-naive `YYYY-MM-DD`
1261
1760
  * calendar-day string (ADR-0053 Phase 1). A `Date` collapses to its UTC
@@ -1349,7 +1848,7 @@ var SqlDriver = class {
1349
1848
  }
1350
1849
  applyFilters(builder, filters) {
1351
1850
  if (!filters) return;
1352
- const table = this.tableNameForBuilder(builder);
1851
+ const table = this.coercionKey(builder);
1353
1852
  if (!Array.isArray(filters) && typeof filters === "object") {
1354
1853
  const hasMongoOperators = Object.keys(filters).some(
1355
1854
  (k) => k.startsWith("$") || typeof filters[k] === "object" && filters[k] !== null && Object.keys(filters[k]).some((op) => op.startsWith("$"))
@@ -1360,7 +1859,7 @@ var SqlDriver = class {
1360
1859
  }
1361
1860
  for (const [key, value] of Object.entries(filters)) {
1362
1861
  if (["limit", "offset", "fields", "orderBy"].includes(key)) continue;
1363
- builder.where(key, this.coerceFilterValue(table, key, value));
1862
+ builder.where(this.remoteColumn(table, key, key), this.coerceFilterValue(table, key, value));
1364
1863
  }
1365
1864
  return;
1366
1865
  }
@@ -1376,8 +1875,9 @@ var SqlDriver = class {
1376
1875
  const [fieldRaw, op, value] = item;
1377
1876
  const isCriterion = typeof fieldRaw === "string" && typeof op === "string";
1378
1877
  if (isCriterion) {
1379
- const field = this.mapSortField(fieldRaw);
1380
- const coerced = this.coerceFilterValue(table, field, value);
1878
+ const localField = this.mapSortField(fieldRaw);
1879
+ const field = this.remoteColumn(table, fieldRaw, localField);
1880
+ const coerced = this.coerceFilterValue(table, localField, value);
1381
1881
  const apply = (b) => {
1382
1882
  const method = nextJoin === "or" ? "orWhere" : "where";
1383
1883
  const methodIn = nextJoin === "or" ? "orWhereIn" : "whereIn";
@@ -1429,7 +1929,7 @@ var SqlDriver = class {
1429
1929
  }
1430
1930
  applyFilterCondition(builder, condition, logicalOp = "and", tableHint) {
1431
1931
  if (!condition || typeof condition !== "object") return;
1432
- const table = tableHint ?? this.tableNameForBuilder(builder);
1932
+ const table = tableHint ?? this.coercionKey(builder);
1433
1933
  for (const [key, value] of Object.entries(condition)) {
1434
1934
  if (key === "$and" && Array.isArray(value)) {
1435
1935
  builder.where((qb) => {
@@ -1449,10 +1949,11 @@ var SqlDriver = class {
1449
1949
  }
1450
1950
  });
1451
1951
  } else if (typeof value === "object" && value !== null && !Array.isArray(value)) {
1452
- const field = this.mapSortField(key);
1952
+ const localField = this.mapSortField(key);
1953
+ const field = this.remoteColumn(table, key, localField);
1453
1954
  for (const [op, opValue] of Object.entries(value)) {
1454
1955
  const method = logicalOp === "or" ? "orWhere" : "where";
1455
- const coerced = this.coerceFilterValue(table, field, opValue);
1956
+ const coerced = this.coerceFilterValue(table, localField, opValue);
1456
1957
  switch (op) {
1457
1958
  case "$eq":
1458
1959
  builder[method](field, coerced);
@@ -1490,9 +1991,10 @@ var SqlDriver = class {
1490
1991
  }
1491
1992
  }
1492
1993
  } else {
1493
- const field = this.mapSortField(key);
1994
+ const localField = this.mapSortField(key);
1995
+ const field = this.remoteColumn(table, key, localField);
1494
1996
  const method = logicalOp === "or" ? "orWhere" : "where";
1495
- builder[method](field, this.coerceFilterValue(table, field, value));
1997
+ builder[method](field, this.coerceFilterValue(table, localField, value));
1496
1998
  }
1497
1999
  }
1498
2000
  }
@@ -1502,6 +2004,28 @@ var SqlDriver = class {
1502
2004
  if (field === "updatedAt") return "updated_at";
1503
2005
  return field;
1504
2006
  }
2007
+ /**
2008
+ * Physical column for a logical field on an external object that declares an
2009
+ * `external.columnMap` (ADR-0015). Returns `fallback` (the caller's existing
2010
+ * per-site resolution) when the object has no columnMap, so managed objects
2011
+ * and external objects without a columnMap are byte-for-byte unchanged.
2012
+ */
2013
+ remoteColumn(object, field, fallback) {
2014
+ const m = object ? this.fieldColumnByObject[object] : void 0;
2015
+ return m && m[field] || fallback;
2016
+ }
2017
+ /**
2018
+ * Remap a write payload's logical field keys to physical remote columns for an
2019
+ * external object with a columnMap. No-op otherwise. Applied AFTER formatInput
2020
+ * (whose value coercion is keyed by logical field name).
2021
+ */
2022
+ applyWriteColumnMap(object, data) {
2023
+ const m = this.fieldColumnByObject[object];
2024
+ if (!m || !data || typeof data !== "object") return data;
2025
+ const out = {};
2026
+ for (const [k, v] of Object.entries(data)) out[m[k] ?? k] = v;
2027
+ return out;
2028
+ }
1505
2029
  mapAggregateFunc(func) {
1506
2030
  switch (func) {
1507
2031
  case "count":
@@ -1758,6 +2282,15 @@ var SqlDriver = class {
1758
2282
  }
1759
2283
  formatOutput(object, data) {
1760
2284
  if (!data) return data;
2285
+ const colToField = this.columnFieldByObject[object];
2286
+ if (colToField && typeof data === "object") {
2287
+ for (const [remoteCol, localField] of Object.entries(colToField)) {
2288
+ if (remoteCol !== localField && Object.prototype.hasOwnProperty.call(data, remoteCol)) {
2289
+ data[localField] = data[remoteCol];
2290
+ delete data[remoteCol];
2291
+ }
2292
+ }
2293
+ }
1761
2294
  if (this.isSqlite) {
1762
2295
  const jsonFields = this.jsonFields[object];
1763
2296
  if (jsonFields && jsonFields.length > 0) {
@@ -2029,7 +2562,11 @@ var index_default = {
2029
2562
  }
2030
2563
  };
2031
2564
  export {
2565
+ BUILTIN_COLUMNS,
2032
2566
  SqlDriver,
2033
- index_default as default
2567
+ index_default as default,
2568
+ diffManagedTable,
2569
+ driftKey,
2570
+ fieldHasColumn
2034
2571
  };
2035
2572
  //# sourceMappingURL=index.mjs.map