@happyvertical/smrt-core 0.47.1 → 0.48.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 +1 -0
- package/agents/generators.md +12 -0
- package/agents/registry-snapshot.md +28 -0
- package/agents/schema-paths.md +57 -0
- package/agents/system-diagnostics.md +6 -1
- package/dist/browser.js +3 -2
- package/dist/collection.d.ts.map +1 -1
- package/dist/collection.js +3 -1
- package/dist/collection.js.map +1 -1
- 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 +88 -4
- package/dist/migrations/differ.d.ts.map +1 -1
- package/dist/migrations/differ.js +363 -5
- 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 +26 -0
- package/dist/schema/foreign-key-ddl.d.ts.map +1 -1
- package/dist/schema/foreign-key-ddl.js +26 -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 +159 -0
- package/dist/schema/foreign-key-orphan-report.js.map +1 -0
- package/dist/schema/index.d.ts +2 -1
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/index.js +3 -2
- 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 +172 -2
- package/dist/schema/live-parity.js.map +1 -1
- package/dist/schema/text-cast-probe.d.ts +66 -0
- package/dist/schema/text-cast-probe.d.ts.map +1 -0
- package/dist/schema/text-cast-probe.js +142 -0
- package/dist/schema/text-cast-probe.js.map +1 -0
- package/dist/schema/types.d.ts +39 -1
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema.js +3 -2
- package/dist/smrt-knowledge.json +17 -11
- package/dist/system/diagnostics.d.ts +22 -0
- package/dist/system/diagnostics.d.ts.map +1 -1
- package/dist/system/diagnostics.js +88 -31
- package/dist/system/diagnostics.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 +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/dev-plane-route.d.ts +5 -0
- package/dist/vite-plugin/dev-plane-route.d.ts.map +1 -0
- package/dist/vite-plugin/dev-plane-route.js +91 -0
- package/dist/vite-plugin/dev-plane-route.js.map +1 -0
- 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/sveltekit-generator.d.ts +8 -0
- package/dist/vite-plugin/sveltekit-generator.d.ts.map +1 -1
- package/dist/vite-plugin/sveltekit-generator.js +2 -0
- package/dist/vite-plugin/sveltekit-generator.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
|
@@ -70,10 +70,6 @@ export interface DiffOptions {
|
|
|
70
70
|
* constraint-owned.
|
|
71
71
|
*/
|
|
72
72
|
export declare function uniqueColumnIndexName(tableName: string, columnName: string): string;
|
|
73
|
-
/**
|
|
74
|
-
* True when a change is report-only: it carries an advisory and no
|
|
75
|
-
* executable statement. Such changes never reach the migration stream.
|
|
76
|
-
*/
|
|
77
73
|
export declare function isAdvisoryOnlyChange(change: SchemaChange): boolean;
|
|
78
74
|
/**
|
|
79
75
|
* True for an info-level report-only change: listed in `SchemaDiff.changes`
|
|
@@ -189,6 +185,16 @@ export declare class SchemaComparer {
|
|
|
189
185
|
* planner treats as a block — SMRT never coerces identifier values.
|
|
190
186
|
*/
|
|
191
187
|
private probeUuidShape;
|
|
188
|
+
/**
|
|
189
|
+
* Probe a candidate `text` -> `timestamptz`/`jsonb` column (#2771, #2772)
|
|
190
|
+
* with a real, exception-safe cast attempt (see `text-cast-probe.ts`).
|
|
191
|
+
* Unlike {@link probeUuidShape}, a failed/unrealistic probe
|
|
192
|
+
* (`unavailable`) is the caller's cue to fall back to the pre-existing
|
|
193
|
+
* behavior for that pair rather than surface a new finding — this keeps
|
|
194
|
+
* the new convergence strictly additive for every case that isn't
|
|
195
|
+
* affirmatively confirmed safe or affirmatively confirmed unsafe.
|
|
196
|
+
*/
|
|
197
|
+
private probeTextCastShape;
|
|
192
198
|
/**
|
|
193
199
|
* Turn the convergence plan into diff entries: executable `type_upgrade`
|
|
194
200
|
* conversions marked `phase: 'pre_foreign_key'`, plus report-only warnings
|
|
@@ -220,6 +226,84 @@ export declare class SchemaComparer {
|
|
|
220
226
|
* that orphan gets an executable `DROP NOT NULL` where the engine allows.
|
|
221
227
|
*/
|
|
222
228
|
private compareColumns;
|
|
229
|
+
/**
|
|
230
|
+
* Detect a pending rename backfill (#2752): a manifest-declared column
|
|
231
|
+
* that exists live but holds no data, paired with an orphan (undeclared)
|
|
232
|
+
* live column of a compatible type that does hold data — the shape a
|
|
233
|
+
* framework field rename leaves behind when `db:migrate` adds the new
|
|
234
|
+
* column additively and never moves data into it. For each such pair,
|
|
235
|
+
* emit the copy-then-drop repair as an advisory: never executed,
|
|
236
|
+
* PostgreSQL and SQLite only (DuckDB/JSON are out of scope). The repair
|
|
237
|
+
* is genuinely idempotent SQL on PostgreSQL (a self-guarding
|
|
238
|
+
* `DO $$ ... $$` block); SQLite has no conditional-DDL construct, so its
|
|
239
|
+
* repair is operator-mediated instead — a guard query plus instructions,
|
|
240
|
+
* not a blind-rerun-safe statement. See {@link describeRenameDataPending}.
|
|
241
|
+
*/
|
|
242
|
+
private detectRenameDataPending;
|
|
243
|
+
/**
|
|
244
|
+
* Live-data probe: does the column hold any non-null, non-empty value?
|
|
245
|
+
* Used by {@link detectRenameDataPending}. Errors propagate to the
|
|
246
|
+
* caller, which skips the pair rather than guessing (#2752).
|
|
247
|
+
*/
|
|
248
|
+
private columnHasNonEmptyValue;
|
|
249
|
+
/**
|
|
250
|
+
* Live-data probe: are every one of a column's non-empty values
|
|
251
|
+
* UUID-shaped ({@link CANONICAL_UUID_PATTERN})? PostgreSQL pushes the
|
|
252
|
+
* check into the query with its regex operator; SQLite has no regex
|
|
253
|
+
* operator, but its case-sensitive `GLOB` can still express the fixed
|
|
254
|
+
* 36-character canonical shape ({@link CANONICAL_UUID_SQLITE_GLOB_PATTERN}
|
|
255
|
+
* against `LOWER(...)`, guarded by an exact `LENGTH(...) = 36` check), so
|
|
256
|
+
* both engines run one server-side aggregate `count(*)` rather than
|
|
257
|
+
* fetching every non-empty value into JS to test in a loop — important on
|
|
258
|
+
* a production table with many rows (#2767 review).
|
|
259
|
+
*/
|
|
260
|
+
private allNonEmptyValuesUuidShaped;
|
|
261
|
+
/**
|
|
262
|
+
* Render the repair for one rename-data-pending pair (#2752): copy
|
|
263
|
+
* non-empty `oldColumn` into `newColumn` only where `newColumn` is still
|
|
264
|
+
* empty (casting to `uuid` when the new column is native uuid, otherwise a
|
|
265
|
+
* plain copy), then drop `oldColumn`.
|
|
266
|
+
*
|
|
267
|
+
* Idempotency review findings and the fix:
|
|
268
|
+
*
|
|
269
|
+
* - (P1) The `newColumn`-is-empty predicate previously compared any
|
|
270
|
+
* non-uuid destination to `''` (`col IS NULL OR col = ''`), which is
|
|
271
|
+
* only valid for text-affinity types — PostgreSQL rejects `col = ''`
|
|
272
|
+
* for `integer`/`boolean`/`timestamp`/etc. before a same-type rename
|
|
273
|
+
* pair of one of those types ever copies anything. Fixed by comparing
|
|
274
|
+
* `CAST(col AS TEXT) = ''` instead, mirroring the same portable
|
|
275
|
+
* emptiness check {@link columnHasNonEmptyValue} already uses for
|
|
276
|
+
* detection, so the predicate is valid for every column type.
|
|
277
|
+
* - (P2) An `UPDATE` unconditionally naming `oldColumn` fails once that
|
|
278
|
+
* column is gone, on either engine, regardless of the DROP's own
|
|
279
|
+
* idempotency — a guard query placed before the DROP (or even before
|
|
280
|
+
* the UPDATE) still leaves a human decision between "run" and "don't",
|
|
281
|
+
* which is not genuine SQL-level idempotency. PostgreSQL CAN express a
|
|
282
|
+
* truly self-contained, unconditionally-safe-to-rerun statement here:
|
|
283
|
+
* an anonymous `DO $$ ... $$` block (the same idiom this file already
|
|
284
|
+
* uses for `generatePostgresIntegerPreflightSQL`) that checks
|
|
285
|
+
* `information_schema.columns` and only runs the UPDATE + DROP when the
|
|
286
|
+
* old column still exists — one executable statement, no operator
|
|
287
|
+
* judgment required, safe to rerun any number of times. SQLite has no
|
|
288
|
+
* procedural block or conditional-DDL construct at all (confirmed
|
|
289
|
+
* against a real connection), so there the contract is explicitly
|
|
290
|
+
* downgraded from "idempotent SQL" to "operator-mediated": a guard
|
|
291
|
+
* query plus instructions, not a single statement that is itself safe
|
|
292
|
+
* to blindly rerun.
|
|
293
|
+
*
|
|
294
|
+
* This is report-only either way: `db:migrate` never executes it.
|
|
295
|
+
*/
|
|
296
|
+
private describeRenameDataPending;
|
|
297
|
+
/**
|
|
298
|
+
* Render the advisory for an ambiguous rename-data-pending match (#2767
|
|
299
|
+
* review): more than one orphan column is a populated, type-compatible
|
|
300
|
+
* candidate for the same empty declared column, so the real rename source
|
|
301
|
+
* cannot be inferred. Unlike {@link describeRenameDataPending}, this
|
|
302
|
+
* carries no `suggestedSql` — running a per-candidate copy-and-drop would
|
|
303
|
+
* let output order decide which candidate's data wins and then drop every
|
|
304
|
+
* one of them, which is not a repair an advisory should suggest blindly.
|
|
305
|
+
*/
|
|
306
|
+
private describeRenameDataPendingAmbiguous;
|
|
223
307
|
/**
|
|
224
308
|
* Compare nullability and default between a manifest column and the live
|
|
225
309
|
* 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,7 +1,8 @@
|
|
|
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,
|
|
3
|
+
import { CANONICAL_UUID_PATTERN, CANONICAL_UUID_SQLITE_GLOB_PATTERN, foreignKeyConstraintName, foreignKeyRelationshipKey, renderForeignKeyAddStatements, renderForeignKeyOrphanDetector, renderForeignKeyOrphanRepair, schemaForeignKeys, schemaForeignKeysForEngine } from "../schema/foreign-key-ddl.js";
|
|
4
4
|
import { detectEngine, getDDLStrategy } from "../schema/ddl/index.js";
|
|
5
|
+
import { maskSampleValue, probeCastSafety, renderJsonbColumnConversion, renderTimestamptzColumnConversion } from "../schema/text-cast-probe.js";
|
|
5
6
|
import { describeForeignKeyTypeConflict, planUuidConvergence, renderUuidConvergenceRepairHint, renderUuidShapeProbe } from "../schema/uuid-convergence.js";
|
|
6
7
|
import "../schema/utils.js";
|
|
7
8
|
import { planSqliteTableRebuilds, sqliteRebuildPlaceholderSql } from "./sqlite-rebuild.js";
|
|
@@ -64,6 +65,18 @@ function uniqueColumnIndexName(tableName, columnName) {
|
|
|
64
65
|
* True when a change is report-only: it carries an advisory and no
|
|
65
66
|
* executable statement. Such changes never reach the migration stream.
|
|
66
67
|
*/
|
|
68
|
+
/**
|
|
69
|
+
* Single- vs double-precision float classification for #2770's float-width
|
|
70
|
+
* drift detection. `null` for anything that isn't unambiguously one or the
|
|
71
|
+
* other (DECIMAL/NUMERIC have no fixed binary width and are out of scope).
|
|
72
|
+
*/
|
|
73
|
+
function floatPrecisionOf(type, engine) {
|
|
74
|
+
const upper = type.toUpperCase().trim().replace(/\(\s*\d+(\s*,\s*\d+)?\s*\)/g, "");
|
|
75
|
+
if (/^(REAL|FLOAT4)$/.test(upper)) return "single";
|
|
76
|
+
if (engine === "duckdb" && upper === "FLOAT") return "single";
|
|
77
|
+
if (/^(FLOAT8|DOUBLE|DOUBLE PRECISION|FLOAT)$/.test(upper)) return "double";
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
67
80
|
function isAdvisoryOnlyChange(change) {
|
|
68
81
|
if (!change.advisory) return false;
|
|
69
82
|
return (change.sqlStatements ?? (change.sql ? [change.sql] : [])).length === 0;
|
|
@@ -475,6 +488,18 @@ var SchemaComparer = class {
|
|
|
475
488
|
}
|
|
476
489
|
}
|
|
477
490
|
/**
|
|
491
|
+
* Probe a candidate `text` -> `timestamptz`/`jsonb` column (#2771, #2772)
|
|
492
|
+
* with a real, exception-safe cast attempt (see `text-cast-probe.ts`).
|
|
493
|
+
* Unlike {@link probeUuidShape}, a failed/unrealistic probe
|
|
494
|
+
* (`unavailable`) is the caller's cue to fall back to the pre-existing
|
|
495
|
+
* behavior for that pair rather than surface a new finding — this keeps
|
|
496
|
+
* the new convergence strictly additive for every case that isn't
|
|
497
|
+
* affirmatively confirmed safe or affirmatively confirmed unsafe.
|
|
498
|
+
*/
|
|
499
|
+
async probeTextCastShape(tableName, columnName, kind) {
|
|
500
|
+
return probeCastSafety(this.db, tableName, columnName, kind);
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
478
503
|
* Turn the convergence plan into diff entries: executable `type_upgrade`
|
|
479
504
|
* conversions marked `phase: 'pre_foreign_key'`, plus report-only warnings
|
|
480
505
|
* for components the planner refused.
|
|
@@ -608,6 +633,7 @@ var SchemaComparer = class {
|
|
|
608
633
|
table: tableName,
|
|
609
634
|
name: foreignKeyConstraintName(tableName, foreignKey),
|
|
610
635
|
foreignKey,
|
|
636
|
+
engineUnsupported: true,
|
|
611
637
|
advisory: {
|
|
612
638
|
severity: "warning",
|
|
613
639
|
message: `${engineReason} Run the orphan detector, repair rows, and rebuild the table with the generated constraint.`,
|
|
@@ -645,6 +671,8 @@ var SchemaComparer = class {
|
|
|
645
671
|
table: tableName,
|
|
646
672
|
name: foreignKeyConstraintName(tableName, foreignKey),
|
|
647
673
|
foreignKey,
|
|
674
|
+
orphanBlocked: true,
|
|
675
|
+
orphanNullable: orphanOptions.nullable,
|
|
648
676
|
advisory: {
|
|
649
677
|
severity: "warning",
|
|
650
678
|
message: `Cannot add foreign key ${tableName}.${foreignKey.column}: existing rows do not match ${foreignKey.referencesTable}.${foreignKey.referencesColumn}. Repair them, then rerun.`,
|
|
@@ -659,7 +687,7 @@ var SchemaComparer = class {
|
|
|
659
687
|
table: tableName,
|
|
660
688
|
name: constraintName,
|
|
661
689
|
foreignKey,
|
|
662
|
-
sqlStatements:
|
|
690
|
+
sqlStatements: renderForeignKeyAddStatements(tableName, foreignKey)
|
|
663
691
|
});
|
|
664
692
|
}
|
|
665
693
|
return changes;
|
|
@@ -680,7 +708,9 @@ var SchemaComparer = class {
|
|
|
680
708
|
async getForeignKeyOrphanOptions(tableName, manifest, dbSchema, foreignKey, manifestSchemas) {
|
|
681
709
|
const sourceColumn = manifest.columns[foreignKey.column];
|
|
682
710
|
const targetColumn = manifestSchemas[foreignKey.referencesTable]?.columns[foreignKey.referencesColumn];
|
|
683
|
-
const
|
|
711
|
+
const manifestNullable = sourceColumn?.notNull !== true;
|
|
712
|
+
const liveNotNull = dbSchema.columns[foreignKey.column]?.notNull === true;
|
|
713
|
+
const nullable = manifestNullable && !liveNotNull;
|
|
684
714
|
const uuidComparison = sourceColumn?.type === "UUID" && (targetColumn === void 0 || targetColumn.type === "UUID");
|
|
685
715
|
if (!uuidComparison) return {
|
|
686
716
|
nullable,
|
|
@@ -743,10 +773,148 @@ var SchemaComparer = class {
|
|
|
743
773
|
const expectedEngineType = this.ddlStrategy.mapType(validatedType);
|
|
744
774
|
const normalizedExpected = this.normalizeType(expectedEngineType);
|
|
745
775
|
const normalizedActual = this.normalizeType(dbCol.type);
|
|
776
|
+
if ((this.engine === "postgres" || this.engine === "duckdb") && normalizedExpected === "REAL" && normalizedActual === "REAL") {
|
|
777
|
+
const expectedPrecision = floatPrecisionOf(expectedEngineType, this.engine);
|
|
778
|
+
const actualPrecision = floatPrecisionOf(dbCol.type, this.engine);
|
|
779
|
+
if (expectedPrecision && actualPrecision && expectedPrecision !== actualPrecision) {
|
|
780
|
+
typeDrifted = true;
|
|
781
|
+
const table = this.quoteIdentifier(tableName);
|
|
782
|
+
const column = this.quoteIdentifier(colName);
|
|
783
|
+
if (expectedPrecision === "double" && actualPrecision === "single") {
|
|
784
|
+
const targetType = this.engine === "postgres" ? "DOUBLE PRECISION" : "DOUBLE";
|
|
785
|
+
changes.push({
|
|
786
|
+
type: "type_upgrade",
|
|
787
|
+
table: tableName,
|
|
788
|
+
name: colName,
|
|
789
|
+
column: colDef,
|
|
790
|
+
mismatch: {
|
|
791
|
+
expected: expectedEngineType,
|
|
792
|
+
actual: dbCol.type
|
|
793
|
+
},
|
|
794
|
+
sql: `ALTER TABLE ${table} ALTER COLUMN ${column} TYPE ${targetType} USING ${column}::${targetType}`
|
|
795
|
+
});
|
|
796
|
+
} else changes.push({
|
|
797
|
+
type: "type_upgrade",
|
|
798
|
+
table: tableName,
|
|
799
|
+
name: colName,
|
|
800
|
+
column: colDef,
|
|
801
|
+
mismatch: {
|
|
802
|
+
expected: expectedEngineType,
|
|
803
|
+
actual: dbCol.type
|
|
804
|
+
},
|
|
805
|
+
advisory: {
|
|
806
|
+
severity: "warning",
|
|
807
|
+
message: `blocked: ${tableName}.${colName} is declared single-precision (${expectedEngineType}) but the live column is double-precision (${dbCol.type}). Narrowing loses precision, so this stays manual: confirm the narrower declaration is intentional, or widen the manifest field instead of the column.`,
|
|
808
|
+
suggestedSql: [`ALTER TABLE ${table} ALTER COLUMN ${column} TYPE ${expectedEngineType} USING ${column}::${expectedEngineType}`]
|
|
809
|
+
}
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
}
|
|
746
813
|
const isUuidTextEquivalent = this.isUuidTextEquivalentColumn(colName, colDef, normalizedExpected, normalizedActual);
|
|
747
|
-
|
|
814
|
+
const jsonUpgradeCandidate = this.engine === "postgres" && normalizedExpected === "JSON" && normalizedActual === "TEXT";
|
|
815
|
+
let jsonProbe;
|
|
816
|
+
if (jsonUpgradeCandidate) jsonProbe = await this.probeTextCastShape(tableName, colName, "jsonb");
|
|
817
|
+
const isJsonTextEquivalent = normalizedExpected === "TEXT" && normalizedActual === "JSON" || normalizedExpected === "JSON" && normalizedActual === "TEXT" && (!jsonUpgradeCandidate || jsonProbe?.status === "unavailable");
|
|
818
|
+
const timestamptzUpgradeCandidate = this.engine === "postgres" && this.normalizeType(colDef.type) === "TIMESTAMP" && normalizedActual === "TEXT" && normalizedExpected === "TIMESTAMPTZ" && !this.isCompatibleTypeUpgrade(colDef.type, dbCol.type);
|
|
819
|
+
let timestamptzProbe;
|
|
820
|
+
if (timestamptzUpgradeCandidate) timestamptzProbe = await this.probeTextCastShape(tableName, colName, "timestamptz");
|
|
821
|
+
if (normalizedExpected !== normalizedActual && !isUuidTextEquivalent && !isJsonTextEquivalent) {
|
|
748
822
|
typeDrifted = true;
|
|
749
|
-
|
|
823
|
+
const hasLiveDefault = dbCol.defaultValue !== null && dbCol.defaultValue !== void 0;
|
|
824
|
+
const conversionOptions = {
|
|
825
|
+
hasLiveDefault,
|
|
826
|
+
manifestDefaultValue: colDef.defaultValue
|
|
827
|
+
};
|
|
828
|
+
const liveOnlyDefaultNeedsRelaxOptIn = hasLiveDefault && colDef.defaultValue === void 0 && !this.options.relaxColumns;
|
|
829
|
+
if (jsonUpgradeCandidate && jsonProbe?.status === "clean" && liveOnlyDefaultNeedsRelaxOptIn) changes.push({
|
|
830
|
+
type: "type_upgrade",
|
|
831
|
+
table: tableName,
|
|
832
|
+
name: colName,
|
|
833
|
+
column: colDef,
|
|
834
|
+
mismatch: {
|
|
835
|
+
expected: colDef.type,
|
|
836
|
+
actual: dbCol.type
|
|
837
|
+
},
|
|
838
|
+
advisory: {
|
|
839
|
+
severity: "warning",
|
|
840
|
+
message: `blocked: ${tableName}.${colName} has a live default (${String(dbCol.defaultValue)}) the manifest no longer declares; converging to jsonb requires dropping it first. ${this.relaxHint("drop it as part of this conversion")}`,
|
|
841
|
+
suggestedSql: renderJsonbColumnConversion(tableName, colName, conversionOptions)
|
|
842
|
+
}
|
|
843
|
+
});
|
|
844
|
+
else if (jsonUpgradeCandidate && jsonProbe?.status === "clean") {
|
|
845
|
+
const statements = renderJsonbColumnConversion(tableName, colName, conversionOptions);
|
|
846
|
+
changes.push({
|
|
847
|
+
type: "type_upgrade",
|
|
848
|
+
table: tableName,
|
|
849
|
+
name: colName,
|
|
850
|
+
column: colDef,
|
|
851
|
+
mismatch: {
|
|
852
|
+
expected: colDef.type,
|
|
853
|
+
actual: dbCol.type
|
|
854
|
+
},
|
|
855
|
+
sql: statements[statements.length - 1],
|
|
856
|
+
sqlStatements: statements
|
|
857
|
+
});
|
|
858
|
+
} else if (jsonUpgradeCandidate && jsonProbe?.status === "dirty") changes.push({
|
|
859
|
+
type: "type_upgrade",
|
|
860
|
+
table: tableName,
|
|
861
|
+
name: colName,
|
|
862
|
+
column: colDef,
|
|
863
|
+
mismatch: {
|
|
864
|
+
expected: colDef.type,
|
|
865
|
+
actual: dbCol.type
|
|
866
|
+
},
|
|
867
|
+
advisory: {
|
|
868
|
+
severity: "warning",
|
|
869
|
+
message: `blocked: ${tableName}.${colName} is declared JSON but ${jsonProbe.count} live value(s) are not valid JSON (sample: ${jsonProbe.sample ? maskSampleValue(jsonProbe.sample) : "unavailable"}). Repair or clear the offending value(s), then rerun \`smrt db:migrate\`.`,
|
|
870
|
+
suggestedSql: renderJsonbColumnConversion(tableName, colName, conversionOptions)
|
|
871
|
+
}
|
|
872
|
+
});
|
|
873
|
+
else if (timestamptzUpgradeCandidate && timestamptzProbe?.status === "clean" && liveOnlyDefaultNeedsRelaxOptIn) changes.push({
|
|
874
|
+
type: "type_upgrade",
|
|
875
|
+
table: tableName,
|
|
876
|
+
name: colName,
|
|
877
|
+
column: colDef,
|
|
878
|
+
mismatch: {
|
|
879
|
+
expected: colDef.type,
|
|
880
|
+
actual: dbCol.type
|
|
881
|
+
},
|
|
882
|
+
advisory: {
|
|
883
|
+
severity: "warning",
|
|
884
|
+
message: `blocked: ${tableName}.${colName} has a live default (${String(dbCol.defaultValue)}) the manifest no longer declares; converging to timestamptz requires dropping it first. ${this.relaxHint("drop it as part of this conversion")}`,
|
|
885
|
+
suggestedSql: renderTimestamptzColumnConversion(tableName, colName, conversionOptions)
|
|
886
|
+
}
|
|
887
|
+
});
|
|
888
|
+
else if (timestamptzUpgradeCandidate && timestamptzProbe?.status === "clean") {
|
|
889
|
+
const statements = renderTimestamptzColumnConversion(tableName, colName, conversionOptions);
|
|
890
|
+
changes.push({
|
|
891
|
+
type: "type_upgrade",
|
|
892
|
+
table: tableName,
|
|
893
|
+
name: colName,
|
|
894
|
+
column: colDef,
|
|
895
|
+
mismatch: {
|
|
896
|
+
expected: colDef.type,
|
|
897
|
+
actual: dbCol.type
|
|
898
|
+
},
|
|
899
|
+
sql: statements[statements.length - 1],
|
|
900
|
+
sqlStatements: statements
|
|
901
|
+
});
|
|
902
|
+
} else if (timestamptzUpgradeCandidate && timestamptzProbe?.status === "dirty") changes.push({
|
|
903
|
+
type: "type_upgrade",
|
|
904
|
+
table: tableName,
|
|
905
|
+
name: colName,
|
|
906
|
+
column: colDef,
|
|
907
|
+
mismatch: {
|
|
908
|
+
expected: colDef.type,
|
|
909
|
+
actual: dbCol.type
|
|
910
|
+
},
|
|
911
|
+
advisory: {
|
|
912
|
+
severity: "warning",
|
|
913
|
+
message: `blocked: ${tableName}.${colName} is declared TIMESTAMP but ${timestamptzProbe.count} live value(s) do not parse as an unambiguous timestamp (sample: ${timestamptzProbe.sample ? maskSampleValue(timestamptzProbe.sample) : "unavailable"}). Repair the offending value(s), or confirm legacy naive wall-clock provenance with \`smrt db:migrate --postgres-timestamp-legacy-timezone=UTC\`, then rerun.`,
|
|
914
|
+
suggestedSql: renderTimestamptzColumnConversion(tableName, colName, conversionOptions)
|
|
915
|
+
}
|
|
916
|
+
});
|
|
917
|
+
else if (this.isCompatibleTypeUpgrade(colDef.type, dbCol.type)) {
|
|
750
918
|
const generatedSQL = this.generateTypeUpgradeSQL(tableName, colName, colDef, dbCol.type);
|
|
751
919
|
changes.push({
|
|
752
920
|
type: "type_upgrade",
|
|
@@ -791,9 +959,199 @@ var SchemaComparer = class {
|
|
|
791
959
|
}
|
|
792
960
|
changes.push(this.describeOrphanColumn(tableName, colName, dbCol));
|
|
793
961
|
}
|
|
962
|
+
changes.push(...await this.detectRenameDataPending(tableName, manifest, dbSchema));
|
|
794
963
|
return changes;
|
|
795
964
|
}
|
|
796
965
|
/**
|
|
966
|
+
* Detect a pending rename backfill (#2752): a manifest-declared column
|
|
967
|
+
* that exists live but holds no data, paired with an orphan (undeclared)
|
|
968
|
+
* live column of a compatible type that does hold data — the shape a
|
|
969
|
+
* framework field rename leaves behind when `db:migrate` adds the new
|
|
970
|
+
* column additively and never moves data into it. For each such pair,
|
|
971
|
+
* emit the copy-then-drop repair as an advisory: never executed,
|
|
972
|
+
* PostgreSQL and SQLite only (DuckDB/JSON are out of scope). The repair
|
|
973
|
+
* is genuinely idempotent SQL on PostgreSQL (a self-guarding
|
|
974
|
+
* `DO $$ ... $$` block); SQLite has no conditional-DDL construct, so its
|
|
975
|
+
* repair is operator-mediated instead — a guard query plus instructions,
|
|
976
|
+
* not a blind-rerun-safe statement. See {@link describeRenameDataPending}.
|
|
977
|
+
*/
|
|
978
|
+
async detectRenameDataPending(tableName, manifest, dbSchema) {
|
|
979
|
+
if (this.engine !== "postgres" && this.engine !== "sqlite") return [];
|
|
980
|
+
const dbColumnNames = new Set(Object.keys(dbSchema.columns));
|
|
981
|
+
const manifestColumnNames = new Set(Object.keys(manifest.columns));
|
|
982
|
+
const orphanColumnNames = [...dbColumnNames].filter((name) => !manifestColumnNames.has(name));
|
|
983
|
+
if (orphanColumnNames.length === 0) return [];
|
|
984
|
+
const changes = [];
|
|
985
|
+
for (const [colName, colDef] of Object.entries(manifest.columns)) {
|
|
986
|
+
if (!dbSchema.columns[colName]) continue;
|
|
987
|
+
let declaredHasData;
|
|
988
|
+
try {
|
|
989
|
+
declaredHasData = await this.columnHasNonEmptyValue(tableName, colName);
|
|
990
|
+
} catch {
|
|
991
|
+
continue;
|
|
992
|
+
}
|
|
993
|
+
if (declaredHasData) continue;
|
|
994
|
+
const validatedType = isValidSQLDataType(colDef.type) ? colDef.type : "TEXT";
|
|
995
|
+
const isLogicalUuid = validatedType === "UUID";
|
|
996
|
+
const declaredNormalized = this.normalizeType(this.ddlStrategy.mapType(validatedType));
|
|
997
|
+
const candidates = [];
|
|
998
|
+
for (const orphanName of orphanColumnNames) {
|
|
999
|
+
const orphanCol = dbSchema.columns[orphanName];
|
|
1000
|
+
const orphanNormalized = this.normalizeType(orphanCol.type);
|
|
1001
|
+
const requiresShapeCheck = isLogicalUuid && orphanNormalized === "TEXT";
|
|
1002
|
+
if (!requiresShapeCheck && !(orphanNormalized === declaredNormalized)) continue;
|
|
1003
|
+
let orphanHasData;
|
|
1004
|
+
try {
|
|
1005
|
+
orphanHasData = await this.columnHasNonEmptyValue(tableName, orphanName);
|
|
1006
|
+
} catch {
|
|
1007
|
+
continue;
|
|
1008
|
+
}
|
|
1009
|
+
if (!orphanHasData) continue;
|
|
1010
|
+
if (requiresShapeCheck) {
|
|
1011
|
+
let shaped;
|
|
1012
|
+
try {
|
|
1013
|
+
shaped = await this.allNonEmptyValuesUuidShaped(tableName, orphanName);
|
|
1014
|
+
} catch {
|
|
1015
|
+
continue;
|
|
1016
|
+
}
|
|
1017
|
+
if (!shaped) continue;
|
|
1018
|
+
}
|
|
1019
|
+
candidates.push({
|
|
1020
|
+
orphanName,
|
|
1021
|
+
isUuidCast: declaredNormalized === "UUID"
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
if (candidates.length === 1) changes.push(this.describeRenameDataPending(tableName, colName, candidates[0].orphanName, candidates[0].isUuidCast));
|
|
1025
|
+
else if (candidates.length > 1) changes.push(this.describeRenameDataPendingAmbiguous(tableName, colName, candidates.map((c) => c.orphanName)));
|
|
1026
|
+
}
|
|
1027
|
+
return changes;
|
|
1028
|
+
}
|
|
1029
|
+
/**
|
|
1030
|
+
* Live-data probe: does the column hold any non-null, non-empty value?
|
|
1031
|
+
* Used by {@link detectRenameDataPending}. Errors propagate to the
|
|
1032
|
+
* caller, which skips the pair rather than guessing (#2752).
|
|
1033
|
+
*/
|
|
1034
|
+
async columnHasNonEmptyValue(tableName, colName) {
|
|
1035
|
+
const quotedTable = this.quoteIdentifier(tableName);
|
|
1036
|
+
const quotedCol = this.quoteIdentifier(colName);
|
|
1037
|
+
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;
|
|
1038
|
+
}
|
|
1039
|
+
/**
|
|
1040
|
+
* Live-data probe: are every one of a column's non-empty values
|
|
1041
|
+
* UUID-shaped ({@link CANONICAL_UUID_PATTERN})? PostgreSQL pushes the
|
|
1042
|
+
* check into the query with its regex operator; SQLite has no regex
|
|
1043
|
+
* operator, but its case-sensitive `GLOB` can still express the fixed
|
|
1044
|
+
* 36-character canonical shape ({@link CANONICAL_UUID_SQLITE_GLOB_PATTERN}
|
|
1045
|
+
* against `LOWER(...)`, guarded by an exact `LENGTH(...) = 36` check), so
|
|
1046
|
+
* both engines run one server-side aggregate `count(*)` rather than
|
|
1047
|
+
* fetching every non-empty value into JS to test in a loop — important on
|
|
1048
|
+
* a production table with many rows (#2767 review).
|
|
1049
|
+
*/
|
|
1050
|
+
async allNonEmptyValuesUuidShaped(tableName, colName) {
|
|
1051
|
+
const quotedTable = this.quoteIdentifier(tableName);
|
|
1052
|
+
const quotedCol = this.quoteIdentifier(colName);
|
|
1053
|
+
const nonEmptyPredicate = `${quotedCol} IS NOT NULL AND CAST(${quotedCol} AS TEXT) <> ''`;
|
|
1054
|
+
if (this.engine === "postgres") {
|
|
1055
|
+
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];
|
|
1056
|
+
return Number(row?.invalid_count ?? 0) === 0;
|
|
1057
|
+
}
|
|
1058
|
+
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];
|
|
1059
|
+
return Number(row?.invalid_count ?? 0) === 0;
|
|
1060
|
+
}
|
|
1061
|
+
/**
|
|
1062
|
+
* Render the repair for one rename-data-pending pair (#2752): copy
|
|
1063
|
+
* non-empty `oldColumn` into `newColumn` only where `newColumn` is still
|
|
1064
|
+
* empty (casting to `uuid` when the new column is native uuid, otherwise a
|
|
1065
|
+
* plain copy), then drop `oldColumn`.
|
|
1066
|
+
*
|
|
1067
|
+
* Idempotency review findings and the fix:
|
|
1068
|
+
*
|
|
1069
|
+
* - (P1) The `newColumn`-is-empty predicate previously compared any
|
|
1070
|
+
* non-uuid destination to `''` (`col IS NULL OR col = ''`), which is
|
|
1071
|
+
* only valid for text-affinity types — PostgreSQL rejects `col = ''`
|
|
1072
|
+
* for `integer`/`boolean`/`timestamp`/etc. before a same-type rename
|
|
1073
|
+
* pair of one of those types ever copies anything. Fixed by comparing
|
|
1074
|
+
* `CAST(col AS TEXT) = ''` instead, mirroring the same portable
|
|
1075
|
+
* emptiness check {@link columnHasNonEmptyValue} already uses for
|
|
1076
|
+
* detection, so the predicate is valid for every column type.
|
|
1077
|
+
* - (P2) An `UPDATE` unconditionally naming `oldColumn` fails once that
|
|
1078
|
+
* column is gone, on either engine, regardless of the DROP's own
|
|
1079
|
+
* idempotency — a guard query placed before the DROP (or even before
|
|
1080
|
+
* the UPDATE) still leaves a human decision between "run" and "don't",
|
|
1081
|
+
* which is not genuine SQL-level idempotency. PostgreSQL CAN express a
|
|
1082
|
+
* truly self-contained, unconditionally-safe-to-rerun statement here:
|
|
1083
|
+
* an anonymous `DO $$ ... $$` block (the same idiom this file already
|
|
1084
|
+
* uses for `generatePostgresIntegerPreflightSQL`) that checks
|
|
1085
|
+
* `information_schema.columns` and only runs the UPDATE + DROP when the
|
|
1086
|
+
* old column still exists — one executable statement, no operator
|
|
1087
|
+
* judgment required, safe to rerun any number of times. SQLite has no
|
|
1088
|
+
* procedural block or conditional-DDL construct at all (confirmed
|
|
1089
|
+
* against a real connection), so there the contract is explicitly
|
|
1090
|
+
* downgraded from "idempotent SQL" to "operator-mediated": a guard
|
|
1091
|
+
* query plus instructions, not a single statement that is itself safe
|
|
1092
|
+
* to blindly rerun.
|
|
1093
|
+
*
|
|
1094
|
+
* This is report-only either way: `db:migrate` never executes it.
|
|
1095
|
+
*/
|
|
1096
|
+
describeRenameDataPending(tableName, newColumn, oldColumn, newIsNativeUuid) {
|
|
1097
|
+
const quotedTable = this.quoteIdentifier(tableName);
|
|
1098
|
+
const quotedNew = this.quoteIdentifier(newColumn);
|
|
1099
|
+
const quotedOld = this.quoteIdentifier(oldColumn);
|
|
1100
|
+
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) <> ''`;
|
|
1101
|
+
let suggestedSql;
|
|
1102
|
+
let dropIdempotencyNote;
|
|
1103
|
+
if (this.engine === "postgres") {
|
|
1104
|
+
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 $$`];
|
|
1105
|
+
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.`;
|
|
1106
|
+
} else {
|
|
1107
|
+
suggestedSql = [
|
|
1108
|
+
`SELECT count(*) AS old_column_still_present FROM pragma_table_info(${this.quoteLiteral(tableName)}) WHERE name = ${this.quoteLiteral(oldColumn)}`,
|
|
1109
|
+
copySql,
|
|
1110
|
+
this.generateDropColumnSQL(tableName, oldColumn)
|
|
1111
|
+
];
|
|
1112
|
+
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.`;
|
|
1113
|
+
}
|
|
1114
|
+
return {
|
|
1115
|
+
type: "rename_data_pending",
|
|
1116
|
+
table: tableName,
|
|
1117
|
+
name: newColumn,
|
|
1118
|
+
mismatch: {
|
|
1119
|
+
expected: `data in ${newColumn}`,
|
|
1120
|
+
actual: `data appears to still be in ${oldColumn}`
|
|
1121
|
+
},
|
|
1122
|
+
advisory: {
|
|
1123
|
+
severity: "warning",
|
|
1124
|
+
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}`,
|
|
1125
|
+
suggestedSql
|
|
1126
|
+
}
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1129
|
+
/**
|
|
1130
|
+
* Render the advisory for an ambiguous rename-data-pending match (#2767
|
|
1131
|
+
* review): more than one orphan column is a populated, type-compatible
|
|
1132
|
+
* candidate for the same empty declared column, so the real rename source
|
|
1133
|
+
* cannot be inferred. Unlike {@link describeRenameDataPending}, this
|
|
1134
|
+
* carries no `suggestedSql` — running a per-candidate copy-and-drop would
|
|
1135
|
+
* let output order decide which candidate's data wins and then drop every
|
|
1136
|
+
* one of them, which is not a repair an advisory should suggest blindly.
|
|
1137
|
+
*/
|
|
1138
|
+
describeRenameDataPendingAmbiguous(tableName, newColumn, candidateColumns) {
|
|
1139
|
+
const candidateList = candidateColumns.map((name) => `${tableName}.${name}`).join(", ");
|
|
1140
|
+
return {
|
|
1141
|
+
type: "rename_data_pending",
|
|
1142
|
+
table: tableName,
|
|
1143
|
+
name: newColumn,
|
|
1144
|
+
mismatch: {
|
|
1145
|
+
expected: `data in ${newColumn}`,
|
|
1146
|
+
actual: `data appears to still be in one of several columns: ${candidateList}`
|
|
1147
|
+
},
|
|
1148
|
+
advisory: {
|
|
1149
|
+
severity: "warning",
|
|
1150
|
+
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.`
|
|
1151
|
+
}
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
1154
|
+
/**
|
|
797
1155
|
* Compare nullability and default between a manifest column and the live
|
|
798
1156
|
* column, emitting `alter_column` changes (#2369).
|
|
799
1157
|
*
|