@happyvertical/smrt-core 0.57.32 → 0.57.34
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/README.md +2 -1
- package/agents/data-query.md +21 -0
- package/agents/schema-paths.md +24 -0
- package/dist/browser.d.ts +2 -2
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +3 -3
- package/dist/data-query.d.ts +16 -1
- package/dist/data-query.d.ts.map +1 -1
- package/dist/data-query.js +104 -4
- package/dist/data-query.js.map +1 -1
- package/dist/db-errors.d.ts +7 -0
- package/dist/db-errors.d.ts.map +1 -1
- package/dist/db-errors.js +34 -1
- package/dist/db-errors.js.map +1 -1
- package/dist/dispatch/bus.d.ts +3 -0
- package/dist/dispatch/bus.d.ts.map +1 -1
- package/dist/dispatch/bus.js +27 -4
- package/dist/dispatch/bus.js.map +1 -1
- package/dist/dispatch/index.d.ts +1 -1
- package/dist/dispatch/index.d.ts.map +1 -1
- package/dist/dispatch/types.d.ts +17 -0
- package/dist/dispatch/types.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- 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 +3 -2
- package/dist/migrations/differ.d.ts.map +1 -1
- package/dist/migrations/differ.js +4 -3
- package/dist/migrations/differ.js.map +1 -1
- package/dist/migrations/orchestrate.d.ts +4 -0
- package/dist/migrations/orchestrate.d.ts.map +1 -1
- package/dist/migrations/orchestrate.js +4 -2
- package/dist/migrations/orchestrate.js.map +1 -1
- package/dist/migrations/sqlite-rebuild.d.ts +11 -0
- package/dist/migrations/sqlite-rebuild.d.ts.map +1 -1
- package/dist/migrations/sqlite-rebuild.js +92 -9
- package/dist/migrations/sqlite-rebuild.js.map +1 -1
- package/dist/migrations/tracker.d.ts +7 -0
- package/dist/migrations/tracker.d.ts.map +1 -1
- package/dist/migrations/tracker.js +44 -0
- package/dist/migrations/tracker.js.map +1 -1
- package/dist/smrt-knowledge.json +7 -8
- package/dist/testing/database.d.ts.map +1 -1
- package/dist/testing/database.js +18 -0
- package/dist/testing/database.js.map +1 -1
- package/package.json +4 -5
|
@@ -74,6 +74,8 @@ export interface MigrateSmrtSchemasOptions {
|
|
|
74
74
|
* `DiffOptions.emptyTextAsNull`.
|
|
75
75
|
*/
|
|
76
76
|
emptyTextAsNull?: boolean;
|
|
77
|
+
/** Explicitly allow relaxing live column constraints. */
|
|
78
|
+
relaxColumns?: boolean;
|
|
77
79
|
}
|
|
78
80
|
export interface MigrateSmrtSchemasResult {
|
|
79
81
|
applied: boolean;
|
|
@@ -137,6 +139,8 @@ export declare function getPendingSchemaStatements(db: DatabaseInterface, option
|
|
|
137
139
|
legacyTimezone: 'UTC';
|
|
138
140
|
};
|
|
139
141
|
emptyTextAsNull?: boolean;
|
|
142
|
+
/** Explicitly allow relaxing live column constraints. */
|
|
143
|
+
relaxColumns?: boolean;
|
|
140
144
|
}): Promise<PendingSchemaStatementsResult>;
|
|
141
145
|
/**
|
|
142
146
|
* Apply any pending schema changes from `ObjectRegistry` to `db`.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"orchestrate.d.ts","sourceRoot":"","sources":["../../src/migrations/orchestrate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAG5D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAE7D,OAAO,KAAK,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AACxE,OAAO,EACL,kBAAkB,EAGnB,MAAM,aAAa,CAAC;AAOrB,MAAM,WAAW,yBAAyB;IACxC,EAAE,EAAE,iBAAiB,CAAC;IACtB,kFAAkF;IAClF,WAAW,EAAE,MAAM,CAAC;IACpB;;;;;;OAMG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,sEAAsE;IACtE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,6EAA6E;IAC7E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,yEAAyE;IACzE,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,wDAAwD;IACxD,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,wFAAwF;IACxF,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAC/B;;;;;OAKG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;;;;;;;;;;OAWG;IACH,UAAU,CAAC,EAAE,cAAc,CAAC;IAC5B;;;OAGG;IACH,0BAA0B,CAAC,EAAE;QAAE,cAAc,EAAE,KAAK,CAAA;KAAE,CAAC;IACvD;;;;OAIG;IACH,eAAe,CAAC,EAAE,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"orchestrate.d.ts","sourceRoot":"","sources":["../../src/migrations/orchestrate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAG5D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAE7D,OAAO,KAAK,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AACxE,OAAO,EACL,kBAAkB,EAGnB,MAAM,aAAa,CAAC;AAOrB,MAAM,WAAW,yBAAyB;IACxC,EAAE,EAAE,iBAAiB,CAAC;IACtB,kFAAkF;IAClF,WAAW,EAAE,MAAM,CAAC;IACpB;;;;;;OAMG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,sEAAsE;IACtE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,6EAA6E;IAC7E,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,yEAAyE;IACzE,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,wDAAwD;IACxD,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,wFAAwF;IACxF,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAC/B;;;;;OAKG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;;;;;;;;;;OAWG;IACH,UAAU,CAAC,EAAE,cAAc,CAAC;IAC5B;;;OAGG;IACH,0BAA0B,CAAC,EAAE;QAAE,cAAc,EAAE,KAAK,CAAA;KAAE,CAAC;IACvD;;;;OAIG;IACH,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,yDAAyD;IACzD,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,wBAAwB;IACvC,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,EAAE,eAAe,EAAE,CAAC;IAC3B,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,WAAW,EAAE,MAAM,CAAC;IACpB;;;;;OAKG;IACH,mBAAmB,EAAE,YAAY,EAAE,CAAC;IACpC;;;;;OAKG;IACH,cAAc,EAAE,OAAO,CAAC;CACzB;AAED,MAAM,WAAW,6BAA6B;IAC5C,IAAI,EAAE,OAAO,CAAC,UAAU,CAAC,OAAO,kBAAkB,CAAC,CAAC,CAAC;IACrD,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,OAAO,CAAC;IACpB;;;;;;;OAOG;IACH,mBAAmB,EAAE,YAAY,EAAE,CAAC;IACpC;;;OAGG;IACH,cAAc,EAAE,OAAO,CAAC;CACzB;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,0BAA0B,CAC9C,EAAE,EAAE,iBAAiB,EACrB,OAAO,GAAE;IACP,UAAU,CAAC,EAAE,cAAc,CAAC;IAC5B,0BAA0B,CAAC,EAAE;QAAE,cAAc,EAAE,KAAK,CAAA;KAAE,CAAC;IACvD,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,yDAAyD;IACzD,YAAY,CAAC,EAAE,OAAO,CAAC;CACnB,GACL,OAAO,CAAC,6BAA6B,CAAC,CAuBxC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,yBAAyB,GACjC,OAAO,CAAC,wBAAwB,CAAC,CA4DnC;AAcD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,yBAAyB,CACvC,IAAI,EAAE,OAAO,CAAC,UAAU,CAAC,OAAO,kBAAkB,CAAC,CAAC,EACpD,EAAE,EAAE,iBAAiB,EACrB,UAAU,CAAC,EAAE,cAAc,GAC1B,MAAM,EAAE,CAwCV"}
|
|
@@ -26,7 +26,8 @@ async function getPendingSchemaStatements(db, options = {}) {
|
|
|
26
26
|
const diff = await generateSchemaDiff(db, schemas, {
|
|
27
27
|
engineHint: options.engineHint,
|
|
28
28
|
postgresTimestampMigration: options.postgresTimestampMigration,
|
|
29
|
-
emptyTextAsNull: options.emptyTextAsNull
|
|
29
|
+
emptyTextAsNull: options.emptyTextAsNull,
|
|
30
|
+
relaxColumns: options.relaxColumns
|
|
30
31
|
});
|
|
31
32
|
const statements = collectStatementsFromDiff(diff, db, options.engineHint);
|
|
32
33
|
const unactionableChanges = collectUnactionableChanges(diff);
|
|
@@ -78,7 +79,8 @@ async function migrateSmrtSchemas(options) {
|
|
|
78
79
|
const pending = await getPendingSchemaStatements(options.db, {
|
|
79
80
|
engineHint: options.engineHint,
|
|
80
81
|
postgresTimestampMigration: options.postgresTimestampMigration,
|
|
81
|
-
emptyTextAsNull: options.emptyTextAsNull
|
|
82
|
+
emptyTextAsNull: options.emptyTextAsNull,
|
|
83
|
+
relaxColumns: options.relaxColumns
|
|
82
84
|
});
|
|
83
85
|
if (!pending.hasChanges || pending.statements.length === 0) return {
|
|
84
86
|
applied: false,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"orchestrate.js","names":[],"sources":["../../src/migrations/orchestrate.ts"],"sourcesContent":["/**\n * High-level \"make my SMRT app's schema match its database\" orchestration.\n *\n * Wraps `ObjectRegistry.getAllSchemasAsDefinitions()` → `generateSchemaDiff`\n * → `MigrationTracker.applyAll`. Apps that just want to run pending\n * schema changes call `migrateSmrtSchemas({ db, packageName, version })`\n * and don't have to assemble the migration definition by hand.\n *\n * The migration name is timestamped, so re-running on an up-to-date\n * database is a no-op (the diff is empty, no statements are generated,\n * `applied: false` is returned).\n */\nimport type { DatabaseInterface } from '@happyvertical/sql';\nimport { ObjectRegistry } from '../registry.js';\nimport { detectEngine, getDDLStrategy } from '../schema/ddl/index.js';\nimport type { DatabaseEngine } from '../schema/ddl/types.js';\nimport { planForeignKeyCreation } from '../schema/foreign-key-planner.js';\nimport type { MigrationResult, SchemaChange } from '../schema/types.js';\nimport {\n generateSchemaDiff,\n getSQLFromChanges,\n hasActionableChanges,\n} from './differ.js';\nimport {\n createMigrationDefinition,\n generateMigrationTimestamp,\n} from './generator.js';\nimport { MigrationTracker } from './tracker.js';\n\nexport interface MigrateSmrtSchemasOptions {\n db: DatabaseInterface;\n /** Stored on the migration record so the audit log shows which app applied it. */\n packageName: string;\n /**\n * Stored on the migration record. Typically the app's package.json\n * version. Optional because most apps don't have a runtime-readable\n * version handy and would hardcode a stale string if forced to supply\n * one — `'unknown'` is honest. Pass an explicit value when you can\n * (e.g. `version: process.env.APP_VERSION`).\n */\n version?: string;\n /** Override the description on the synthetic migration definition. */\n description?: string;\n /** Override the migration name (default: `<timestamp>_smrt_schema_sync`). */\n name?: string;\n /** Forwarded to MigrationTracker — defaults to true for pg-safe runs. */\n postgresSafe?: boolean;\n /** Forwarded to MigrationTracker — defaults to true. */\n reconcile?: boolean;\n /** Forwarded to MigrationTracker — defaults to true for `CREATE INDEX CONCURRENTLY`. */\n useConcurrentIndexes?: boolean;\n /**\n * Forwarded to MigrationTracker (Postgres lock timeout, milliseconds).\n * Lets apps that customize tracker timeouts directly preserve their\n * tuning when going through the orchestrator. Falls back to the\n * tracker's default (30000ms) when omitted.\n */\n lockTimeout?: number;\n /**\n * Forwarded to MigrationTracker (Postgres statement timeout, ms). Falls\n * back to the tracker's default (60000ms) when omitted.\n */\n statementTimeout?: number;\n /**\n * Explicit engine hint forwarded to `detectEngine`, `SchemaComparer`, and\n * `MigrationTracker`. Useful when `db.url` doesn't unambiguously identify\n * the engine (e.g. the JSON adapter, which uses DuckDB internally and may\n * not be recognizable by URL alone). All three layers honor the same hint\n * so the generated DDL, drift comparison, and execution path stay\n * consistent — without it, an empty-URL connection could produce\n * Postgres-flavored DDL but run through the SQLite tracker path.\n *\n * Typed as `DatabaseEngine` so unsupported values (e.g. `'mysql'`) fail at\n * compile time rather than silently falling back to `'sqlite'` at runtime.\n */\n engineHint?: DatabaseEngine;\n /**\n * Confirm that historical PostgreSQL Date writers used UTC wall times before\n * reinterpreting legacy TIMESTAMP/TEXT/JSON columns as TIMESTAMPTZ.\n */\n postgresTimestampMigration?: { legacyTimezone: 'UTC' };\n /**\n * Store empty or whitespace-only text as NULL when converging a nullable\n * legacy `text` column to a typed column (#3226). See\n * `DiffOptions.emptyTextAsNull`.\n */\n emptyTextAsNull?: boolean;\n}\n\nexport interface MigrateSmrtSchemasResult {\n applied: boolean;\n results: MigrationResult[];\n statements: string[];\n schemaCount: number;\n /**\n * Schema changes the orchestrator detected but cannot apply automatically\n * (incompatible type mismatches, SQLite type upgrades that require table\n * recreation, etc.). Empty when the schema is fully reconciled. Inspect\n * this to surface a \"manual migration required\" warning in CLI output.\n */\n unactionableChanges: SchemaChange[];\n /**\n * Convenience: `unactionableChanges.length > 0`. Lets a caller write\n * `if (result.hasManualDrift) ...` without iterating the array first,\n * and makes the case visible to anyone scanning the result shape (the\n * full array is easy to gloss over when `applied: false`).\n */\n hasManualDrift: boolean;\n}\n\nexport interface PendingSchemaStatementsResult {\n diff: Awaited<ReturnType<typeof generateSchemaDiff>>;\n statements: string[];\n schemaCount: number;\n hasChanges: boolean;\n /**\n * Schema changes the differ detected but that have no executable SQL\n * (incompatible `type_mismatch` entries, or `type_upgrade` entries whose\n * only generated SQL is an advisory comment because the engine cannot\n * upgrade the column in place — e.g. SQLite type widening). Surfacing\n * these lets status commands distinguish \"no drift\" from \"drift the\n * orchestrator can't fix on its own.\"\n */\n unactionableChanges: SchemaChange[];\n /**\n * Convenience: `unactionableChanges.length > 0`. Mirrors the\n * `MigrateSmrtSchemasResult.hasManualDrift` shortcut.\n */\n hasManualDrift: boolean;\n}\n\n/**\n * Compute the SQL statements needed to bring `db` in sync with the\n * currently-registered SMRT object schemas. Pure inspection — no writes.\n *\n * Useful for status commands (\"how far behind is the schema?\") and as the\n * inner step of `migrateSmrtSchemas`.\n *\n * **Note on the returned `statements`:** these are the engine-correct DDL\n * statements as the differ + DDL strategy produce them, *prior to* any\n * Postgres-specific rewrites the `MigrationTracker` applies at execution\n * time (e.g. rewriting `CREATE INDEX` → `CREATE INDEX CONCURRENTLY` and\n * moving CONCURRENTLY statements outside the surrounding transaction).\n * The list is suitable for preview/status/\"what would change?\" use cases.\n * For the exact byte-for-byte SQL the tracker ran, inspect the\n * `MigrationResult`s from `migrateSmrtSchemas` or the tracker's own logs.\n */\nexport async function getPendingSchemaStatements(\n db: DatabaseInterface,\n options: {\n engineHint?: DatabaseEngine;\n postgresTimestampMigration?: { legacyTimezone: 'UTC' };\n emptyTextAsNull?: boolean;\n } = {},\n): Promise<PendingSchemaStatementsResult> {\n const schemas = ObjectRegistry.getAllSchemasAsDefinitions();\n // Forward engineHint into the diff itself so the SchemaComparer's\n // existing-table SQL (ALTER/index drift) uses the same DDL strategy\n // we use for newly-added tables — otherwise the two halves of the\n // statement list can land on different engines (split-brain) when\n // `db.url` is empty or ambiguous.\n const diff = await generateSchemaDiff(db, schemas, {\n engineHint: options.engineHint,\n postgresTimestampMigration: options.postgresTimestampMigration,\n emptyTextAsNull: options.emptyTextAsNull,\n });\n const statements = collectStatementsFromDiff(diff, db, options.engineHint);\n const unactionableChanges = collectUnactionableChanges(diff);\n return {\n diff,\n schemaCount: Object.keys(schemas).length,\n statements,\n hasChanges: hasActionableChanges(diff),\n unactionableChanges,\n hasManualDrift: unactionableChanges.length > 0,\n };\n}\n\n/**\n * Apply any pending schema changes from `ObjectRegistry` to `db`.\n *\n * Returns `applied: false` (with no error) if the database is already in\n * sync. Throws on the first migration failure with the underlying error\n * preserved (callers should let it propagate to the CLI).\n *\n * **Note on the returned `statements`:** mirrors `getPendingSchemaStatements`\n * — the list is the planned DDL, prior to Postgres-specific tracker rewrites\n * (CONCURRENTLY, transaction reordering). The actual executed SQL is\n * tracked in the returned `results`.\n *\n * **Note on `applied` semantics under `reconcile: true` (the default):**\n * the tracker may re-execute a migration that is already in `completed`\n * state when the checksum matches — that counts as `applied: true` in the\n * returned `MigrationResult`. So `result.applied === true` means \"the\n * tracker ran the migration\", not specifically \"the database changed for\n * the first time.\" Pass `reconcile: false` if you need to distinguish a\n * fresh apply from a reconcile-replay over an existing completed record.\n *\n * **Note on repeated failures:** the migration name defaults to a fresh\n * timestamp on every call, so a sequence of failed runs leaves one\n * `failed` row in `_smrt_schema_migrations` per attempt. Pass an explicit\n * `name` if you want to overwrite the same record across retries.\n *\n * **Note on `unactionableChanges`:** the differ can detect schema drift\n * the migration step can't auto-resolve — incompatible type mismatches,\n * SQLite type widening that needs table recreation, etc. Those changes\n * appear in `result.unactionableChanges` and are NOT applied (the\n * statements list is filtered to executable DDL only). When the diff\n * contains *only* unactionable changes, the function returns\n * `applied: false` with a populated `unactionableChanges` so callers can\n * surface a \"manual migration required\" signal. Without inspecting that\n * field, the result would look indistinguishable from \"already in sync.\"\n */\nexport async function migrateSmrtSchemas(\n options: MigrateSmrtSchemasOptions,\n): Promise<MigrateSmrtSchemasResult> {\n const pending = await getPendingSchemaStatements(options.db, {\n engineHint: options.engineHint,\n postgresTimestampMigration: options.postgresTimestampMigration,\n emptyTextAsNull: options.emptyTextAsNull,\n });\n if (!pending.hasChanges || pending.statements.length === 0) {\n return {\n applied: false,\n results: [],\n statements: [],\n schemaCount: pending.schemaCount,\n unactionableChanges: pending.unactionableChanges,\n hasManualDrift: pending.hasManualDrift,\n };\n }\n\n const tracker = new MigrationTracker({\n db: options.db,\n engineHint: options.engineHint,\n lockTimeout: options.lockTimeout,\n statementTimeout: options.statementTimeout,\n useConcurrentIndexes: options.useConcurrentIndexes ?? true,\n });\n const migration = createMigrationDefinition(\n options.name ?? `${generateMigrationTimestamp()}_smrt_schema_sync`,\n pending.statements,\n [],\n {\n description: options.description ?? 'Synchronize SMRT object schemas',\n packageName: options.packageName,\n version: options.version ?? 'unknown',\n },\n );\n\n const results = await tracker.applyAll([migration], {\n postgresSafe: options.postgresSafe ?? true,\n reconcile: options.reconcile ?? true,\n });\n\n const failed = results.find((result) => !result.success);\n if (failed) {\n throw failed.error instanceof Error\n ? failed.error\n : new Error(String(failed.error ?? `Migration ${failed.name} failed`));\n }\n\n const applied = results.some(\n (result) => result.applied !== false && result.skipped !== true,\n );\n\n return {\n applied,\n results,\n statements: pending.statements,\n schemaCount: pending.schemaCount,\n unactionableChanges: pending.unactionableChanges,\n hasManualDrift: pending.hasManualDrift,\n };\n}\n\n/** Mirror of the `getDatabaseUrl` helper in class.ts — some adapters\n * (JSON, certain in-memory wrappers) leave `db.url` undefined and expose\n * the URL on `db.config?.url` instead. Detecting the engine from a raw\n * `db.url` would throw or silently fall through to the sqlite default.\n */\nfunction resolveDatabaseUrl(db: DatabaseInterface): string {\n const dbWithConfig = db as DatabaseInterface & {\n config?: { url?: string };\n };\n return db.url || dbWithConfig.config?.url || '';\n}\n\n/**\n * Materialize the diff into the engine-correct SQL statements.\n *\n * For new tables we delegate to the engine's `DDLStrategy` so column types\n * (`REAL` → `DOUBLE PRECISION` on Postgres, `JSON` → `JSONB`, etc.),\n * partial-index `WHERE` clauses, and trigger syntax all match what the\n * SchemaComparer would expect on subsequent runs. Emitting the raw\n * abstract `SchemaDefinition.ddl` here would cause immediate type-drift\n * migrations on Postgres/DuckDB.\n *\n * Column/index/trigger changes for existing tables continue to come from\n * `getSQLFromDiff`, which is already engine-aware via the differ's per-\n * change `sqlStatements`.\n *\n * Exported for statement-ordering tests; it is not part of the package's\n * public entry point (`migrations/index.ts` re-exports names explicitly).\n *\n * @param diff - Schema diff to materialize.\n * @param db - Database whose URL selects the DDL strategy.\n * @param engineHint - Explicit engine when `db.url` is empty or ambiguous.\n * @returns Ordered, executable DDL statements.\n */\nexport function collectStatementsFromDiff(\n diff: Awaited<ReturnType<typeof generateSchemaDiff>>,\n db: DatabaseInterface,\n engineHint?: DatabaseEngine,\n): string[] {\n const strategy = getDDLStrategy(\n detectEngine(resolveDatabaseUrl(db), engineHint),\n );\n const statements: string[] = [];\n const tablePlan = planForeignKeyCreation(diff.added_tables, strategy.engine);\n // #2608: the pre-R11 uuid convergence has to precede *every* foreign-key\n // statement. `planForeignKeyCreation` only defers the constraints inside a\n // mutual cycle, so an acyclic new child table keeps its FK **inline in\n // CREATE TABLE** — a fresh `uuid` child pointing at a legacy `text` parent\n // would fail with SQLSTATE 42804 before the parent could be converted.\n // Conversions only ever rewrite columns that already exist, so they are\n // safe to run ahead of every CREATE TABLE in this batch.\n statements.push(\n ...getSQLFromChanges(\n diff.changes.filter((change) => change.phase === 'pre_foreign_key'),\n ),\n );\n for (const schema of tablePlan.schemas) {\n statements.push(strategy.generateCreateTable(schema));\n statements.push(...strategy.generateIndexes(schema));\n statements.push(...strategy.generateTriggers(schema));\n }\n statements.push(...tablePlan.deferredStatements);\n statements.push(\n ...getSQLFromChanges(\n diff.changes.filter((change) => change.phase !== 'pre_foreign_key'),\n ),\n );\n // Drop empty and comment-only entries. SQLite type-widening upgrades\n // surface as `-- SQLite: Type upgrade for X requires table recreation`\n // — passing those through to the tracker records a successful migration\n // without actually fixing the column, so the same drift re-appears on\n // every subsequent run. Those changes are surfaced separately via\n // `unactionableChanges` so callers can prompt for manual remediation.\n return statements.filter((statement) => {\n const trimmed = statement.trim();\n if (trimmed.length === 0) return false;\n return !isCommentOnlySql(trimmed);\n });\n}\n\n/**\n * True if `sql` contains no executable DDL — only line comments (`--`)\n * and/or block comments (`/* … */`). Used to discard advisory comments\n * the differ emits when a column upgrade can't be performed in place\n * (e.g. SQLite type widening). Defensive: strips block comments first so\n * a future differ that emits `/* type_upgrade: no-op */` doesn't slip\n * through.\n */\nfunction isCommentOnlySql(sql: string): boolean {\n // Strip /* ... */ block comments (non-greedy, spans newlines).\n const withoutBlockComments = sql.replace(/\\/\\*[\\s\\S]*?\\*\\//g, '');\n const lines = withoutBlockComments\n .split('\\n')\n .map((line) => line.trim())\n .filter((line) => line.length > 0);\n if (lines.length === 0) return sql.trim().length > 0;\n return lines.every((line) => line.startsWith('--'));\n}\n\n/**\n * True if `sql` is an advisory comment that says *nothing needs to happen* —\n * as opposed to one that says a human has to act.\n *\n * The differ emits both kinds. \"This SQLite column already stores JSON as\n * TEXT\" and \"#2370: this column is rebuilt together with its sibling\" are\n * no-ops; \"requires table recreation\" / \"blocked: …\" are real manual drift.\n * Treating the first kind as drift makes a fully auto-repairable migration\n * report `hasManualDrift: true`.\n *\n * Keep in sync with `classifyTypeUpgradeSql`'s `noop` arm in\n * `@happyvertical/smrt-cli` (`src/commands/db-migrate-actions.ts`), which\n * applies the same rule on the CLI side.\n */\nfunction isNoOpAdvisorySql(sql: string): boolean {\n const trimmed = sql.trim();\n return (\n /no change needed/i.test(trimmed) || /already stores .* as /i.test(trimmed)\n );\n}\n\n/**\n * Surface changes the differ produced but that the migrator cannot apply\n * automatically — `type_mismatch` entries (the differ explicitly gives up on\n * these), `type_upgrade`/`alter_column` entries whose generated SQL is\n * advisory-comment-only (a SQLite rebuild that would be unsafe, an in-place\n * constraint change SQLite cannot perform), and warning-level report-only\n * advisories (an orphan NOT NULL column that will break inserts, a required\n * column that could not be enforced, a stale unique constraint, a relaxation\n * the caller has not opted into). Info-level advisories (a harmless orphan\n * column, a stale default) and comments that mean \"already handled\" (the\n * no-op advisories a table rebuild leaves on covered siblings, #2370) are\n * deliberately excluded so `hasManualDrift` keeps meaning \"something needs\n * an operator\". Callers use this to distinguish \"schema is in sync\" from\n * \"schema is drifted but we can't fix it from here\".\n */\nfunction collectUnactionableChanges(\n diff: Awaited<ReturnType<typeof generateSchemaDiff>>,\n): SchemaChange[] {\n const unactionable: SchemaChange[] = [];\n for (const change of diff.changes) {\n if (change.type === 'type_mismatch') {\n unactionable.push(change);\n continue;\n }\n const statements = change.sqlStatements ?? (change.sql ? [change.sql] : []);\n if (statements.length === 0) {\n if (change.advisory?.severity === 'warning') {\n unactionable.push(change);\n }\n continue;\n }\n if (\n statements.every((stmt) => isCommentOnlySql(stmt.trim())) &&\n !statements.every((stmt) => isNoOpAdvisorySql(stmt))\n ) {\n unactionable.push(change);\n }\n }\n return unactionable;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;AAmJA,eAAsB,2BACpB,IACA,UAII,CAAC,GACmC;CACxC,MAAM,UAAU,eAAe,2BAA2B;CAM1D,MAAM,OAAO,MAAM,mBAAmB,IAAI,SAAS;EACjD,YAAY,QAAQ;EACpB,4BAA4B,QAAQ;EACpC,iBAAiB,QAAQ;CAC3B,CAAC;CACD,MAAM,aAAa,0BAA0B,MAAM,IAAI,QAAQ,UAAU;CACzE,MAAM,sBAAsB,2BAA2B,IAAI;CAC3D,OAAO;EACL;EACA,aAAa,OAAO,KAAK,OAAO,CAAC,CAAC;EAClC;EACA,YAAY,qBAAqB,IAAI;EACrC;EACA,gBAAgB,oBAAoB,SAAS;CAC/C;AACF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqCA,eAAsB,mBACpB,SACmC;CACnC,MAAM,UAAU,MAAM,2BAA2B,QAAQ,IAAI;EAC3D,YAAY,QAAQ;EACpB,4BAA4B,QAAQ;EACpC,iBAAiB,QAAQ;CAC3B,CAAC;CACD,IAAI,CAAC,QAAQ,cAAc,QAAQ,WAAW,WAAW,GACvD,OAAO;EACL,SAAS;EACT,SAAS,CAAC;EACV,YAAY,CAAC;EACb,aAAa,QAAQ;EACrB,qBAAqB,QAAQ;EAC7B,gBAAgB,QAAQ;CAC1B;CAGF,MAAM,UAAU,IAAI,iBAAiB;EACnC,IAAI,QAAQ;EACZ,YAAY,QAAQ;EACpB,aAAa,QAAQ;EACrB,kBAAkB,QAAQ;EAC1B,sBAAsB,QAAQ,wBAAwB;CACxD,CAAC;CACD,MAAM,YAAY,0BAChB,QAAQ,QAAQ,GAAG,2BAA2B,EAAE,oBAChD,QAAQ,YACR,CAAC,GACD;EACE,aAAa,QAAQ,eAAe;EACpC,aAAa,QAAQ;EACrB,SAAS,QAAQ,WAAW;CAC9B,CACF;CAEA,MAAM,UAAU,MAAM,QAAQ,SAAS,CAAC,SAAS,GAAG;EAClD,cAAc,QAAQ,gBAAgB;EACtC,WAAW,QAAQ,aAAa;CAClC,CAAC;CAED,MAAM,SAAS,QAAQ,MAAM,WAAW,CAAC,OAAO,OAAO;CACvD,IAAI,QACF,MAAM,OAAO,iBAAiB,QAC1B,OAAO,QACP,IAAI,MAAM,OAAO,OAAO,SAAS,aAAa,OAAO,KAAK,QAAQ,CAAC;CAOzE,OAAO;EACL,SALc,QAAQ,MACrB,WAAW,OAAO,YAAY,SAAS,OAAO,YAAY,IAI3D;EACA;EACA,YAAY,QAAQ;EACpB,aAAa,QAAQ;EACrB,qBAAqB,QAAQ;EAC7B,gBAAgB,QAAQ;CAC1B;AACF;;;;;;AAOA,SAAS,mBAAmB,IAA+B;CACzD,MAAM,eAAe;CAGrB,OAAO,GAAG,OAAO,aAAa,QAAQ,OAAO;AAC/C;;;;;;;;;;;;;;;;;;;;;;;AAwBA,SAAgB,0BACd,MACA,IACA,YACU;CACV,MAAM,WAAW,eACf,aAAa,mBAAmB,EAAE,GAAG,UAAU,CACjD;CACA,MAAM,aAAuB,CAAC;CAC9B,MAAM,YAAY,uBAAuB,KAAK,cAAc,SAAS,MAAM;CAQ3E,WAAW,KACT,GAAG,kBACD,KAAK,QAAQ,QAAQ,WAAW,OAAO,UAAU,iBAAiB,CACpE,CACF;CACA,KAAK,MAAM,UAAU,UAAU,SAAS;EACtC,WAAW,KAAK,SAAS,oBAAoB,MAAM,CAAC;EACpD,WAAW,KAAK,GAAG,SAAS,gBAAgB,MAAM,CAAC;EACnD,WAAW,KAAK,GAAG,SAAS,iBAAiB,MAAM,CAAC;CACtD;CACA,WAAW,KAAK,GAAG,UAAU,kBAAkB;CAC/C,WAAW,KACT,GAAG,kBACD,KAAK,QAAQ,QAAQ,WAAW,OAAO,UAAU,iBAAiB,CACpE,CACF;CAOA,OAAO,WAAW,QAAQ,cAAc;EACtC,MAAM,UAAU,UAAU,KAAK;EAC/B,IAAI,QAAQ,WAAW,GAAG,OAAO;EACjC,OAAO,CAAC,iBAAiB,OAAO;CAClC,CAAC;AACH;;;;;;;;;AAUA,SAAS,iBAAiB,KAAsB;CAG9C,MAAM,QADuB,IAAI,QAAQ,qBAAqB,EAChD,CAAA,CACX,MAAM,IAAI,CAAC,CACX,KAAK,SAAS,KAAK,KAAK,CAAC,CAAC,CAC1B,QAAQ,SAAS,KAAK,SAAS,CAAC;CACnC,IAAI,MAAM,WAAW,GAAG,OAAO,IAAI,KAAK,CAAC,CAAC,SAAS;CACnD,OAAO,MAAM,OAAO,SAAS,KAAK,WAAW,IAAI,CAAC;AACpD;;;;;;;;;;;;;;;AAgBA,SAAS,kBAAkB,KAAsB;CAC/C,MAAM,UAAU,IAAI,KAAK;CACzB,OACE,oBAAoB,KAAK,OAAO,KAAK,yBAAyB,KAAK,OAAO;AAE9E;;;;;;;;;;;;;;;;AAiBA,SAAS,2BACP,MACgB;CAChB,MAAM,eAA+B,CAAC;CACtC,KAAK,MAAM,UAAU,KAAK,SAAS;EACjC,IAAI,OAAO,SAAS,iBAAiB;GACnC,aAAa,KAAK,MAAM;GACxB;EACF;EACA,MAAM,aAAa,OAAO,kBAAkB,OAAO,MAAM,CAAC,OAAO,GAAG,IAAI,CAAC;EACzE,IAAI,WAAW,WAAW,GAAG;GAC3B,IAAI,OAAO,UAAU,aAAa,WAChC,aAAa,KAAK,MAAM;GAE1B;EACF;EACA,IACE,WAAW,OAAO,SAAS,iBAAiB,KAAK,KAAK,CAAC,CAAC,KACxD,CAAC,WAAW,OAAO,SAAS,kBAAkB,IAAI,CAAC,GAEnD,aAAa,KAAK,MAAM;CAE5B;CACA,OAAO;AACT"}
|
|
1
|
+
{"version":3,"file":"orchestrate.js","names":[],"sources":["../../src/migrations/orchestrate.ts"],"sourcesContent":["/**\n * High-level \"make my SMRT app's schema match its database\" orchestration.\n *\n * Wraps `ObjectRegistry.getAllSchemasAsDefinitions()` → `generateSchemaDiff`\n * → `MigrationTracker.applyAll`. Apps that just want to run pending\n * schema changes call `migrateSmrtSchemas({ db, packageName, version })`\n * and don't have to assemble the migration definition by hand.\n *\n * The migration name is timestamped, so re-running on an up-to-date\n * database is a no-op (the diff is empty, no statements are generated,\n * `applied: false` is returned).\n */\nimport type { DatabaseInterface } from '@happyvertical/sql';\nimport { ObjectRegistry } from '../registry.js';\nimport { detectEngine, getDDLStrategy } from '../schema/ddl/index.js';\nimport type { DatabaseEngine } from '../schema/ddl/types.js';\nimport { planForeignKeyCreation } from '../schema/foreign-key-planner.js';\nimport type { MigrationResult, SchemaChange } from '../schema/types.js';\nimport {\n generateSchemaDiff,\n getSQLFromChanges,\n hasActionableChanges,\n} from './differ.js';\nimport {\n createMigrationDefinition,\n generateMigrationTimestamp,\n} from './generator.js';\nimport { MigrationTracker } from './tracker.js';\n\nexport interface MigrateSmrtSchemasOptions {\n db: DatabaseInterface;\n /** Stored on the migration record so the audit log shows which app applied it. */\n packageName: string;\n /**\n * Stored on the migration record. Typically the app's package.json\n * version. Optional because most apps don't have a runtime-readable\n * version handy and would hardcode a stale string if forced to supply\n * one — `'unknown'` is honest. Pass an explicit value when you can\n * (e.g. `version: process.env.APP_VERSION`).\n */\n version?: string;\n /** Override the description on the synthetic migration definition. */\n description?: string;\n /** Override the migration name (default: `<timestamp>_smrt_schema_sync`). */\n name?: string;\n /** Forwarded to MigrationTracker — defaults to true for pg-safe runs. */\n postgresSafe?: boolean;\n /** Forwarded to MigrationTracker — defaults to true. */\n reconcile?: boolean;\n /** Forwarded to MigrationTracker — defaults to true for `CREATE INDEX CONCURRENTLY`. */\n useConcurrentIndexes?: boolean;\n /**\n * Forwarded to MigrationTracker (Postgres lock timeout, milliseconds).\n * Lets apps that customize tracker timeouts directly preserve their\n * tuning when going through the orchestrator. Falls back to the\n * tracker's default (30000ms) when omitted.\n */\n lockTimeout?: number;\n /**\n * Forwarded to MigrationTracker (Postgres statement timeout, ms). Falls\n * back to the tracker's default (60000ms) when omitted.\n */\n statementTimeout?: number;\n /**\n * Explicit engine hint forwarded to `detectEngine`, `SchemaComparer`, and\n * `MigrationTracker`. Useful when `db.url` doesn't unambiguously identify\n * the engine (e.g. the JSON adapter, which uses DuckDB internally and may\n * not be recognizable by URL alone). All three layers honor the same hint\n * so the generated DDL, drift comparison, and execution path stay\n * consistent — without it, an empty-URL connection could produce\n * Postgres-flavored DDL but run through the SQLite tracker path.\n *\n * Typed as `DatabaseEngine` so unsupported values (e.g. `'mysql'`) fail at\n * compile time rather than silently falling back to `'sqlite'` at runtime.\n */\n engineHint?: DatabaseEngine;\n /**\n * Confirm that historical PostgreSQL Date writers used UTC wall times before\n * reinterpreting legacy TIMESTAMP/TEXT/JSON columns as TIMESTAMPTZ.\n */\n postgresTimestampMigration?: { legacyTimezone: 'UTC' };\n /**\n * Store empty or whitespace-only text as NULL when converging a nullable\n * legacy `text` column to a typed column (#3226). See\n * `DiffOptions.emptyTextAsNull`.\n */\n emptyTextAsNull?: boolean;\n /** Explicitly allow relaxing live column constraints. */\n relaxColumns?: boolean;\n}\n\nexport interface MigrateSmrtSchemasResult {\n applied: boolean;\n results: MigrationResult[];\n statements: string[];\n schemaCount: number;\n /**\n * Schema changes the orchestrator detected but cannot apply automatically\n * (incompatible type mismatches, SQLite type upgrades that require table\n * recreation, etc.). Empty when the schema is fully reconciled. Inspect\n * this to surface a \"manual migration required\" warning in CLI output.\n */\n unactionableChanges: SchemaChange[];\n /**\n * Convenience: `unactionableChanges.length > 0`. Lets a caller write\n * `if (result.hasManualDrift) ...` without iterating the array first,\n * and makes the case visible to anyone scanning the result shape (the\n * full array is easy to gloss over when `applied: false`).\n */\n hasManualDrift: boolean;\n}\n\nexport interface PendingSchemaStatementsResult {\n diff: Awaited<ReturnType<typeof generateSchemaDiff>>;\n statements: string[];\n schemaCount: number;\n hasChanges: boolean;\n /**\n * Schema changes the differ detected but that have no executable SQL\n * (incompatible `type_mismatch` entries, or `type_upgrade` entries whose\n * only generated SQL is an advisory comment because the engine cannot\n * upgrade the column in place — e.g. SQLite type widening). Surfacing\n * these lets status commands distinguish \"no drift\" from \"drift the\n * orchestrator can't fix on its own.\"\n */\n unactionableChanges: SchemaChange[];\n /**\n * Convenience: `unactionableChanges.length > 0`. Mirrors the\n * `MigrateSmrtSchemasResult.hasManualDrift` shortcut.\n */\n hasManualDrift: boolean;\n}\n\n/**\n * Compute the SQL statements needed to bring `db` in sync with the\n * currently-registered SMRT object schemas. Pure inspection — no writes.\n *\n * Useful for status commands (\"how far behind is the schema?\") and as the\n * inner step of `migrateSmrtSchemas`.\n *\n * **Note on the returned `statements`:** these are the engine-correct DDL\n * statements as the differ + DDL strategy produce them, *prior to* any\n * Postgres-specific rewrites the `MigrationTracker` applies at execution\n * time (e.g. rewriting `CREATE INDEX` → `CREATE INDEX CONCURRENTLY` and\n * moving CONCURRENTLY statements outside the surrounding transaction).\n * The list is suitable for preview/status/\"what would change?\" use cases.\n * For the exact byte-for-byte SQL the tracker ran, inspect the\n * `MigrationResult`s from `migrateSmrtSchemas` or the tracker's own logs.\n */\nexport async function getPendingSchemaStatements(\n db: DatabaseInterface,\n options: {\n engineHint?: DatabaseEngine;\n postgresTimestampMigration?: { legacyTimezone: 'UTC' };\n emptyTextAsNull?: boolean;\n /** Explicitly allow relaxing live column constraints. */\n relaxColumns?: boolean;\n } = {},\n): Promise<PendingSchemaStatementsResult> {\n const schemas = ObjectRegistry.getAllSchemasAsDefinitions();\n // Forward engineHint into the diff itself so the SchemaComparer's\n // existing-table SQL (ALTER/index drift) uses the same DDL strategy\n // we use for newly-added tables — otherwise the two halves of the\n // statement list can land on different engines (split-brain) when\n // `db.url` is empty or ambiguous.\n const diff = await generateSchemaDiff(db, schemas, {\n engineHint: options.engineHint,\n postgresTimestampMigration: options.postgresTimestampMigration,\n emptyTextAsNull: options.emptyTextAsNull,\n relaxColumns: options.relaxColumns,\n });\n const statements = collectStatementsFromDiff(diff, db, options.engineHint);\n const unactionableChanges = collectUnactionableChanges(diff);\n return {\n diff,\n schemaCount: Object.keys(schemas).length,\n statements,\n hasChanges: hasActionableChanges(diff),\n unactionableChanges,\n hasManualDrift: unactionableChanges.length > 0,\n };\n}\n\n/**\n * Apply any pending schema changes from `ObjectRegistry` to `db`.\n *\n * Returns `applied: false` (with no error) if the database is already in\n * sync. Throws on the first migration failure with the underlying error\n * preserved (callers should let it propagate to the CLI).\n *\n * **Note on the returned `statements`:** mirrors `getPendingSchemaStatements`\n * — the list is the planned DDL, prior to Postgres-specific tracker rewrites\n * (CONCURRENTLY, transaction reordering). The actual executed SQL is\n * tracked in the returned `results`.\n *\n * **Note on `applied` semantics under `reconcile: true` (the default):**\n * the tracker may re-execute a migration that is already in `completed`\n * state when the checksum matches — that counts as `applied: true` in the\n * returned `MigrationResult`. So `result.applied === true` means \"the\n * tracker ran the migration\", not specifically \"the database changed for\n * the first time.\" Pass `reconcile: false` if you need to distinguish a\n * fresh apply from a reconcile-replay over an existing completed record.\n *\n * **Note on repeated failures:** the migration name defaults to a fresh\n * timestamp on every call, so a sequence of failed runs leaves one\n * `failed` row in `_smrt_schema_migrations` per attempt. Pass an explicit\n * `name` if you want to overwrite the same record across retries.\n *\n * **Note on `unactionableChanges`:** the differ can detect schema drift\n * the migration step can't auto-resolve — incompatible type mismatches,\n * SQLite type widening that needs table recreation, etc. Those changes\n * appear in `result.unactionableChanges` and are NOT applied (the\n * statements list is filtered to executable DDL only). When the diff\n * contains *only* unactionable changes, the function returns\n * `applied: false` with a populated `unactionableChanges` so callers can\n * surface a \"manual migration required\" signal. Without inspecting that\n * field, the result would look indistinguishable from \"already in sync.\"\n */\nexport async function migrateSmrtSchemas(\n options: MigrateSmrtSchemasOptions,\n): Promise<MigrateSmrtSchemasResult> {\n const pending = await getPendingSchemaStatements(options.db, {\n engineHint: options.engineHint,\n postgresTimestampMigration: options.postgresTimestampMigration,\n emptyTextAsNull: options.emptyTextAsNull,\n relaxColumns: options.relaxColumns,\n });\n if (!pending.hasChanges || pending.statements.length === 0) {\n return {\n applied: false,\n results: [],\n statements: [],\n schemaCount: pending.schemaCount,\n unactionableChanges: pending.unactionableChanges,\n hasManualDrift: pending.hasManualDrift,\n };\n }\n\n const tracker = new MigrationTracker({\n db: options.db,\n engineHint: options.engineHint,\n lockTimeout: options.lockTimeout,\n statementTimeout: options.statementTimeout,\n useConcurrentIndexes: options.useConcurrentIndexes ?? true,\n });\n const migration = createMigrationDefinition(\n options.name ?? `${generateMigrationTimestamp()}_smrt_schema_sync`,\n pending.statements,\n [],\n {\n description: options.description ?? 'Synchronize SMRT object schemas',\n packageName: options.packageName,\n version: options.version ?? 'unknown',\n },\n );\n\n const results = await tracker.applyAll([migration], {\n postgresSafe: options.postgresSafe ?? true,\n reconcile: options.reconcile ?? true,\n });\n\n const failed = results.find((result) => !result.success);\n if (failed) {\n throw failed.error instanceof Error\n ? failed.error\n : new Error(String(failed.error ?? `Migration ${failed.name} failed`));\n }\n\n const applied = results.some(\n (result) => result.applied !== false && result.skipped !== true,\n );\n\n return {\n applied,\n results,\n statements: pending.statements,\n schemaCount: pending.schemaCount,\n unactionableChanges: pending.unactionableChanges,\n hasManualDrift: pending.hasManualDrift,\n };\n}\n\n/** Mirror of the `getDatabaseUrl` helper in class.ts — some adapters\n * (JSON, certain in-memory wrappers) leave `db.url` undefined and expose\n * the URL on `db.config?.url` instead. Detecting the engine from a raw\n * `db.url` would throw or silently fall through to the sqlite default.\n */\nfunction resolveDatabaseUrl(db: DatabaseInterface): string {\n const dbWithConfig = db as DatabaseInterface & {\n config?: { url?: string };\n };\n return db.url || dbWithConfig.config?.url || '';\n}\n\n/**\n * Materialize the diff into the engine-correct SQL statements.\n *\n * For new tables we delegate to the engine's `DDLStrategy` so column types\n * (`REAL` → `DOUBLE PRECISION` on Postgres, `JSON` → `JSONB`, etc.),\n * partial-index `WHERE` clauses, and trigger syntax all match what the\n * SchemaComparer would expect on subsequent runs. Emitting the raw\n * abstract `SchemaDefinition.ddl` here would cause immediate type-drift\n * migrations on Postgres/DuckDB.\n *\n * Column/index/trigger changes for existing tables continue to come from\n * `getSQLFromDiff`, which is already engine-aware via the differ's per-\n * change `sqlStatements`.\n *\n * Exported for statement-ordering tests; it is not part of the package's\n * public entry point (`migrations/index.ts` re-exports names explicitly).\n *\n * @param diff - Schema diff to materialize.\n * @param db - Database whose URL selects the DDL strategy.\n * @param engineHint - Explicit engine when `db.url` is empty or ambiguous.\n * @returns Ordered, executable DDL statements.\n */\nexport function collectStatementsFromDiff(\n diff: Awaited<ReturnType<typeof generateSchemaDiff>>,\n db: DatabaseInterface,\n engineHint?: DatabaseEngine,\n): string[] {\n const strategy = getDDLStrategy(\n detectEngine(resolveDatabaseUrl(db), engineHint),\n );\n const statements: string[] = [];\n const tablePlan = planForeignKeyCreation(diff.added_tables, strategy.engine);\n // #2608: the pre-R11 uuid convergence has to precede *every* foreign-key\n // statement. `planForeignKeyCreation` only defers the constraints inside a\n // mutual cycle, so an acyclic new child table keeps its FK **inline in\n // CREATE TABLE** — a fresh `uuid` child pointing at a legacy `text` parent\n // would fail with SQLSTATE 42804 before the parent could be converted.\n // Conversions only ever rewrite columns that already exist, so they are\n // safe to run ahead of every CREATE TABLE in this batch.\n statements.push(\n ...getSQLFromChanges(\n diff.changes.filter((change) => change.phase === 'pre_foreign_key'),\n ),\n );\n for (const schema of tablePlan.schemas) {\n statements.push(strategy.generateCreateTable(schema));\n statements.push(...strategy.generateIndexes(schema));\n statements.push(...strategy.generateTriggers(schema));\n }\n statements.push(...tablePlan.deferredStatements);\n statements.push(\n ...getSQLFromChanges(\n diff.changes.filter((change) => change.phase !== 'pre_foreign_key'),\n ),\n );\n // Drop empty and comment-only entries. SQLite type-widening upgrades\n // surface as `-- SQLite: Type upgrade for X requires table recreation`\n // — passing those through to the tracker records a successful migration\n // without actually fixing the column, so the same drift re-appears on\n // every subsequent run. Those changes are surfaced separately via\n // `unactionableChanges` so callers can prompt for manual remediation.\n return statements.filter((statement) => {\n const trimmed = statement.trim();\n if (trimmed.length === 0) return false;\n return !isCommentOnlySql(trimmed);\n });\n}\n\n/**\n * True if `sql` contains no executable DDL — only line comments (`--`)\n * and/or block comments (`/* … */`). Used to discard advisory comments\n * the differ emits when a column upgrade can't be performed in place\n * (e.g. SQLite type widening). Defensive: strips block comments first so\n * a future differ that emits `/* type_upgrade: no-op */` doesn't slip\n * through.\n */\nfunction isCommentOnlySql(sql: string): boolean {\n // Strip /* ... */ block comments (non-greedy, spans newlines).\n const withoutBlockComments = sql.replace(/\\/\\*[\\s\\S]*?\\*\\//g, '');\n const lines = withoutBlockComments\n .split('\\n')\n .map((line) => line.trim())\n .filter((line) => line.length > 0);\n if (lines.length === 0) return sql.trim().length > 0;\n return lines.every((line) => line.startsWith('--'));\n}\n\n/**\n * True if `sql` is an advisory comment that says *nothing needs to happen* —\n * as opposed to one that says a human has to act.\n *\n * The differ emits both kinds. \"This SQLite column already stores JSON as\n * TEXT\" and \"#2370: this column is rebuilt together with its sibling\" are\n * no-ops; \"requires table recreation\" / \"blocked: …\" are real manual drift.\n * Treating the first kind as drift makes a fully auto-repairable migration\n * report `hasManualDrift: true`.\n *\n * Keep in sync with `classifyTypeUpgradeSql`'s `noop` arm in\n * `@happyvertical/smrt-cli` (`src/commands/db-migrate-actions.ts`), which\n * applies the same rule on the CLI side.\n */\nfunction isNoOpAdvisorySql(sql: string): boolean {\n const trimmed = sql.trim();\n return (\n /no change needed/i.test(trimmed) || /already stores .* as /i.test(trimmed)\n );\n}\n\n/**\n * Surface changes the differ produced but that the migrator cannot apply\n * automatically — `type_mismatch` entries (the differ explicitly gives up on\n * these), `type_upgrade`/`alter_column` entries whose generated SQL is\n * advisory-comment-only (a SQLite rebuild that would be unsafe, an in-place\n * constraint change SQLite cannot perform), and warning-level report-only\n * advisories (an orphan NOT NULL column that will break inserts, a required\n * column that could not be enforced, a stale unique constraint, a relaxation\n * the caller has not opted into). Info-level advisories (a harmless orphan\n * column, a stale default) and comments that mean \"already handled\" (the\n * no-op advisories a table rebuild leaves on covered siblings, #2370) are\n * deliberately excluded so `hasManualDrift` keeps meaning \"something needs\n * an operator\". Callers use this to distinguish \"schema is in sync\" from\n * \"schema is drifted but we can't fix it from here\".\n */\nfunction collectUnactionableChanges(\n diff: Awaited<ReturnType<typeof generateSchemaDiff>>,\n): SchemaChange[] {\n const unactionable: SchemaChange[] = [];\n for (const change of diff.changes) {\n if (change.type === 'type_mismatch') {\n unactionable.push(change);\n continue;\n }\n const statements = change.sqlStatements ?? (change.sql ? [change.sql] : []);\n if (statements.length === 0) {\n if (change.advisory?.severity === 'warning') {\n unactionable.push(change);\n }\n continue;\n }\n if (\n statements.every((stmt) => isCommentOnlySql(stmt.trim())) &&\n !statements.every((stmt) => isNoOpAdvisorySql(stmt))\n ) {\n unactionable.push(change);\n }\n }\n return unactionable;\n}\n"],"mappings":"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"}
|
|
@@ -62,6 +62,12 @@
|
|
|
62
62
|
* the change stays a manual intervention. That includes the target's own
|
|
63
63
|
* self-references, because the staging table copies them and so becomes a
|
|
64
64
|
* child of the table being dropped.
|
|
65
|
+
*
|
|
66
|
+
* #4413: explicit ordinary NOT NULL relaxation uses the same live-DDL rewrite,
|
|
67
|
+
* but carries SQLITE_RELAX_REBUILD_MARKER to MigrationTracker. The tracker owns
|
|
68
|
+
* FK suspension outside the transaction, validation before commit and setting
|
|
69
|
+
* restoration. That path safely supports inbound references. Type-only rebuilds
|
|
70
|
+
* retain the refusal above; ambiguous relaxation shapes remain manual.
|
|
65
71
|
*/
|
|
66
72
|
import type { SchemaChange, SQLDataType } from '../schema/types.js';
|
|
67
73
|
import type { DatabaseInterface } from './types.js';
|
|
@@ -72,6 +78,8 @@ import type { DatabaseInterface } from './types.js';
|
|
|
72
78
|
* mistaken for user data.
|
|
73
79
|
*/
|
|
74
80
|
export declare const SQLITE_REBUILD_TABLE_PREFIX = "_smrt_rebuild_";
|
|
81
|
+
/** Executable handoff to MigrationTracker: suspend FKs outside its transaction. */
|
|
82
|
+
export declare const SQLITE_RELAX_REBUILD_MARKER = "PRAGMA foreign_keys = OFF";
|
|
75
83
|
/**
|
|
76
84
|
* The advisory SQL the differ emits for a SQLite type upgrade it cannot
|
|
77
85
|
* perform with an `ALTER TABLE`. `planSqliteTableRebuilds` replaces it with an
|
|
@@ -167,6 +175,8 @@ export interface BuildSqliteRebuildStatementsInput {
|
|
|
167
175
|
* copy expression that fills their NULLs.
|
|
168
176
|
*/
|
|
169
177
|
tightenColumns?: Map<string, string>;
|
|
178
|
+
/** Explicit NOT NULL relaxations; all other clauses remain unchanged. */
|
|
179
|
+
nullableColumns?: Set<string>;
|
|
170
180
|
}
|
|
171
181
|
/** A column a rebuild appends to the table (#3008). */
|
|
172
182
|
export interface SqliteAppendedColumn {
|
|
@@ -212,6 +222,7 @@ export declare function rewriteSqliteCreateTable(createTableSql: string, target:
|
|
|
212
222
|
columnTypes: Map<string, string>;
|
|
213
223
|
/** #3008: columns whose definition gains `NOT NULL`. */
|
|
214
224
|
notNullColumns?: Set<string>;
|
|
225
|
+
nullableColumns?: Set<string>;
|
|
215
226
|
/** #3008: column definitions appended after the existing columns. */
|
|
216
227
|
appendDefinitions?: string[];
|
|
217
228
|
}): string | null;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sqlite-rebuild.d.ts","sourceRoot":"","sources":["../../src/migrations/sqlite-rebuild.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"sqlite-rebuild.d.ts","sourceRoot":"","sources":["../../src/migrations/sqlite-rebuild.ts"],"names":[],"mappings":"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"}
|
|
@@ -65,6 +65,12 @@ import { createLogger } from "@happyvertical/logger";
|
|
|
65
65
|
* the change stays a manual intervention. That includes the target's own
|
|
66
66
|
* self-references, because the staging table copies them and so becomes a
|
|
67
67
|
* child of the table being dropped.
|
|
68
|
+
*
|
|
69
|
+
* #4413: explicit ordinary NOT NULL relaxation uses the same live-DDL rewrite,
|
|
70
|
+
* but carries SQLITE_RELAX_REBUILD_MARKER to MigrationTracker. The tracker owns
|
|
71
|
+
* FK suspension outside the transaction, validation before commit and setting
|
|
72
|
+
* restoration. That path safely supports inbound references. Type-only rebuilds
|
|
73
|
+
* retain the refusal above; ambiguous relaxation shapes remain manual.
|
|
68
74
|
*/
|
|
69
75
|
var logger = createLogger({ level: "info" });
|
|
70
76
|
/**
|
|
@@ -74,6 +80,8 @@ var logger = createLogger({ level: "info" });
|
|
|
74
80
|
* mistaken for user data.
|
|
75
81
|
*/
|
|
76
82
|
var SQLITE_REBUILD_TABLE_PREFIX = "_smrt_rebuild_";
|
|
83
|
+
/** Executable handoff to MigrationTracker: suspend FKs outside its transaction. */
|
|
84
|
+
var SQLITE_RELAX_REBUILD_MARKER = "PRAGMA foreign_keys = OFF";
|
|
77
85
|
/**
|
|
78
86
|
* The advisory SQL the differ emits for a SQLite type upgrade it cannot
|
|
79
87
|
* perform with an `ALTER TABLE`. `planSqliteTableRebuilds` replaces it with an
|
|
@@ -137,7 +145,12 @@ async function planSqliteTableRebuilds(options) {
|
|
|
137
145
|
const { db, tableName, changes, mapType } = options;
|
|
138
146
|
const retypes = changes.filter((change) => change.type === "type_upgrade" && change.name && change.column && isSqliteRebuildPlaceholder(change.sql));
|
|
139
147
|
const backfills = changes.filter((change) => (change.type === "add_column" || change.type === "alter_column") && change.name && typeof change.column?.backfill === "string" && isSqliteBackfillPlaceholder(change.sql));
|
|
140
|
-
const
|
|
148
|
+
const relaxations = changes.filter((change) => change.type === "alter_column" && change.alteration === "drop_not_null" && change.name && !change.advisory && change.sql?.includes("requires table recreation"));
|
|
149
|
+
const pending = [
|
|
150
|
+
...retypes,
|
|
151
|
+
...backfills,
|
|
152
|
+
...relaxations
|
|
153
|
+
];
|
|
141
154
|
if (pending.length === 0) return changes;
|
|
142
155
|
const block = (reason) => {
|
|
143
156
|
logger.debug(`[SQLiteRebuild] Cannot rebuild ${tableName}: ${reason}; leaving ${pending.length} type upgrade(s) as manual drift`);
|
|
@@ -153,8 +166,9 @@ async function planSqliteTableRebuilds(options) {
|
|
|
153
166
|
return block(`live schema introspection failed (${error instanceof Error ? error.message : String(error)})`);
|
|
154
167
|
}
|
|
155
168
|
if (!context.createTableSql) return block("no CREATE TABLE statement found in sqlite_master");
|
|
169
|
+
if (relaxations.length && /\bAUTOINCREMENT\b/i.test(context.createTableSql)) return block("AUTOINCREMENT sequence preservation is not supported for nullable relaxation");
|
|
156
170
|
if (context.columns.length === 0) return block("PRAGMA table_info returned no columns");
|
|
157
|
-
if (context.foreignKeysEnabled && context.inboundReferences.length > 0) return block(`PRAGMA foreign_keys is ON and ${context.inboundReferences.map((name) => `"${name}"`).join(", ")} declare a foreign key onto this table, so DROP TABLE would fire their ON DELETE actions`);
|
|
171
|
+
if (context.foreignKeysEnabled && context.inboundReferences.length > 0 && relaxations.length === 0) return block(`PRAGMA foreign_keys is ON and ${context.inboundReferences.map((name) => `"${name}"`).join(", ")} declare a foreign key onto this table, so DROP TABLE would fire their ON DELETE actions`);
|
|
158
172
|
const columnTypes = /* @__PURE__ */ new Map();
|
|
159
173
|
for (const change of retypes) columnTypes.set(change.name, mapType(change.column.type));
|
|
160
174
|
const appendColumns = [];
|
|
@@ -182,11 +196,16 @@ async function planSqliteTableRebuilds(options) {
|
|
|
182
196
|
objects: context.objects,
|
|
183
197
|
legacyAlterTable: context.legacyAlterTable,
|
|
184
198
|
appendColumns,
|
|
185
|
-
tightenColumns
|
|
199
|
+
tightenColumns,
|
|
200
|
+
nullableColumns: new Set(relaxations.map((change) => change.name))
|
|
186
201
|
});
|
|
187
202
|
if (!plan) return block("the live CREATE TABLE statement could not be rewritten for the target column types");
|
|
188
203
|
const [primary, ...covered] = pending;
|
|
189
|
-
const statements = [
|
|
204
|
+
const statements = [
|
|
205
|
+
...relaxations.length ? [SQLITE_RELAX_REBUILD_MARKER] : [],
|
|
206
|
+
...plan.statements,
|
|
207
|
+
...followUps
|
|
208
|
+
];
|
|
190
209
|
const rebuilt = changes.map((change) => {
|
|
191
210
|
if (change === primary) return {
|
|
192
211
|
...change,
|
|
@@ -289,22 +308,41 @@ function readPragmaFlag(result) {
|
|
|
289
308
|
* documented in `packages/core/agents/schema-paths.md`.
|
|
290
309
|
*/
|
|
291
310
|
function buildSqliteRebuildStatements(input) {
|
|
292
|
-
const { tableName, createTableSql, columnTypes, columns, objects, legacyAlterTable, appendColumns = [], tightenColumns = /* @__PURE__ */ new Map() } = input;
|
|
311
|
+
const { tableName, createTableSql, columnTypes, columns, objects, legacyAlterTable, appendColumns = [], tightenColumns = /* @__PURE__ */ new Map(), nullableColumns = /* @__PURE__ */ new Set() } = input;
|
|
293
312
|
const stagingTable = `${SQLITE_REBUILD_TABLE_PREFIX}${tableName}`;
|
|
294
313
|
const createStaging = rewriteSqliteCreateTable(createTableSql, {
|
|
295
314
|
tableName: stagingTable,
|
|
296
315
|
columnTypes,
|
|
297
316
|
notNullColumns: new Set(tightenColumns.keys()),
|
|
317
|
+
nullableColumns,
|
|
298
318
|
appendDefinitions: appendColumns.map((column) => column.definition)
|
|
299
319
|
});
|
|
300
320
|
if (!createStaging) return null;
|
|
301
321
|
const liveColumns = new Set(columns.map((name) => name.toLowerCase()));
|
|
302
322
|
for (const name of tightenColumns.keys()) if (!liveColumns.has(name.toLowerCase())) return null;
|
|
303
323
|
for (const column of appendColumns) if (liveColumns.has(column.name.toLowerCase())) return null;
|
|
324
|
+
let rowidColumns = [];
|
|
325
|
+
if (nullableColumns.size && !/\bWITHOUT\s+ROWID\b/i.test(parseCreateTableBody(createTableSql)?.tail ?? "")) {
|
|
326
|
+
const alias = [
|
|
327
|
+
"rowid",
|
|
328
|
+
"_rowid_",
|
|
329
|
+
"oid"
|
|
330
|
+
].find((name) => !liveColumns.has(name));
|
|
331
|
+
if (!alias) return null;
|
|
332
|
+
rowidColumns = [alias];
|
|
333
|
+
}
|
|
304
334
|
const quotedTable = quoteIdentifier(tableName);
|
|
305
335
|
const quotedStaging = quoteIdentifier(stagingTable);
|
|
306
|
-
const targetList = [
|
|
307
|
-
|
|
336
|
+
const targetList = [
|
|
337
|
+
...rowidColumns,
|
|
338
|
+
...columns,
|
|
339
|
+
...appendColumns.map((c) => c.name)
|
|
340
|
+
].map((name) => quoteIdentifier(name)).join(", ");
|
|
341
|
+
const sourceList = [
|
|
342
|
+
...rowidColumns.map(quoteIdentifier),
|
|
343
|
+
...columns.map((name) => tightenColumns.get(name) ?? quoteIdentifier(name)),
|
|
344
|
+
...appendColumns.map((column) => column.copyExpression)
|
|
345
|
+
].join(", ");
|
|
308
346
|
return {
|
|
309
347
|
statements: [
|
|
310
348
|
"PRAGMA defer_foreign_keys = ON",
|
|
@@ -339,12 +377,19 @@ function rewriteSqliteCreateTable(createTableSql, target) {
|
|
|
339
377
|
const wanted = /* @__PURE__ */ new Map();
|
|
340
378
|
for (const [name, type] of target.columnTypes) wanted.set(name.toLowerCase(), type);
|
|
341
379
|
const tighten = new Set([...target.notNullColumns ?? []].map((name) => name.toLowerCase()));
|
|
380
|
+
const nullable = new Set([...target.nullableColumns ?? []].map((name) => name.toLowerCase()));
|
|
342
381
|
const rewritten = [];
|
|
343
382
|
let lastColumnIndex = -1;
|
|
344
383
|
for (const item of parsed.items) {
|
|
345
384
|
const columnName = readColumnDefinitionName(item);
|
|
346
385
|
if (columnName) lastColumnIndex = rewritten.length;
|
|
347
386
|
let current = item;
|
|
387
|
+
if (columnName && nullable.has(columnName.toLowerCase())) {
|
|
388
|
+
const relaxed = removeColumnNotNull(current);
|
|
389
|
+
if (relaxed === null) return null;
|
|
390
|
+
current = relaxed;
|
|
391
|
+
nullable.delete(columnName.toLowerCase());
|
|
392
|
+
}
|
|
348
393
|
if (columnName && tighten.has(columnName.toLowerCase())) {
|
|
349
394
|
tighten.delete(columnName.toLowerCase());
|
|
350
395
|
if (!/\bnot\s+null\b/i.test(current)) current = `${current} NOT NULL`;
|
|
@@ -359,12 +404,50 @@ function rewriteSqliteCreateTable(createTableSql, target) {
|
|
|
359
404
|
rewritten.push(replaced);
|
|
360
405
|
wanted.delete(columnName.toLowerCase());
|
|
361
406
|
}
|
|
362
|
-
if (tighten.size > 0) return null;
|
|
407
|
+
if (tighten.size > 0 || nullable.size > 0) return null;
|
|
363
408
|
rewritten.splice(lastColumnIndex + 1, 0, ...target.appendDefinitions ?? []);
|
|
364
409
|
if (wanted.size > 0) return null;
|
|
365
410
|
const tail = parsed.tail ? ` ${parsed.tail}` : "";
|
|
366
411
|
return `CREATE TABLE ${quoteIdentifier(target.tableName)} (\n ${rewritten.join(",\n ")}\n)${tail}`;
|
|
367
412
|
}
|
|
413
|
+
/** Refuse named/conflict/PK constraints instead of changing their semantics. */
|
|
414
|
+
function removeColumnNotNull(item) {
|
|
415
|
+
const identifier = readLeadingIdentifier(item);
|
|
416
|
+
if (!identifier) return null;
|
|
417
|
+
const words = [];
|
|
418
|
+
let depth = 0;
|
|
419
|
+
for (let index = identifier.end; index < item.length;) {
|
|
420
|
+
const skipped = skipQuotedOrComment(item, index);
|
|
421
|
+
if (skipped !== null) {
|
|
422
|
+
index = skipped;
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
const char = item[index];
|
|
426
|
+
if (char === "(") depth++;
|
|
427
|
+
if (char === ")") depth--;
|
|
428
|
+
const word = /^[A-Za-z_][\w$]*/.exec(item.slice(index));
|
|
429
|
+
if (word) {
|
|
430
|
+
if (depth === 0) words.push({
|
|
431
|
+
text: word[0].toLowerCase(),
|
|
432
|
+
start: index,
|
|
433
|
+
end: index + word[0].length
|
|
434
|
+
});
|
|
435
|
+
index += word[0].length;
|
|
436
|
+
} else index++;
|
|
437
|
+
}
|
|
438
|
+
if (words.some((word) => [
|
|
439
|
+
"constraint",
|
|
440
|
+
"primary",
|
|
441
|
+
"generated",
|
|
442
|
+
"as"
|
|
443
|
+
].includes(word.text))) return null;
|
|
444
|
+
const matches = words.flatMap((word, i) => word.text === "not" && words[i + 1]?.text === "null" ? [i] : []);
|
|
445
|
+
if (matches.length !== 1) return null;
|
|
446
|
+
const i = matches[0];
|
|
447
|
+
if (words[i + 2]?.text === "on") return null;
|
|
448
|
+
if (!/^not\s+null$/i.test(item.slice(words[i].start, words[i + 1].end))) return null;
|
|
449
|
+
return item.slice(0, words[i].start) + item.slice(words[i + 1].end);
|
|
450
|
+
}
|
|
368
451
|
/**
|
|
369
452
|
* Split a `CREATE TABLE` statement into its top-level definition items plus
|
|
370
453
|
* any trailing table options, honoring SQLite's quoting (`'…'`, `"…"`,
|
|
@@ -564,6 +647,6 @@ function replaceColumnType(item, newType) {
|
|
|
564
647
|
return `${head} ${newType}${rest.length === 0 || /^\s/.test(rest) ? "" : " "}${rest}`;
|
|
565
648
|
}
|
|
566
649
|
//#endregion
|
|
567
|
-
export { SQLITE_REBUILD_TABLE_PREFIX, buildSqliteRebuildStatements, isSqliteBackfillPlaceholder, isSqliteRebuildPlaceholder, parseCreateTableBody, planSqliteTableRebuilds, readColumnDefinitionName, replaceColumnType, rewriteSqliteCreateTable, sqliteBackfillPlaceholderSql, sqliteRebuildPlaceholderSql };
|
|
650
|
+
export { SQLITE_REBUILD_TABLE_PREFIX, SQLITE_RELAX_REBUILD_MARKER, buildSqliteRebuildStatements, isSqliteBackfillPlaceholder, isSqliteRebuildPlaceholder, parseCreateTableBody, planSqliteTableRebuilds, readColumnDefinitionName, replaceColumnType, rewriteSqliteCreateTable, sqliteBackfillPlaceholderSql, sqliteRebuildPlaceholderSql };
|
|
568
651
|
|
|
569
652
|
//# sourceMappingURL=sqlite-rebuild.js.map
|