@intentius/chant-lexicon-sql 0.124.46 → 0.124.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/change-set.d.ts +1 -1
- package/dist/change-set.d.ts.map +1 -1
- package/dist/change-set.js +2 -1
- package/dist/clickhouse/apply/statements.d.ts +9 -0
- package/dist/clickhouse/apply/statements.d.ts.map +1 -1
- package/dist/clickhouse/apply/statements.js +24 -0
- package/dist/clickhouse/import/live-export.d.ts +7 -3
- package/dist/clickhouse/import/live-export.d.ts.map +1 -1
- package/dist/clickhouse/import/live-export.js +21 -5
- package/dist/clickhouse/live/named-collections.d.ts +27 -0
- package/dist/clickhouse/live/named-collections.d.ts.map +1 -0
- package/dist/clickhouse/live/named-collections.js +53 -0
- package/dist/clickhouse/live/undeclarable.d.ts +15 -3
- package/dist/clickhouse/live/undeclarable.d.ts.map +1 -1
- package/dist/clickhouse/live/undeclarable.js +18 -4
- package/dist/clickhouse/named-collections.d.ts +83 -0
- package/dist/clickhouse/named-collections.d.ts.map +1 -0
- package/dist/clickhouse/named-collections.js +203 -0
- package/dist/clickhouse/parser.d.ts.map +1 -1
- package/dist/clickhouse/parser.js +1 -1
- package/dist/clickhouse/plan/commands.d.ts +2 -1
- package/dist/clickhouse/plan/commands.d.ts.map +1 -1
- package/dist/clickhouse/plan/commands.js +17 -2
- package/dist/clickhouse/plan/diff.d.ts +15 -0
- package/dist/clickhouse/plan/diff.d.ts.map +1 -1
- package/dist/clickhouse/plan/diff.js +43 -1
- package/dist/clickhouse/plan/disruption.d.ts.map +1 -1
- package/dist/clickhouse/plan/disruption.js +5 -0
- package/dist/clickhouse/plan/rebuild-handoff.d.ts.map +1 -1
- package/dist/clickhouse/plan/rebuild-handoff.js +4 -0
- package/dist/clickhouse/plan/report.d.ts.map +1 -1
- package/dist/clickhouse/plan/report.js +6 -1
- package/dist/clickhouse/plan/rules.d.ts +5 -1
- package/dist/clickhouse/plan/rules.d.ts.map +1 -1
- package/dist/clickhouse/plan/rules.js +7 -3
- package/dist/clickhouse/plan/stream.d.ts +40 -0
- package/dist/clickhouse/plan/stream.d.ts.map +1 -0
- package/dist/clickhouse/plan/stream.js +107 -0
- package/dist/clickhouse/rebuild/observe.d.ts +3 -1
- package/dist/clickhouse/rebuild/observe.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/observe.js +14 -1
- package/dist/clickhouse/review-catalog.d.ts +213 -0
- package/dist/clickhouse/review-catalog.d.ts.map +1 -0
- package/dist/clickhouse/review-catalog.js +330 -0
- package/dist/codegen/classifier-page.d.ts.map +1 -1
- package/dist/codegen/classifier-page.js +21 -4
- package/dist/codegen/clickhouse-module.d.ts.map +1 -1
- package/dist/codegen/clickhouse-module.js +3 -0
- package/dist/codegen/coverage-page.d.ts.map +1 -1
- package/dist/codegen/coverage-page.js +5 -3
- package/dist/codegen/docs.js +1 -1
- package/dist/codegen/postgres-classifier-page.js +1 -1
- package/dist/codegen/registry.d.ts.map +1 -1
- package/dist/codegen/registry.js +5 -0
- package/dist/composites/catalog.d.ts.map +1 -1
- package/dist/composites/catalog.js +100 -0
- package/dist/composites/clickhouse.d.ts +2 -0
- package/dist/composites/clickhouse.d.ts.map +1 -1
- package/dist/composites/clickhouse.js +2 -0
- package/dist/composites/otel-tables.d.ts +100 -0
- package/dist/composites/otel-tables.d.ts.map +1 -0
- package/dist/composites/otel-tables.js +282 -0
- package/dist/composites/stream-ingest.d.ts +63 -0
- package/dist/composites/stream-ingest.d.ts.map +1 -0
- package/dist/composites/stream-ingest.js +39 -0
- package/dist/coverage/clickhouse.json +1 -1
- package/dist/coverage/postgres.json +7 -7
- package/dist/coverage.d.ts +16 -6
- package/dist/coverage.d.ts.map +1 -1
- package/dist/coverage.js +10 -5
- package/dist/drizzle-command.d.ts +17 -0
- package/dist/drizzle-command.d.ts.map +1 -0
- package/dist/drizzle-command.js +91 -0
- package/dist/drizzle.d.ts +70 -0
- package/dist/drizzle.d.ts.map +1 -0
- package/dist/drizzle.js +684 -0
- package/dist/files/clickhouse.d.ts.map +1 -1
- package/dist/files/clickhouse.js +8 -1
- package/dist/files/common.d.ts +13 -0
- package/dist/files/common.d.ts.map +1 -1
- package/dist/files/common.js +5 -0
- package/dist/files/postgres.js +12 -2
- package/dist/generated/clickhouse.d.ts +2 -0
- package/dist/generated/clickhouse.d.ts.map +1 -1
- package/dist/generated/clickhouse.js +2 -0
- package/dist/import-parser.d.ts +21 -1
- package/dist/import-parser.d.ts.map +1 -1
- package/dist/import-parser.js +79 -5
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/integrity.json +6 -5
- package/dist/lint/post-synth/audit-entries.d.ts.map +1 -1
- package/dist/lint/post-synth/audit-entries.js +1 -0
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/index.js +2 -0
- package/dist/lint/post-synth/sqlch128.d.ts +18 -0
- package/dist/lint/post-synth/sqlch128.d.ts.map +1 -0
- package/dist/lint/post-synth/sqlch128.js +222 -0
- package/dist/lint/rules/postgres-templates.js +2 -2
- package/dist/lsp/postgres-template.d.ts.map +1 -1
- package/dist/lsp/postgres-template.js +11 -0
- package/dist/manifest.json +1 -1
- package/dist/mcp/postgres.js +1 -1
- package/dist/meta.json +25 -0
- package/dist/migrate/from-postgres/detect.d.ts +4 -0
- package/dist/migrate/from-postgres/detect.d.ts.map +1 -0
- package/dist/migrate/from-postgres/detect.js +7 -0
- package/dist/migrate/from-postgres/emit.d.ts +26 -0
- package/dist/migrate/from-postgres/emit.d.ts.map +1 -0
- package/dist/migrate/from-postgres/emit.js +86 -0
- package/dist/migrate/from-postgres/index.d.ts +51 -0
- package/dist/migrate/from-postgres/index.d.ts.map +1 -0
- package/dist/migrate/from-postgres/index.js +115 -0
- package/dist/migrate/from-postgres/translate.d.ts +73 -0
- package/dist/migrate/from-postgres/translate.d.ts.map +1 -0
- package/dist/migrate/from-postgres/translate.js +261 -0
- package/dist/migrate/from-postgres/types.d.ts +51 -0
- package/dist/migrate/from-postgres/types.d.ts.map +1 -0
- package/dist/migrate/from-postgres/types.js +211 -0
- package/dist/migration-replay.d.ts +73 -0
- package/dist/migration-replay.d.ts.map +1 -0
- package/dist/migration-replay.js +294 -0
- package/dist/okf/index.md +6 -0
- package/dist/okf/rules/SQLCH128.md +11 -0
- package/dist/okf/types/PostgresForeignDataWrapper.md +9 -0
- package/dist/okf/types/PostgresForeignServer.md +9 -0
- package/dist/okf/types/PostgresOperator.md +9 -0
- package/dist/okf/types/PostgresPublication.md +9 -0
- package/dist/okf/types/PostgresStatistics.md +9 -0
- package/dist/op/activities/postgres-migration.d.ts.map +1 -1
- package/dist/op/activities/postgres-migration.js +4 -0
- package/dist/op/activity-contracts.d.ts +20 -0
- package/dist/op/activity-contracts.d.ts.map +1 -1
- package/dist/op/activity-contracts.js +2 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +36 -2
- package/dist/postgres/apply/apply.js +8 -1
- package/dist/postgres/apply/statements.d.ts.map +1 -1
- package/dist/postgres/apply/statements.js +87 -2
- package/dist/postgres/constraint-names.d.ts +30 -0
- package/dist/postgres/constraint-names.d.ts.map +1 -0
- package/dist/postgres/constraint-names.js +72 -0
- package/dist/postgres/entities.d.ts +110 -1
- package/dist/postgres/entities.d.ts.map +1 -1
- package/dist/postgres/entities.js +146 -0
- package/dist/postgres/entity-types.d.ts +5 -0
- package/dist/postgres/entity-types.d.ts.map +1 -1
- package/dist/postgres/entity-types.js +5 -0
- package/dist/postgres/import/generator.d.ts.map +1 -1
- package/dist/postgres/import/generator.js +50 -2
- package/dist/postgres/import/live-export.d.ts.map +1 -1
- package/dist/postgres/import/live-export.js +2 -0
- package/dist/postgres/live/catalog-kinds.d.ts +14 -0
- package/dist/postgres/live/catalog-kinds.d.ts.map +1 -1
- package/dist/postgres/live/catalog-kinds.js +206 -0
- package/dist/postgres/live/catalog.d.ts +2 -0
- package/dist/postgres/live/catalog.d.ts.map +1 -1
- package/dist/postgres/live/catalog.js +3 -1
- package/dist/postgres/live/describe-resources.d.ts.map +1 -1
- package/dist/postgres/live/describe-resources.js +10 -2
- package/dist/postgres/migrate/backfill.d.ts +2 -0
- package/dist/postgres/migrate/backfill.d.ts.map +1 -1
- package/dist/postgres/migrate/backfill.js +7 -1
- package/dist/postgres/migrate/carry.d.ts +45 -1
- package/dist/postgres/migrate/carry.d.ts.map +1 -1
- package/dist/postgres/migrate/carry.js +72 -8
- package/dist/postgres/migrate/observe.d.ts +28 -4
- package/dist/postgres/migrate/observe.d.ts.map +1 -1
- package/dist/postgres/migrate/observe.js +41 -12
- package/dist/postgres/migrate/op.d.ts +26 -1
- package/dist/postgres/migrate/op.d.ts.map +1 -1
- package/dist/postgres/migrate/op.js +3 -1
- package/dist/postgres/migrate/steps.d.ts +35 -2
- package/dist/postgres/migrate/steps.d.ts.map +1 -1
- package/dist/postgres/migrate/steps.js +158 -12
- package/dist/postgres/parser.d.ts +72 -1
- package/dist/postgres/parser.d.ts.map +1 -1
- package/dist/postgres/parser.js +194 -2
- package/dist/postgres/plan/deep.d.ts.map +1 -1
- package/dist/postgres/plan/deep.js +29 -1
- package/dist/postgres/plan/diff.d.ts.map +1 -1
- package/dist/postgres/plan/diff.js +39 -1
- package/dist/postgres/plan/normalize.d.ts +17 -2
- package/dist/postgres/plan/normalize.d.ts.map +1 -1
- package/dist/postgres/plan/normalize.js +109 -1
- package/dist/postgres/plan/rules.d.ts +1 -0
- package/dist/postgres/plan/rules.d.ts.map +1 -1
- package/dist/postgres/plan/rules.js +3 -2
- package/dist/postgres/plan/schema.js +1 -1
- package/dist/postgres/secret-options.d.ts +10 -0
- package/dist/postgres/secret-options.d.ts.map +1 -0
- package/dist/postgres/secret-options.js +9 -0
- package/dist/postgres/testing/roundtrip-schema.d.ts +3 -2
- package/dist/postgres/testing/roundtrip-schema.d.ts.map +1 -1
- package/dist/postgres/testing/roundtrip-schema.js +13 -1
- package/dist/postgres.d.ts +1 -1
- package/dist/postgres.d.ts.map +1 -1
- package/dist/postgres.js +1 -1
- package/dist/rules/audit-entries.ts +1 -0
- package/dist/rules/postgres-templates.ts +2 -2
- package/dist/rules/sqlch128.ts +230 -0
- package/dist/serializer.d.ts +5 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/serializer.js +13 -2
- package/dist/sql-types-command.d.ts +13 -0
- package/dist/sql-types-command.d.ts.map +1 -1
- package/dist/sql-types-command.js +31 -16
- package/dist/sql-types.d.ts +22 -0
- package/dist/sql-types.d.ts.map +1 -1
- package/dist/sql-types.js +10 -6
- package/dist/sqlserver/lexer.d.ts +37 -0
- package/dist/sqlserver/lexer.d.ts.map +1 -0
- package/dist/sqlserver/lexer.js +254 -0
- package/dist/sqlserver/parse.d.ts +185 -0
- package/dist/sqlserver/parse.d.ts.map +1 -0
- package/dist/sqlserver/parse.js +767 -0
- package/dist/sqlserver/translate.d.ts +34 -0
- package/dist/sqlserver/translate.d.ts.map +1 -0
- package/dist/sqlserver/translate.js +495 -0
- package/dist/sqlserver/types.d.ts +44 -0
- package/dist/sqlserver/types.d.ts.map +1 -0
- package/dist/sqlserver/types.js +133 -0
- package/dist/sqlserver/validate.d.ts +31 -0
- package/dist/sqlserver/validate.d.ts.map +1 -0
- package/dist/sqlserver/validate.js +67 -0
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +5 -0
- package/package.json +2 -2
- package/src/change-set.ts +2 -1
- package/src/clickhouse/apply/statements.ts +25 -0
- package/src/clickhouse/import/live-export.ts +20 -5
- package/src/clickhouse/live/named-collections.e2e.test.ts +88 -0
- package/src/clickhouse/live/named-collections.ts +61 -0
- package/src/clickhouse/live/undeclarable.e2e.test.ts +5 -3
- package/src/clickhouse/live/undeclarable.ts +20 -4
- package/src/clickhouse/named-collections.test.ts +187 -0
- package/src/clickhouse/named-collections.ts +224 -0
- package/src/clickhouse/parser.ts +2 -1
- package/src/clickhouse/plan/commands.ts +17 -2
- package/src/clickhouse/plan/diff.ts +57 -1
- package/src/clickhouse/plan/disruption.ts +5 -0
- package/src/clickhouse/plan/rebuild-handoff.ts +3 -0
- package/src/clickhouse/plan/report.ts +6 -1
- package/src/clickhouse/plan/rules.ts +14 -4
- package/src/clickhouse/plan/stream-s3queue.e2e.test.ts +148 -0
- package/src/clickhouse/plan/stream.test.ts +237 -0
- package/src/clickhouse/plan/stream.ts +111 -0
- package/src/clickhouse/rebuild/observe.ts +15 -1
- package/src/clickhouse/review-catalog.test.ts +181 -0
- package/src/clickhouse/review-catalog.ts +489 -0
- package/src/codegen/classifier-page.ts +21 -4
- package/src/codegen/clickhouse-module.ts +3 -0
- package/src/codegen/coverage-page.ts +5 -3
- package/src/codegen/docs.ts +1 -1
- package/src/codegen/postgres-classifier-page.ts +1 -1
- package/src/codegen/registry.ts +5 -0
- package/src/composites/catalog.test.ts +6 -1
- package/src/composites/catalog.ts +100 -0
- package/src/composites/clickhouse.ts +2 -0
- package/src/composites/composites.test.ts +1 -1
- package/src/composites/otel-tables.test.ts +118 -0
- package/src/composites/otel-tables.ts +339 -0
- package/src/composites/stream-ingest.test.ts +114 -0
- package/src/composites/stream-ingest.ts +82 -0
- package/src/core/undeclarable.test.ts +6 -5
- package/src/coverage/clickhouse.json +1 -1
- package/src/coverage/postgres.json +7 -7
- package/src/coverage.test.ts +27 -2
- package/src/coverage.ts +18 -7
- package/src/drizzle-command.ts +94 -0
- package/src/drizzle-kit.e2e.test.ts +89 -0
- package/src/drizzle-types.e2e.test.ts +67 -0
- package/src/drizzle.test.ts +358 -0
- package/src/drizzle.ts +737 -0
- package/src/files/clickhouse.ts +8 -1
- package/src/files/common.ts +14 -1
- package/src/files/postgres.ts +12 -2
- package/src/generated/clickhouse.ts +3 -0
- package/src/generated/lexicon-sql.json +25 -0
- package/src/import-parser.sqlserver.test.ts +92 -0
- package/src/import-parser.ts +79 -5
- package/src/index.ts +1 -1
- package/src/lint/post-synth/audit-entries.ts +1 -0
- package/src/lint/post-synth/index.ts +2 -0
- package/src/lint/post-synth/sqlch128.test.ts +107 -0
- package/src/lint/post-synth/sqlch128.ts +230 -0
- package/src/lint/rules/postgres-templates.ts +2 -2
- package/src/lsp/postgres-template.ts +11 -0
- package/src/mcp/postgres.ts +1 -1
- package/src/migrate/from-postgres/cli.test.ts +165 -0
- package/src/migrate/from-postgres/detect.ts +7 -0
- package/src/migrate/from-postgres/emit.ts +96 -0
- package/src/migrate/from-postgres/example.test.ts +205 -0
- package/src/migrate/from-postgres/index.ts +122 -0
- package/src/migrate/from-postgres/translate.test.ts +242 -0
- package/src/migrate/from-postgres/translate.ts +359 -0
- package/src/migrate/from-postgres/types.test.ts +116 -0
- package/src/migrate/from-postgres/types.ts +236 -0
- package/src/migrate/from-postgres/validate.e2e.test.ts +44 -0
- package/src/migrate/from-postgres/workspace.e2e.test.ts +114 -0
- package/src/migration-replay.e2e.test.ts +155 -0
- package/src/migration-replay.test.ts +173 -0
- package/src/migration-replay.ts +289 -0
- package/src/op/__snapshots__/watch-pipeline.test.ts.snap +2 -2
- package/src/op/activities/postgres-migration.ts +4 -0
- package/src/op/activity-contracts.ts +2 -0
- package/src/plugin.ts +37 -2
- package/src/postgres/apply/apply.ts +9 -1
- package/src/postgres/apply/statements.ts +74 -1
- package/src/postgres/constraint-names.ts +71 -0
- package/src/postgres/database-kinds.test.ts +384 -0
- package/src/postgres/entities.ts +266 -1
- package/src/postgres/entity-types.ts +5 -0
- package/src/postgres/import/generator.ts +42 -2
- package/src/postgres/import/live-export.ts +1 -0
- package/src/postgres/live/catalog-kinds.ts +223 -0
- package/src/postgres/live/catalog.ts +6 -1
- package/src/postgres/live/database-kinds.e2e.test.ts +164 -0
- package/src/postgres/live/describe-resources.ts +11 -2
- package/src/postgres/live/undeclarable.e2e.test.ts +13 -6
- package/src/postgres/migrate/backfill.ts +13 -1
- package/src/postgres/migrate/carry.ts +103 -8
- package/src/postgres/migrate/followups.e2e.test.ts +218 -0
- package/src/postgres/migrate/followups.test.ts +199 -0
- package/src/postgres/migrate/kinds.e2e.test.ts +5 -5
- package/src/postgres/migrate/observe.ts +53 -14
- package/src/postgres/migrate/op.ts +28 -1
- package/src/postgres/migrate/partition.e2e.test.ts +129 -0
- package/src/postgres/migrate/partition.test.ts +206 -0
- package/src/postgres/migrate/steps.ts +157 -12
- package/src/postgres/parser.ts +234 -2
- package/src/postgres/plan/deep.ts +28 -1
- package/src/postgres/plan/diff.ts +36 -1
- package/src/postgres/plan/normalize.ts +122 -1
- package/src/postgres/plan/rules.ts +10 -3
- package/src/postgres/plan/schema.ts +1 -1
- package/src/postgres/secret-options.ts +10 -0
- package/src/postgres/testing/roundtrip-schema.ts +13 -1
- package/src/postgres.ts +16 -0
- package/src/serializer.ts +13 -2
- package/src/skills/chant-sql-postgres-migration.md +3 -3
- package/src/skills/chant-sql-postgres.md +6 -1
- package/src/skills/chant-sql.md +2 -1
- package/src/sql-types-command.ts +38 -16
- package/src/sql-types.ts +11 -6
- package/src/sqlserver/fixture.test.ts +186 -0
- package/src/sqlserver/import-cli.test.ts +61 -0
- package/src/sqlserver/lexer.ts +260 -0
- package/src/sqlserver/parse.ts +865 -0
- package/src/sqlserver/translate.test.ts +396 -0
- package/src/sqlserver/translate.ts +558 -0
- package/src/sqlserver/types.test.ts +102 -0
- package/src/sqlserver/types.ts +154 -0
- package/src/sqlserver/validate.e2e.test.ts +32 -0
- package/src/sqlserver/validate.test.ts +49 -0
- package/src/sqlserver/validate.ts +76 -0
- package/src/validate.ts +5 -0
- package/src/workspace-kinds.test.ts +11 -0
- package/workspace-kinds.json +100 -2
|
@@ -24,12 +24,13 @@
|
|
|
24
24
|
* - `done`: the server holds the declared column and nothing of the
|
|
25
25
|
* migration is left.
|
|
26
26
|
*
|
|
27
|
+
* A partitioned table's indexes are carried over per partition (`./steps.ts`).
|
|
27
28
|
* Anything else is refused with a {@link MigrationRefusal}, naming what to
|
|
28
29
|
* do: a table that is not there, a partition (run it on the partitioned
|
|
29
30
|
* table) or an inheritance tree; no primary key to batch by; a column in the
|
|
30
31
|
* partition key; a generated column; a column change the Op does not make
|
|
31
|
-
* (a rename and a type change
|
|
32
|
-
* or a sequence); something that uses the column and is
|
|
32
|
+
* (a rename and a type change without `reverse`, a rename of a column with a
|
|
33
|
+
* default or a sequence without `defaultAtContract`); something that uses the column and is
|
|
33
34
|
* not carried over to the new one (`./carry.ts`: indexes, key, check and
|
|
34
35
|
* foreign key constraints and declared views are); a publication that sends
|
|
35
36
|
* it; a working object chant did not make.
|
|
@@ -50,7 +51,7 @@ import type { DeclaredPgObject } from "../apply/statements";
|
|
|
50
51
|
import { col, migrationNames, MIGRATION_TRAILER_KEY, pgName, type MigrationChange, type MigrationNames } from "./names";
|
|
51
52
|
import { publicationsOf, type PublicationHit } from "./replication";
|
|
52
53
|
import { keyText, type KeyColumn } from "./batches";
|
|
53
|
-
import { carriedStates, carriedSubject, discoverDependents, replaceColumn, storedType, type CarriedObject, type CarriedState, type CarriedView, type ColumnSequence, type Dependents } from "./carry";
|
|
54
|
+
import { carriedStates, carriedSubject, columnSequence, discoverDependents, replaceColumn, storedType, type CarriedObject, type CarriedState, type CarriedView, type ColumnSequence, type Dependents } from "./carry";
|
|
54
55
|
|
|
55
56
|
/** A refusal: the migration cannot start, or cannot go on, for a reason a person has to act on. */
|
|
56
57
|
export class MigrationRefusal extends Error {
|
|
@@ -102,7 +103,7 @@ export interface MigrationObservation {
|
|
|
102
103
|
carriedStates: Map<string, CarriedState>;
|
|
103
104
|
/** The views that read the old column, made again at the switch. */
|
|
104
105
|
views: CarriedView[];
|
|
105
|
-
/** The sequence that gives the old column its values (identity or serial)
|
|
106
|
+
/** The sequence that gives the old column its values (identity or serial): moved to the new column at the switch for a type change, at the contract for a rename. */
|
|
106
107
|
sequence?: ColumnSequence;
|
|
107
108
|
/** The table is partitioned: every step runs on it, and its partitions follow. */
|
|
108
109
|
partitioned: boolean;
|
|
@@ -118,6 +119,10 @@ export interface MigrationObservation {
|
|
|
118
119
|
changes: PgChange[];
|
|
119
120
|
/** The expression the new column's value is computed with, over the old row's columns. */
|
|
120
121
|
expression: string;
|
|
122
|
+
/** A rename with a type change: the expression over the new row's columns that gives the old column's value. */
|
|
123
|
+
reverse?: string;
|
|
124
|
+
/** A rename keeps its default, serial or identity on the old column until the contract (`defaultAtContract`). */
|
|
125
|
+
defaultAtContract?: boolean;
|
|
121
126
|
publications: PublicationHit[];
|
|
122
127
|
major: number;
|
|
123
128
|
}
|
|
@@ -132,6 +137,10 @@ export interface ObserveInput {
|
|
|
132
137
|
objects?: readonly DeclaredPgObject[];
|
|
133
138
|
/** The type change's expression, over the old row's columns. Default: `CAST(<column> AS <declared type>)`. */
|
|
134
139
|
using?: string;
|
|
140
|
+
/** A rename with a type change: the expression over the new row's columns that gives the old column's value. */
|
|
141
|
+
reverse?: string;
|
|
142
|
+
/** A rename of a column with a default, serial or identity: the declared default lands on the new column at the contract, not the switch. */
|
|
143
|
+
defaultAtContract?: boolean;
|
|
135
144
|
/** The major the build targets; the server's own wins. */
|
|
136
145
|
major?: number;
|
|
137
146
|
}
|
|
@@ -216,7 +225,7 @@ async function columnChanges(input: ObserveInput): Promise<{ changes: PgChange[]
|
|
|
216
225
|
|
|
217
226
|
const ruleText = (c: PgChange) => `${c.rule} ${PG_CLASSIFIER_RULES[c.rule].title} (${c.field}${c.before !== undefined || c.after !== undefined ? `: ${c.before ?? "-"} -> ${c.after ?? "-"}` : ""})`;
|
|
218
227
|
|
|
219
|
-
interface RelationRow {
|
|
228
|
+
export interface RelationRow {
|
|
220
229
|
oid: string;
|
|
221
230
|
kind: string;
|
|
222
231
|
partition: boolean;
|
|
@@ -371,7 +380,8 @@ export async function observeMigration(input: ObserveInput): Promise<MigrationOb
|
|
|
371
380
|
);
|
|
372
381
|
},
|
|
373
382
|
);
|
|
374
|
-
|
|
383
|
+
const retyped = change === "rename" && input.reverse !== undefined;
|
|
384
|
+
if (change === "type" || retyped) {
|
|
375
385
|
expression = input.using ?? `CAST(${col(sourceName)} AS ${storedType(column.type)})`;
|
|
376
386
|
if (expression.includes("$chant$")) throw new MigrationRefusal(`${where}: the using expression may not contain $chant$, which quotes the dual-write function's body`);
|
|
377
387
|
} else if (input.using !== undefined) {
|
|
@@ -379,7 +389,7 @@ export async function observeMigration(input: ObserveInput): Promise<MigrationOb
|
|
|
379
389
|
}
|
|
380
390
|
// The declared type as the classifier compared it: the server's spelling (character varying(10), not varchar(10)).
|
|
381
391
|
const serial = storedType(column.type) !== column.type;
|
|
382
|
-
const expectedType = change === "type" ? (serial ? storedType(column.type)! : String(typeChange?.after ?? column.type)) : source.type;
|
|
392
|
+
const expectedType = change === "type" || retyped ? (serial ? storedType(column.type)! : String(typeChange?.after ?? column.type)) : source.type;
|
|
383
393
|
if (newColumn && newColumn.type !== expectedType) {
|
|
384
394
|
throw new MigrationRefusal(
|
|
385
395
|
`${where}: ${newColumn.name} was made as ${newColumn.type}, from another declaration of ${input.column} (now ${column.type}). The run fails so onFailure drops it, and the next run starts again from the current declaration.`,
|
|
@@ -397,6 +407,12 @@ export async function observeMigration(input: ObserveInput): Promise<MigrationOb
|
|
|
397
407
|
}
|
|
398
408
|
}
|
|
399
409
|
|
|
410
|
+
// After a rename's switch the old column still holds the default or the identity the contract moves, and its sequence is read for it.
|
|
411
|
+
if (state === "switched" && old && (old.identity || old.default?.includes("nextval("))) {
|
|
412
|
+
const seq = await columnSequence(client, rel.oid, old.attnum);
|
|
413
|
+
if (seq) dependents = { ...dependents, sequence: seq };
|
|
414
|
+
}
|
|
415
|
+
|
|
400
416
|
return {
|
|
401
417
|
state,
|
|
402
418
|
names,
|
|
@@ -419,13 +435,15 @@ export async function observeMigration(input: ObserveInput): Promise<MigrationOb
|
|
|
419
435
|
...(check ? { check } : {}),
|
|
420
436
|
changes,
|
|
421
437
|
expression,
|
|
438
|
+
...(input.reverse !== undefined && state === "migrate" ? { reverse: input.reverse } : {}),
|
|
439
|
+
...(input.defaultAtContract && state === "migrate" ? { defaultAtContract: true } : {}),
|
|
422
440
|
publications,
|
|
423
441
|
major,
|
|
424
442
|
};
|
|
425
443
|
}
|
|
426
444
|
|
|
427
445
|
/** The checks that hold only while there is something to migrate. Returns the batch key and what the Op carries over. */
|
|
428
|
-
async function refuseUnsupported(
|
|
446
|
+
export async function refuseUnsupported(
|
|
429
447
|
input: ObserveInput,
|
|
430
448
|
names: MigrationNames,
|
|
431
449
|
rel: RelationRow,
|
|
@@ -437,20 +455,38 @@ async function refuseUnsupported(
|
|
|
437
455
|
): Promise<{ batchKey: string; keyColumns: KeyColumn[]; dependents: Pick<Dependents, "carried" | "views" | "sequence"> }> {
|
|
438
456
|
const where = `${names.schema}.${names.table}`;
|
|
439
457
|
const oid = rel.oid;
|
|
440
|
-
if (rename && typeChange) {
|
|
458
|
+
if (rename && typeChange && input.reverse === undefined) {
|
|
441
459
|
throw new MigrationRefusal(
|
|
442
460
|
`${where}: ${source.name} is renamed to ${names.column} and its type changes (${typeChange.before} -> ${typeChange.after}) in one declaration. ` +
|
|
443
|
-
`
|
|
444
|
-
`(
|
|
461
|
+
`Both names stay written until the contract, so a writer of the new name needs its value converted back to the old type: give that expression as reverse ` +
|
|
462
|
+
`(over the row's columns, the new column by its declared name; using gives the new value from the old one). The verification fails the run unless reverse over every new value gives the old one back. ` +
|
|
463
|
+
`Or migrate one at a time: declare the rename with the old type, run the Op to its end, then change the type.`,
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
if (input.reverse !== undefined && !(rename && typeChange)) {
|
|
467
|
+
throw new MigrationRefusal(`${where}.${names.column}: reverse is for a rename combined with a type change, and this migration is ${rename ? "a rename only" : "a type change without a rename"}`);
|
|
468
|
+
}
|
|
469
|
+
if (input.reverse !== undefined && input.reverse.includes("$chant$")) {
|
|
470
|
+
throw new MigrationRefusal(`${where}: the reverse expression may not contain $chant$, which quotes the dual-write function's body`);
|
|
471
|
+
}
|
|
472
|
+
const hasDefault = source.default !== undefined || column.default !== undefined;
|
|
473
|
+
if (rename && typeChange && (hasDefault || source.identity || column.generated?.kind === "identity")) {
|
|
474
|
+
throw new MigrationRefusal(
|
|
475
|
+
`${where}: ${source.name} has a default, serial or identity, and its rename also changes its type. The default is an expression of the old type; converting it, and the sequence's type, ` +
|
|
476
|
+
`is a third conversion on top of using and reverse that nothing verifies. Rename first (defaultAtContract: true), run the Op to its end, then change the type.`,
|
|
445
477
|
);
|
|
446
478
|
}
|
|
447
|
-
if (rename && (
|
|
479
|
+
if (rename && (hasDefault || source.identity) && !input.defaultAtContract) {
|
|
448
480
|
throw new MigrationRefusal(
|
|
449
|
-
`${where}: ${source.name} has a default (${source.default ?? column.default}). During a rename both columns are written, each from the other, ` +
|
|
481
|
+
`${where}: ${source.name} has a default (${source.default ?? column.default ?? "identity"}). During a rename both columns are written, each from the other, ` +
|
|
450
482
|
`and a row inserted by a writer that names one column would get the other's default, which the trigger cannot tell from a value it was given. ` +
|
|
451
|
-
`
|
|
483
|
+
`Set defaultAtContract: true to leave the new column without the default until the contract, which gives the default to the new column in the contract's transaction; ` +
|
|
484
|
+
`until then an INSERT that names the new column with an explicit NULL gets the default, and UPDATE ... SET <new> = DEFAULT sets NULL. Or drop the default first (the applier does), rename, then declare it again.`,
|
|
452
485
|
);
|
|
453
486
|
}
|
|
487
|
+
if (input.defaultAtContract && !(rename && (hasDefault || source.identity))) {
|
|
488
|
+
throw new MigrationRefusal(`${where}.${names.column}: defaultAtContract is for a rename of a column with a default, serial or identity, and this migration is not one`);
|
|
489
|
+
}
|
|
454
490
|
if (source.generated || (column.generated && column.generated.kind !== "identity")) {
|
|
455
491
|
throw new MigrationRefusal(
|
|
456
492
|
`${where}.${source.name} is a generated column. There is no expand and contract for one: Postgres cannot make an existing column generated, so the new column could only be added generated, ` +
|
|
@@ -475,6 +511,7 @@ async function refuseUnsupported(
|
|
|
475
511
|
declared: input.declared,
|
|
476
512
|
defaultSchema: input.target.defaultSchema,
|
|
477
513
|
partitioned: rel.kind === "p",
|
|
514
|
+
...(input.defaultAtContract ? { keepSequence: true } : {}),
|
|
478
515
|
});
|
|
479
516
|
if (dependents.refused.length > 0) {
|
|
480
517
|
throw new MigrationRefusal(
|
|
@@ -510,6 +547,8 @@ export function migrationPlanSubject(o: MigrationObservation): Record<string, un
|
|
|
510
547
|
from: o.source ? { name: o.source.name, type: o.source.type, notNull: o.source.notNull, default: o.source.default ?? null } : null,
|
|
511
548
|
to: { name: o.column.name, type: o.column.type ?? null, notNull: o.column.notNull === true, default: o.column.default ?? null, comment: o.column.comment ?? null },
|
|
512
549
|
expression: o.expression,
|
|
550
|
+
...(o.reverse !== undefined ? { reverse: o.reverse } : {}),
|
|
551
|
+
...(o.defaultAtContract ? { defaultAtContract: true } : {}),
|
|
513
552
|
batchKey: o.batchKey,
|
|
514
553
|
rules: o.changes.map((c) => c.rule),
|
|
515
554
|
...(o.carried.length > 0 || o.views.length > 0 || o.sequence ? { dependents: carriedSubject(o) } : {}),
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* | Expand | add the new column, nullable, no default (a catalog change) |
|
|
21
21
|
* | Dual write | a trigger that keeps the new column written: computed from the old one for a type change, both ways for a rename |
|
|
22
22
|
* | Backfill | `UPDATE` per key range, each batch with its receipt in its own transaction, under `lock_timeout`, pausing while a replica is behind |
|
|
23
|
-
* | Carry over | each index on the old column built again on the new one `CONCURRENTLY
|
|
23
|
+
* | Carry over | each index on the old column built again on the new one `CONCURRENTLY` (on a partitioned table: `ON ONLY` the table, `CONCURRENTLY` per partition, then attached), each check and foreign key (another table's too) added `NOT VALID` and validated (`./carry.ts`) |
|
|
24
24
|
* | Verify | rows, mismatches and checksums of the new column against the old |
|
|
25
25
|
* | Approve | a gate bound to the plan and the verified new column |
|
|
26
26
|
* | Switch | NOT NULL proven by a validated check; then one short transaction: a type change swaps the columns by name, a rename finishes the new column; the carried indexes and constraints take their names (keys `USING INDEX`), and the views that read the column are made again from their declarations |
|
|
@@ -64,6 +64,29 @@ export interface PostgresMigrationOpConfig {
|
|
|
64
64
|
* and the verification all use it.
|
|
65
65
|
*/
|
|
66
66
|
using?: string;
|
|
67
|
+
/**
|
|
68
|
+
* A rename combined with a type change: the SQL expression that gives the
|
|
69
|
+
* old column's value from the new column's, over the row's columns (the
|
|
70
|
+
* new column by its declared name). `using` gives the new value from the
|
|
71
|
+
* old one. Both stay in force until the contract: a writer of the old name
|
|
72
|
+
* has `using` applied, a writer of the new name has `reverse` applied. The
|
|
73
|
+
* verification fails the run unless `reverse` over every new value gives
|
|
74
|
+
* the old value back. A new value the old type cannot hold fails the
|
|
75
|
+
* writer's statement until the contract drops the old column.
|
|
76
|
+
*/
|
|
77
|
+
reverse?: string;
|
|
78
|
+
/**
|
|
79
|
+
* A rename of a column with a default, an identity or a serial sequence.
|
|
80
|
+
* The declared default (or identity, or the serial's default) lands on the
|
|
81
|
+
* new column at the contract, not at the switch, so that until then a
|
|
82
|
+
* writer of either name gets the old column's default once. Two writes
|
|
83
|
+
* differ from the declaration until then, and `true` accepts both: an
|
|
84
|
+
* INSERT that names the new column with an explicit NULL gets the default
|
|
85
|
+
* instead of NULL, and `UPDATE ... SET <new> = DEFAULT` sets NULL. An
|
|
86
|
+
* `ApplyOp` between the switch and the contract must not set the default
|
|
87
|
+
* on the new column. Needs `gates: "own"` to end with the contract.
|
|
88
|
+
*/
|
|
89
|
+
defaultAtContract?: boolean;
|
|
67
90
|
/** The build output the declaration is read from. Default: `dist/schema.json`. */
|
|
68
91
|
output?: string;
|
|
69
92
|
/** Project directory to build. Default: `.`. */
|
|
@@ -138,6 +161,8 @@ export interface PostgresMigrationArgs {
|
|
|
138
161
|
buildPath: string;
|
|
139
162
|
environment?: string;
|
|
140
163
|
using?: string;
|
|
164
|
+
reverse?: string;
|
|
165
|
+
defaultAtContract?: boolean;
|
|
141
166
|
retain?: string;
|
|
142
167
|
batchSize?: number;
|
|
143
168
|
replicationLag?: { max?: string; wait?: string } | false;
|
|
@@ -178,6 +203,8 @@ export function PostgresMigrationOp(config: PostgresMigrationOpConfig): Postgres
|
|
|
178
203
|
buildPath: config.output ?? "dist/schema.json",
|
|
179
204
|
environment: config.env,
|
|
180
205
|
...(config.using !== undefined ? { using: config.using } : {}),
|
|
206
|
+
...(config.reverse !== undefined ? { reverse: config.reverse } : {}),
|
|
207
|
+
...(config.defaultAtContract ? { defaultAtContract: true } : {}),
|
|
181
208
|
...(config.retain ? { retain: config.retain } : {}),
|
|
182
209
|
...(config.batchSize !== undefined ? { batchSize: config.batchSize } : {}),
|
|
183
210
|
...(config.replicationLag !== undefined ? { replicationLag: config.replicationLag } : {}),
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An index on a partitioned table's migrated column (#3333), against the
|
|
3
|
+
* pinned server: the Op builds it per partition and attaches it, and what
|
|
4
|
+
* stays refused.
|
|
5
|
+
*
|
|
6
|
+
* - `text` to `numeric` on a column with a plain index and a partial unique
|
|
7
|
+
* index, on a table with a default partition, two partitions and a
|
|
8
|
+
* sub-partitioned one: every partition's index is built `CONCURRENTLY`
|
|
9
|
+
* (never under a lock that blocks writes), attached, and the partitioned
|
|
10
|
+
* index is valid and named as declared after the switch.
|
|
11
|
+
* - A write while the Op waits at its gate lands in the new column.
|
|
12
|
+
* - A unique constraint on the column stays refused, naming why.
|
|
13
|
+
*
|
|
14
|
+
* Needs Docker; skips cleanly without it.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
21
|
+
import { dockerAvailable, startTestPostgres, type TestPostgres } from "../testing/server";
|
|
22
|
+
import { ApprovingLedger, runMigrationOp } from "../testing/migration";
|
|
23
|
+
import type { PostgresClient } from "../live/client";
|
|
24
|
+
import { planPgAgainstServer } from "../plan/commands";
|
|
25
|
+
import { postgresApply } from "../../op/activities/postgres-apply";
|
|
26
|
+
import { postgresMigrationPlan, type PostgresMigrationDeps } from "../../op/activities/postgres-migration";
|
|
27
|
+
import type { PostgresMigrationOpConfig } from "./op";
|
|
28
|
+
|
|
29
|
+
const enabled = await dockerAvailable();
|
|
30
|
+
let server: TestPostgres | undefined;
|
|
31
|
+
let admin: PostgresClient | undefined;
|
|
32
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-pg-partition-"));
|
|
33
|
+
|
|
34
|
+
beforeAll(async () => {
|
|
35
|
+
if (!enabled) return;
|
|
36
|
+
server = await startTestPostgres();
|
|
37
|
+
admin = await server.connect();
|
|
38
|
+
}, 600_000);
|
|
39
|
+
|
|
40
|
+
afterAll(async () => {
|
|
41
|
+
await admin?.end();
|
|
42
|
+
await server?.stop();
|
|
43
|
+
rmSync(dir, { recursive: true, force: true });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
interface Obj {
|
|
47
|
+
export: string;
|
|
48
|
+
type: string;
|
|
49
|
+
ddl: string;
|
|
50
|
+
dependsOn?: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function writeBuild(file: string, objects: Obj[]): string {
|
|
54
|
+
writeFileSync(join(dir, file), JSON.stringify({ dialect: "postgres", postgresMajor: 18, applyOrder: objects.map((o) => o.export), objects: objects.map((o) => ({ dependsOn: [], ...o })) }));
|
|
55
|
+
return file;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const MARKER = { stack: "e2e", env: "test" };
|
|
59
|
+
const profile = () => ({
|
|
60
|
+
config: { ownership: MARKER, sql: { profiles: { e2e: { url: server!.endpoint().url, password: { env: "PG_E2E_PASSWORD" } } } } },
|
|
61
|
+
env: { PG_E2E_PASSWORD: server!.endpoint().password },
|
|
62
|
+
});
|
|
63
|
+
const deps = (): PostgresMigrationDeps => ({ ...profile(), log: () => undefined });
|
|
64
|
+
const apply = (file: string) => postgresApply({ buildPath: join(dir, file), environment: "e2e" }, undefined, { ...profile(), log: () => undefined });
|
|
65
|
+
const plan = (file: string) => planPgAgainstServer("e2e", join(dir, file), profile());
|
|
66
|
+
const one = async <T>(sql: string): Promise<T> => (await admin!.query<T>(sql))[0]!;
|
|
67
|
+
const schemaObj = (name: string): Obj => ({ export: name, type: "Postgres::Schema", ddl: `CREATE SCHEMA ${name}` });
|
|
68
|
+
const table = (exportName: string, schema: string, ddl: string, dependsOn: string[] = []): Obj => ({ export: exportName, type: "Postgres::Table", dependsOn: [schema, ...dependsOn], ddl });
|
|
69
|
+
|
|
70
|
+
describe.skipIf(!enabled)("a plain and a partial unique index on a partitioned table's migrated column", () => {
|
|
71
|
+
const PI = schemaObj("pi");
|
|
72
|
+
const events = (t: string): Obj => table("events", "pi", `CREATE TABLE pi.events (id bigint, at date NOT NULL, amount ${t} NOT NULL, PRIMARY KEY (id, at)) PARTITION BY RANGE (at)`);
|
|
73
|
+
const part = (y: number): Obj => table(`events${y}`, "pi", `CREATE TABLE pi.events_${y} PARTITION OF pi.events FOR VALUES FROM ('${y}-01-01') TO ('${y + 1}-01-01')`, ["events"]);
|
|
74
|
+
const sub = table("events2027", "pi", "CREATE TABLE pi.events_2027 PARTITION OF pi.events FOR VALUES FROM ('2027-01-01') TO ('2028-01-01') PARTITION BY RANGE (at)", ["events"]);
|
|
75
|
+
const q1 = table("events2027q1", "pi", "CREATE TABLE pi.events_2027_q1 PARTITION OF pi.events_2027 FOR VALUES FROM ('2027-01-01') TO ('2027-04-01')", ["events2027"]);
|
|
76
|
+
const byAmount: Obj = { export: "eventsByAmount", type: "Postgres::Index", dependsOn: ["events"], ddl: "CREATE INDEX events_amount_idx ON pi.events (amount)" };
|
|
77
|
+
const uniq = (col: string): Obj => ({ export: "eventsUnique", type: "Postgres::Index", dependsOn: ["events"], ddl: `CREATE UNIQUE INDEX events_unique_idx ON pi.events (at, ${col}) WHERE id > 0` });
|
|
78
|
+
const objects = (t: string, col: string) => [PI, events(t), part(2025), part(2026), sub, q1, { ...byAmount, ddl: `CREATE INDEX events_amount_idx ON pi.events (${col})` }, uniq(col)];
|
|
79
|
+
|
|
80
|
+
test("each partition's index is built and attached, and the partitioned index takes its declared name", async () => {
|
|
81
|
+
await apply(writeBuild("pi-v1.json", objects("text", "amount")));
|
|
82
|
+
await admin!.query("INSERT INTO pi.events SELECT g, DATE '2025-01-01' + (g % 900), (g % 90) || '.5' FROM generate_series(1, 1500) g");
|
|
83
|
+
writeBuild("pi-v2.json", objects("numeric(10,2)", "amount"));
|
|
84
|
+
|
|
85
|
+
const ledger = new ApprovingLedger();
|
|
86
|
+
const config: PostgresMigrationOpConfig = { name: "migrate-events-amount", env: "e2e", table: "pi.events", column: "amount", build: false, path: dir, output: "pi-v2.json", retain: "0s", stack: MARKER.stack, ownershipEnv: MARKER.env, batchSize: 500 };
|
|
87
|
+
const statuses: string[] = [];
|
|
88
|
+
for (let i = 0; i < 4; i++) {
|
|
89
|
+
const r = await runMigrationOp(config, ledger.port, deps);
|
|
90
|
+
statuses.push(r.status);
|
|
91
|
+
if (i === 0) {
|
|
92
|
+
// Waiting at the gate: the partitioned working index is valid, every leaf partition has its own, and writes land in the new column.
|
|
93
|
+
expect(await one("SELECT indisvalid FROM pg_index WHERE indexrelid = 'pi.events_amount_idx__chant_new'::regclass")).toEqual({ indisvalid: true });
|
|
94
|
+
expect(await one("SELECT count(*)::int AS n FROM pg_index i JOIN pg_inherits h ON h.inhrelid = i.indexrelid WHERE h.inhparent = 'pi.events_amount_idx__chant_new'::regclass")).toEqual({ n: 3 });
|
|
95
|
+
await admin!.query("INSERT INTO pi.events VALUES (9000, DATE '2026-03-01', '2.25')");
|
|
96
|
+
expect(await one("SELECT amount__chant_new::text AS a FROM pi.events WHERE id = 9000")).toEqual({ a: "2.25" });
|
|
97
|
+
}
|
|
98
|
+
if (r.status !== "gated") break;
|
|
99
|
+
ledger.approveLast();
|
|
100
|
+
}
|
|
101
|
+
expect(statuses).toEqual(["gated", "gated", "ok"]);
|
|
102
|
+
expect(await one("SELECT i.indisvalid, c.relkind::text AS kind, pg_get_indexdef(c.oid) AS def FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid WHERE c.oid = 'pi.events_amount_idx'::regclass")).toEqual({
|
|
103
|
+
indisvalid: true,
|
|
104
|
+
kind: "I",
|
|
105
|
+
def: "CREATE INDEX events_amount_idx ON ONLY pi.events USING btree (amount)",
|
|
106
|
+
});
|
|
107
|
+
expect(await one("SELECT count(*)::int AS n FROM pg_class WHERE relnamespace = 'pi'::regnamespace AND relname LIKE '%\\_\\_chant\\_old'")).toEqual({ n: 0 });
|
|
108
|
+
// The query planner uses a partition's index on the new column.
|
|
109
|
+
await admin!.query("SET enable_seqscan = off");
|
|
110
|
+
expect(JSON.stringify(await admin!.query("EXPLAIN SELECT * FROM pi.events_2025 WHERE amount = 2.5"))).toContain("Index");
|
|
111
|
+
expect((await plan("pi-v2.json")).changes).toEqual([]);
|
|
112
|
+
}, 300_000);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe.skipIf(!enabled)("a key on a partitioned table's migrated column", () => {
|
|
116
|
+
const PK = schemaObj("pk");
|
|
117
|
+
const events = (t: string): Obj => table("events", "pk", `CREATE TABLE pk.events (id bigint, at date NOT NULL, amount ${t} NOT NULL, PRIMARY KEY (id, at, amount)) PARTITION BY RANGE (at)`);
|
|
118
|
+
const part: Obj = table("events2025", "pk", "CREATE TABLE pk.events_2025 PARTITION OF pk.events FOR VALUES FROM ('2025-01-01') TO ('2026-01-01')", ["events"]);
|
|
119
|
+
|
|
120
|
+
test("stays refused, naming why", async () => {
|
|
121
|
+
await apply(writeBuild("pk-v1.json", [PK, events("text"), part]));
|
|
122
|
+
writeBuild("pk-v2.json", [PK, events("numeric(10,2)"), part]);
|
|
123
|
+
const message = await postgresMigrationPlan({ table: "pk.events", column: "amount", buildPath: "pk-v2.json", environment: "e2e", stack: MARKER.stack, ownershipEnv: MARKER.env, cwd: dir }, undefined, deps()).then(
|
|
124
|
+
() => "",
|
|
125
|
+
(e: unknown) => String((e as Error).message),
|
|
126
|
+
);
|
|
127
|
+
expect(message).toMatch(/primary key events_pkey on pk\.events: on a partitioned table a key cannot be attached from an index built CONCURRENTLY/);
|
|
128
|
+
}, 300_000);
|
|
129
|
+
});
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An index on a partitioned table's migrated column (#3333), carried over
|
|
3
|
+
* per partition: the `ON ONLY` statement, each partition's `CONCURRENTLY`
|
|
4
|
+
* build, the attach, and a run that goes on from where the last stopped.
|
|
5
|
+
* The e2e is `partition.e2e.test.ts`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "vitest";
|
|
9
|
+
import type { PostgresClient } from "../live/client";
|
|
10
|
+
import { carriedPartitionedIndexStatement, discoverDependents, partitionIndexName, partitionIndexStatement, type CarriedObject } from "./carry";
|
|
11
|
+
import { migrationNames } from "./names";
|
|
12
|
+
import { carryPartitionedIndex, type MigrationRun } from "./steps";
|
|
13
|
+
|
|
14
|
+
const DEF = "CREATE INDEX events_amount_idx ON ONLY app.events USING btree (amount)";
|
|
15
|
+
|
|
16
|
+
describe("the statements", () => {
|
|
17
|
+
test("a partitioned index is made again ON ONLY on the new column, without CONCURRENTLY", () => {
|
|
18
|
+
expect(carriedPartitionedIndexStatement(DEF, "events_amount_idx__chant_new", "amount", "amount__chant_new")).toBe("CREATE INDEX events_amount_idx__chant_new ON ONLY app.events USING btree (amount__chant_new)");
|
|
19
|
+
expect(carriedPartitionedIndexStatement("CREATE UNIQUE INDEX e ON ONLY app.events USING btree (id, amount) WHERE (amount > 0)", "e__chant_new", "amount", "login")).toBe(
|
|
20
|
+
"CREATE UNIQUE INDEX e__chant_new ON ONLY app.events USING btree (id, login) WHERE (login > 0)",
|
|
21
|
+
);
|
|
22
|
+
// Without ONLY in the definition, the statement still has it.
|
|
23
|
+
expect(carriedPartitionedIndexStatement("CREATE INDEX e ON app.events USING btree (amount)", "w", "amount", "n")).toBe("CREATE INDEX w ON ONLY app.events USING btree (n)");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("a partition that holds rows gets a CONCURRENTLY build, one that is partitioned further gets ON ONLY", () => {
|
|
27
|
+
const c = { definition: "CREATE UNIQUE INDEX events_amount_idx__chant_new ON ONLY app.events USING btree (id, amount__chant_new) WHERE (amount__chant_new > 0)" };
|
|
28
|
+
expect(partitionIndexStatement(c, { ident: "app.events_2025", leaf: true }, "events_2025_events_amount_idx__chant_new")).toBe(
|
|
29
|
+
"CREATE UNIQUE INDEX CONCURRENTLY events_2025_events_amount_idx__chant_new ON app.events_2025 USING btree (id, amount__chant_new) WHERE (amount__chant_new > 0)",
|
|
30
|
+
);
|
|
31
|
+
expect(partitionIndexStatement(c, { ident: "app.events_2026", leaf: false }, "events_2026_x")).toBe(
|
|
32
|
+
"CREATE UNIQUE INDEX events_2026_x ON ONLY app.events_2026 USING btree (id, amount__chant_new) WHERE (amount__chant_new > 0)",
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("the partition's index name keeps within what Postgres keeps", () => {
|
|
37
|
+
expect(partitionIndexName({ name: "events_2025" }, "events_amount_idx__chant_new")).toBe("events_2025_events_amount_idx__chant_new");
|
|
38
|
+
expect(partitionIndexName({ name: "p".repeat(40) }, "i".repeat(40))).toHaveLength(63);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const names = migrationNames("app", "events", "amount", "type", "amount");
|
|
43
|
+
const c: CarriedObject = {
|
|
44
|
+
kind: "index",
|
|
45
|
+
name: "events_amount_idx",
|
|
46
|
+
table: "app.events",
|
|
47
|
+
tableName: "app.events",
|
|
48
|
+
tableOid: "100",
|
|
49
|
+
schema: "app",
|
|
50
|
+
working: "events_amount_idx__chant_new",
|
|
51
|
+
target: "events_amount_idx",
|
|
52
|
+
definition: "CREATE INDEX events_amount_idx__chant_new ON ONLY app.events USING btree (amount__chant_new)",
|
|
53
|
+
validated: true,
|
|
54
|
+
partitioned: true,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
interface Node {
|
|
58
|
+
oid: string;
|
|
59
|
+
name: string;
|
|
60
|
+
parent?: string;
|
|
61
|
+
leaf: boolean;
|
|
62
|
+
level: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A fake server for one partitioned index: the tree it has, and what of the working index exists already. */
|
|
66
|
+
function server(tree: Node[], have: { root?: boolean; attached?: string[]; standalone?: Record<string, { valid: boolean; comment: string | null }> } = {}) {
|
|
67
|
+
const statements: string[] = [];
|
|
68
|
+
const indexOid = (name: string) => `i-${name}`;
|
|
69
|
+
const query = async (sql: string, params: unknown[] = []): Promise<Record<string, unknown>[]> => {
|
|
70
|
+
if (/^(CREATE|ALTER INDEX|COMMENT|DROP)/.test(sql)) statements.push(sql);
|
|
71
|
+
if (sql.includes("pg_partition_tree")) return tree.map((n) => ({ oid: n.oid, schema: "app", name: n.name, parent: n.parent ?? null, leaf: n.leaf, kind: n.leaf ? "r" : "p", level: n.level }));
|
|
72
|
+
if (sql.includes("AS oid") && sql.includes("to_regclass")) {
|
|
73
|
+
const ident = String(params[0]);
|
|
74
|
+
if (ident === "app.events_amount_idx__chant_new") return [{ oid: have.root ? "root" : null }];
|
|
75
|
+
return [{ oid: null }];
|
|
76
|
+
}
|
|
77
|
+
if (sql.includes("FROM pg_catalog.pg_inherits h")) {
|
|
78
|
+
const parent = String(params[0]);
|
|
79
|
+
const partition = String(params[1]);
|
|
80
|
+
const hit = tree.find((n) => n.oid === partition);
|
|
81
|
+
if (parent === "root" && hit && have.attached?.includes(hit.name)) return [{ oid: indexOid(hit.name), schema: "app", name: `${hit.name}_auto_idx` }];
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
if (sql.includes("indisvalid AS valid")) {
|
|
85
|
+
const ident = String(params[0]);
|
|
86
|
+
const name = /\.(.+)$/.exec(ident)![1]!;
|
|
87
|
+
const s = have.standalone?.[name];
|
|
88
|
+
return s ? [{ valid: s.valid, comment: s.comment }] : [];
|
|
89
|
+
}
|
|
90
|
+
return [];
|
|
91
|
+
};
|
|
92
|
+
const run = { client: { query } as unknown as PostgresClient, target: { defaultSchema: "app" }, lockTimeoutMs: 1000, statementTimeoutMs: 1000, log: () => undefined } as unknown as MigrationRun;
|
|
93
|
+
return { run, statements: () => statements };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const TREE: Node[] = [
|
|
97
|
+
{ oid: "100", name: "events", leaf: false, level: 0 },
|
|
98
|
+
{ oid: "101", name: "events_2025", parent: "100", leaf: true, level: 1 },
|
|
99
|
+
{ oid: "102", name: "events_2026", parent: "100", leaf: true, level: 1 },
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
describe("carrying a partitioned index over", () => {
|
|
103
|
+
test("ON ONLY first, each partition's index built CONCURRENTLY, then each attached", async () => {
|
|
104
|
+
const s = server(TREE);
|
|
105
|
+
expect(await carryPartitionedIndex(s.run, names, c, "the comment", undefined)).toBe(true);
|
|
106
|
+
const sql = s.statements();
|
|
107
|
+
expect(sql[0]).toBe(c.definition);
|
|
108
|
+
expect(sql[1]).toMatch(/^COMMENT ON INDEX app\.events_amount_idx__chant_new IS /);
|
|
109
|
+
expect(sql.filter((x) => x.startsWith("CREATE"))).toEqual([
|
|
110
|
+
c.definition,
|
|
111
|
+
"CREATE INDEX CONCURRENTLY events_2025_events_amount_idx__chant_new ON app.events_2025 USING btree (amount__chant_new)",
|
|
112
|
+
"CREATE INDEX CONCURRENTLY events_2026_events_amount_idx__chant_new ON app.events_2026 USING btree (amount__chant_new)",
|
|
113
|
+
]);
|
|
114
|
+
const attaches = sql.filter((x) => x.startsWith("ALTER INDEX"));
|
|
115
|
+
expect(attaches).toEqual([
|
|
116
|
+
"ALTER INDEX app.events_amount_idx__chant_new ATTACH PARTITION app.events_2025_events_amount_idx__chant_new",
|
|
117
|
+
"ALTER INDEX app.events_amount_idx__chant_new ATTACH PARTITION app.events_2026_events_amount_idx__chant_new",
|
|
118
|
+
]);
|
|
119
|
+
// Every attach comes after every build, and each partition's index carries the migration's trailer.
|
|
120
|
+
expect(sql.lastIndexOf(sql.filter((x) => x.startsWith("CREATE")).at(-1)!)).toBeLessThan(sql.indexOf(attaches[0]!));
|
|
121
|
+
expect(sql.filter((x) => x.startsWith("COMMENT ON INDEX app.events_2025_")).join()).toContain("migration=app.events.amount role=carry");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("a run after a crash keeps the partitions already attached, drops an invalid build and goes on", async () => {
|
|
125
|
+
const s = server(TREE, { root: true, attached: ["events_2025"], standalone: { events_2026_events_amount_idx__chant_new: { valid: false, comment: null } } });
|
|
126
|
+
const state = { present: true, ready: false, marked: true };
|
|
127
|
+
await carryPartitionedIndex(s.run, names, c, "the comment", state);
|
|
128
|
+
const sql = s.statements();
|
|
129
|
+
expect(sql.some((x) => x.includes("events_2025"))).toBe(false);
|
|
130
|
+
expect(sql.filter((x) => !x.startsWith("COMMENT"))).toEqual([
|
|
131
|
+
"DROP INDEX CONCURRENTLY IF EXISTS app.events_2026_events_amount_idx__chant_new",
|
|
132
|
+
"CREATE INDEX CONCURRENTLY events_2026_events_amount_idx__chant_new ON app.events_2026 USING btree (amount__chant_new)",
|
|
133
|
+
"ALTER INDEX app.events_amount_idx__chant_new ATTACH PARTITION app.events_2026_events_amount_idx__chant_new",
|
|
134
|
+
]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("a valid partitioned index that is marked is left alone", async () => {
|
|
138
|
+
const s = server(TREE, { root: true });
|
|
139
|
+
expect(await carryPartitionedIndex(s.run, names, c, "the comment", { present: true, ready: true, marked: true })).toBe(false);
|
|
140
|
+
expect(s.statements()).toEqual([]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("a partition that is partitioned further gets ON ONLY, and is attached after its own partitions", async () => {
|
|
144
|
+
const tree: Node[] = [
|
|
145
|
+
{ oid: "100", name: "events", leaf: false, level: 0 },
|
|
146
|
+
{ oid: "101", name: "events_2025", parent: "100", leaf: false, level: 1 },
|
|
147
|
+
{ oid: "103", name: "events_2025_q1", parent: "101", leaf: true, level: 2 },
|
|
148
|
+
];
|
|
149
|
+
const s = server(tree);
|
|
150
|
+
await carryPartitionedIndex(s.run, names, c, "the comment", undefined);
|
|
151
|
+
const sql = s.statements().filter((x) => !x.startsWith("COMMENT"));
|
|
152
|
+
expect(sql).toEqual([
|
|
153
|
+
c.definition,
|
|
154
|
+
"CREATE INDEX events_2025_events_amount_idx__chant_new ON ONLY app.events_2025 USING btree (amount__chant_new)",
|
|
155
|
+
"CREATE INDEX CONCURRENTLY events_2025_q1_events_amount_idx__chant_new ON app.events_2025_q1 USING btree (amount__chant_new)",
|
|
156
|
+
"ALTER INDEX app.events_2025_events_amount_idx__chant_new ATTACH PARTITION app.events_2025_q1_events_amount_idx__chant_new",
|
|
157
|
+
"ALTER INDEX app.events_amount_idx__chant_new ATTACH PARTITION app.events_2025_events_amount_idx__chant_new",
|
|
158
|
+
]);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("finding a partitioned index on the column", () => {
|
|
163
|
+
const find = async (tree: Array<{ name: string; kind: string }>, row: Record<string, unknown> = {}) => {
|
|
164
|
+
const query = async (sql: string): Promise<Record<string, unknown>[]> => {
|
|
165
|
+
if (sql.includes("pg_catalog.pg_depend d") && sql.includes("UNION")) return [{ kind: "class", objid: "9", objsubid: 0, what: "index", deptype: "n" }];
|
|
166
|
+
if (sql.includes("AS has_exprs")) {
|
|
167
|
+
return [{ oid: "9", name: "events_amount_idx", kind: "I", schema: "app", comment: null, def: DEF, valid: true, is_unique: false, replident: false, clustered: false, cols: ["amount"], has_exprs: false, ...row }];
|
|
168
|
+
}
|
|
169
|
+
if (sql.includes("pg_partition_tree")) return tree.map((t, i) => ({ oid: String(100 + i), schema: "app", name: t.name, parent: i === 0 ? null : "100", leaf: i > 0, kind: t.kind, level: i === 0 ? 0 : 1 }));
|
|
170
|
+
return [];
|
|
171
|
+
};
|
|
172
|
+
return discoverDependents({
|
|
173
|
+
client: { query } as unknown as PostgresClient,
|
|
174
|
+
names: migrationNames("app", "events", "amount", "type", "amount"),
|
|
175
|
+
oid: "100",
|
|
176
|
+
attnum: 3,
|
|
177
|
+
objects: [],
|
|
178
|
+
declared: { props: {}, canonical: { schema: "app", name: "events" } } as never,
|
|
179
|
+
defaultSchema: "app",
|
|
180
|
+
partitioned: true,
|
|
181
|
+
});
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
test("it is carried over, as a partitioned index", async () => {
|
|
185
|
+
const d = await find([{ name: "events", kind: "p" }, { name: "events_2025", kind: "r" }]);
|
|
186
|
+
expect(d.refused).toEqual([]);
|
|
187
|
+
expect(d.carried).toHaveLength(1);
|
|
188
|
+
expect(d.carried[0]).toMatchObject({
|
|
189
|
+
kind: "index",
|
|
190
|
+
partitioned: true,
|
|
191
|
+
working: "events_amount_idx__chant_new",
|
|
192
|
+
definition: "CREATE INDEX events_amount_idx__chant_new ON ONLY app.events USING btree (amount__chant_new)",
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("a foreign table among the partitions refuses it, naming the partition", async () => {
|
|
197
|
+
const d = await find([{ name: "events", kind: "p" }, { name: "events_remote", kind: "f" }]);
|
|
198
|
+
expect(d.carried).toEqual([]);
|
|
199
|
+
expect(d.refused).toEqual([expect.stringMatching(/partitioned index app\.events_amount_idx: app\.events_remote is a foreign table/)]);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("an INVALID one (an ON ONLY never finished) still refuses", async () => {
|
|
203
|
+
const d = await find([{ name: "events", kind: "p" }], { valid: false });
|
|
204
|
+
expect(d.refused).toEqual([expect.stringMatching(/index app\.events_amount_idx is INVALID/)]);
|
|
205
|
+
});
|
|
206
|
+
});
|