@happyvertical/smrt-core 0.40.69 → 0.41.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/AGENTS.md +29 -4
- package/README.md +20 -1
- package/agents/change-feed.md +1 -1
- package/agents/query-bounds.md +45 -0
- package/agents/schema-paths.md +786 -0
- package/dist/browser.d.ts +1 -0
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +5 -3
- package/dist/cascade.d.ts +120 -0
- package/dist/cascade.d.ts.map +1 -0
- package/dist/cascade.js +430 -0
- package/dist/cascade.js.map +1 -0
- package/dist/change-feed.d.ts +34 -2
- package/dist/change-feed.d.ts.map +1 -1
- package/dist/change-feed.js +52 -11
- package/dist/change-feed.js.map +1 -1
- package/dist/class.d.ts +36 -3
- package/dist/class.d.ts.map +1 -1
- package/dist/class.js +87 -9
- package/dist/class.js.map +1 -1
- package/dist/collection-cache.js +0 -0
- package/dist/collection-cache.js.map +1 -1
- package/dist/collection.d.ts +130 -2
- package/dist/collection.d.ts.map +1 -1
- package/dist/collection.js +290 -57
- package/dist/collection.js.map +1 -1
- package/dist/config.d.ts +10 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/database.d.ts +8 -0
- package/dist/database.d.ts.map +1 -1
- package/dist/database.js +16 -8
- package/dist/database.js.map +1 -1
- package/dist/db-errors.d.ts +105 -0
- package/dist/db-errors.d.ts.map +1 -0
- package/dist/db-errors.js +382 -0
- package/dist/db-errors.js.map +1 -0
- package/dist/decorators/index.d.ts +80 -6
- package/dist/decorators/index.d.ts.map +1 -1
- package/dist/decorators/index.js +102 -12
- package/dist/decorators/index.js.map +1 -1
- package/dist/dispatch/bus.d.ts.map +1 -1
- package/dist/dispatch/bus.js +4 -3
- package/dist/dispatch/bus.js.map +1 -1
- package/dist/dispatch/collections/Dispatches.d.ts.map +1 -1
- package/dist/dispatch/collections/Dispatches.js +19 -4
- package/dist/dispatch/collections/Dispatches.js.map +1 -1
- package/dist/dispatch/types.d.ts +5 -0
- package/dist/dispatch/types.d.ts.map +1 -1
- package/dist/embedded-write-queue.d.ts +46 -0
- package/dist/embedded-write-queue.d.ts.map +1 -0
- package/dist/embedded-write-queue.js +66 -0
- package/dist/embedded-write-queue.js.map +1 -0
- package/dist/embeddings/storage.d.ts +7 -0
- package/dist/embeddings/storage.d.ts.map +1 -1
- package/dist/embeddings/storage.js +29 -12
- package/dist/embeddings/storage.js.map +1 -1
- package/dist/errors.d.ts +31 -3
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +34 -2
- package/dist/errors.js.map +1 -1
- package/dist/generators/changes-route.d.ts.map +1 -1
- package/dist/generators/changes-route.js +6 -3
- package/dist/generators/changes-route.js.map +1 -1
- package/dist/generators/mcp-runtime-template.d.ts +8 -0
- package/dist/generators/mcp-runtime-template.d.ts.map +1 -1
- package/dist/generators/mcp-runtime-template.js +38 -4
- package/dist/generators/mcp-runtime-template.js.map +1 -1
- package/dist/generators/mcp.d.ts +16 -0
- package/dist/generators/mcp.d.ts.map +1 -1
- package/dist/generators/mcp.js +41 -3
- package/dist/generators/mcp.js.map +1 -1
- package/dist/generators/rest.d.ts +22 -0
- package/dist/generators/rest.d.ts.map +1 -1
- package/dist/generators/rest.js +34 -3
- package/dist/generators/rest.js.map +1 -1
- package/dist/hierarchical.js +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -5
- package/dist/interceptors.d.ts +21 -0
- package/dist/interceptors.d.ts.map +1 -1
- package/dist/interceptors.js +27 -1
- package/dist/interceptors.js.map +1 -1
- package/dist/manifest/generator.d.ts.map +1 -1
- package/dist/manifest/generator.js +4 -7
- package/dist/manifest/generator.js.map +1 -1
- package/dist/manifest/static-manifest.js +10 -10
- package/dist/manifest/static-manifest.js.map +1 -1
- package/dist/manifest/store.js +1 -1
- package/dist/manifest/store.js.map +1 -1
- package/dist/manifest.json +19 -19
- package/dist/migrations/differ.d.ts +211 -9
- package/dist/migrations/differ.d.ts.map +1 -1
- package/dist/migrations/differ.js +613 -50
- package/dist/migrations/differ.js.map +1 -1
- package/dist/migrations/generator.d.ts +31 -4
- package/dist/migrations/generator.d.ts.map +1 -1
- package/dist/migrations/generator.js +49 -5
- package/dist/migrations/generator.js.map +1 -1
- package/dist/migrations/index.d.ts +4 -2
- package/dist/migrations/index.d.ts.map +1 -1
- package/dist/migrations/index.js +6 -3
- package/dist/migrations/minor-units.d.ts +162 -0
- package/dist/migrations/minor-units.d.ts.map +1 -0
- package/dist/migrations/minor-units.js +381 -0
- package/dist/migrations/minor-units.js.map +1 -0
- package/dist/migrations/orchestrate.js +35 -6
- package/dist/migrations/orchestrate.js.map +1 -1
- package/dist/migrations/sqlite-rebuild.d.ts +142 -0
- package/dist/migrations/sqlite-rebuild.d.ts.map +1 -0
- package/dist/migrations/sqlite-rebuild.js +514 -0
- package/dist/migrations/sqlite-rebuild.js.map +1 -0
- package/dist/migrations/tracker.d.ts +114 -1
- package/dist/migrations/tracker.d.ts.map +1 -1
- package/dist/migrations/tracker.js +331 -16
- package/dist/migrations/tracker.js.map +1 -1
- package/dist/migrations/types.d.ts +19 -4
- package/dist/migrations/types.d.ts.map +1 -1
- package/dist/migrations.js +6 -3
- package/dist/object.d.ts +142 -10
- package/dist/object.d.ts.map +1 -1
- package/dist/object.js +196 -41
- package/dist/object.js.map +1 -1
- package/dist/postgres-timeouts.d.ts +240 -0
- package/dist/postgres-timeouts.d.ts.map +1 -0
- package/dist/postgres-timeouts.js +204 -0
- package/dist/postgres-timeouts.js.map +1 -0
- package/dist/query-bounds.d.ts +101 -0
- package/dist/query-bounds.d.ts.map +1 -0
- package/dist/query-bounds.js +177 -0
- package/dist/query-bounds.js.map +1 -0
- package/dist/registry/class-registration.d.ts.map +1 -1
- package/dist/registry/class-registration.js +3 -1
- package/dist/registry/class-registration.js.map +1 -1
- package/dist/registry/manifest-field-merge.d.ts +12 -0
- package/dist/registry/manifest-field-merge.d.ts.map +1 -1
- package/dist/registry/manifest-field-merge.js +14 -2
- package/dist/registry/manifest-field-merge.js.map +1 -1
- package/dist/registry/schema-builder.d.ts +22 -1
- package/dist/registry/schema-builder.d.ts.map +1 -1
- package/dist/registry/schema-builder.js +205 -165
- package/dist/registry/schema-builder.js.map +1 -1
- package/dist/registry/types.d.ts +35 -3
- package/dist/registry/types.d.ts.map +1 -1
- package/dist/registry.d.ts +41 -46
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +61 -83
- package/dist/registry.js.map +1 -1
- package/dist/scanner/manifest-generator.d.ts +45 -0
- package/dist/scanner/manifest-generator.d.ts.map +1 -1
- package/dist/scanner/manifest-generator.js +92 -28
- package/dist/scanner/manifest-generator.js.map +1 -1
- package/dist/scanner/types.d.ts +5 -0
- package/dist/scanner/types.d.ts.map +1 -1
- package/dist/scanner/types.js.map +1 -1
- package/dist/schema/conflict-target.d.ts +104 -0
- package/dist/schema/conflict-target.d.ts.map +1 -0
- package/dist/schema/conflict-target.js +129 -0
- package/dist/schema/conflict-target.js.map +1 -0
- package/dist/schema/ddl/base-strategy.d.ts.map +1 -1
- package/dist/schema/ddl/base-strategy.js +2 -2
- package/dist/schema/ddl/base-strategy.js.map +1 -1
- package/dist/schema/ddl/duckdb-strategy.d.ts.map +1 -1
- package/dist/schema/ddl/duckdb-strategy.js +2 -1
- package/dist/schema/ddl/duckdb-strategy.js.map +1 -1
- package/dist/schema/ddl/postgres-strategy.d.ts.map +1 -1
- package/dist/schema/ddl/postgres-strategy.js +12 -1
- package/dist/schema/ddl/postgres-strategy.js.map +1 -1
- package/dist/schema/generator.d.ts +307 -6
- package/dist/schema/generator.d.ts.map +1 -1
- package/dist/schema/generator.js +510 -87
- package/dist/schema/generator.js.map +1 -1
- package/dist/schema/index-utils.d.ts +120 -0
- package/dist/schema/index-utils.d.ts.map +1 -1
- package/dist/schema/index-utils.js +242 -1
- package/dist/schema/index-utils.js.map +1 -1
- package/dist/schema/index.d.ts +3 -0
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/index.js +4 -1
- package/dist/schema/live-parity.d.ts +90 -0
- package/dist/schema/live-parity.d.ts.map +1 -0
- package/dist/schema/live-parity.js +602 -0
- package/dist/schema/live-parity.js.map +1 -0
- package/dist/schema/manifest-schema.d.ts +121 -0
- package/dist/schema/manifest-schema.d.ts.map +1 -0
- package/dist/schema/manifest-schema.js +267 -0
- package/dist/schema/manifest-schema.js.map +1 -0
- package/dist/schema/schema-aggregator.d.ts +24 -10
- package/dist/schema/schema-aggregator.d.ts.map +1 -1
- package/dist/schema/schema-aggregator.js +35 -90
- package/dist/schema/schema-aggregator.js.map +1 -1
- package/dist/schema/system-table-shapes.d.ts +65 -0
- package/dist/schema/system-table-shapes.d.ts.map +1 -0
- package/dist/schema/system-table-shapes.js +187 -0
- package/dist/schema/system-table-shapes.js.map +1 -0
- package/dist/schema/types.d.ts +103 -4
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema/utils.d.ts +2 -1
- package/dist/schema/utils.d.ts.map +1 -1
- package/dist/schema/utils.js +5 -3
- package/dist/schema/utils.js.map +1 -1
- package/dist/schema.js +4 -1
- package/dist/smrt-knowledge.json +20 -8
- package/dist/sync/apply.d.ts.map +1 -1
- package/dist/sync/apply.js +9 -16
- package/dist/sync/apply.js.map +1 -1
- package/dist/system/compatibility.d.ts +42 -0
- package/dist/system/compatibility.d.ts.map +1 -1
- package/dist/system/compatibility.js +182 -9
- package/dist/system/compatibility.js.map +1 -1
- package/dist/system/index.d.ts +1 -0
- package/dist/system/index.d.ts.map +1 -1
- package/dist/system/index.js +3 -2
- package/dist/system/retention.d.ts +237 -0
- package/dist/system/retention.d.ts.map +1 -0
- package/dist/system/retention.js +497 -0
- package/dist/system/retention.js.map +1 -0
- package/dist/system/schema.d.ts +100 -15
- package/dist/system/schema.d.ts.map +1 -1
- package/dist/system/schema.js +81 -45
- package/dist/system/schema.js.map +1 -1
- package/dist/system/types.d.ts +0 -2
- package/dist/system/types.d.ts.map +1 -1
- package/dist/testing/database.d.ts.map +1 -1
- package/dist/testing/database.js +1 -0
- package/dist/testing/database.js.map +1 -1
- package/dist/vite-plugin/index.d.ts.map +1 -1
- package/dist/vite-plugin/index.js +4 -9
- package/dist/vite-plugin/index.js.map +1 -1
- package/dist/vite-plugin/sveltekit-generator.d.ts.map +1 -1
- package/dist/vite-plugin/sveltekit-generator.js +71 -5
- package/dist/vite-plugin/sveltekit-generator.js.map +1 -1
- package/dist/vite-plugin/web-collections.d.ts.map +1 -1
- package/dist/vite-plugin/web-collections.js +6 -4
- package/dist/vite-plugin/web-collections.js.map +1 -1
- package/package.json +5 -5
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { isJsonPathIndex, renderIndexTarget } from "../schema/index-utils.js";
|
|
1
|
+
import { isJsonPathIndex, isStiSubtypeUniqueIndex, renderIndexTarget } from "../schema/index-utils.js";
|
|
2
2
|
import { detectEngine, getDDLStrategy } from "../schema/ddl/index.js";
|
|
3
3
|
import "../schema/utils.js";
|
|
4
|
+
import { planSqliteTableRebuilds, sqliteRebuildPlaceholderSql } from "./sqlite-rebuild.js";
|
|
4
5
|
import { createLogger } from "@happyvertical/logger";
|
|
5
6
|
//#region src/migrations/differ.ts
|
|
6
7
|
/**
|
|
@@ -33,14 +34,56 @@ function isValidSQLDataType(type) {
|
|
|
33
34
|
* Suffix patterns for indexes that the differ refuses to drop.
|
|
34
35
|
* - `_pkey`: PostgreSQL primary key implicit index.
|
|
35
36
|
* - `_key`: PostgreSQL implicit index for inline `UNIQUE (...)` table
|
|
36
|
-
* constraints
|
|
37
|
-
*
|
|
37
|
+
* constraints, and the unique index the differ itself creates for a
|
|
38
|
+
* `unique: true` column added via ADD COLUMN on engines that reject an
|
|
39
|
+
* inline UNIQUE there (SQLite/DuckDB — see {@link uniqueColumnIndexName}).
|
|
40
|
+
* Dropping these by name does not drop the underlying constraint on
|
|
41
|
+
* PostgreSQL, and dropping the constraint requires a separate DDL path
|
|
38
42
|
* (`ALTER TABLE ... DROP CONSTRAINT`) the differ does not emit today.
|
|
43
|
+
* Unclaimed `_key` unique indexes are instead *reported* as
|
|
44
|
+
* `orphan_index` advisories (#2369).
|
|
39
45
|
*/
|
|
40
46
|
var PROTECTED_INDEX_SUFFIXES = ["_pkey", "_key"];
|
|
41
47
|
function isProtectedDbIndexName(name) {
|
|
42
48
|
return PROTECTED_INDEX_SUFFIXES.some((suffix) => name.endsWith(suffix));
|
|
43
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Name of the unique index that backs a `unique: true` column. Mirrors the
|
|
52
|
+
* `<table>_<column>_key` name PostgreSQL gives the implicit index of an
|
|
53
|
+
* inline column UNIQUE constraint, so the same column ends up with the same
|
|
54
|
+
* index name on every engine and the orphan-index sweep treats it as
|
|
55
|
+
* constraint-owned.
|
|
56
|
+
*/
|
|
57
|
+
function uniqueColumnIndexName(tableName, columnName) {
|
|
58
|
+
return `${tableName}_${columnName}_key`;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* True when a change is report-only: it carries an advisory and no
|
|
62
|
+
* executable statement. Such changes never reach the migration stream.
|
|
63
|
+
*/
|
|
64
|
+
function isAdvisoryOnlyChange(change) {
|
|
65
|
+
if (!change.advisory) return false;
|
|
66
|
+
return (change.sqlStatements ?? (change.sql ? [change.sql] : [])).length === 0;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* True for an info-level report-only change: listed in `SchemaDiff.changes`
|
|
70
|
+
* for visibility but not counted by `has_changes` (a harmless orphan column,
|
|
71
|
+
* a live default the manifest no longer declares).
|
|
72
|
+
*/
|
|
73
|
+
function isInfoOnlyChange(change) {
|
|
74
|
+
return isAdvisoryOnlyChange(change) && change.advisory?.severity === "info";
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* True when a change has no executable DDL: `type_mismatch`, an advisory-only
|
|
78
|
+
* change, or a change whose only SQL is an advisory comment (e.g. a SQLite
|
|
79
|
+
* `ALTER COLUMN` the engine cannot perform in place).
|
|
80
|
+
*/
|
|
81
|
+
function isManualOrAdvisoryChange(change) {
|
|
82
|
+
if (change.type === "type_mismatch") return true;
|
|
83
|
+
if (isAdvisoryOnlyChange(change)) return true;
|
|
84
|
+
const statements = change.sqlStatements ?? (change.sql ? [change.sql] : []);
|
|
85
|
+
return statements.length > 0 && statements.every((statement) => statement.trim().startsWith("--"));
|
|
86
|
+
}
|
|
44
87
|
function resolveDatabaseUrl(db) {
|
|
45
88
|
const dbWithConfig = db;
|
|
46
89
|
return db.url || dbWithConfig.config?.url || "";
|
|
@@ -120,6 +163,125 @@ function extractIndexPredicate(createIndexSql) {
|
|
|
120
163
|
return normalizeIndexPredicate(match[1]);
|
|
121
164
|
}
|
|
122
165
|
/**
|
|
166
|
+
* Canonical form of a column default for drift comparison (#2369).
|
|
167
|
+
*
|
|
168
|
+
* The manifest side is the DDL fragment `formatDefaultValue()` would emit
|
|
169
|
+
* (`'anon'`, `0`, `TRUE`, `'{}'`, `current_timestamp`); the database side is
|
|
170
|
+
* whatever the catalog reports, which each engine renders differently:
|
|
171
|
+
* PostgreSQL appends casts (`'anon'::text`, `'{}'::jsonb`, `'-1'::integer`)
|
|
172
|
+
* and upper-cases `CURRENT_TIMESTAMP`, DuckDB wraps booleans
|
|
173
|
+
* (`CAST('t' AS BOOLEAN)`), SQLite echoes the literal verbatim. Both sides
|
|
174
|
+
* are reduced by the *manifest column type* to a `{kind, key}` pair:
|
|
175
|
+
*
|
|
176
|
+
* - `none` — no default (`undefined`, empty, or the `NULL` keyword)
|
|
177
|
+
* - `now` — the current-timestamp family (`current_timestamp`, `now()`,
|
|
178
|
+
* `transaction_timestamp()`, `datetime('now')`)
|
|
179
|
+
* - `number` — numeric columns, compared by numeric value
|
|
180
|
+
* - `bool` — boolean columns (`true`/`false`/`t`/`f`/`1`/`0`)
|
|
181
|
+
* - `text` — string literal contents (JSON columns compare the parsed
|
|
182
|
+
* value when both sides parse)
|
|
183
|
+
* - `func` — other function calls / keywords, compared lowercase and
|
|
184
|
+
* whitespace-collapsed
|
|
185
|
+
* - `raw` — could not be classified; callers must skip the comparison
|
|
186
|
+
* rather than risk a false positive
|
|
187
|
+
*/
|
|
188
|
+
function canonicalizeDefault(fragment, columnType) {
|
|
189
|
+
if (fragment === void 0) return {
|
|
190
|
+
kind: "none",
|
|
191
|
+
key: ""
|
|
192
|
+
};
|
|
193
|
+
let s = fragment.trim();
|
|
194
|
+
if (s === "") return {
|
|
195
|
+
kind: "none",
|
|
196
|
+
key: ""
|
|
197
|
+
};
|
|
198
|
+
for (let guard = 0; guard < 8; guard++) {
|
|
199
|
+
const before = s;
|
|
200
|
+
const castMatch = s.match(/^CAST\s*\((.*)\s+AS\s+[A-Za-z_][\w\s()]*\)$/is);
|
|
201
|
+
if (castMatch) s = castMatch[1].trim();
|
|
202
|
+
if (s.startsWith("(") && s.endsWith(")")) s = s.slice(1, -1).trim();
|
|
203
|
+
s = s.replace(/::[A-Za-z_][\w ]*(\(\s*\d+(\s*,\s*\d+)?\s*\))?(\[\])?\s*$/i, "").trim();
|
|
204
|
+
if (s === before) break;
|
|
205
|
+
}
|
|
206
|
+
if (/^null$/i.test(s)) return {
|
|
207
|
+
kind: "none",
|
|
208
|
+
key: ""
|
|
209
|
+
};
|
|
210
|
+
const lower = s.toLowerCase().replace(/\s+/g, " ");
|
|
211
|
+
if (lower === "current_timestamp" || lower === "current_timestamp()" || lower === "now()" || lower === "transaction_timestamp()" || lower === "datetime('now')" || lower === "localtimestamp") return {
|
|
212
|
+
kind: "now",
|
|
213
|
+
key: "now"
|
|
214
|
+
};
|
|
215
|
+
const upperType = String(columnType).toUpperCase();
|
|
216
|
+
const isNumericType = upperType === "INTEGER" || upperType === "REAL" || upperType === "DECIMAL";
|
|
217
|
+
const isBooleanType = upperType === "BOOLEAN";
|
|
218
|
+
const isJsonType = upperType === "JSON";
|
|
219
|
+
const literalMatch = s.match(/^'((?:[^']|'')*)'$/s);
|
|
220
|
+
const literal = literalMatch !== null ? literalMatch[1].replace(/''/g, "'") : null;
|
|
221
|
+
if (isBooleanType) {
|
|
222
|
+
const token = (literal ?? s).trim().toLowerCase();
|
|
223
|
+
if ([
|
|
224
|
+
"true",
|
|
225
|
+
"t",
|
|
226
|
+
"1",
|
|
227
|
+
"yes",
|
|
228
|
+
"on"
|
|
229
|
+
].includes(token)) return {
|
|
230
|
+
kind: "bool",
|
|
231
|
+
key: "true"
|
|
232
|
+
};
|
|
233
|
+
if ([
|
|
234
|
+
"false",
|
|
235
|
+
"f",
|
|
236
|
+
"0",
|
|
237
|
+
"no",
|
|
238
|
+
"off"
|
|
239
|
+
].includes(token)) return {
|
|
240
|
+
kind: "bool",
|
|
241
|
+
key: "false"
|
|
242
|
+
};
|
|
243
|
+
return {
|
|
244
|
+
kind: "raw",
|
|
245
|
+
key: lower
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
if (isNumericType) {
|
|
249
|
+
const token = (literal ?? s).trim();
|
|
250
|
+
if (/^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(token)) return {
|
|
251
|
+
kind: "number",
|
|
252
|
+
key: String(Number(token))
|
|
253
|
+
};
|
|
254
|
+
if (literal !== null) return {
|
|
255
|
+
kind: "text",
|
|
256
|
+
key: literal
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
if (literal !== null) {
|
|
260
|
+
if (isJsonType) try {
|
|
261
|
+
return {
|
|
262
|
+
kind: "text",
|
|
263
|
+
key: JSON.stringify(JSON.parse(literal))
|
|
264
|
+
};
|
|
265
|
+
} catch {}
|
|
266
|
+
return {
|
|
267
|
+
kind: "text",
|
|
268
|
+
key: literal
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
if (/^[+-]?(\d+\.?\d*|\.\d+)$/.test(s)) return {
|
|
272
|
+
kind: "text",
|
|
273
|
+
key: String(Number(s))
|
|
274
|
+
};
|
|
275
|
+
if (/^[A-Za-z_][\w.]*\s*\(.*\)$/s.test(s) || /^[A-Za-z_]\w*$/.test(s)) return {
|
|
276
|
+
kind: "func",
|
|
277
|
+
key: lower
|
|
278
|
+
};
|
|
279
|
+
return {
|
|
280
|
+
kind: "raw",
|
|
281
|
+
key: lower
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
123
285
|
* SchemaComparer class for comparing manifest schemas to database
|
|
124
286
|
*/
|
|
125
287
|
var SchemaComparer = class {
|
|
@@ -157,15 +319,19 @@ var SchemaComparer = class {
|
|
|
157
319
|
const tableChanges = await this.compareTable(tableName, schema);
|
|
158
320
|
if (tableChanges.length > 0) {
|
|
159
321
|
diff.changes.push(...tableChanges);
|
|
160
|
-
diff.has_changes = true;
|
|
322
|
+
if (tableChanges.some((change) => !isInfoOnlyChange(change))) diff.has_changes = true;
|
|
161
323
|
}
|
|
162
324
|
}
|
|
163
|
-
|
|
325
|
+
const orphanTables = [];
|
|
326
|
+
for (const tableName of existingTables) {
|
|
164
327
|
if (tableName.startsWith("_smrt_") || tableName.startsWith("sqlite_")) continue;
|
|
165
|
-
if (!manifestSchemas[tableName])
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
328
|
+
if (!manifestSchemas[tableName]) orphanTables.push(tableName);
|
|
329
|
+
}
|
|
330
|
+
orphanTables.sort();
|
|
331
|
+
diff.orphan_tables = orphanTables;
|
|
332
|
+
if (this.options.includeDroppedTables && orphanTables.length > 0) {
|
|
333
|
+
diff.dropped_tables.push(...orphanTables);
|
|
334
|
+
diff.has_changes = true;
|
|
169
335
|
}
|
|
170
336
|
return diff;
|
|
171
337
|
}
|
|
@@ -176,28 +342,40 @@ var SchemaComparer = class {
|
|
|
176
342
|
const changes = [];
|
|
177
343
|
const dbSchema = await this.db.getTableSchema?.(tableName);
|
|
178
344
|
if (!dbSchema) return changes;
|
|
179
|
-
const columnChanges = this.compareColumns(tableName, manifest, dbSchema);
|
|
180
|
-
changes.push(...
|
|
345
|
+
const columnChanges = await this.compareColumns(tableName, manifest, dbSchema);
|
|
346
|
+
changes.push(...this.engine === "sqlite" ? await planSqliteTableRebuilds({
|
|
347
|
+
db: this.db,
|
|
348
|
+
tableName,
|
|
349
|
+
changes: columnChanges,
|
|
350
|
+
mapType: (type) => this.ddlStrategy.mapType(type)
|
|
351
|
+
}) : columnChanges);
|
|
181
352
|
const dbIndexPredicates = await this.getDbIndexPredicates(tableName);
|
|
182
353
|
const indexChanges = this.compareIndexes(tableName, manifest, dbSchema, dbIndexPredicates);
|
|
183
354
|
changes.push(...indexChanges);
|
|
184
355
|
return changes;
|
|
185
356
|
}
|
|
186
357
|
/**
|
|
187
|
-
* Compare columns between manifest and database
|
|
358
|
+
* Compare columns between manifest and database.
|
|
359
|
+
*
|
|
360
|
+
* Three families of drift (#2369):
|
|
361
|
+
*
|
|
362
|
+
* 1. **Missing column** — emit `add_column` with an executable plan for the
|
|
363
|
+
* engine (see {@link generateAddColumnChanges}).
|
|
364
|
+
* 2. **Existing column** — type drift (as before), then nullability and
|
|
365
|
+
* default drift (see {@link compareColumnConstraints}).
|
|
366
|
+
* 3. **Orphan column** (in DB, not in manifest) — always reported. With
|
|
367
|
+
* `includeDroppedColumns` it becomes an executable `drop_column`; a
|
|
368
|
+
* NOT NULL orphan without a default is a `warning` advisory because ORM
|
|
369
|
+
* inserts never supply it and will fail (`23502`); with `relaxColumns`
|
|
370
|
+
* that orphan gets an executable `DROP NOT NULL` where the engine allows.
|
|
188
371
|
*/
|
|
189
|
-
compareColumns(tableName, manifest, dbSchema) {
|
|
372
|
+
async compareColumns(tableName, manifest, dbSchema) {
|
|
190
373
|
const changes = [];
|
|
191
374
|
const dbColumnNames = new Set(Object.keys(dbSchema.columns));
|
|
192
|
-
for (const [colName, colDef] of Object.entries(manifest.columns)) if (!dbColumnNames.has(colName)) changes.push(
|
|
193
|
-
type: "add_column",
|
|
194
|
-
table: tableName,
|
|
195
|
-
name: colName,
|
|
196
|
-
column: colDef,
|
|
197
|
-
sql: this.generateAddColumnSQL(tableName, colName, colDef)
|
|
198
|
-
});
|
|
375
|
+
for (const [colName, colDef] of Object.entries(manifest.columns)) if (!dbColumnNames.has(colName)) changes.push(...await this.generateAddColumnChanges(tableName, colName, colDef));
|
|
199
376
|
else {
|
|
200
377
|
const dbCol = dbSchema.columns[colName];
|
|
378
|
+
let typeDrifted = false;
|
|
201
379
|
const manifestType = colDef.type;
|
|
202
380
|
if (!isValidSQLDataType(manifestType)) logger.warn(`[SchemaComparer] Invalid manifest type "${manifestType}" for ${tableName}.${colName}, treating as TEXT`);
|
|
203
381
|
const validatedType = isValidSQLDataType(manifestType) ? manifestType : "TEXT";
|
|
@@ -206,6 +384,7 @@ var SchemaComparer = class {
|
|
|
206
384
|
const normalizedActual = this.normalizeType(dbCol.type);
|
|
207
385
|
const isUuidTextEquivalent = this.isUuidTextEquivalentColumn(colName, colDef, normalizedExpected, normalizedActual);
|
|
208
386
|
if (normalizedExpected !== normalizedActual && !isUuidTextEquivalent && !(normalizedExpected === "JSON" && normalizedActual === "TEXT" || normalizedExpected === "TEXT" && normalizedActual === "JSON")) {
|
|
387
|
+
typeDrifted = true;
|
|
209
388
|
if (this.isCompatibleTypeUpgrade(colDef.type, dbCol.type)) {
|
|
210
389
|
const generatedSQL = this.generateTypeUpgradeSQL(tableName, colName, colDef, dbCol.type);
|
|
211
390
|
changes.push({
|
|
@@ -230,18 +409,297 @@ var SchemaComparer = class {
|
|
|
230
409
|
}
|
|
231
410
|
});
|
|
232
411
|
}
|
|
412
|
+
if (!typeDrifted) changes.push(...await this.compareColumnConstraints(tableName, colName, colDef, validatedType, dbCol));
|
|
233
413
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
414
|
+
const manifestColumnNames = new Set(Object.keys(manifest.columns));
|
|
415
|
+
for (const colName of dbColumnNames) {
|
|
416
|
+
if (manifestColumnNames.has(colName)) continue;
|
|
417
|
+
const dbCol = dbSchema.columns[colName];
|
|
418
|
+
if (this.options.includeDroppedColumns) {
|
|
419
|
+
changes.push({
|
|
420
|
+
type: "drop_column",
|
|
421
|
+
table: tableName,
|
|
422
|
+
name: colName,
|
|
423
|
+
mismatch: {
|
|
424
|
+
expected: "(not in manifest)",
|
|
425
|
+
actual: this.describeDbColumn(dbCol)
|
|
426
|
+
},
|
|
427
|
+
sql: this.generateDropColumnSQL(tableName, colName)
|
|
428
|
+
});
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
changes.push(this.describeOrphanColumn(tableName, colName, dbCol));
|
|
432
|
+
}
|
|
433
|
+
return changes;
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Compare nullability and default between a manifest column and the live
|
|
437
|
+
* column, emitting `alter_column` changes (#2369).
|
|
438
|
+
*
|
|
439
|
+
* Rules:
|
|
440
|
+
* - Primary-key columns are owned by the PK constraint (SQLite reports a
|
|
441
|
+
* `TEXT PRIMARY KEY` as nullable while PostgreSQL/DuckDB imply NOT NULL),
|
|
442
|
+
* so they are skipped entirely.
|
|
443
|
+
* - Strengthening (`SET NOT NULL`, `SET DEFAULT`) is emitted as executable
|
|
444
|
+
* SQL on engines with `ALTER COLUMN` (PostgreSQL/DuckDB). A `SET NOT NULL`
|
|
445
|
+
* is preceded by a backfill of the manifest default when there is one;
|
|
446
|
+
* without a default the live data is probed and, if NULLs exist, the
|
|
447
|
+
* change is reported as manual with the suggested backfill instead of
|
|
448
|
+
* emitting an ALTER the engine would reject mid-batch.
|
|
449
|
+
* - Relaxing (`DROP NOT NULL`, `DROP DEFAULT`) is reported as an advisory
|
|
450
|
+
* unless `relaxColumns` is set, in which case it is executable.
|
|
451
|
+
* - SQLite has no `ALTER COLUMN`; every alteration is reported as manual
|
|
452
|
+
* (advisory-comment SQL) until the table-rebuild path (#2370) lands.
|
|
453
|
+
* - Defaults are compared through {@link canonicalizeDefault}; when either
|
|
454
|
+
* side cannot be canonicalized the comparison is skipped rather than
|
|
455
|
+
* risking a false positive that would churn every run.
|
|
456
|
+
*/
|
|
457
|
+
async compareColumnConstraints(tableName, colName, colDef, validatedType, dbCol) {
|
|
458
|
+
if (colDef.primaryKey || dbCol.primaryKey) return [];
|
|
459
|
+
const changes = [];
|
|
460
|
+
const manifestNotNull = colDef.notNull === true;
|
|
461
|
+
const dbNotNull = dbCol.notNull === true;
|
|
462
|
+
const manifestDefaultSql = colDef.defaultValue !== void 0 ? this.ddlStrategy.formatDefaultValue(colDef.defaultValue, validatedType) : void 0;
|
|
463
|
+
const manifestDefaultCanon = canonicalizeDefault(manifestDefaultSql, validatedType);
|
|
464
|
+
const dbDefaultCanon = canonicalizeDefault(dbCol.defaultValue === null || dbCol.defaultValue === void 0 ? void 0 : String(dbCol.defaultValue), validatedType);
|
|
465
|
+
const hasManifestDefault = manifestDefaultCanon.kind !== "none";
|
|
466
|
+
const defaultDrifted = manifestDefaultCanon.kind !== "raw" && dbDefaultCanon.kind !== "raw" && `${manifestDefaultCanon.kind}:${manifestDefaultCanon.key}` !== `${dbDefaultCanon.kind}:${dbDefaultCanon.key}`;
|
|
467
|
+
const dbHasDefault = dbDefaultCanon.kind !== "none";
|
|
468
|
+
if (defaultDrifted) {
|
|
469
|
+
if (hasManifestDefault && manifestDefaultSql !== void 0) changes.push(this.buildAlterColumnChange({
|
|
470
|
+
tableName,
|
|
471
|
+
colName,
|
|
472
|
+
colDef,
|
|
473
|
+
alteration: "set_default",
|
|
474
|
+
expected: `DEFAULT ${manifestDefaultSql}`,
|
|
475
|
+
actual: dbHasDefault ? `DEFAULT ${String(dbCol.defaultValue)}` : "(no default)",
|
|
476
|
+
statements: this.supportsAlterColumn() ? [this.generateSetDefaultSQL(tableName, colName, manifestDefaultSql, validatedType)] : null
|
|
477
|
+
}));
|
|
478
|
+
else if (dbHasDefault) changes.push(this.buildAlterColumnChange({
|
|
479
|
+
tableName,
|
|
480
|
+
colName,
|
|
481
|
+
colDef,
|
|
482
|
+
alteration: "drop_default",
|
|
483
|
+
expected: "(no default)",
|
|
484
|
+
actual: `DEFAULT ${String(dbCol.defaultValue)}`,
|
|
485
|
+
statements: this.supportsAlterColumn() ? [`ALTER TABLE ${this.quoteIdentifier(tableName)} ALTER COLUMN ${this.quoteIdentifier(colName)} DROP DEFAULT`] : null,
|
|
486
|
+
relaxation: {
|
|
487
|
+
severity: "info",
|
|
488
|
+
message: `${tableName}.${colName} has a live default (${String(dbCol.defaultValue)}) the manifest no longer declares; inserts that omit the column keep receiving it. ${this.relaxHint("drop it")}`
|
|
489
|
+
}
|
|
490
|
+
}));
|
|
491
|
+
}
|
|
492
|
+
if (manifestNotNull && !dbNotNull) {
|
|
493
|
+
const quotedTable = this.quoteIdentifier(tableName);
|
|
494
|
+
const quotedCol = this.quoteIdentifier(colName);
|
|
495
|
+
const setNotNull = `ALTER TABLE ${quotedTable} ALTER COLUMN ${quotedCol} SET NOT NULL`;
|
|
496
|
+
if (!this.supportsAlterColumn()) changes.push(this.buildAlterColumnChange({
|
|
497
|
+
tableName,
|
|
498
|
+
colName,
|
|
499
|
+
colDef,
|
|
500
|
+
alteration: "set_not_null",
|
|
501
|
+
expected: "NOT NULL",
|
|
502
|
+
actual: "NULL",
|
|
503
|
+
statements: null
|
|
504
|
+
}));
|
|
505
|
+
else if (hasManifestDefault && manifestDefaultSql !== void 0) changes.push(this.buildAlterColumnChange({
|
|
506
|
+
tableName,
|
|
507
|
+
colName,
|
|
508
|
+
colDef,
|
|
509
|
+
alteration: "set_not_null",
|
|
510
|
+
expected: "NOT NULL",
|
|
511
|
+
actual: "NULL",
|
|
512
|
+
statements: [`UPDATE ${quotedTable} SET ${quotedCol} = ${manifestDefaultSql} WHERE ${quotedCol} IS NULL`, setNotNull]
|
|
513
|
+
}));
|
|
514
|
+
else if (await this.columnHasNulls(tableName, colName)) changes.push({
|
|
515
|
+
type: "alter_column",
|
|
238
516
|
table: tableName,
|
|
239
517
|
name: colName,
|
|
240
|
-
|
|
518
|
+
column: colDef,
|
|
519
|
+
alteration: "set_not_null",
|
|
520
|
+
mismatch: {
|
|
521
|
+
expected: "NOT NULL",
|
|
522
|
+
actual: "NULL"
|
|
523
|
+
},
|
|
524
|
+
sql: `-- ${tableName}.${colName} is required by the manifest but live rows hold NULL and the column has no default to backfill; UPDATE those rows, then run: ${setNotNull}`,
|
|
525
|
+
advisory: {
|
|
526
|
+
severity: "warning",
|
|
527
|
+
message: `${tableName}.${colName} is required by the manifest but live rows hold NULL and there is no default to backfill. Backfill the NULLs, then rerun db:migrate (or declare a default on the field).`,
|
|
528
|
+
suggestedSql: [`UPDATE ${quotedTable} SET ${quotedCol} = <value> WHERE ${quotedCol} IS NULL`, setNotNull]
|
|
529
|
+
}
|
|
241
530
|
});
|
|
242
|
-
|
|
531
|
+
else changes.push(this.buildAlterColumnChange({
|
|
532
|
+
tableName,
|
|
533
|
+
colName,
|
|
534
|
+
colDef,
|
|
535
|
+
alteration: "set_not_null",
|
|
536
|
+
expected: "NOT NULL",
|
|
537
|
+
actual: "NULL",
|
|
538
|
+
statements: [setNotNull]
|
|
539
|
+
}));
|
|
540
|
+
} else if (!manifestNotNull && dbNotNull) changes.push(this.buildAlterColumnChange({
|
|
541
|
+
tableName,
|
|
542
|
+
colName,
|
|
543
|
+
colDef,
|
|
544
|
+
alteration: "drop_not_null",
|
|
545
|
+
expected: "NULL",
|
|
546
|
+
actual: "NOT NULL",
|
|
547
|
+
statements: this.supportsAlterColumn() ? [`ALTER TABLE ${this.quoteIdentifier(tableName)} ALTER COLUMN ${this.quoteIdentifier(colName)} DROP NOT NULL`] : null,
|
|
548
|
+
relaxation: {
|
|
549
|
+
severity: "warning",
|
|
550
|
+
message: `${tableName}.${colName} is NOT NULL in the database but nullable in the manifest; writes that store NULL will fail. ${this.relaxHint("drop the constraint")}`
|
|
551
|
+
}
|
|
552
|
+
}));
|
|
243
553
|
return changes;
|
|
244
554
|
}
|
|
555
|
+
/**
|
|
556
|
+
* Assemble an `alter_column` change.
|
|
557
|
+
*
|
|
558
|
+
* - `statements: null` means the engine cannot alter the column in place
|
|
559
|
+
* (SQLite): the change is reported as manual with advisory-comment SQL.
|
|
560
|
+
* - `relaxation` marks a weakening change: without `relaxColumns` it is
|
|
561
|
+
* emitted as a report-only advisory (no SQL); with it, the statements are
|
|
562
|
+
* executable.
|
|
563
|
+
*/
|
|
564
|
+
buildAlterColumnChange(input) {
|
|
565
|
+
const { tableName, colName, colDef, alteration, expected, actual, statements, relaxation } = input;
|
|
566
|
+
const base = {
|
|
567
|
+
type: "alter_column",
|
|
568
|
+
table: tableName,
|
|
569
|
+
name: colName,
|
|
570
|
+
column: colDef,
|
|
571
|
+
alteration,
|
|
572
|
+
mismatch: {
|
|
573
|
+
expected,
|
|
574
|
+
actual
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
if (relaxation && !this.options.relaxColumns) return {
|
|
578
|
+
...base,
|
|
579
|
+
advisory: {
|
|
580
|
+
severity: relaxation.severity,
|
|
581
|
+
message: relaxation.message,
|
|
582
|
+
suggestedSql: statements ?? [`-- SQLite cannot ALTER COLUMN; ${alteration === "drop_not_null" ? "dropping NOT NULL" : "dropping the default"} requires a table rebuild (#2370)`]
|
|
583
|
+
}
|
|
584
|
+
};
|
|
585
|
+
if (statements === null) {
|
|
586
|
+
const what = alteration === "set_not_null" ? `enforcing NOT NULL on ${colName}` : alteration === "drop_not_null" ? `dropping NOT NULL from ${colName}` : alteration === "set_default" ? `setting the default on ${colName}` : `dropping the default from ${colName}`;
|
|
587
|
+
return {
|
|
588
|
+
...base,
|
|
589
|
+
sql: `-- SQLite: ${what} requires table recreation (expected ${expected}, found ${actual}; #2370)`
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
return {
|
|
593
|
+
...base,
|
|
594
|
+
sql: statements[statements.length - 1],
|
|
595
|
+
...statements.length > 1 ? { sqlStatements: statements } : {}
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Describe a DB column that has no manifest counterpart. Blocking when it
|
|
600
|
+
* is NOT NULL without a default: ORM inserts never supply it, so every
|
|
601
|
+
* insert fails with a not-null violation while `db:status` would otherwise
|
|
602
|
+
* say the schema is in sync (#2369).
|
|
603
|
+
*/
|
|
604
|
+
describeOrphanColumn(tableName, colName, dbCol) {
|
|
605
|
+
const dbHasDefault = dbCol.defaultValue !== null && dbCol.defaultValue !== void 0;
|
|
606
|
+
const blocking = dbCol.notNull === true && !dbHasDefault;
|
|
607
|
+
const quotedTable = this.quoteIdentifier(tableName);
|
|
608
|
+
const quotedCol = this.quoteIdentifier(colName);
|
|
609
|
+
const dropSql = `ALTER TABLE ${quotedTable} DROP COLUMN ${quotedCol}`;
|
|
610
|
+
const relaxSql = `ALTER TABLE ${quotedTable} ALTER COLUMN ${quotedCol} DROP NOT NULL`;
|
|
611
|
+
const shape = this.describeDbColumn(dbCol);
|
|
612
|
+
const base = {
|
|
613
|
+
type: "orphan_column",
|
|
614
|
+
table: tableName,
|
|
615
|
+
name: colName,
|
|
616
|
+
mismatch: {
|
|
617
|
+
expected: "(not in manifest)",
|
|
618
|
+
actual: shape
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
if (!blocking) return {
|
|
622
|
+
...base,
|
|
623
|
+
advisory: {
|
|
624
|
+
severity: "info",
|
|
625
|
+
message: `${tableName}.${colName} exists in the database but not in the manifest (${shape}). Harmless for inserts; pass includeDroppedColumns / --drop-columns to drop it.`,
|
|
626
|
+
suggestedSql: [dropSql]
|
|
627
|
+
}
|
|
628
|
+
};
|
|
629
|
+
if (this.options.relaxColumns && this.supportsAlterColumn()) return {
|
|
630
|
+
type: "alter_column",
|
|
631
|
+
table: tableName,
|
|
632
|
+
name: colName,
|
|
633
|
+
alteration: "drop_not_null",
|
|
634
|
+
mismatch: {
|
|
635
|
+
expected: "(not in manifest)",
|
|
636
|
+
actual: shape
|
|
637
|
+
},
|
|
638
|
+
sql: relaxSql
|
|
639
|
+
};
|
|
640
|
+
const fix = this.supportsAlterColumn() ? `Pass relaxColumns / --relax-columns to drop the NOT NULL (keeps data), or includeDroppedColumns / --drop-columns to drop the column.` : `SQLite cannot drop NOT NULL in place; pass includeDroppedColumns / --drop-columns to drop the column (or rebuild the table, #2370).`;
|
|
641
|
+
return {
|
|
642
|
+
...base,
|
|
643
|
+
advisory: {
|
|
644
|
+
severity: "warning",
|
|
645
|
+
message: `${tableName}.${colName} is NOT NULL without a default and is not in the manifest — every ORM insert into ${tableName} will fail (not-null violation). ${fix}`,
|
|
646
|
+
suggestedSql: this.supportsAlterColumn() ? [relaxSql, dropSql] : [dropSql]
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* Remediation hint for a relaxation advisory. On engines with `ALTER
|
|
652
|
+
* COLUMN` the flag executes it; SQLite cannot relax in place, so the hint
|
|
653
|
+
* says so instead of implying the flag will act (#2370).
|
|
654
|
+
*/
|
|
655
|
+
relaxHint(action) {
|
|
656
|
+
return this.supportsAlterColumn() ? `Pass relaxColumns / --relax-columns to ${action}.` : `SQLite cannot ${action} in place (no ALTER COLUMN); it requires a table rebuild (#2370) — relaxColumns / --relax-columns only reports it as manual here.`;
|
|
657
|
+
}
|
|
658
|
+
describeDbColumn(dbCol) {
|
|
659
|
+
const parts = [String(dbCol.type ?? "").toUpperCase() || "UNKNOWN"];
|
|
660
|
+
if (dbCol.notNull) parts.push("NOT NULL");
|
|
661
|
+
if (dbCol.defaultValue !== null && dbCol.defaultValue !== void 0) parts.push(`DEFAULT ${String(dbCol.defaultValue)}`);
|
|
662
|
+
return parts.join(" ");
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Whether the engine can alter nullability/defaults of an existing column
|
|
666
|
+
* in place. SQLite cannot (`ALTER TABLE ... ALTER COLUMN` does not exist);
|
|
667
|
+
* PostgreSQL, DuckDB and the DuckDB-backed JSON adapter can.
|
|
668
|
+
*/
|
|
669
|
+
supportsAlterColumn() {
|
|
670
|
+
return this.engine !== "sqlite";
|
|
671
|
+
}
|
|
672
|
+
generateSetDefaultSQL(tableName, colName, formattedDefault, validatedType) {
|
|
673
|
+
const defaultSql = this.engine === "postgres" && this.normalizeType(validatedType) === "JSON" ? `${formattedDefault}::${this.ddlStrategy.mapType(validatedType).toLowerCase()}` : formattedDefault;
|
|
674
|
+
return `ALTER TABLE ${this.quoteIdentifier(tableName)} ALTER COLUMN ${this.quoteIdentifier(colName)} SET DEFAULT ${defaultSql}`;
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Live-data probe: does the table hold any row? Used to decide whether an
|
|
678
|
+
* added required column without a default can be enforced NOT NULL right
|
|
679
|
+
* away. Errors (adapter without raw query, etc.) resolve to `true` so the
|
|
680
|
+
* caller takes the conservative path.
|
|
681
|
+
*/
|
|
682
|
+
async tableHasRows(tableName) {
|
|
683
|
+
try {
|
|
684
|
+
return ((await this.db.query(`SELECT 1 AS present FROM ${this.quoteIdentifier(tableName)} LIMIT 1`)).rows?.length ?? 0) > 0;
|
|
685
|
+
} catch (err) {
|
|
686
|
+
logger.debug(`[SchemaComparer] Row probe unavailable for ${tableName}; assuming populated`, { error: err instanceof Error ? err.message : String(err) });
|
|
687
|
+
return true;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Live-data probe: does the column hold any NULL? Used before emitting a
|
|
692
|
+
* `SET NOT NULL` that has no default to backfill with. Errors resolve to
|
|
693
|
+
* `true` (conservative: report instead of emitting an ALTER that fails).
|
|
694
|
+
*/
|
|
695
|
+
async columnHasNulls(tableName, colName) {
|
|
696
|
+
try {
|
|
697
|
+
return ((await this.db.query(`SELECT 1 AS present FROM ${this.quoteIdentifier(tableName)} WHERE ${this.quoteIdentifier(colName)} IS NULL LIMIT 1`)).rows?.length ?? 0) > 0;
|
|
698
|
+
} catch (err) {
|
|
699
|
+
logger.debug(`[SchemaComparer] NULL probe unavailable for ${tableName}.${colName}; assuming NULLs exist`, { error: err instanceof Error ? err.message : String(err) });
|
|
700
|
+
return true;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
245
703
|
isUuidTextEquivalentColumn(columnName, colDef, normalizedExpected, normalizedActual) {
|
|
246
704
|
if (!(normalizedExpected === "UUID" && normalizedActual === "TEXT") && !(normalizedExpected === "TEXT" && normalizedActual === "UUID")) return false;
|
|
247
705
|
return this.isStructuralUuidCompatibleColumn(columnName, colDef);
|
|
@@ -273,7 +731,10 @@ var SchemaComparer = class {
|
|
|
273
731
|
* caller opts in via `includeDroppedIndexes`, and even then never for
|
|
274
732
|
* PostgreSQL implicit indexes (`*_pkey`, `*_key`) — those are owned by
|
|
275
733
|
* table-level constraints and need a separate `DROP CONSTRAINT` path
|
|
276
|
-
* that the differ does not emit yet.
|
|
734
|
+
* that the differ does not emit yet. One exception is unconditional: a
|
|
735
|
+
* redundant single-column index over the live primary key (the legacy
|
|
736
|
+
* `<table>_id_idx`, #2359) is dropped even without the opt-in, because
|
|
737
|
+
* the primary-key constraint already serves it.
|
|
277
738
|
*
|
|
278
739
|
* 4. **Partial-index predicate drift / collision** — two indexes on the
|
|
279
740
|
* same column(s) and uniqueness that differ only by their `WHERE`
|
|
@@ -292,6 +753,8 @@ var SchemaComparer = class {
|
|
|
292
753
|
const predicateAware = dbIndexPredicates !== null;
|
|
293
754
|
const dbPredicateFor = (name) => predicateAware ? dbIndexPredicates?.get(name) ?? "" : "";
|
|
294
755
|
const manifestPredicateFor = (idx) => predicateAware ? normalizeIndexPredicate(idx.where) : "";
|
|
756
|
+
const manifestIndexes = this.supportsPartialIndexes() ? manifest.indexes : manifest.indexes.filter((idx) => !isStiSubtypeUniqueIndex(idx));
|
|
757
|
+
const primaryKeyColumns = Object.entries(dbSchema.columns).filter(([, column]) => column.primaryKey === true).map(([name]) => name);
|
|
295
758
|
const dbIndexesByName = /* @__PURE__ */ new Map();
|
|
296
759
|
const dbIndexSignatures = /* @__PURE__ */ new Map();
|
|
297
760
|
for (const idx of dbSchema.indexes) {
|
|
@@ -309,9 +772,9 @@ var SchemaComparer = class {
|
|
|
309
772
|
bucket.add(idx.name);
|
|
310
773
|
}
|
|
311
774
|
const manifestSignatureSet = /* @__PURE__ */ new Set();
|
|
312
|
-
for (const idx of
|
|
775
|
+
for (const idx of manifestIndexes) manifestSignatureSet.add(this.getIndexSignature(idx, void 0, manifestPredicateFor(idx)));
|
|
313
776
|
const claimedDbIndexes = /* @__PURE__ */ new Set();
|
|
314
|
-
for (const idx of
|
|
777
|
+
for (const idx of manifestIndexes) {
|
|
315
778
|
const manifestSignature = this.getIndexSignature(idx, void 0, manifestPredicateFor(idx));
|
|
316
779
|
const dbByName = dbIndexesByName.get(idx.name);
|
|
317
780
|
if (dbByName) {
|
|
@@ -346,11 +809,31 @@ var SchemaComparer = class {
|
|
|
346
809
|
sql: this.generateAddIndexSQL(tableName, idx)
|
|
347
810
|
});
|
|
348
811
|
}
|
|
349
|
-
|
|
812
|
+
for (const idx of dbSchema.indexes) {
|
|
350
813
|
if (claimedDbIndexes.has(idx.name)) continue;
|
|
351
|
-
if (isProtectedDbIndexName(idx.name)) continue;
|
|
352
814
|
const idxSignature = this.getIndexSignature(idx.columns, idx.unique ?? false, dbPredicateFor(idx.name));
|
|
353
815
|
if (manifestSignatureSet.has(idxSignature)) continue;
|
|
816
|
+
if (isProtectedDbIndexName(idx.name)) {
|
|
817
|
+
if (!idx.unique || idx.name.endsWith("_pkey")) continue;
|
|
818
|
+
if (idx.columns.length === 1 && manifest.columns[idx.columns[0]]?.unique === true) continue;
|
|
819
|
+
const cols = idx.columns.map((c) => this.quoteIdentifier(c)).join(", ");
|
|
820
|
+
changes.push({
|
|
821
|
+
type: "orphan_index",
|
|
822
|
+
table: tableName,
|
|
823
|
+
name: idx.name,
|
|
824
|
+
mismatch: {
|
|
825
|
+
expected: "(not in manifest)",
|
|
826
|
+
actual: `UNIQUE (${idx.columns.join(", ")})`
|
|
827
|
+
},
|
|
828
|
+
advisory: {
|
|
829
|
+
severity: "warning",
|
|
830
|
+
message: `Unique constraint ${idx.name} on ${tableName}(${idx.columns.join(", ")}) is no longer declared by the manifest; duplicate values the model now allows will still be rejected. Drop it manually.`,
|
|
831
|
+
suggestedSql: this.engine === "postgres" ? [`ALTER TABLE ${this.quoteIdentifier(tableName)} DROP CONSTRAINT IF EXISTS ${this.quoteIdentifier(idx.name)}`, `DROP INDEX IF EXISTS ${this.quoteIdentifier(idx.name)} -- if it is a plain unique index on ${cols} rather than a constraint`] : [this.generateDropIndexSQL(idx.name)]
|
|
832
|
+
}
|
|
833
|
+
});
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
if (!(idx.unique !== true && idx.columns.length === 1 && dbPredicateFor(idx.name) === "" && primaryKeyColumns.length === 1 && primaryKeyColumns[0] === idx.columns[0]) && !this.options.includeDroppedIndexes) continue;
|
|
354
837
|
changes.push({
|
|
355
838
|
type: "drop_index",
|
|
356
839
|
table: tableName,
|
|
@@ -529,7 +1012,7 @@ var SchemaComparer = class {
|
|
|
529
1012
|
switch (this.engine) {
|
|
530
1013
|
case "sqlite":
|
|
531
1014
|
if (this.normalizeType(manifestType) === "JSON" && this.normalizeType(dbType) === "TEXT") return { sql: `-- SQLite: ${quotedCol} already stores JSON as TEXT (no change needed)` };
|
|
532
|
-
return { sql:
|
|
1015
|
+
return { sql: sqliteRebuildPlaceholderSql(colName) };
|
|
533
1016
|
case "postgres": {
|
|
534
1017
|
const manifestNormalized = this.normalizeType(manifestType);
|
|
535
1018
|
const dbNormalized = this.normalizeType(dbType);
|
|
@@ -593,21 +1076,88 @@ var SchemaComparer = class {
|
|
|
593
1076
|
return `'${value.replace(/'/g, "''")}'`;
|
|
594
1077
|
}
|
|
595
1078
|
/**
|
|
596
|
-
*
|
|
1079
|
+
* Plan the changes that add a missing column so the emitted DDL is
|
|
1080
|
+
* executable on the active engine (#2369).
|
|
1081
|
+
*
|
|
1082
|
+
* Engines disagree on what `ALTER TABLE ... ADD COLUMN` may carry inline
|
|
1083
|
+
* (probed on SQLite 3.4x/3.5x, DuckDB 1.4.x, PostgreSQL 16/17):
|
|
1084
|
+
*
|
|
1085
|
+
* | clause | SQLite | DuckDB | PostgreSQL |
|
|
1086
|
+
* |-----------------|--------------------------------|----------|---------------------|
|
|
1087
|
+
* | NOT NULL + DEF | ok | rejected | ok |
|
|
1088
|
+
* | NOT NULL, no DEF| ok on empty, rejected populated| rejected | ok empty, rej. pop. |
|
|
1089
|
+
* | UNIQUE | rejected | rejected | ok |
|
|
1090
|
+
* | DEFAULT | ok | ok | ok |
|
|
1091
|
+
* | CHECK | ok | rejected | ok |
|
|
1092
|
+
*
|
|
1093
|
+
* so the plan is:
|
|
1094
|
+
* - `NOT NULL` with a default: inline on SQLite/PostgreSQL (existing rows
|
|
1095
|
+
* receive the default); DuckDB adds with `DEFAULT` then a separate
|
|
1096
|
+
* `ALTER COLUMN ... SET NOT NULL`.
|
|
1097
|
+
* - `NOT NULL` without a default: enforced only when the table is empty
|
|
1098
|
+
* (inline on SQLite/PostgreSQL, `SET NOT NULL` step on DuckDB). On a
|
|
1099
|
+
* populated table there is nothing to backfill with, so the column is
|
|
1100
|
+
* added nullable and a manual `alter_column` (`set_not_null`) is reported
|
|
1101
|
+
* with the exact remediation instead of emitting DDL every engine rejects.
|
|
1102
|
+
* - `UNIQUE`: inline on PostgreSQL (a real constraint); SQLite/DuckDB get a
|
|
1103
|
+
* separate `CREATE UNIQUE INDEX <table>_<col>_key` (the PostgreSQL
|
|
1104
|
+
* constraint-index name, so the orphan sweep leaves it alone).
|
|
1105
|
+
* - `CHECK`: inline where accepted; DuckDB rejects it in ADD COLUMN, so it
|
|
1106
|
+
* is dropped there with a warning (SMRT's generators never emit `check`).
|
|
1107
|
+
*
|
|
1108
|
+
* The primary `add_column` change carries the whole plan in
|
|
1109
|
+
* `sqlStatements` (and the last statement in `sql`) so both the CLI and the
|
|
1110
|
+
* orchestrator execute it as one unit.
|
|
597
1111
|
*/
|
|
598
|
-
|
|
1112
|
+
async generateAddColumnChanges(tableName, colName, colDef) {
|
|
599
1113
|
const validatedType = isValidSQLDataType(colDef.type) ? colDef.type : "TEXT";
|
|
600
1114
|
if (!isValidSQLDataType(colDef.type)) logger.warn(`[SchemaComparer] Invalid manifest type "${colDef.type}" for ${tableName}.${colName}, treating as TEXT`);
|
|
601
|
-
const
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
const
|
|
610
|
-
|
|
1115
|
+
const quotedTable = this.quoteIdentifier(tableName);
|
|
1116
|
+
const quotedCol = this.quoteIdentifier(colName);
|
|
1117
|
+
const parts = [quotedCol, this.ddlStrategy.mapType(validatedType)];
|
|
1118
|
+
const followUps = [];
|
|
1119
|
+
const extraChanges = [];
|
|
1120
|
+
const hasDefault = colDef.defaultValue !== void 0 && colDef.defaultValue !== null;
|
|
1121
|
+
const formattedDefault = hasDefault ? this.ddlStrategy.formatDefaultValue(colDef.defaultValue, validatedType) : void 0;
|
|
1122
|
+
const inlineConstraintsAllowed = this.engine !== "duckdb" && this.engine !== "json";
|
|
1123
|
+
const setNotNull = `ALTER TABLE ${quotedTable} ALTER COLUMN ${quotedCol} SET NOT NULL`;
|
|
1124
|
+
let enforceNotNull = false;
|
|
1125
|
+
if (colDef.notNull) if (hasDefault) enforceNotNull = true;
|
|
1126
|
+
else if (!await this.tableHasRows(tableName)) enforceNotNull = true;
|
|
1127
|
+
else extraChanges.push({
|
|
1128
|
+
type: "alter_column",
|
|
1129
|
+
table: tableName,
|
|
1130
|
+
name: colName,
|
|
1131
|
+
column: colDef,
|
|
1132
|
+
alteration: "set_not_null",
|
|
1133
|
+
mismatch: {
|
|
1134
|
+
expected: "NOT NULL",
|
|
1135
|
+
actual: "NULL"
|
|
1136
|
+
},
|
|
1137
|
+
sql: `-- ${tableName}.${colName} is required by the manifest but has no default to backfill existing rows; the column is added nullable. Backfill it, then run: ${setNotNull}`,
|
|
1138
|
+
advisory: {
|
|
1139
|
+
severity: "warning",
|
|
1140
|
+
message: `${tableName}.${colName} is required by the manifest but has no default, and ${tableName} already holds rows — no engine can add it NOT NULL. It is added nullable; backfill it, then rerun db:migrate (or declare a default on the field).`,
|
|
1141
|
+
suggestedSql: [`UPDATE ${quotedTable} SET ${quotedCol} = <value> WHERE ${quotedCol} IS NULL`, this.supportsAlterColumn() ? setNotNull : `-- SQLite: enforcing NOT NULL afterwards requires a table rebuild (#2370)`]
|
|
1142
|
+
}
|
|
1143
|
+
});
|
|
1144
|
+
if (enforceNotNull && inlineConstraintsAllowed) parts.push("NOT NULL");
|
|
1145
|
+
if (colDef.unique && this.engine === "postgres") parts.push("UNIQUE");
|
|
1146
|
+
if (formattedDefault !== void 0) parts.push(`DEFAULT ${formattedDefault}`);
|
|
1147
|
+
if (colDef.check) if (inlineConstraintsAllowed) parts.push(`CHECK (${colDef.check})`);
|
|
1148
|
+
else logger.warn(`[SchemaComparer] ${this.engine} rejects CHECK constraints in ADD COLUMN; ${tableName}.${colName} is added without CHECK (${colDef.check})`);
|
|
1149
|
+
const addColumn = `ALTER TABLE ${quotedTable} ADD COLUMN ${parts.join(" ")}`;
|
|
1150
|
+
if (enforceNotNull && !inlineConstraintsAllowed) followUps.push(setNotNull);
|
|
1151
|
+
if (colDef.unique && this.engine !== "postgres") followUps.push(`CREATE UNIQUE INDEX ${this.quoteIdentifier(uniqueColumnIndexName(tableName, colName))} ON ${quotedTable} (${quotedCol})`);
|
|
1152
|
+
const statements = [addColumn, ...followUps];
|
|
1153
|
+
return [{
|
|
1154
|
+
type: "add_column",
|
|
1155
|
+
table: tableName,
|
|
1156
|
+
name: colName,
|
|
1157
|
+
column: colDef,
|
|
1158
|
+
sql: addColumn,
|
|
1159
|
+
...statements.length > 1 ? { sqlStatements: statements } : {}
|
|
1160
|
+
}, ...extraChanges];
|
|
611
1161
|
}
|
|
612
1162
|
/**
|
|
613
1163
|
* Generate SQL for dropping a column
|
|
@@ -626,11 +1176,16 @@ var SchemaComparer = class {
|
|
|
626
1176
|
* reject partial indexes — the predicate is dropped so the emitted DDL stays
|
|
627
1177
|
* executable (a partial index degrades to a full index there). The predicate
|
|
628
1178
|
* is trimmed and a redundant leading `WHERE` stripped for robustness.
|
|
1179
|
+
*
|
|
1180
|
+
* `IF NOT EXISTS` matches the canonical `generateIndexes()` DDL path and
|
|
1181
|
+
* makes a retry after a partially applied batch repair the schema instead of
|
|
1182
|
+
* erroring on the indexes that did land (issue #2362). All three engines
|
|
1183
|
+
* (SQLite, PostgreSQL, DuckDB) accept the clause.
|
|
629
1184
|
*/
|
|
630
1185
|
generateAddIndexSQL(tableName, idx) {
|
|
631
1186
|
const uniqueStr = idx.unique ? "UNIQUE " : "";
|
|
632
1187
|
const target = renderIndexTarget(idx, this.engine);
|
|
633
|
-
let sql = `CREATE ${uniqueStr}INDEX ${this.quoteIdentifier(idx.name)} ON ${this.quoteIdentifier(tableName)} (${target})`;
|
|
1188
|
+
let sql = `CREATE ${uniqueStr}INDEX IF NOT EXISTS ${this.quoteIdentifier(idx.name)} ON ${this.quoteIdentifier(tableName)} (${target})`;
|
|
634
1189
|
const where = idx.where?.trim().replace(/^WHERE\s+/i, "");
|
|
635
1190
|
if (this.supportsPartialIndexes() && where) sql += ` WHERE ${where}`;
|
|
636
1191
|
return sql;
|
|
@@ -660,22 +1215,30 @@ async function generateSchemaDiff(db, manifestSchemas, options = {}) {
|
|
|
660
1215
|
return new SchemaComparer(db, options).compare(manifestSchemas);
|
|
661
1216
|
}
|
|
662
1217
|
/**
|
|
663
|
-
* Check if a diff has any actionable changes
|
|
1218
|
+
* Check if a diff has any actionable changes — a table to create or drop, or
|
|
1219
|
+
* a change with executable DDL. Incompatible type mismatches, report-only
|
|
1220
|
+
* advisories (orphan columns/indexes, relaxations the caller has not opted
|
|
1221
|
+
* into) and comment-only changes (SQLite in-place limitations, live data
|
|
1222
|
+
* blocking a repair) are not actionable; they surface through
|
|
1223
|
+
* `unactionableChanges` / the CLI's manual bucket instead.
|
|
664
1224
|
*/
|
|
665
1225
|
function hasActionableChanges(diff) {
|
|
666
1226
|
if (diff.added_tables.length > 0) return true;
|
|
667
1227
|
if (diff.dropped_tables.length > 0) return true;
|
|
668
|
-
return diff.changes.some((c) => c
|
|
1228
|
+
return diff.changes.some((c) => !isManualOrAdvisoryChange(c));
|
|
669
1229
|
}
|
|
670
1230
|
/**
|
|
671
|
-
* Get SQL statements from a diff for execution
|
|
1231
|
+
* Get SQL statements from a diff for execution. Advisory-only changes carry
|
|
1232
|
+
* no statements and are skipped; comment-only statements (SQLite in-place
|
|
1233
|
+
* limitations) pass through unchanged so callers can filter them the way
|
|
1234
|
+
* they already do for `type_upgrade`.
|
|
672
1235
|
*/
|
|
673
1236
|
function getSQLFromDiff(diff) {
|
|
674
1237
|
const statements = [];
|
|
675
|
-
for (const change of diff.changes) if (change.type !== "type_mismatch") statements.push(...change.sqlStatements ?? (change.sql ? [change.sql] : []));
|
|
1238
|
+
for (const change of diff.changes) if (change.type !== "type_mismatch" && !isAdvisoryOnlyChange(change)) statements.push(...change.sqlStatements ?? (change.sql ? [change.sql] : []));
|
|
676
1239
|
return statements;
|
|
677
1240
|
}
|
|
678
1241
|
//#endregion
|
|
679
|
-
export { SchemaComparer, extractIndexPredicate, generateSchemaDiff, getSQLFromDiff, hasActionableChanges, normalizeIndexPredicate };
|
|
1242
|
+
export { SchemaComparer, canonicalizeDefault, extractIndexPredicate, generateSchemaDiff, getSQLFromDiff, hasActionableChanges, isAdvisoryOnlyChange, isInfoOnlyChange, isManualOrAdvisoryChange, normalizeIndexPredicate, uniqueColumnIndexName };
|
|
680
1243
|
|
|
681
1244
|
//# sourceMappingURL=differ.js.map
|