@happyvertical/smrt-core 0.37.0 → 0.37.1
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/consumer-plugin/index.js.map +1 -1
- package/dist/manifest/discover-smrt-packages.d.ts +10 -0
- package/dist/manifest/discover-smrt-packages.d.ts.map +1 -1
- package/dist/manifest/discover-smrt-packages.js.map +1 -1
- package/dist/manifest/generator.d.ts.map +1 -1
- package/dist/manifest/generator.js +34 -37
- package/dist/manifest/generator.js.map +1 -1
- package/dist/manifest/index.js +2 -2
- package/dist/manifest/index.js.map +1 -1
- package/dist/manifest/manifest-loader.d.ts +10 -0
- package/dist/manifest/manifest-loader.d.ts.map +1 -1
- package/dist/manifest/manifest-loader.js.map +1 -1
- package/dist/manifest/static-manifest.js +2 -2
- package/dist/manifest/static-manifest.js.map +1 -1
- package/dist/manifest/store.js +2 -2
- package/dist/manifest/test-manifest-stub.js +2 -2
- package/dist/manifest/test-manifest-stub.js.map +1 -1
- package/dist/manifest.json +2 -2
- package/dist/migrations/differ.d.ts +104 -13
- package/dist/migrations/differ.d.ts.map +1 -1
- package/dist/migrations/differ.js +199 -26
- package/dist/migrations/differ.js.map +1 -1
- package/dist/node_modules/.pnpm/{minimatch@10.2.3 → minimatch@10.2.5}/node_modules/minimatch/dist/esm/assert-valid-pattern.js.map +1 -1
- package/dist/node_modules/.pnpm/{minimatch@10.2.3 → minimatch@10.2.5}/node_modules/minimatch/dist/esm/ast.js +1 -7
- package/dist/node_modules/.pnpm/minimatch@10.2.5/node_modules/minimatch/dist/esm/ast.js.map +1 -0
- package/dist/node_modules/.pnpm/{minimatch@10.2.3 → minimatch@10.2.5}/node_modules/minimatch/dist/esm/brace-expressions.js.map +1 -1
- package/dist/node_modules/.pnpm/{minimatch@10.2.3 → minimatch@10.2.5}/node_modules/minimatch/dist/esm/escape.js.map +1 -1
- package/dist/node_modules/.pnpm/{minimatch@10.2.3 → minimatch@10.2.5}/node_modules/minimatch/dist/esm/index.js +17 -14
- package/dist/node_modules/.pnpm/minimatch@10.2.5/node_modules/minimatch/dist/esm/index.js.map +1 -0
- package/dist/node_modules/.pnpm/minimatch@10.2.5/node_modules/minimatch/dist/esm/unescape.js +10 -0
- package/dist/node_modules/.pnpm/{minimatch@10.2.3 → minimatch@10.2.5}/node_modules/minimatch/dist/esm/unescape.js.map +1 -1
- package/dist/object.d.ts.map +1 -1
- package/dist/object.js.map +1 -1
- package/dist/scanner/manifest-generator.d.ts.map +1 -1
- package/dist/scanner/manifest-generator.js +22 -20
- package/dist/scanner/manifest-generator.js.map +1 -1
- package/dist/smrt-knowledge.json +7 -7
- package/dist/vite-plugin/index.js +1 -1
- package/package.json +7 -7
- package/dist/node_modules/.pnpm/minimatch@10.2.3/node_modules/minimatch/dist/esm/ast.js.map +0 -1
- package/dist/node_modules/.pnpm/minimatch@10.2.3/node_modules/minimatch/dist/esm/index.js.map +0 -1
- package/dist/node_modules/.pnpm/minimatch@10.2.3/node_modules/minimatch/dist/esm/unescape.js +0 -10
- /package/dist/node_modules/.pnpm/{minimatch@10.2.3 → minimatch@10.2.5}/node_modules/minimatch/dist/esm/assert-valid-pattern.js +0 -0
- /package/dist/node_modules/.pnpm/{minimatch@10.2.3 → minimatch@10.2.5}/node_modules/minimatch/dist/esm/brace-expressions.js +0 -0
- /package/dist/node_modules/.pnpm/{minimatch@10.2.3 → minimatch@10.2.5}/node_modules/minimatch/dist/esm/escape.js +0 -0
package/dist/manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": "1.0.0",
|
|
3
|
-
"timestamp":
|
|
3
|
+
"timestamp": 1782792357137,
|
|
4
4
|
"packageName": "@happyvertical/smrt-core",
|
|
5
|
-
"packageVersion": "0.37.
|
|
5
|
+
"packageVersion": "0.37.1",
|
|
6
6
|
"objects": {
|
|
7
7
|
"@happyvertical/smrt-core:SmrtClass": {
|
|
8
8
|
"name": "smrtclass",
|
|
@@ -29,6 +29,35 @@ export interface DiffOptions {
|
|
|
29
29
|
*/
|
|
30
30
|
engineHint?: string;
|
|
31
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* Normalize a partial-index `WHERE` predicate so semantically-identical
|
|
34
|
+
* clauses from different sources compare equal (issue #1692).
|
|
35
|
+
*
|
|
36
|
+
* The manifest stores predicates roughly as written (`_meta_type = 'Article'`),
|
|
37
|
+
* SQLite/DuckDB echo the original CREATE INDEX text verbatim, and PostgreSQL
|
|
38
|
+
* re-renders them with type casts and extra parentheses
|
|
39
|
+
* (`((_meta_type)::text = 'Article'::text)`). Normalization:
|
|
40
|
+
*
|
|
41
|
+
* - strips a leading `WHERE` keyword,
|
|
42
|
+
* - removes PostgreSQL `::type` casts (single-word type names — the only kind
|
|
43
|
+
* SMRT-generated partial predicates produce, e.g. `_meta_type::text`),
|
|
44
|
+
* - removes parentheses (SMRT only emits simple `col = 'literal'` predicates,
|
|
45
|
+
* so grouping carries no meaning here),
|
|
46
|
+
* - lowercases everything OUTSIDE single-quoted string literals (SQL keywords
|
|
47
|
+
* and identifiers are case-insensitive; literals such as STI discriminator
|
|
48
|
+
* class names are case-sensitive, so they are preserved verbatim),
|
|
49
|
+
* - collapses whitespace and tightens spacing around comparison operators.
|
|
50
|
+
*
|
|
51
|
+
* Returns '' for an absent/empty predicate (i.e. a non-partial index).
|
|
52
|
+
*/
|
|
53
|
+
export declare function normalizeIndexPredicate(where?: string | null): string;
|
|
54
|
+
/**
|
|
55
|
+
* Extract the normalized partial-index predicate from a `CREATE INDEX`
|
|
56
|
+
* statement — the `WHERE` tail that follows the column-list close paren.
|
|
57
|
+
* Works for both SQLite/DuckDB `sqlite_master.sql` text and PostgreSQL
|
|
58
|
+
* `pg_indexes.indexdef`. Returns '' for a non-partial index.
|
|
59
|
+
*/
|
|
60
|
+
export declare function extractIndexPredicate(createIndexSql: string): string;
|
|
32
61
|
/**
|
|
33
62
|
* SchemaComparer class for comparing manifest schemas to database
|
|
34
63
|
*/
|
|
@@ -55,7 +84,7 @@ export declare class SchemaComparer {
|
|
|
55
84
|
/**
|
|
56
85
|
* Compare indexes between manifest and database
|
|
57
86
|
*
|
|
58
|
-
*
|
|
87
|
+
* Four classes of drift the differ now detects:
|
|
59
88
|
*
|
|
60
89
|
* 1. **Missing index** — manifest has an index neither the DB has by name
|
|
61
90
|
* nor any equivalent-by-signature. Emit `add_index`. (Issue #741: the
|
|
@@ -63,11 +92,13 @@ export declare class SchemaComparer {
|
|
|
63
92
|
* classes register indexes with different name prefixes.)
|
|
64
93
|
*
|
|
65
94
|
* 2. **Same-name shape drift** — DB has an index with the manifest's name,
|
|
66
|
-
* but its columns
|
|
67
|
-
* in issue #1165
|
|
68
|
-
* non-unique
|
|
69
|
-
*
|
|
70
|
-
*
|
|
95
|
+
* but its columns, uniqueness flag, or partial-index `WHERE` predicate
|
|
96
|
+
* differ. This covers the uniqueness flip in issue #1165
|
|
97
|
+
* (`tenants_slug_context_meta_type_idx` materialized non-unique while
|
|
98
|
+
* the manifest declares it unique) and the predicate drift in issue
|
|
99
|
+
* #1692 (a partial index whose `WHERE` clause was added, removed, or
|
|
100
|
+
* altered). Emit `drop_index` + `add_index` so the next migrate cycle
|
|
101
|
+
* recreates it with the correct shape.
|
|
71
102
|
*
|
|
72
103
|
* 3. **Orphan in DB** — DB has an index with no manifest counterpart by
|
|
73
104
|
* name and no signature equivalent. Emit `drop_index` *only* when the
|
|
@@ -75,20 +106,36 @@ export declare class SchemaComparer {
|
|
|
75
106
|
* PostgreSQL implicit indexes (`*_pkey`, `*_key`) — those are owned by
|
|
76
107
|
* table-level constraints and need a separate `DROP CONSTRAINT` path
|
|
77
108
|
* that the differ does not emit yet.
|
|
109
|
+
*
|
|
110
|
+
* 4. **Partial-index predicate drift / collision** — two indexes on the
|
|
111
|
+
* same column(s) and uniqueness that differ only by their `WHERE`
|
|
112
|
+
* predicate (e.g. distinct STI child partial indexes) are no longer
|
|
113
|
+
* collapsed to one signature, so the signature-equivalence path (b)
|
|
114
|
+
* won't claim one for the other.
|
|
115
|
+
*
|
|
116
|
+
* @param dbIndexPredicates - Normalized `WHERE` predicate per DB index
|
|
117
|
+
* name from {@link getDbIndexPredicates}. `null` means predicate
|
|
118
|
+
* introspection was unavailable for this engine/adapter, in which case
|
|
119
|
+
* the comparison falls back to predicate-unaware signatures (the prior
|
|
120
|
+
* behavior) so existing partial indexes are never flagged as false drift.
|
|
78
121
|
*/
|
|
79
122
|
private compareIndexes;
|
|
80
123
|
/**
|
|
81
|
-
* Generate a signature for an index based on its columns and
|
|
82
|
-
* Used for functional equivalence
|
|
124
|
+
* Generate a signature for an index based on its columns, uniqueness, and
|
|
125
|
+
* (normalized) partial-index predicate. Used for functional equivalence
|
|
126
|
+
* checking (Issue #741) and predicate-drift detection (Issue #1692).
|
|
83
127
|
*
|
|
84
128
|
* Note: Column order is preserved because it is semantically significant for
|
|
85
129
|
* composite indexes. An index on (a, b) is NOT equivalent to (b, a) - they
|
|
86
130
|
* have different query performance characteristics.
|
|
87
131
|
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
132
|
+
* The trailing predicate component distinguishes partial indexes that share
|
|
133
|
+
* columns and uniqueness but differ by their `WHERE` clause (e.g. distinct
|
|
134
|
+
* STI child partial indexes). Callers pass the already-normalized predicate
|
|
135
|
+
* so both the manifest (desired) and introspected (DB) sides compare equal
|
|
136
|
+
* for semantically-identical clauses. An empty string means "no predicate"
|
|
137
|
+
* (a non-partial index) and is also used on both sides when predicate
|
|
138
|
+
* introspection is unavailable, preserving the prior behavior.
|
|
92
139
|
*
|
|
93
140
|
* For JSON-path indexes (`@meta({ indexed: true })`) the signature is
|
|
94
141
|
* derived from the JSON path instead of an empty column list, so the
|
|
@@ -96,9 +143,44 @@ export declare class SchemaComparer {
|
|
|
96
143
|
*
|
|
97
144
|
* @param idxOrColumns - Either an IndexDefinition or a column array (legacy)
|
|
98
145
|
* @param uniqueArg - Unique flag (used when first arg is a column array)
|
|
146
|
+
* @param predicateArg - Normalized partial-index predicate (default '')
|
|
99
147
|
* @returns Signature string
|
|
100
148
|
*/
|
|
101
149
|
private getIndexSignature;
|
|
150
|
+
/**
|
|
151
|
+
* Whether the active engine supports partial indexes (`CREATE INDEX … WHERE`).
|
|
152
|
+
*
|
|
153
|
+
* SQLite and PostgreSQL do. DuckDB rejects them outright, and the JSON
|
|
154
|
+
* adapter is DuckDB-backed, so on those engines a "partial" index can only
|
|
155
|
+
* ever exist as a full index. Treating the predicate as significant there
|
|
156
|
+
* would (a) flag existing full indexes as false drift and (b) emit
|
|
157
|
+
* `CREATE INDEX … WHERE` DDL the engine rejects, breaking the migration.
|
|
158
|
+
* Gating on this keeps both the comparison and the generated DDL aligned
|
|
159
|
+
* with what the engine actually accepts (a partial index degrades to a
|
|
160
|
+
* full index), matching the pre-#1692 behavior on those engines.
|
|
161
|
+
*/
|
|
162
|
+
private supportsPartialIndexes;
|
|
163
|
+
/**
|
|
164
|
+
* Introspect partial-index predicates for a table, keyed by index name.
|
|
165
|
+
*
|
|
166
|
+
* `getTableSchema()` (the @happyvertical/sql introspection) returns only
|
|
167
|
+
* name/columns/unique, so the `WHERE` predicate is read directly here:
|
|
168
|
+
*
|
|
169
|
+
* - PostgreSQL: `pg_indexes.indexdef` carries the full CREATE INDEX text.
|
|
170
|
+
* - SQLite: the `sqlite_master.sql` column carries the original CREATE INDEX
|
|
171
|
+
* text.
|
|
172
|
+
*
|
|
173
|
+
* Engines that don't support partial indexes (DuckDB / the DuckDB-backed
|
|
174
|
+
* JSON adapter) short-circuit to `null` so the comparison stays
|
|
175
|
+
* predicate-unaware there — see {@link supportsPartialIndexes}.
|
|
176
|
+
*
|
|
177
|
+
* Non-partial indexes are omitted from the map (callers treat a missing
|
|
178
|
+
* entry as the empty predicate). Returns `null` when the catalog query
|
|
179
|
+
* fails — e.g. an adapter exposing neither catalog — so the index
|
|
180
|
+
* comparison can fall back to predicate-unaware behavior rather than
|
|
181
|
+
* flagging every existing partial index as false drift.
|
|
182
|
+
*/
|
|
183
|
+
private getDbIndexPredicates;
|
|
102
184
|
/**
|
|
103
185
|
* Get list of existing tables from database
|
|
104
186
|
*/
|
|
@@ -152,7 +234,16 @@ export declare class SchemaComparer {
|
|
|
152
234
|
*/
|
|
153
235
|
private generateDropColumnSQL;
|
|
154
236
|
/**
|
|
155
|
-
* Generate SQL for adding an index
|
|
237
|
+
* Generate SQL for adding an index.
|
|
238
|
+
*
|
|
239
|
+
* On engines that support partial indexes (SQLite/PostgreSQL) this mirrors
|
|
240
|
+
* the canonical CREATE INDEX path in the DDL strategies: a partial index
|
|
241
|
+
* appends its `WHERE` predicate so a detected predicate add/alter (issue
|
|
242
|
+
* #1692) recreates the index with the correct partial condition rather than
|
|
243
|
+
* silently widening it to a full index. On DuckDB / the JSON adapter — which
|
|
244
|
+
* reject partial indexes — the predicate is dropped so the emitted DDL stays
|
|
245
|
+
* executable (a partial index degrades to a full index there). The predicate
|
|
246
|
+
* is trimmed and a redundant leading `WHERE` stripped for robustness.
|
|
156
247
|
*/
|
|
157
248
|
private generateAddIndexSQL;
|
|
158
249
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"differ.d.ts","sourceRoot":"","sources":["../../src/migrations/differ.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAKH,OAAO,KAAK,EAGV,YAAY,EACZ,gBAAgB,EAChB,UAAU,EAEX,MAAM,oBAAoB,CAAC;AAE5B,OAAO,KAAK,EAAE,iBAAiB,EAAsB,MAAM,YAAY,CAAC;AA8BxE;;GAEG;AACH,MAAM,WAAW,WAAW;IAC1B,iEAAiE;IACjE,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAC/B,kEAAkE;IAClE,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAChC;;;;;;;OAOG;IACH,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAChC,iDAAiD;IACjD,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAC/B;;;;;;;OAOG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAuBD;;GAEG;AACH,qBAAa,cAAc;IACzB,OAAO,CAAC,EAAE,CAAoB;IAC9B,OAAO,CAAC,OAAO,CAAc;IAC7B,OAAO,CAAC,MAAM,CAAiB;IAC/B,OAAO,CAAC,WAAW,CAAoC;gBAE3C,EAAE,EAAE,iBAAiB,EAAE,OAAO,GAAE,WAAgB;IAsB5D;;OAEG;IACG,OAAO,CACX,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,GAChD,OAAO,CAAC,UAAU,CAAC;IA4CtB;;OAEG;IACG,YAAY,CAChB,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,gBAAgB,GACzB,OAAO,CAAC,YAAY,EAAE,CAAC;
|
|
1
|
+
{"version":3,"file":"differ.d.ts","sourceRoot":"","sources":["../../src/migrations/differ.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAKH,OAAO,KAAK,EAGV,YAAY,EACZ,gBAAgB,EAChB,UAAU,EAEX,MAAM,oBAAoB,CAAC;AAE5B,OAAO,KAAK,EAAE,iBAAiB,EAAsB,MAAM,YAAY,CAAC;AA8BxE;;GAEG;AACH,MAAM,WAAW,WAAW;IAC1B,iEAAiE;IACjE,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAC/B,kEAAkE;IAClE,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAChC;;;;;;;OAOG;IACH,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAChC,iDAAiD;IACjD,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAC/B;;;;;;;OAOG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAuBD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAsCrE;AAiBD;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,cAAc,EAAE,MAAM,GAAG,MAAM,CAOpE;AAED;;GAEG;AACH,qBAAa,cAAc;IACzB,OAAO,CAAC,EAAE,CAAoB;IAC9B,OAAO,CAAC,OAAO,CAAc;IAC7B,OAAO,CAAC,MAAM,CAAiB;IAC/B,OAAO,CAAC,WAAW,CAAoC;gBAE3C,EAAE,EAAE,iBAAiB,EAAE,OAAO,GAAE,WAAgB;IAsB5D;;OAEG;IACG,OAAO,CACX,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,GAChD,OAAO,CAAC,UAAU,CAAC;IA4CtB;;OAEG;IACG,YAAY,CAChB,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,gBAAgB,GACzB,OAAO,CAAC,YAAY,EAAE,CAAC;IAkC1B;;OAEG;IACH,OAAO,CAAC,cAAc;IAwItB,OAAO,CAAC,0BAA0B;IAkBlC,OAAO,CAAC,gCAAgC;IAexC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAqCG;IACH,OAAO,CAAC,cAAc;IA2KtB;;;;;;;;;;;;;;;;;;;;;;;;;OAyBG;IACH,OAAO,CAAC,iBAAiB;IAezB;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,sBAAsB;IAI9B;;;;;;;;;;;;;;;;;;;OAmBG;YACW,oBAAoB;IAmDlC;;OAEG;YACW,iBAAiB;IAoB/B;;OAEG;IACH,OAAO,CAAC,aAAa;IAmDrB;;;;;;;;;;;;;;OAcG;IACH,OAAO,CAAC,uBAAuB;IAiD/B;;;;;;;OAOG;IACH,OAAO,CAAC,sBAAsB;IAoI9B;;OAEG;IACH,OAAO,CAAC,eAAe;IAIvB;;;;OAIG;IACH,OAAO,CAAC,mCAAmC;IAgB3C,OAAO,CAAC,YAAY;IAIpB;;OAEG;IACH,OAAO,CAAC,oBAAoB;IAiD5B;;OAEG;IACH,OAAO,CAAC,qBAAqB;IAI7B;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,mBAAmB;IAW3B;;;;;;;;;;;;;OAaG;IACH,OAAO,CAAC,oBAAoB;CAG7B;AAED;;GAEG;AACH,wBAAsB,kBAAkB,CACtC,EAAE,EAAE,iBAAiB,EACrB,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,EACjD,OAAO,GAAE,WAAgB,GACxB,OAAO,CAAC,UAAU,CAAC,CAGrB;AAED;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAI9D;AAED;;GAEG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,UAAU,GAAG,MAAM,EAAE,CAgBzD"}
|
|
@@ -25,6 +25,50 @@ function resolveDatabaseUrl(db) {
|
|
|
25
25
|
const dbWithConfig = db;
|
|
26
26
|
return db.url || dbWithConfig.config?.url || "";
|
|
27
27
|
}
|
|
28
|
+
function normalizeIndexPredicate(where) {
|
|
29
|
+
if (!where) return "";
|
|
30
|
+
const stripped = where.trim().replace(/^WHERE\s+/i, "");
|
|
31
|
+
if (!stripped) return "";
|
|
32
|
+
let out = "";
|
|
33
|
+
let i = 0;
|
|
34
|
+
while (i < stripped.length) {
|
|
35
|
+
if (stripped[i] === "'") {
|
|
36
|
+
let literal = "'";
|
|
37
|
+
i++;
|
|
38
|
+
while (i < stripped.length) {
|
|
39
|
+
if (stripped[i] === "'") {
|
|
40
|
+
if (stripped[i + 1] === "'") {
|
|
41
|
+
literal += "''";
|
|
42
|
+
i += 2;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
literal += "'";
|
|
46
|
+
i++;
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
literal += stripped[i];
|
|
50
|
+
i++;
|
|
51
|
+
}
|
|
52
|
+
out += literal;
|
|
53
|
+
} else {
|
|
54
|
+
let run = "";
|
|
55
|
+
while (i < stripped.length && stripped[i] !== "'") {
|
|
56
|
+
run += stripped[i];
|
|
57
|
+
i++;
|
|
58
|
+
}
|
|
59
|
+
out += normalizeNonLiteralPredicateRun(run);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
function normalizeNonLiteralPredicateRun(run) {
|
|
65
|
+
return run.replace(/::[A-Za-z_]\w*/g, "").replace(/[()]/g, "").toLowerCase().replace(/\s+/g, " ").replace(/\s*([=<>!]+)\s*/g, "$1").trim();
|
|
66
|
+
}
|
|
67
|
+
function extractIndexPredicate(createIndexSql) {
|
|
68
|
+
const match = createIndexSql.match(/\)\s*WHERE\s+([\s\S]+?)\s*;?\s*$/i);
|
|
69
|
+
if (!match) return "";
|
|
70
|
+
return normalizeIndexPredicate(match[1]);
|
|
71
|
+
}
|
|
28
72
|
class SchemaComparer {
|
|
29
73
|
db;
|
|
30
74
|
options;
|
|
@@ -89,7 +133,13 @@ class SchemaComparer {
|
|
|
89
133
|
}
|
|
90
134
|
const columnChanges = this.compareColumns(tableName, manifest, dbSchema);
|
|
91
135
|
changes.push(...columnChanges);
|
|
92
|
-
const
|
|
136
|
+
const dbIndexPredicates = await this.getDbIndexPredicates(tableName);
|
|
137
|
+
const indexChanges = this.compareIndexes(
|
|
138
|
+
tableName,
|
|
139
|
+
manifest,
|
|
140
|
+
dbSchema,
|
|
141
|
+
dbIndexPredicates
|
|
142
|
+
);
|
|
93
143
|
changes.push(...indexChanges);
|
|
94
144
|
return changes;
|
|
95
145
|
}
|
|
@@ -190,7 +240,7 @@ class SchemaComparer {
|
|
|
190
240
|
/**
|
|
191
241
|
* Compare indexes between manifest and database
|
|
192
242
|
*
|
|
193
|
-
*
|
|
243
|
+
* Four classes of drift the differ now detects:
|
|
194
244
|
*
|
|
195
245
|
* 1. **Missing index** — manifest has an index neither the DB has by name
|
|
196
246
|
* nor any equivalent-by-signature. Emit `add_index`. (Issue #741: the
|
|
@@ -198,11 +248,13 @@ class SchemaComparer {
|
|
|
198
248
|
* classes register indexes with different name prefixes.)
|
|
199
249
|
*
|
|
200
250
|
* 2. **Same-name shape drift** — DB has an index with the manifest's name,
|
|
201
|
-
* but its columns
|
|
202
|
-
* in issue #1165
|
|
203
|
-
* non-unique
|
|
204
|
-
*
|
|
205
|
-
*
|
|
251
|
+
* but its columns, uniqueness flag, or partial-index `WHERE` predicate
|
|
252
|
+
* differ. This covers the uniqueness flip in issue #1165
|
|
253
|
+
* (`tenants_slug_context_meta_type_idx` materialized non-unique while
|
|
254
|
+
* the manifest declares it unique) and the predicate drift in issue
|
|
255
|
+
* #1692 (a partial index whose `WHERE` clause was added, removed, or
|
|
256
|
+
* altered). Emit `drop_index` + `add_index` so the next migrate cycle
|
|
257
|
+
* recreates it with the correct shape.
|
|
206
258
|
*
|
|
207
259
|
* 3. **Orphan in DB** — DB has an index with no manifest counterpart by
|
|
208
260
|
* name and no signature equivalent. Emit `drop_index` *only* when the
|
|
@@ -210,15 +262,34 @@ class SchemaComparer {
|
|
|
210
262
|
* PostgreSQL implicit indexes (`*_pkey`, `*_key`) — those are owned by
|
|
211
263
|
* table-level constraints and need a separate `DROP CONSTRAINT` path
|
|
212
264
|
* that the differ does not emit yet.
|
|
265
|
+
*
|
|
266
|
+
* 4. **Partial-index predicate drift / collision** — two indexes on the
|
|
267
|
+
* same column(s) and uniqueness that differ only by their `WHERE`
|
|
268
|
+
* predicate (e.g. distinct STI child partial indexes) are no longer
|
|
269
|
+
* collapsed to one signature, so the signature-equivalence path (b)
|
|
270
|
+
* won't claim one for the other.
|
|
271
|
+
*
|
|
272
|
+
* @param dbIndexPredicates - Normalized `WHERE` predicate per DB index
|
|
273
|
+
* name from {@link getDbIndexPredicates}. `null` means predicate
|
|
274
|
+
* introspection was unavailable for this engine/adapter, in which case
|
|
275
|
+
* the comparison falls back to predicate-unaware signatures (the prior
|
|
276
|
+
* behavior) so existing partial indexes are never flagged as false drift.
|
|
213
277
|
*/
|
|
214
|
-
compareIndexes(tableName, manifest, dbSchema) {
|
|
278
|
+
compareIndexes(tableName, manifest, dbSchema, dbIndexPredicates = null) {
|
|
215
279
|
const changes = [];
|
|
280
|
+
const predicateAware = dbIndexPredicates !== null;
|
|
281
|
+
const dbPredicateFor = (name) => predicateAware ? dbIndexPredicates?.get(name) ?? "" : "";
|
|
282
|
+
const manifestPredicateFor = (idx) => predicateAware ? normalizeIndexPredicate(idx.where) : "";
|
|
216
283
|
const dbIndexesByName = /* @__PURE__ */ new Map();
|
|
217
284
|
const dbIndexSignatures = /* @__PURE__ */ new Map();
|
|
218
285
|
for (const idx of dbSchema.indexes) {
|
|
219
286
|
const unique = idx.unique ?? false;
|
|
220
287
|
dbIndexesByName.set(idx.name, { columns: idx.columns, unique });
|
|
221
|
-
const signature = this.getIndexSignature(
|
|
288
|
+
const signature = this.getIndexSignature(
|
|
289
|
+
idx.columns,
|
|
290
|
+
unique,
|
|
291
|
+
dbPredicateFor(idx.name)
|
|
292
|
+
);
|
|
222
293
|
let bucket = dbIndexSignatures.get(signature);
|
|
223
294
|
if (!bucket) {
|
|
224
295
|
bucket = /* @__PURE__ */ new Set();
|
|
@@ -228,11 +299,17 @@ class SchemaComparer {
|
|
|
228
299
|
}
|
|
229
300
|
const manifestSignatureSet = /* @__PURE__ */ new Set();
|
|
230
301
|
for (const idx of manifest.indexes) {
|
|
231
|
-
manifestSignatureSet.add(
|
|
302
|
+
manifestSignatureSet.add(
|
|
303
|
+
this.getIndexSignature(idx, void 0, manifestPredicateFor(idx))
|
|
304
|
+
);
|
|
232
305
|
}
|
|
233
306
|
const claimedDbIndexes = /* @__PURE__ */ new Set();
|
|
234
307
|
for (const idx of manifest.indexes) {
|
|
235
|
-
const manifestSignature = this.getIndexSignature(
|
|
308
|
+
const manifestSignature = this.getIndexSignature(
|
|
309
|
+
idx,
|
|
310
|
+
void 0,
|
|
311
|
+
manifestPredicateFor(idx)
|
|
312
|
+
);
|
|
236
313
|
const dbByName = dbIndexesByName.get(idx.name);
|
|
237
314
|
if (dbByName) {
|
|
238
315
|
claimedDbIndexes.add(idx.name);
|
|
@@ -241,7 +318,8 @@ class SchemaComparer {
|
|
|
241
318
|
}
|
|
242
319
|
const dbSignature = this.getIndexSignature(
|
|
243
320
|
dbByName.columns,
|
|
244
|
-
dbByName.unique
|
|
321
|
+
dbByName.unique,
|
|
322
|
+
dbPredicateFor(idx.name)
|
|
245
323
|
);
|
|
246
324
|
if (dbSignature === manifestSignature) {
|
|
247
325
|
continue;
|
|
@@ -282,7 +360,8 @@ class SchemaComparer {
|
|
|
282
360
|
if (isProtectedDbIndexName(idx.name)) continue;
|
|
283
361
|
const idxSignature = this.getIndexSignature(
|
|
284
362
|
idx.columns,
|
|
285
|
-
idx.unique ?? false
|
|
363
|
+
idx.unique ?? false,
|
|
364
|
+
dbPredicateFor(idx.name)
|
|
286
365
|
);
|
|
287
366
|
if (manifestSignatureSet.has(idxSignature)) continue;
|
|
288
367
|
changes.push({
|
|
@@ -296,17 +375,21 @@ class SchemaComparer {
|
|
|
296
375
|
return changes;
|
|
297
376
|
}
|
|
298
377
|
/**
|
|
299
|
-
* Generate a signature for an index based on its columns and
|
|
300
|
-
* Used for functional equivalence
|
|
378
|
+
* Generate a signature for an index based on its columns, uniqueness, and
|
|
379
|
+
* (normalized) partial-index predicate. Used for functional equivalence
|
|
380
|
+
* checking (Issue #741) and predicate-drift detection (Issue #1692).
|
|
301
381
|
*
|
|
302
382
|
* Note: Column order is preserved because it is semantically significant for
|
|
303
383
|
* composite indexes. An index on (a, b) is NOT equivalent to (b, a) - they
|
|
304
384
|
* have different query performance characteristics.
|
|
305
385
|
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
386
|
+
* The trailing predicate component distinguishes partial indexes that share
|
|
387
|
+
* columns and uniqueness but differ by their `WHERE` clause (e.g. distinct
|
|
388
|
+
* STI child partial indexes). Callers pass the already-normalized predicate
|
|
389
|
+
* so both the manifest (desired) and introspected (DB) sides compare equal
|
|
390
|
+
* for semantically-identical clauses. An empty string means "no predicate"
|
|
391
|
+
* (a non-partial index) and is also used on both sides when predicate
|
|
392
|
+
* introspection is unavailable, preserving the prior behavior.
|
|
310
393
|
*
|
|
311
394
|
* For JSON-path indexes (`@meta({ indexed: true })`) the signature is
|
|
312
395
|
* derived from the JSON path instead of an empty column list, so the
|
|
@@ -314,17 +397,91 @@ class SchemaComparer {
|
|
|
314
397
|
*
|
|
315
398
|
* @param idxOrColumns - Either an IndexDefinition or a column array (legacy)
|
|
316
399
|
* @param uniqueArg - Unique flag (used when first arg is a column array)
|
|
400
|
+
* @param predicateArg - Normalized partial-index predicate (default '')
|
|
317
401
|
* @returns Signature string
|
|
318
402
|
*/
|
|
319
|
-
getIndexSignature(idxOrColumns, uniqueArg) {
|
|
403
|
+
getIndexSignature(idxOrColumns, uniqueArg, predicateArg = "") {
|
|
320
404
|
if (Array.isArray(idxOrColumns)) {
|
|
321
|
-
return `${idxOrColumns.join(",")}:${Boolean(uniqueArg)}`;
|
|
405
|
+
return `${idxOrColumns.join(",")}:${Boolean(uniqueArg)}:${predicateArg}`;
|
|
322
406
|
}
|
|
323
407
|
const idx = idxOrColumns;
|
|
324
408
|
if (isJsonPathIndex(idx) && idx.jsonPath) {
|
|
325
|
-
return `json:${idx.jsonPath.column}.${idx.jsonPath.path}:${Boolean(idx.unique)}`;
|
|
409
|
+
return `json:${idx.jsonPath.column}.${idx.jsonPath.path}:${Boolean(idx.unique)}:${predicateArg}`;
|
|
410
|
+
}
|
|
411
|
+
return `${(idx.columns ?? []).join(",")}:${Boolean(idx.unique)}:${predicateArg}`;
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Whether the active engine supports partial indexes (`CREATE INDEX … WHERE`).
|
|
415
|
+
*
|
|
416
|
+
* SQLite and PostgreSQL do. DuckDB rejects them outright, and the JSON
|
|
417
|
+
* adapter is DuckDB-backed, so on those engines a "partial" index can only
|
|
418
|
+
* ever exist as a full index. Treating the predicate as significant there
|
|
419
|
+
* would (a) flag existing full indexes as false drift and (b) emit
|
|
420
|
+
* `CREATE INDEX … WHERE` DDL the engine rejects, breaking the migration.
|
|
421
|
+
* Gating on this keeps both the comparison and the generated DDL aligned
|
|
422
|
+
* with what the engine actually accepts (a partial index degrades to a
|
|
423
|
+
* full index), matching the pre-#1692 behavior on those engines.
|
|
424
|
+
*/
|
|
425
|
+
supportsPartialIndexes() {
|
|
426
|
+
return this.engine === "sqlite" || this.engine === "postgres";
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Introspect partial-index predicates for a table, keyed by index name.
|
|
430
|
+
*
|
|
431
|
+
* `getTableSchema()` (the @happyvertical/sql introspection) returns only
|
|
432
|
+
* name/columns/unique, so the `WHERE` predicate is read directly here:
|
|
433
|
+
*
|
|
434
|
+
* - PostgreSQL: `pg_indexes.indexdef` carries the full CREATE INDEX text.
|
|
435
|
+
* - SQLite: the `sqlite_master.sql` column carries the original CREATE INDEX
|
|
436
|
+
* text.
|
|
437
|
+
*
|
|
438
|
+
* Engines that don't support partial indexes (DuckDB / the DuckDB-backed
|
|
439
|
+
* JSON adapter) short-circuit to `null` so the comparison stays
|
|
440
|
+
* predicate-unaware there — see {@link supportsPartialIndexes}.
|
|
441
|
+
*
|
|
442
|
+
* Non-partial indexes are omitted from the map (callers treat a missing
|
|
443
|
+
* entry as the empty predicate). Returns `null` when the catalog query
|
|
444
|
+
* fails — e.g. an adapter exposing neither catalog — so the index
|
|
445
|
+
* comparison can fall back to predicate-unaware behavior rather than
|
|
446
|
+
* flagging every existing partial index as false drift.
|
|
447
|
+
*/
|
|
448
|
+
async getDbIndexPredicates(tableName) {
|
|
449
|
+
if (!this.supportsPartialIndexes()) {
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
const predicates = /* @__PURE__ */ new Map();
|
|
453
|
+
try {
|
|
454
|
+
if (this.engine === "postgres") {
|
|
455
|
+
const result2 = await this.db.query(
|
|
456
|
+
`SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = 'public' AND tablename = ${this.quoteLiteral(
|
|
457
|
+
tableName
|
|
458
|
+
)}`
|
|
459
|
+
);
|
|
460
|
+
for (const row of result2.rows) {
|
|
461
|
+
if (!row.indexname || !row.indexdef) continue;
|
|
462
|
+
const predicate = extractIndexPredicate(row.indexdef);
|
|
463
|
+
if (predicate) predicates.set(row.indexname, predicate);
|
|
464
|
+
}
|
|
465
|
+
return predicates;
|
|
466
|
+
}
|
|
467
|
+
const result = await this.db.query(
|
|
468
|
+
`SELECT name, sql FROM sqlite_master WHERE type = 'index' AND tbl_name = ${this.quoteLiteral(
|
|
469
|
+
tableName
|
|
470
|
+
)} AND name NOT LIKE 'sqlite_%'`
|
|
471
|
+
);
|
|
472
|
+
for (const row of result.rows) {
|
|
473
|
+
if (!row.name || !row.sql) continue;
|
|
474
|
+
const predicate = extractIndexPredicate(row.sql);
|
|
475
|
+
if (predicate) predicates.set(row.name, predicate);
|
|
476
|
+
}
|
|
477
|
+
return predicates;
|
|
478
|
+
} catch (err) {
|
|
479
|
+
logger.debug(
|
|
480
|
+
`[SchemaComparer] Partial-index predicate introspection unavailable for ${tableName}; falling back to predicate-unaware index comparison`,
|
|
481
|
+
{ error: err instanceof Error ? err.message : String(err) }
|
|
482
|
+
);
|
|
483
|
+
return null;
|
|
326
484
|
}
|
|
327
|
-
return `${(idx.columns ?? []).join(",")}:${Boolean(idx.unique)}`;
|
|
328
485
|
}
|
|
329
486
|
/**
|
|
330
487
|
* Get list of existing tables from database
|
|
@@ -547,12 +704,26 @@ class SchemaComparer {
|
|
|
547
704
|
return `ALTER TABLE ${this.quoteIdentifier(tableName)} DROP COLUMN ${this.quoteIdentifier(colName)}`;
|
|
548
705
|
}
|
|
549
706
|
/**
|
|
550
|
-
* Generate SQL for adding an index
|
|
707
|
+
* Generate SQL for adding an index.
|
|
708
|
+
*
|
|
709
|
+
* On engines that support partial indexes (SQLite/PostgreSQL) this mirrors
|
|
710
|
+
* the canonical CREATE INDEX path in the DDL strategies: a partial index
|
|
711
|
+
* appends its `WHERE` predicate so a detected predicate add/alter (issue
|
|
712
|
+
* #1692) recreates the index with the correct partial condition rather than
|
|
713
|
+
* silently widening it to a full index. On DuckDB / the JSON adapter — which
|
|
714
|
+
* reject partial indexes — the predicate is dropped so the emitted DDL stays
|
|
715
|
+
* executable (a partial index degrades to a full index there). The predicate
|
|
716
|
+
* is trimmed and a redundant leading `WHERE` stripped for robustness.
|
|
551
717
|
*/
|
|
552
718
|
generateAddIndexSQL(tableName, idx) {
|
|
553
719
|
const uniqueStr = idx.unique ? "UNIQUE " : "";
|
|
554
720
|
const target = renderIndexTarget(idx, this.engine);
|
|
555
|
-
|
|
721
|
+
let sql = `CREATE ${uniqueStr}INDEX ${this.quoteIdentifier(idx.name)} ON ${this.quoteIdentifier(tableName)} (${target})`;
|
|
722
|
+
const where = idx.where?.trim().replace(/^WHERE\s+/i, "");
|
|
723
|
+
if (this.supportsPartialIndexes() && where) {
|
|
724
|
+
sql += ` WHERE ${where}`;
|
|
725
|
+
}
|
|
726
|
+
return sql;
|
|
556
727
|
}
|
|
557
728
|
/**
|
|
558
729
|
* Generate SQL for dropping an index.
|
|
@@ -594,8 +765,10 @@ function getSQLFromDiff(diff) {
|
|
|
594
765
|
}
|
|
595
766
|
export {
|
|
596
767
|
SchemaComparer,
|
|
768
|
+
extractIndexPredicate,
|
|
597
769
|
generateSchemaDiff,
|
|
598
770
|
getSQLFromDiff,
|
|
599
|
-
hasActionableChanges
|
|
771
|
+
hasActionableChanges,
|
|
772
|
+
normalizeIndexPredicate
|
|
600
773
|
};
|
|
601
774
|
//# sourceMappingURL=differ.js.map
|