@objectstack/driver-sql 10.0.0 → 10.3.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.d.mts +211 -1
- package/dist/index.d.ts +211 -1
- package/dist/index.js +420 -27
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +415 -26
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
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";
|
|
@@ -115,8 +217,19 @@ var SqlDriver = class {
|
|
|
115
217
|
this.logger = {
|
|
116
218
|
warn: (msg, meta) => console.warn(msg, meta ?? "")
|
|
117
219
|
};
|
|
118
|
-
|
|
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;
|
|
119
231
|
this.schemaMode = schemaMode ?? "managed";
|
|
232
|
+
this.autoMigrate = autoMigrate ?? "off";
|
|
120
233
|
this.config = knexConfig;
|
|
121
234
|
this.knex = knex(knexConfig);
|
|
122
235
|
}
|
|
@@ -934,6 +1047,37 @@ var SqlDriver = class {
|
|
|
934
1047
|
this.assertSchemaMutable("dropTable");
|
|
935
1048
|
await this.knex.schema.dropTableIfExists(object);
|
|
936
1049
|
}
|
|
1050
|
+
/**
|
|
1051
|
+
* Resolve the per-table tenant-isolation column for a schema, honoring an
|
|
1052
|
+
* explicit tenancy opt-out. Single source of truth for both {@link initObjects}
|
|
1053
|
+
* and {@link registerExternalObject} (they previously inlined this logic and
|
|
1054
|
+
* drifted).
|
|
1055
|
+
*
|
|
1056
|
+
* Precedence:
|
|
1057
|
+
* 1. `tenancy.enabled === false` → `null` (NO driver-level org scope), even
|
|
1058
|
+
* when the object carries an `organization_id` column. Platform-global
|
|
1059
|
+
* objects (e.g. `sys_license`) keep an optional, often-NULL org FK but must
|
|
1060
|
+
* NOT be tenant-scoped: otherwise an authenticated caller's active-org
|
|
1061
|
+
* `DriverOptions.tenantId` injects `WHERE organization_id = <org>` and every
|
|
1062
|
+
* NULL-org / cross-org row silently disappears (the platform admin then
|
|
1063
|
+
* reads zero licenses while an unscoped/anonymous read still sees them).
|
|
1064
|
+
* The declarative branch below already respected `enabled !== false`; the
|
|
1065
|
+
* implicit `organization_id` fallback did not — this closes that gap.
|
|
1066
|
+
* 2. Declared `tenancy.tenantField` (when that field exists on the object).
|
|
1067
|
+
* 3. Implicit `organization_id` column detection (legacy objects whose
|
|
1068
|
+
* multi-tenant column was injected by the kernel without a spec migration).
|
|
1069
|
+
*/
|
|
1070
|
+
computeTenantField(schema) {
|
|
1071
|
+
const tenancyDecl = schema?.tenancy;
|
|
1072
|
+
if (tenancyDecl?.enabled === false) return null;
|
|
1073
|
+
const fields = schema?.fields;
|
|
1074
|
+
if (tenancyDecl?.tenantField) {
|
|
1075
|
+
const declared = String(tenancyDecl.tenantField);
|
|
1076
|
+
if (fields && Object.prototype.hasOwnProperty.call(fields, declared)) return declared;
|
|
1077
|
+
}
|
|
1078
|
+
if (fields && Object.prototype.hasOwnProperty.call(fields, "organization_id")) return "organization_id";
|
|
1079
|
+
return null;
|
|
1080
|
+
}
|
|
937
1081
|
/**
|
|
938
1082
|
* Batch-initialise tables from an array of object definitions.
|
|
939
1083
|
*/
|
|
@@ -985,18 +1129,7 @@ var SqlDriver = class {
|
|
|
985
1129
|
const dateCols = [];
|
|
986
1130
|
const datetimeCols = [];
|
|
987
1131
|
const autoNumberCols = [];
|
|
988
|
-
const
|
|
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
|
-
}
|
|
1132
|
+
const tenantField = this.computeTenantField(schema);
|
|
1000
1133
|
if (schema.fields) {
|
|
1001
1134
|
for (const [name, field] of Object.entries(schema.fields)) {
|
|
1002
1135
|
const type = field.type || "string";
|
|
@@ -1026,22 +1159,15 @@ var SqlDriver = class {
|
|
|
1026
1159
|
await this.ensureDatabaseExists();
|
|
1027
1160
|
for (const obj of objects) {
|
|
1028
1161
|
const tableName = StorageNameMapping.resolveTableName(obj);
|
|
1162
|
+
this.managedObjectFields.set(tableName, obj.fields ?? {});
|
|
1163
|
+
if (Array.isArray(obj.indexes)) {
|
|
1164
|
+
this.managedObjectIndexes.set(tableName, obj.indexes);
|
|
1165
|
+
}
|
|
1029
1166
|
const jsonCols = [];
|
|
1030
1167
|
const booleanCols = [];
|
|
1031
1168
|
const numericCols = [];
|
|
1032
1169
|
const autoNumberCols = [];
|
|
1033
|
-
const
|
|
1034
|
-
let tenantField = null;
|
|
1035
|
-
if (tenancyDecl && tenancyDecl.enabled !== false && tenancyDecl.tenantField) {
|
|
1036
|
-
const declared = String(tenancyDecl.tenantField);
|
|
1037
|
-
if (obj.fields && Object.prototype.hasOwnProperty.call(obj.fields, declared)) {
|
|
1038
|
-
tenantField = declared;
|
|
1039
|
-
}
|
|
1040
|
-
}
|
|
1041
|
-
if (!tenantField) {
|
|
1042
|
-
const hasOrgField = !!(obj.fields && Object.prototype.hasOwnProperty.call(obj.fields, "organization_id"));
|
|
1043
|
-
tenantField = hasOrgField ? "organization_id" : null;
|
|
1044
|
-
}
|
|
1170
|
+
const tenantField = this.computeTenantField(obj);
|
|
1045
1171
|
if (obj.fields) {
|
|
1046
1172
|
for (const [name, field] of Object.entries(obj.fields)) {
|
|
1047
1173
|
const type = field.type || "string";
|
|
@@ -1118,7 +1244,266 @@ var SqlDriver = class {
|
|
|
1118
1244
|
const physicalColumns = new Set(Object.keys(colInfo));
|
|
1119
1245
|
await this.syncDeclaredIndexes(tableName, declaredIndexes, physicalColumns);
|
|
1120
1246
|
}
|
|
1247
|
+
if (exists) {
|
|
1248
|
+
await this.reconcileAndWarnDrift(tableName, obj.fields ?? {});
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
// ── Managed-schema drift & reconcile (#2186) ───────────────────────────────
|
|
1253
|
+
/** Canonical dialect name for the drift differ. */
|
|
1254
|
+
get dialectName() {
|
|
1255
|
+
if (this.isSqlite) return "sqlite";
|
|
1256
|
+
if (this.isPostgres) return "postgres";
|
|
1257
|
+
if (this.isMysql) return "mysql";
|
|
1258
|
+
return "unknown";
|
|
1259
|
+
}
|
|
1260
|
+
/** True only when running under `NODE_ENV=production` — auto-DDL is force-disabled there. */
|
|
1261
|
+
isProductionEnv() {
|
|
1262
|
+
try {
|
|
1263
|
+
return (process.env.NODE_ENV ?? "").toLowerCase() === "production";
|
|
1264
|
+
} catch {
|
|
1265
|
+
return false;
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
/** Diff one table's metadata fields against its physical columns. */
|
|
1269
|
+
async detectTableDrift(tableName, fields) {
|
|
1270
|
+
const cols = await this.introspectColumns(tableName);
|
|
1271
|
+
const physical = cols.map((c) => ({
|
|
1272
|
+
name: c.name,
|
|
1273
|
+
type: c.type,
|
|
1274
|
+
nullable: c.nullable,
|
|
1275
|
+
maxLength: c.maxLength
|
|
1276
|
+
}));
|
|
1277
|
+
return diffManagedTable({ table: tableName, fields, columns: physical, dialect: this.dialectName });
|
|
1278
|
+
}
|
|
1279
|
+
/**
|
|
1280
|
+
* Detect every managed-schema divergence between metadata and the physical
|
|
1281
|
+
* database. Metadata is the source of truth. Returns one entry per drift,
|
|
1282
|
+
* sorted by table then column. Used by `os migrate` (P3) and tests.
|
|
1283
|
+
*
|
|
1284
|
+
* @param objects optional explicit object list; defaults to whatever
|
|
1285
|
+
* `initObjects` last synced (captured in {@link managedObjectFields}).
|
|
1286
|
+
*/
|
|
1287
|
+
async detectManagedDrift(objects) {
|
|
1288
|
+
const tables = /* @__PURE__ */ new Map();
|
|
1289
|
+
if (objects) {
|
|
1290
|
+
for (const o of objects) tables.set(StorageNameMapping.resolveTableName(o), o.fields ?? {});
|
|
1291
|
+
} else {
|
|
1292
|
+
for (const [t, f] of this.managedObjectFields) tables.set(t, f);
|
|
1293
|
+
}
|
|
1294
|
+
const out = [];
|
|
1295
|
+
for (const [tableName, fields] of tables) {
|
|
1296
|
+
if (!await this.knex.schema.hasTable(tableName)) continue;
|
|
1297
|
+
out.push(...await this.detectTableDrift(tableName, fields));
|
|
1298
|
+
}
|
|
1299
|
+
out.sort((a, b) => a.table === b.table ? (a.column ?? "").localeCompare(b.column ?? "") : a.table.localeCompare(b.table));
|
|
1300
|
+
return out;
|
|
1301
|
+
}
|
|
1302
|
+
/**
|
|
1303
|
+
* Boot-time per-table drift handling (P1 + P2): detect divergence, in dev
|
|
1304
|
+
* auto-reconcile the *safe* (loosening) subset when `autoMigrate==='safe'`,
|
|
1305
|
+
* then WARN once per remaining divergence with an actionable hint.
|
|
1306
|
+
*/
|
|
1307
|
+
async reconcileAndWarnDrift(tableName, fields) {
|
|
1308
|
+
let drift;
|
|
1309
|
+
try {
|
|
1310
|
+
drift = await this.detectTableDrift(tableName, fields);
|
|
1311
|
+
} catch (e) {
|
|
1312
|
+
this.logger.warn(`[schema-drift] could not introspect '${tableName}' for drift detection`, e?.message ?? e);
|
|
1313
|
+
return;
|
|
1314
|
+
}
|
|
1315
|
+
if (drift.length === 0) return;
|
|
1316
|
+
const autoOn = this.autoMigrate === "safe" && this.schemaMode === "managed";
|
|
1317
|
+
if (autoOn && this.isProductionEnv()) {
|
|
1318
|
+
this.logger.warn(
|
|
1319
|
+
`[schema-drift] autoMigrate='safe' is ignored under NODE_ENV=production \u2014 schema is never auto-altered in production. Run 'os migrate' deliberately.`
|
|
1320
|
+
);
|
|
1321
|
+
} else if (autoOn) {
|
|
1322
|
+
const safe = drift.filter((d) => d.category === "safe");
|
|
1323
|
+
if (safe.length > 0) {
|
|
1324
|
+
try {
|
|
1325
|
+
const { applied } = await this.applyMigrationEntries(safe, { allowDestructive: false });
|
|
1326
|
+
for (const d of applied) {
|
|
1327
|
+
(this.logger.info ?? this.logger.warn)(`[schema-drift] auto-reconciled ${d.op.type} on ${d.table}.${d.column}`);
|
|
1328
|
+
}
|
|
1329
|
+
drift = await this.detectTableDrift(tableName, fields);
|
|
1330
|
+
} catch (e) {
|
|
1331
|
+
this.logger.warn(`[schema-drift] dev auto-reconcile failed for '${tableName}' \u2014 falling back to warning`, e?.message ?? e);
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1121
1334
|
}
|
|
1335
|
+
for (const d of drift) {
|
|
1336
|
+
const k = driftKey(d);
|
|
1337
|
+
if (this.driftWarned.has(k)) continue;
|
|
1338
|
+
this.driftWarned.add(k);
|
|
1339
|
+
this.logger.warn(`[schema-drift] ${d.message}`);
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
/**
|
|
1343
|
+
* Apply a set of drift entries to the physical schema. Destructive entries
|
|
1344
|
+
* are skipped unless `allowDestructive` is set. Postgres/MySQL alter columns
|
|
1345
|
+
* in place; SQLite (which cannot alter constraints in place) rebuilds each
|
|
1346
|
+
* affected table (copy → swap) applying only the requested edits.
|
|
1347
|
+
*
|
|
1348
|
+
* @returns the entries actually applied and those skipped (e.g. destructive
|
|
1349
|
+
* without `allowDestructive`, or unsupported on the dialect).
|
|
1350
|
+
*/
|
|
1351
|
+
async applyMigrationEntries(entries, opts = {}) {
|
|
1352
|
+
this.assertSchemaMutable("reconcileManagedSchema");
|
|
1353
|
+
const allowDestructive = opts.allowDestructive === true;
|
|
1354
|
+
const applied = [];
|
|
1355
|
+
const skipped = [];
|
|
1356
|
+
const candidates = entries.filter((d) => {
|
|
1357
|
+
if (d.category === "destructive" && !allowDestructive) {
|
|
1358
|
+
skipped.push(d);
|
|
1359
|
+
return false;
|
|
1360
|
+
}
|
|
1361
|
+
return true;
|
|
1362
|
+
});
|
|
1363
|
+
if (candidates.length === 0) return { applied, skipped };
|
|
1364
|
+
const byTable = /* @__PURE__ */ new Map();
|
|
1365
|
+
for (const d of candidates) {
|
|
1366
|
+
(byTable.get(d.table) ?? byTable.set(d.table, []).get(d.table)).push(d);
|
|
1367
|
+
}
|
|
1368
|
+
for (const [table, ents] of byTable) {
|
|
1369
|
+
try {
|
|
1370
|
+
if (this.isSqlite) {
|
|
1371
|
+
await this.rebuildSqliteTablePatched(table, ents);
|
|
1372
|
+
applied.push(...ents);
|
|
1373
|
+
} else {
|
|
1374
|
+
for (const d of ents) {
|
|
1375
|
+
const ok = await this.applyDriftOpInPlace(d.op);
|
|
1376
|
+
(ok ? applied : skipped).push(d);
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
} catch (e) {
|
|
1380
|
+
this.logger.warn(`[schema-drift] failed to reconcile '${table}'`, e?.message ?? e);
|
|
1381
|
+
for (const d of ents) if (!applied.includes(d)) skipped.push(d);
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
return { applied, skipped };
|
|
1385
|
+
}
|
|
1386
|
+
/** Apply a single drift op in place (Postgres / MySQL). Returns false if unsupported. */
|
|
1387
|
+
async applyDriftOpInPlace(op) {
|
|
1388
|
+
const { table, column } = op;
|
|
1389
|
+
if (this.isPostgres) {
|
|
1390
|
+
switch (op.type) {
|
|
1391
|
+
case "relax_not_null":
|
|
1392
|
+
await this.knex.raw("ALTER TABLE ?? ALTER COLUMN ?? DROP NOT NULL", [table, column]);
|
|
1393
|
+
return true;
|
|
1394
|
+
case "tighten_not_null":
|
|
1395
|
+
await this.knex.raw("ALTER TABLE ?? ALTER COLUMN ?? SET NOT NULL", [table, column]);
|
|
1396
|
+
return true;
|
|
1397
|
+
case "widen_varchar":
|
|
1398
|
+
case "narrow_varchar":
|
|
1399
|
+
await this.knex.raw(`ALTER TABLE ?? ALTER COLUMN ?? TYPE varchar(${op.to})`, [table, column]);
|
|
1400
|
+
return true;
|
|
1401
|
+
case "drop_column":
|
|
1402
|
+
await this.knex.raw("ALTER TABLE ?? DROP COLUMN ??", [table, column]);
|
|
1403
|
+
return true;
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
if (this.isMysql) {
|
|
1407
|
+
const info = await this.knex(table).columnInfo();
|
|
1408
|
+
const ci = info?.[column];
|
|
1409
|
+
const colType = ci?.type ? /char/i.test(ci.type) && ci.maxLength ? `${ci.type}(${ci.maxLength})` : ci.type : void 0;
|
|
1410
|
+
switch (op.type) {
|
|
1411
|
+
case "relax_not_null":
|
|
1412
|
+
if (!colType) return false;
|
|
1413
|
+
await this.knex.raw(`ALTER TABLE ?? MODIFY ?? ${colType} NULL`, [table, column]);
|
|
1414
|
+
return true;
|
|
1415
|
+
case "tighten_not_null":
|
|
1416
|
+
if (!colType) return false;
|
|
1417
|
+
await this.knex.raw(`ALTER TABLE ?? MODIFY ?? ${colType} NOT NULL`, [table, column]);
|
|
1418
|
+
return true;
|
|
1419
|
+
case "widen_varchar":
|
|
1420
|
+
case "narrow_varchar":
|
|
1421
|
+
await this.knex.raw(`ALTER TABLE ?? MODIFY ?? varchar(${op.to})`, [table, column]);
|
|
1422
|
+
return true;
|
|
1423
|
+
case "drop_column":
|
|
1424
|
+
await this.knex.raw("ALTER TABLE ?? DROP COLUMN ??", [table, column]);
|
|
1425
|
+
return true;
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
this.logger.warn(`[schema-drift] ${op.type} on ${table}.${column} is unsupported on dialect '${this.dialectName}' \u2014 skipped`);
|
|
1429
|
+
return false;
|
|
1430
|
+
}
|
|
1431
|
+
/**
|
|
1432
|
+
* Rebuild a SQLite table applying a set of column edits (relax/tighten NOT
|
|
1433
|
+
* NULL, drop column), preserving all other columns and their data. Follows
|
|
1434
|
+
* the official SQLite procedure: create patched table → copy → drop → rename.
|
|
1435
|
+
* varchar widen/narrow are no-ops on SQLite (dynamic typing) and ignored.
|
|
1436
|
+
*
|
|
1437
|
+
* Unique field-level constraints and declared indexes are recreated from
|
|
1438
|
+
* metadata afterwards (the source of truth). DB-level foreign keys declared
|
|
1439
|
+
* by `lookup` fields are not re-added (ObjectStack enforces relationships at
|
|
1440
|
+
* the application layer, not via SQLite FK constraints).
|
|
1441
|
+
*/
|
|
1442
|
+
async rebuildSqliteTablePatched(table, ents) {
|
|
1443
|
+
const relax = /* @__PURE__ */ new Set();
|
|
1444
|
+
const tighten = /* @__PURE__ */ new Set();
|
|
1445
|
+
const drop = /* @__PURE__ */ new Set();
|
|
1446
|
+
for (const e of ents) {
|
|
1447
|
+
if (e.op.type === "relax_not_null") relax.add(e.op.column);
|
|
1448
|
+
else if (e.op.type === "tighten_not_null") tighten.add(e.op.column);
|
|
1449
|
+
else if (e.op.type === "drop_column") drop.add(e.op.column);
|
|
1450
|
+
}
|
|
1451
|
+
const physical = await this.introspectColumns(table);
|
|
1452
|
+
const kept = physical.filter((c) => !drop.has(c.name));
|
|
1453
|
+
const keptNames = kept.map((c) => c.name);
|
|
1454
|
+
const fields = this.managedObjectFields.get(table) ?? {};
|
|
1455
|
+
const tmp = `__os_mig_${table}`;
|
|
1456
|
+
await this.knex.raw("PRAGMA foreign_keys = OFF");
|
|
1457
|
+
try {
|
|
1458
|
+
await this.knex.transaction(async (trx) => {
|
|
1459
|
+
await trx.schema.dropTableIfExists(tmp);
|
|
1460
|
+
await trx.schema.createTable(tmp, (t) => {
|
|
1461
|
+
for (const c of kept) {
|
|
1462
|
+
const col = this.buildRebuiltColumn(t, c);
|
|
1463
|
+
if (!col) continue;
|
|
1464
|
+
const nullable = relax.has(c.name) ? true : tighten.has(c.name) ? false : c.nullable;
|
|
1465
|
+
if (!nullable && c.name !== "id") col.notNullable();
|
|
1466
|
+
if (c.name === "created_at" || c.name === "updated_at") col.defaultTo(this.knex.fn.now());
|
|
1467
|
+
}
|
|
1468
|
+
});
|
|
1469
|
+
const colList = keptNames.map((n) => `"${n}"`).join(", ");
|
|
1470
|
+
await trx.raw(`INSERT INTO "${tmp}" (${colList}) SELECT ${colList} FROM "${table}"`);
|
|
1471
|
+
await trx.schema.dropTable(table);
|
|
1472
|
+
await trx.schema.renameTable(tmp, table);
|
|
1473
|
+
});
|
|
1474
|
+
} finally {
|
|
1475
|
+
await this.knex.raw("PRAGMA foreign_keys = ON");
|
|
1476
|
+
}
|
|
1477
|
+
try {
|
|
1478
|
+
const keptSet = new Set(keptNames);
|
|
1479
|
+
for (const [name, field] of Object.entries(fields)) {
|
|
1480
|
+
if (field?.unique && keptSet.has(name)) {
|
|
1481
|
+
const idx = `uniq_${table}_${name}`;
|
|
1482
|
+
await this.knex.raw("CREATE UNIQUE INDEX IF NOT EXISTS ?? ON ?? (??)", [idx, table, name]);
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
const declared = this.managedObjectIndexes.get(table);
|
|
1486
|
+
if (Array.isArray(declared) && declared.length > 0) {
|
|
1487
|
+
await this.syncDeclaredIndexes(table, declared, keptSet);
|
|
1488
|
+
}
|
|
1489
|
+
} catch (e) {
|
|
1490
|
+
this.logger.warn(`[schema-drift] could not fully recreate indexes for '${table}' after rebuild`, e?.message ?? e);
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
/** Map an introspected SQLite column to a knex builder for the rebuilt table. */
|
|
1494
|
+
buildRebuiltColumn(t, c) {
|
|
1495
|
+
if (c.name === "id") return t.string("id").primary();
|
|
1496
|
+
const ty = (c.type || "text").toLowerCase();
|
|
1497
|
+
if (ty.includes("int")) return t.integer(c.name);
|
|
1498
|
+
if (/(real|floa|doub|num|dec)/.test(ty)) return t.float(c.name);
|
|
1499
|
+
if (ty.includes("bool")) return t.boolean(c.name);
|
|
1500
|
+
if (ty.includes("datetime") || ty.includes("timestamp")) return t.timestamp(c.name);
|
|
1501
|
+
if (ty === "date") return t.date(c.name);
|
|
1502
|
+
if (ty === "time") return t.time(c.name);
|
|
1503
|
+
if (ty.includes("json")) return t.json(c.name);
|
|
1504
|
+
if (ty.includes("blob") || ty.includes("binary")) return t.binary(c.name);
|
|
1505
|
+
if (ty.includes("text")) return t.text(c.name);
|
|
1506
|
+
return t.string(c.name);
|
|
1122
1507
|
}
|
|
1123
1508
|
/**
|
|
1124
1509
|
* Build a deterministic index name for a declared index so repeated
|
|
@@ -2186,7 +2571,11 @@ var index_default = {
|
|
|
2186
2571
|
}
|
|
2187
2572
|
};
|
|
2188
2573
|
export {
|
|
2574
|
+
BUILTIN_COLUMNS,
|
|
2189
2575
|
SqlDriver,
|
|
2190
|
-
index_default as default
|
|
2576
|
+
index_default as default,
|
|
2577
|
+
diffManagedTable,
|
|
2578
|
+
driftKey,
|
|
2579
|
+
fieldHasColumn
|
|
2191
2580
|
};
|
|
2192
2581
|
//# sourceMappingURL=index.mjs.map
|