@happyvertical/smrt-core 0.47.1 → 0.47.2
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 +1 -0
- package/agents/registry-snapshot.md +28 -0
- package/agents/schema-paths.md +12 -0
- package/dist/browser.js +3 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/manifest/static-manifest.js +1 -1
- package/dist/manifest/static-manifest.js.map +1 -1
- package/dist/manifest/store.js +1 -1
- package/dist/manifest.json +1 -1
- package/dist/migrations/differ.d.ts +78 -0
- package/dist/migrations/differ.d.ts.map +1 -1
- package/dist/migrations/differ.js +191 -1
- package/dist/migrations/differ.js.map +1 -1
- package/dist/migrations/generator.d.ts.map +1 -1
- package/dist/migrations/generator.js +2 -1
- package/dist/migrations/generator.js.map +1 -1
- package/dist/prebuild/index.d.ts.map +1 -1
- package/dist/prebuild/index.js +4 -3
- package/dist/prebuild/index.js.map +1 -1
- 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/schema-builder.js +1 -1
- package/dist/registry.js +1 -1
- package/dist/scanner/manifest-generator.d.ts.map +1 -1
- package/dist/scanner/manifest-generator.js +22 -1
- package/dist/scanner/manifest-generator.js.map +1 -1
- package/dist/schema/foreign-key-ddl.d.ts +18 -0
- package/dist/schema/foreign-key-ddl.d.ts.map +1 -1
- package/dist/schema/foreign-key-ddl.js +15 -2
- package/dist/schema/foreign-key-ddl.js.map +1 -1
- package/dist/schema/foreign-key-orphan-report.d.ts +57 -0
- package/dist/schema/foreign-key-orphan-report.d.ts.map +1 -0
- package/dist/schema/foreign-key-orphan-report.js +142 -0
- package/dist/schema/foreign-key-orphan-report.js.map +1 -0
- package/dist/schema/index.d.ts +1 -0
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/index.js +2 -1
- package/dist/schema/live-parity.d.ts +8 -1
- package/dist/schema/live-parity.d.ts.map +1 -1
- package/dist/schema/live-parity.js +98 -2
- package/dist/schema/live-parity.js.map +1 -1
- package/dist/schema/types.d.ts +11 -1
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema.js +2 -1
- package/dist/smrt-knowledge.json +14 -8
- package/dist/system/index.d.ts +1 -0
- package/dist/system/index.d.ts.map +1 -1
- package/dist/system/index.js +2 -1
- package/dist/system/registry-snapshot.d.ts +112 -0
- package/dist/system/registry-snapshot.d.ts.map +1 -0
- package/dist/system/registry-snapshot.js +171 -0
- package/dist/system/registry-snapshot.js.map +1 -0
- package/dist/testing/database.d.ts.map +1 -1
- package/dist/testing/database.js +10 -2
- 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 -3
- package/dist/vite-plugin/index.js.map +1 -1
- package/dist/vite-plugin/web-collections.d.ts +6 -0
- package/dist/vite-plugin/web-collections.d.ts.map +1 -1
- package/dist/vite-plugin/web-collections.js +9 -4
- package/dist/vite-plugin/web-collections.js.map +1 -1
- package/package.json +4 -4
|
@@ -220,6 +220,84 @@ export declare class SchemaComparer {
|
|
|
220
220
|
* that orphan gets an executable `DROP NOT NULL` where the engine allows.
|
|
221
221
|
*/
|
|
222
222
|
private compareColumns;
|
|
223
|
+
/**
|
|
224
|
+
* Detect a pending rename backfill (#2752): a manifest-declared column
|
|
225
|
+
* that exists live but holds no data, paired with an orphan (undeclared)
|
|
226
|
+
* live column of a compatible type that does hold data — the shape a
|
|
227
|
+
* framework field rename leaves behind when `db:migrate` adds the new
|
|
228
|
+
* column additively and never moves data into it. For each such pair,
|
|
229
|
+
* emit the copy-then-drop repair as an advisory: never executed,
|
|
230
|
+
* PostgreSQL and SQLite only (DuckDB/JSON are out of scope). The repair
|
|
231
|
+
* is genuinely idempotent SQL on PostgreSQL (a self-guarding
|
|
232
|
+
* `DO $$ ... $$` block); SQLite has no conditional-DDL construct, so its
|
|
233
|
+
* repair is operator-mediated instead — a guard query plus instructions,
|
|
234
|
+
* not a blind-rerun-safe statement. See {@link describeRenameDataPending}.
|
|
235
|
+
*/
|
|
236
|
+
private detectRenameDataPending;
|
|
237
|
+
/**
|
|
238
|
+
* Live-data probe: does the column hold any non-null, non-empty value?
|
|
239
|
+
* Used by {@link detectRenameDataPending}. Errors propagate to the
|
|
240
|
+
* caller, which skips the pair rather than guessing (#2752).
|
|
241
|
+
*/
|
|
242
|
+
private columnHasNonEmptyValue;
|
|
243
|
+
/**
|
|
244
|
+
* Live-data probe: are every one of a column's non-empty values
|
|
245
|
+
* UUID-shaped ({@link CANONICAL_UUID_PATTERN})? PostgreSQL pushes the
|
|
246
|
+
* check into the query with its regex operator; SQLite has no regex
|
|
247
|
+
* operator, but its case-sensitive `GLOB` can still express the fixed
|
|
248
|
+
* 36-character canonical shape ({@link CANONICAL_UUID_SQLITE_GLOB_PATTERN}
|
|
249
|
+
* against `LOWER(...)`, guarded by an exact `LENGTH(...) = 36` check), so
|
|
250
|
+
* both engines run one server-side aggregate `count(*)` rather than
|
|
251
|
+
* fetching every non-empty value into JS to test in a loop — important on
|
|
252
|
+
* a production table with many rows (#2767 review).
|
|
253
|
+
*/
|
|
254
|
+
private allNonEmptyValuesUuidShaped;
|
|
255
|
+
/**
|
|
256
|
+
* Render the repair for one rename-data-pending pair (#2752): copy
|
|
257
|
+
* non-empty `oldColumn` into `newColumn` only where `newColumn` is still
|
|
258
|
+
* empty (casting to `uuid` when the new column is native uuid, otherwise a
|
|
259
|
+
* plain copy), then drop `oldColumn`.
|
|
260
|
+
*
|
|
261
|
+
* Idempotency review findings and the fix:
|
|
262
|
+
*
|
|
263
|
+
* - (P1) The `newColumn`-is-empty predicate previously compared any
|
|
264
|
+
* non-uuid destination to `''` (`col IS NULL OR col = ''`), which is
|
|
265
|
+
* only valid for text-affinity types — PostgreSQL rejects `col = ''`
|
|
266
|
+
* for `integer`/`boolean`/`timestamp`/etc. before a same-type rename
|
|
267
|
+
* pair of one of those types ever copies anything. Fixed by comparing
|
|
268
|
+
* `CAST(col AS TEXT) = ''` instead, mirroring the same portable
|
|
269
|
+
* emptiness check {@link columnHasNonEmptyValue} already uses for
|
|
270
|
+
* detection, so the predicate is valid for every column type.
|
|
271
|
+
* - (P2) An `UPDATE` unconditionally naming `oldColumn` fails once that
|
|
272
|
+
* column is gone, on either engine, regardless of the DROP's own
|
|
273
|
+
* idempotency — a guard query placed before the DROP (or even before
|
|
274
|
+
* the UPDATE) still leaves a human decision between "run" and "don't",
|
|
275
|
+
* which is not genuine SQL-level idempotency. PostgreSQL CAN express a
|
|
276
|
+
* truly self-contained, unconditionally-safe-to-rerun statement here:
|
|
277
|
+
* an anonymous `DO $$ ... $$` block (the same idiom this file already
|
|
278
|
+
* uses for `generatePostgresIntegerPreflightSQL`) that checks
|
|
279
|
+
* `information_schema.columns` and only runs the UPDATE + DROP when the
|
|
280
|
+
* old column still exists — one executable statement, no operator
|
|
281
|
+
* judgment required, safe to rerun any number of times. SQLite has no
|
|
282
|
+
* procedural block or conditional-DDL construct at all (confirmed
|
|
283
|
+
* against a real connection), so there the contract is explicitly
|
|
284
|
+
* downgraded from "idempotent SQL" to "operator-mediated": a guard
|
|
285
|
+
* query plus instructions, not a single statement that is itself safe
|
|
286
|
+
* to blindly rerun.
|
|
287
|
+
*
|
|
288
|
+
* This is report-only either way: `db:migrate` never executes it.
|
|
289
|
+
*/
|
|
290
|
+
private describeRenameDataPending;
|
|
291
|
+
/**
|
|
292
|
+
* Render the advisory for an ambiguous rename-data-pending match (#2767
|
|
293
|
+
* review): more than one orphan column is a populated, type-compatible
|
|
294
|
+
* candidate for the same empty declared column, so the real rename source
|
|
295
|
+
* cannot be inferred. Unlike {@link describeRenameDataPending}, this
|
|
296
|
+
* carries no `suggestedSql` — running a per-candidate copy-and-drop would
|
|
297
|
+
* let output order decide which candidate's data wins and then drop every
|
|
298
|
+
* one of them, which is not a repair an advisory should suggest blindly.
|
|
299
|
+
*/
|
|
300
|
+
private describeRenameDataPendingAmbiguous;
|
|
223
301
|
/**
|
|
224
302
|
* Compare nullability and default between a manifest column and the live
|
|
225
303
|
* column, emitting `alter_column` changes (#2369).
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"differ.d.ts","sourceRoot":"","sources":["../../src/migrations/differ.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;
|
|
1
|
+
{"version":3,"file":"differ.d.ts","sourceRoot":"","sources":["../../src/migrations/differ.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { normalizeForeignKeyAction, requireForeignKeyAction } from "../schema/foreign-key-policy.js";
|
|
2
2
|
import { isJsonPathIndex, isStiSubtypeUniqueIndex, renderIndexTarget } from "../schema/index-utils.js";
|
|
3
|
-
import { foreignKeyConstraintName, foreignKeyRelationshipKey, renderForeignKeyConstraint, renderForeignKeyOrphanDetector, renderForeignKeyOrphanRepair, schemaForeignKeys, schemaForeignKeysForEngine } from "../schema/foreign-key-ddl.js";
|
|
3
|
+
import { CANONICAL_UUID_PATTERN, CANONICAL_UUID_SQLITE_GLOB_PATTERN, foreignKeyConstraintName, foreignKeyRelationshipKey, renderForeignKeyConstraint, renderForeignKeyOrphanDetector, renderForeignKeyOrphanRepair, schemaForeignKeys, schemaForeignKeysForEngine } from "../schema/foreign-key-ddl.js";
|
|
4
4
|
import { detectEngine, getDDLStrategy } from "../schema/ddl/index.js";
|
|
5
5
|
import { describeForeignKeyTypeConflict, planUuidConvergence, renderUuidConvergenceRepairHint, renderUuidShapeProbe } from "../schema/uuid-convergence.js";
|
|
6
6
|
import "../schema/utils.js";
|
|
@@ -791,9 +791,199 @@ var SchemaComparer = class {
|
|
|
791
791
|
}
|
|
792
792
|
changes.push(this.describeOrphanColumn(tableName, colName, dbCol));
|
|
793
793
|
}
|
|
794
|
+
changes.push(...await this.detectRenameDataPending(tableName, manifest, dbSchema));
|
|
794
795
|
return changes;
|
|
795
796
|
}
|
|
796
797
|
/**
|
|
798
|
+
* Detect a pending rename backfill (#2752): a manifest-declared column
|
|
799
|
+
* that exists live but holds no data, paired with an orphan (undeclared)
|
|
800
|
+
* live column of a compatible type that does hold data — the shape a
|
|
801
|
+
* framework field rename leaves behind when `db:migrate` adds the new
|
|
802
|
+
* column additively and never moves data into it. For each such pair,
|
|
803
|
+
* emit the copy-then-drop repair as an advisory: never executed,
|
|
804
|
+
* PostgreSQL and SQLite only (DuckDB/JSON are out of scope). The repair
|
|
805
|
+
* is genuinely idempotent SQL on PostgreSQL (a self-guarding
|
|
806
|
+
* `DO $$ ... $$` block); SQLite has no conditional-DDL construct, so its
|
|
807
|
+
* repair is operator-mediated instead — a guard query plus instructions,
|
|
808
|
+
* not a blind-rerun-safe statement. See {@link describeRenameDataPending}.
|
|
809
|
+
*/
|
|
810
|
+
async detectRenameDataPending(tableName, manifest, dbSchema) {
|
|
811
|
+
if (this.engine !== "postgres" && this.engine !== "sqlite") return [];
|
|
812
|
+
const dbColumnNames = new Set(Object.keys(dbSchema.columns));
|
|
813
|
+
const manifestColumnNames = new Set(Object.keys(manifest.columns));
|
|
814
|
+
const orphanColumnNames = [...dbColumnNames].filter((name) => !manifestColumnNames.has(name));
|
|
815
|
+
if (orphanColumnNames.length === 0) return [];
|
|
816
|
+
const changes = [];
|
|
817
|
+
for (const [colName, colDef] of Object.entries(manifest.columns)) {
|
|
818
|
+
if (!dbSchema.columns[colName]) continue;
|
|
819
|
+
let declaredHasData;
|
|
820
|
+
try {
|
|
821
|
+
declaredHasData = await this.columnHasNonEmptyValue(tableName, colName);
|
|
822
|
+
} catch {
|
|
823
|
+
continue;
|
|
824
|
+
}
|
|
825
|
+
if (declaredHasData) continue;
|
|
826
|
+
const validatedType = isValidSQLDataType(colDef.type) ? colDef.type : "TEXT";
|
|
827
|
+
const isLogicalUuid = validatedType === "UUID";
|
|
828
|
+
const declaredNormalized = this.normalizeType(this.ddlStrategy.mapType(validatedType));
|
|
829
|
+
const candidates = [];
|
|
830
|
+
for (const orphanName of orphanColumnNames) {
|
|
831
|
+
const orphanCol = dbSchema.columns[orphanName];
|
|
832
|
+
const orphanNormalized = this.normalizeType(orphanCol.type);
|
|
833
|
+
const requiresShapeCheck = isLogicalUuid && orphanNormalized === "TEXT";
|
|
834
|
+
if (!requiresShapeCheck && !(orphanNormalized === declaredNormalized)) continue;
|
|
835
|
+
let orphanHasData;
|
|
836
|
+
try {
|
|
837
|
+
orphanHasData = await this.columnHasNonEmptyValue(tableName, orphanName);
|
|
838
|
+
} catch {
|
|
839
|
+
continue;
|
|
840
|
+
}
|
|
841
|
+
if (!orphanHasData) continue;
|
|
842
|
+
if (requiresShapeCheck) {
|
|
843
|
+
let shaped;
|
|
844
|
+
try {
|
|
845
|
+
shaped = await this.allNonEmptyValuesUuidShaped(tableName, orphanName);
|
|
846
|
+
} catch {
|
|
847
|
+
continue;
|
|
848
|
+
}
|
|
849
|
+
if (!shaped) continue;
|
|
850
|
+
}
|
|
851
|
+
candidates.push({
|
|
852
|
+
orphanName,
|
|
853
|
+
isUuidCast: declaredNormalized === "UUID"
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
if (candidates.length === 1) changes.push(this.describeRenameDataPending(tableName, colName, candidates[0].orphanName, candidates[0].isUuidCast));
|
|
857
|
+
else if (candidates.length > 1) changes.push(this.describeRenameDataPendingAmbiguous(tableName, colName, candidates.map((c) => c.orphanName)));
|
|
858
|
+
}
|
|
859
|
+
return changes;
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* Live-data probe: does the column hold any non-null, non-empty value?
|
|
863
|
+
* Used by {@link detectRenameDataPending}. Errors propagate to the
|
|
864
|
+
* caller, which skips the pair rather than guessing (#2752).
|
|
865
|
+
*/
|
|
866
|
+
async columnHasNonEmptyValue(tableName, colName) {
|
|
867
|
+
const quotedTable = this.quoteIdentifier(tableName);
|
|
868
|
+
const quotedCol = this.quoteIdentifier(colName);
|
|
869
|
+
return ((await this.db.query(`SELECT 1 AS present FROM ${quotedTable} WHERE ${quotedCol} IS NOT NULL AND CAST(${quotedCol} AS TEXT) <> '' LIMIT 1`)).rows?.length ?? 0) > 0;
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* Live-data probe: are every one of a column's non-empty values
|
|
873
|
+
* UUID-shaped ({@link CANONICAL_UUID_PATTERN})? PostgreSQL pushes the
|
|
874
|
+
* check into the query with its regex operator; SQLite has no regex
|
|
875
|
+
* operator, but its case-sensitive `GLOB` can still express the fixed
|
|
876
|
+
* 36-character canonical shape ({@link CANONICAL_UUID_SQLITE_GLOB_PATTERN}
|
|
877
|
+
* against `LOWER(...)`, guarded by an exact `LENGTH(...) = 36` check), so
|
|
878
|
+
* both engines run one server-side aggregate `count(*)` rather than
|
|
879
|
+
* fetching every non-empty value into JS to test in a loop — important on
|
|
880
|
+
* a production table with many rows (#2767 review).
|
|
881
|
+
*/
|
|
882
|
+
async allNonEmptyValuesUuidShaped(tableName, colName) {
|
|
883
|
+
const quotedTable = this.quoteIdentifier(tableName);
|
|
884
|
+
const quotedCol = this.quoteIdentifier(colName);
|
|
885
|
+
const nonEmptyPredicate = `${quotedCol} IS NOT NULL AND CAST(${quotedCol} AS TEXT) <> ''`;
|
|
886
|
+
if (this.engine === "postgres") {
|
|
887
|
+
const row = (await this.db.query(`SELECT count(*) AS invalid_count FROM ${quotedTable} WHERE ${nonEmptyPredicate} AND CAST(${quotedCol} AS TEXT) !~* '${CANONICAL_UUID_PATTERN}'`)).rows?.[0];
|
|
888
|
+
return Number(row?.invalid_count ?? 0) === 0;
|
|
889
|
+
}
|
|
890
|
+
const row = (await this.db.query(`SELECT count(*) AS invalid_count FROM ${quotedTable} WHERE ${nonEmptyPredicate} AND NOT (LENGTH(CAST(${quotedCol} AS TEXT)) = 36 AND LOWER(CAST(${quotedCol} AS TEXT)) GLOB '${CANONICAL_UUID_SQLITE_GLOB_PATTERN}')`)).rows?.[0];
|
|
891
|
+
return Number(row?.invalid_count ?? 0) === 0;
|
|
892
|
+
}
|
|
893
|
+
/**
|
|
894
|
+
* Render the repair for one rename-data-pending pair (#2752): copy
|
|
895
|
+
* non-empty `oldColumn` into `newColumn` only where `newColumn` is still
|
|
896
|
+
* empty (casting to `uuid` when the new column is native uuid, otherwise a
|
|
897
|
+
* plain copy), then drop `oldColumn`.
|
|
898
|
+
*
|
|
899
|
+
* Idempotency review findings and the fix:
|
|
900
|
+
*
|
|
901
|
+
* - (P1) The `newColumn`-is-empty predicate previously compared any
|
|
902
|
+
* non-uuid destination to `''` (`col IS NULL OR col = ''`), which is
|
|
903
|
+
* only valid for text-affinity types — PostgreSQL rejects `col = ''`
|
|
904
|
+
* for `integer`/`boolean`/`timestamp`/etc. before a same-type rename
|
|
905
|
+
* pair of one of those types ever copies anything. Fixed by comparing
|
|
906
|
+
* `CAST(col AS TEXT) = ''` instead, mirroring the same portable
|
|
907
|
+
* emptiness check {@link columnHasNonEmptyValue} already uses for
|
|
908
|
+
* detection, so the predicate is valid for every column type.
|
|
909
|
+
* - (P2) An `UPDATE` unconditionally naming `oldColumn` fails once that
|
|
910
|
+
* column is gone, on either engine, regardless of the DROP's own
|
|
911
|
+
* idempotency — a guard query placed before the DROP (or even before
|
|
912
|
+
* the UPDATE) still leaves a human decision between "run" and "don't",
|
|
913
|
+
* which is not genuine SQL-level idempotency. PostgreSQL CAN express a
|
|
914
|
+
* truly self-contained, unconditionally-safe-to-rerun statement here:
|
|
915
|
+
* an anonymous `DO $$ ... $$` block (the same idiom this file already
|
|
916
|
+
* uses for `generatePostgresIntegerPreflightSQL`) that checks
|
|
917
|
+
* `information_schema.columns` and only runs the UPDATE + DROP when the
|
|
918
|
+
* old column still exists — one executable statement, no operator
|
|
919
|
+
* judgment required, safe to rerun any number of times. SQLite has no
|
|
920
|
+
* procedural block or conditional-DDL construct at all (confirmed
|
|
921
|
+
* against a real connection), so there the contract is explicitly
|
|
922
|
+
* downgraded from "idempotent SQL" to "operator-mediated": a guard
|
|
923
|
+
* query plus instructions, not a single statement that is itself safe
|
|
924
|
+
* to blindly rerun.
|
|
925
|
+
*
|
|
926
|
+
* This is report-only either way: `db:migrate` never executes it.
|
|
927
|
+
*/
|
|
928
|
+
describeRenameDataPending(tableName, newColumn, oldColumn, newIsNativeUuid) {
|
|
929
|
+
const quotedTable = this.quoteIdentifier(tableName);
|
|
930
|
+
const quotedNew = this.quoteIdentifier(newColumn);
|
|
931
|
+
const quotedOld = this.quoteIdentifier(oldColumn);
|
|
932
|
+
const copySql = `UPDATE ${quotedTable} SET ${quotedNew} = ${newIsNativeUuid ? `${quotedOld}::uuid` : quotedOld} WHERE ${newIsNativeUuid ? `${quotedNew} IS NULL` : `(${quotedNew} IS NULL OR CAST(${quotedNew} AS TEXT) = '')`} AND ${quotedOld} IS NOT NULL AND CAST(${quotedOld} AS TEXT) <> ''`;
|
|
933
|
+
let suggestedSql;
|
|
934
|
+
let dropIdempotencyNote;
|
|
935
|
+
if (this.engine === "postgres") {
|
|
936
|
+
suggestedSql = [`DO $$ BEGIN IF ${`EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ${this.quoteLiteral(tableName)} AND column_name = ${this.quoteLiteral(oldColumn)})`} THEN ${copySql}; ${`ALTER TABLE ${quotedTable} DROP COLUMN ${quotedOld}`}; END IF; END $$`];
|
|
937
|
+
dropIdempotencyNote = `This single statement is idempotent: rerunning it after the rename is complete is a genuine no-op, because it only touches ${oldColumn} when the block finds it still exists.`;
|
|
938
|
+
} else {
|
|
939
|
+
suggestedSql = [
|
|
940
|
+
`SELECT count(*) AS old_column_still_present FROM pragma_table_info(${this.quoteLiteral(tableName)}) WHERE name = ${this.quoteLiteral(oldColumn)}`,
|
|
941
|
+
copySql,
|
|
942
|
+
this.generateDropColumnSQL(tableName, oldColumn)
|
|
943
|
+
];
|
|
944
|
+
dropIdempotencyNote = `SQLite has no conditional-DDL construct, so this is operator-mediated, not self-contained idempotent SQL: run the guard query first, and only run the UPDATE and DROP below when it reports ${oldColumn} still present.`;
|
|
945
|
+
}
|
|
946
|
+
return {
|
|
947
|
+
type: "rename_data_pending",
|
|
948
|
+
table: tableName,
|
|
949
|
+
name: newColumn,
|
|
950
|
+
mismatch: {
|
|
951
|
+
expected: `data in ${newColumn}`,
|
|
952
|
+
actual: `data appears to still be in ${oldColumn}`
|
|
953
|
+
},
|
|
954
|
+
advisory: {
|
|
955
|
+
severity: "warning",
|
|
956
|
+
message: `${tableName}.${newColumn} is declared but empty, while undeclared column ${tableName}.${oldColumn} holds data of a compatible type. This looks like a framework field rename whose data was never moved (db:migrate adds columns additively and never drops the old one). ${dropIdempotencyNote}`,
|
|
957
|
+
suggestedSql
|
|
958
|
+
}
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
/**
|
|
962
|
+
* Render the advisory for an ambiguous rename-data-pending match (#2767
|
|
963
|
+
* review): more than one orphan column is a populated, type-compatible
|
|
964
|
+
* candidate for the same empty declared column, so the real rename source
|
|
965
|
+
* cannot be inferred. Unlike {@link describeRenameDataPending}, this
|
|
966
|
+
* carries no `suggestedSql` — running a per-candidate copy-and-drop would
|
|
967
|
+
* let output order decide which candidate's data wins and then drop every
|
|
968
|
+
* one of them, which is not a repair an advisory should suggest blindly.
|
|
969
|
+
*/
|
|
970
|
+
describeRenameDataPendingAmbiguous(tableName, newColumn, candidateColumns) {
|
|
971
|
+
const candidateList = candidateColumns.map((name) => `${tableName}.${name}`).join(", ");
|
|
972
|
+
return {
|
|
973
|
+
type: "rename_data_pending",
|
|
974
|
+
table: tableName,
|
|
975
|
+
name: newColumn,
|
|
976
|
+
mismatch: {
|
|
977
|
+
expected: `data in ${newColumn}`,
|
|
978
|
+
actual: `data appears to still be in one of several columns: ${candidateList}`
|
|
979
|
+
},
|
|
980
|
+
advisory: {
|
|
981
|
+
severity: "warning",
|
|
982
|
+
message: `${tableName}.${newColumn} is declared but empty, while ${candidateColumns.length} undeclared columns of a compatible type hold data (${candidateList}). This looks like a framework field rename, but which column is the actual rename source is ambiguous — no suggested repair SQL is printed. An operator must identify the correct source column and write its own targeted copy-then-drop.`
|
|
983
|
+
}
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
797
987
|
* Compare nullability and default between a manifest column and the live
|
|
798
988
|
* column, emitting `alter_column` changes (#2369).
|
|
799
989
|
*
|