Skip to content

Commit d773f53

Browse files
authored
fix(planner): emit executable DDL for NOT NULL columns on non-empty tables (#241)
closes [TML-2067](https://linear.app/prisma-company/issue/TML-2067/planner-emits-unexecutable-ddl-for-not-null-columns-on-non-empty) ## Intent Fix Postgres migration planning and execution for `ADD COLUMN ... NOT NULL` on existing tables during `db update`. Before this branch, two things were wrong: - the planner could only safely add a NOT NULL column without an explicit default by requiring the table to be empty - the runner could skip `db update` work when the marker already matched the destination, even if the live schema had drifted This PR fixes that bug, extracts the first reusable multi-step planner recipe, and adds an extension-aware seam for temporary-default resolution. It does **not** try to ship a public TypeScript migration API yet. ## What Changed ### Planner - `buildAddColumnOperation(...)` now decides **when** the temporary-default strategy applies. - The actual 2-step recipe is extracted into `buildAddNotNullColumnWithTemporaryDefaultOperation(...)`. - Temporary-default lookup now flows through `resolveTemporaryDefaultLiteral(...)`: 1. ask a codec hook 2. fall back to built-in Postgres defaults 3. if neither can provide a safe value, keep the empty-table precheck - `CodecControlHooks` now exposes `resolveTemporaryDefaultLiteral(...)` so extensions and parameterized codecs can participate in this decision. - `pgvector` implements that hook and returns a dimension-aware zero vector literal. - The built-in Postgres fallback now covers more safe cases, including arrays (`'{}'`), `tsvector` (`''::tsvector`), `bytea` (`''::bytea`), alias forms like `varchar` / `bpchar` / `varbit`, and length-aware zero literals for `bit(n)`. ### Runner - `db update` plans (`origin: null`) now always apply operations. - `migration apply` plans (`origin` set) still skip when the marker already matches the destination. That preserves migration replay idempotency while allowing `db update` to recover from live drift. ### Tests and Evidence - `planner.behavior.test.ts` now verifies: - the extracted 2-step recipe for built-in types - extension-aware temporary defaults via `pgvector` - fallback-to-empty-table behavior when a codec explicitly declines a temporary default - expanded built-in fallback coverage for arrays, `tsvector`, `bytea`, and `bit(n)` - `drift-schema.e2e.test.ts` now exercises the built-in recovery path: - initialize the schema - insert a row - manually drop a NOT NULL column - run `db update` - verify the row was backfilled and that the temporary default was removed - `psl.pgvector-dbinit.test.ts` now exercises the extension path against a live database: - initialize a schema with a `vector(3)` NOT NULL column - insert a row - manually drop the vector column - run `db update` - verify the row was backfilled to `[0,0,0]` and that no default remains on the column - `runner.idempotency.integration.test.ts` now explicitly models `migration apply` semantics by setting `origin`, so it still verifies skip-on-marker-match in the right path. ### Supporting Changes - Migration subsystem docs now describe the temporary-default strategy as: - codec hook first - built-in fallback second - empty-table precheck only when no safe default is known ## Behavior Change For a NOT NULL column without an explicit contract default: - if the planner can resolve a safe temporary default, it emits: 1. `ADD COLUMN ... DEFAULT <temporary> NOT NULL` 2. `ALTER COLUMN ... DROP DEFAULT` - existing rows keep the backfilled value - future inserts must still provide an explicit value - if no safe temporary default is known, the operation still requires an empty table ## Scope and Deferrals This PR intentionally stops at the internal planner boundary. Deferred: - public composable migration utilities for user-authored TypeScript migrations - a strategy registry or broader cross-target abstraction layer - broader work on user-authored TS contracts / TS migrations Those are follow-up design and implementation steps. This PR is the bug fix plus the smallest useful extensibility seam. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * `db update` live reconciliation for recovering schema drift on non-empty tables. * Planner now supports a safe temporary-default strategy to add NOT NULL columns without requiring empty tables. * Extensions (e.g., vector) can provide temporary-defaults for reconciliation. * **Bug Fixes** * Improved idempotency: migration-apply plans are skipped when already at destination. * **Documentation** * Added architecture docs for the `db update` reconciliation flow and planner behavior. * **Tests** * Expanded tests for NOT NULL migrations, pgvector recovery, idempotency, and CLI error reporting. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: jkomyno <12381818+jkomyno@users.noreply.github.com>
1 parent ee1235b commit d773f53

18 files changed

Lines changed: 1436 additions & 466 deletions

File tree

docs/architecture docs/subsystems/7. Migration System.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,16 @@ For CLI usage, output formats, and common failure modes, see the canonical comma
298298

299299
Greenfield, brownfield-conservative, and brownfield-incremental paths are supported. Baselines help bootstrap fresh environments; incremental adoption expands contract coverage safely over time. See [ADR 122 — Database Initialization & Adoption](../adrs/ADR%20122%20-%20Database%20Initialization%20&%20Adoption.md).
300300

301+
### `db update` (live reconciliation)
302+
303+
`prisma-next db update` is the **reconciliation** entrypoint. It introspects the live database schema, diffs it against the destination contract, and applies the resulting operations.
304+
305+
Unlike `migration apply`, which replays serialized edges, `db update` always works from a live introspection. Plans produced by the planner carry `origin: null` (no prior-state assertion). The runner treats this as "trust the planner": operations are always applied regardless of marker state. This ensures `db update` can recover from schema drift — for example, when a DBA manually drops a column, the planner detects the missing column and re-adds it even though the contract marker already matches the destination hash.
306+
307+
By contrast, `migration apply` constructs plans with `origin: { storageHash }` from the migration chain. When the marker matches the destination, the runner skips operations (the migration was already applied). This provides safe idempotent replays.
308+
309+
**NOT NULL columns without defaults.** When a direct `ADD COLUMN ... NOT NULL` would fail on existing rows, the planner chooses a safe reconciliation strategy. It first asks codec hooks for a type-specific identity literal, then falls back to built-in Postgres handling, and otherwise requires an empty-table precheck. The exact SQL recipe is an implementation detail of the planner and may evolve as additional strategies are added.
310+
301311
## Multi-Service Namespacing
302312

303313
Per-service markers allow independent contracts within a single physical database (e.g., Postgres schemas). Each service has its own `prisma_contract.marker`; permissions and verification remain isolated. For engines without schemas, use separate databases. See namespacing notes in [ADR 021 — Contract Marker Storage](../adrs/ADR%20021%20-%20Contract%20Marker%20Storage.md).
@@ -333,5 +343,3 @@ Provide:
333343
- edges/<edgeId>/migration.json — manifests with ops and checks; optional tasks modules
334344
- graph.index.json (optional) — committed graph index when enabled
335345
- digests — content hashes for integrity
336-
337-

packages/2-sql/3-tooling/family/src/core/migrations/types.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,19 @@ export interface ExpandNativeTypeInput {
164164
readonly typeParams?: Record<string, unknown>;
165165
}
166166

167+
/**
168+
* Input for resolving an identity-value SQL literal used to backfill existing rows when
169+
* adding a NOT NULL column without an explicit default.
170+
*
171+
* "Identity value" in the algebraic (monoid) sense: the neutral element for the type
172+
* (0 for numbers, '' for strings, false for booleans, etc.).
173+
*/
174+
export interface ResolveIdentityValueInput {
175+
readonly nativeType: string;
176+
readonly codecId?: string;
177+
readonly typeParams?: Record<string, unknown>;
178+
}
179+
167180
export interface CodecControlHooks<TTargetDetails = unknown> {
168181
planTypeOperations?: (options: {
169182
readonly typeName: string;
@@ -194,6 +207,16 @@ export interface CodecControlHooks<TTargetDetails = unknown> {
194207
* Returns the expanded type string, or the original nativeType if no expansion is needed.
195208
*/
196209
expandNativeType?: (input: ExpandNativeTypeInput) => string;
210+
/**
211+
* Resolves the identity value (monoid neutral element) as a SQL literal for safely adding
212+
* a NOT NULL column without an explicit default to a non-empty table.
213+
*
214+
* Return semantics:
215+
* - string: use this literal
216+
* - null: explicitly no safe identity value is known; fall back to another strategy
217+
* - undefined: no opinion; planner may use built-in fallbacks
218+
*/
219+
resolveIdentityValue?: (input: ResolveIdentityValueInput) => string | null | undefined;
197220
}
198221

199222
export interface SqlControlExtensionDescriptor<TTargetId extends string>

packages/2-sql/3-tooling/family/src/exports/control.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export type {
4747
CreateSqlMigrationPlanOptions,
4848
ExpandNativeTypeInput,
4949
PslScalarTypeDescriptor,
50+
ResolveIdentityValueInput,
5051
SqlControlAdapterDescriptor,
5152
SqlControlExtensionDescriptor,
5253
SqlControlStaticContributions,

packages/3-extensions/pgvector/src/exports/control.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@ import { pgvectorOperationSignature, pgvectorPackMeta } from '../core/descriptor
77

88
const PGVECTOR_CODEC_ID = 'pg/vector@1' as const;
99

10+
function buildVectorIdentityValue(typeParams: Record<string, unknown> | undefined): string | null {
11+
const length = typeParams?.['length'];
12+
if (typeof length !== 'number' || !Number.isInteger(length) || length <= 0) {
13+
return null;
14+
}
15+
16+
const zeroVector = `[${new Array(length).fill('0').join(',')}]`;
17+
return `'${zeroVector}'::vector`;
18+
}
19+
1020
const vectorControlPlaneHooks: CodecControlHooks = {
1121
expandNativeType: ({ nativeType, typeParams }) => {
1222
const length = typeParams?.['length'];
@@ -15,6 +25,7 @@ const vectorControlPlaneHooks: CodecControlHooks = {
1525
}
1626
return nativeType;
1727
},
28+
resolveIdentityValue: ({ typeParams }) => buildVectorIdentityValue(typeParams),
1829
};
1930

2031
const pgvectorDatabaseDependencies: ComponentDatabaseDependencies<unknown> = {
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import type { CodecControlHooks } from '@prisma-next/family-sql/control';
2+
import type { StorageColumn } from '@prisma-next/sql-contract/types';
3+
import { ifDefined } from '@prisma-next/utils/defined';
4+
5+
/**
6+
* Resolves the identity value (monoid neutral element) as a SQL literal for a column's type.
7+
* Checks codec hooks first (extensions can provide type-specific identity values),
8+
* then falls back to the built-in map.
9+
*/
10+
export function resolveIdentityValue(
11+
column: StorageColumn,
12+
codecHooks: Map<string, CodecControlHooks>,
13+
): string | null {
14+
if (column.codecId) {
15+
const hookDefault = codecHooks.get(column.codecId)?.resolveIdentityValue?.({
16+
nativeType: column.nativeType,
17+
codecId: column.codecId,
18+
...ifDefined('typeParams', column.typeParams),
19+
});
20+
if (hookDefault !== undefined) {
21+
return hookDefault;
22+
}
23+
}
24+
25+
return buildBuiltinIdentityValue(column.nativeType, column.typeParams);
26+
}
27+
28+
/**
29+
* Returns the built-in identity value (monoid neutral element) as a SQL literal for the given
30+
* PostgreSQL native type — e.g. 0 for integers, '' for text, false for booleans.
31+
*
32+
* This is the planner's fallback when no codec hook provides a type-specific identity value.
33+
*
34+
* Returns null for unrecognized types (for example enums and extension-owned types without a
35+
* hook), which causes the planner to fall back to the empty-table precheck.
36+
*
37+
* @internal Exported for testing only.
38+
*/
39+
export function buildBuiltinIdentityValue(
40+
nativeType: string,
41+
typeParams?: Record<string, unknown>,
42+
): string | null {
43+
const normalizedNativeType = normalizeIdentityValueNativeType(nativeType);
44+
45+
if (normalizedNativeType.endsWith('[]')) {
46+
return "'{}'";
47+
}
48+
49+
switch (normalizedNativeType) {
50+
case 'text':
51+
case 'character':
52+
case 'bpchar':
53+
case 'character varying':
54+
case 'varchar':
55+
return "''";
56+
57+
case 'int2':
58+
case 'int4':
59+
case 'int8':
60+
case 'integer':
61+
case 'bigint':
62+
case 'smallint':
63+
case 'float4':
64+
case 'float8':
65+
case 'real':
66+
case 'double precision':
67+
case 'numeric':
68+
case 'decimal':
69+
return '0';
70+
71+
case 'bool':
72+
case 'boolean':
73+
return 'false';
74+
75+
case 'uuid':
76+
return "'00000000-0000-0000-0000-000000000000'";
77+
78+
case 'json':
79+
return "'{}'::json";
80+
case 'jsonb':
81+
return "'{}'::jsonb";
82+
83+
case 'date':
84+
case 'timestamp':
85+
case 'timestamptz':
86+
case 'timestamp with time zone':
87+
case 'timestamp without time zone':
88+
return "'epoch'";
89+
90+
case 'time':
91+
case 'time without time zone':
92+
return "'00:00:00'";
93+
case 'timetz':
94+
case 'time with time zone':
95+
return "'00:00:00+00'";
96+
97+
case 'interval':
98+
return "'0'";
99+
100+
case 'bytea':
101+
return "''::bytea";
102+
case 'tsvector':
103+
return "''::tsvector";
104+
105+
case 'bit':
106+
return buildBitIdentityValue(typeParams);
107+
case 'bit varying':
108+
case 'varbit':
109+
return "B''";
110+
111+
default:
112+
return null;
113+
}
114+
}
115+
116+
function normalizeIdentityValueNativeType(nativeType: string): string {
117+
return nativeType.trim().toLowerCase().replace(/\s+/g, ' ');
118+
}
119+
120+
function buildBitIdentityValue(typeParams?: Record<string, unknown>): string | null {
121+
const length = typeParams?.['length'];
122+
if (length === undefined) {
123+
return "B'0'";
124+
}
125+
if (typeof length !== 'number' || !Number.isInteger(length) || length <= 0) {
126+
return null;
127+
}
128+
return `B'${'0'.repeat(length)}'`;
129+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { quoteIdentifier } from '@prisma-next/adapter-postgres/control';
2+
import type { CodecControlHooks, SqlMigrationPlanOperation } from '@prisma-next/family-sql/control';
3+
import type { StorageColumn } from '@prisma-next/sql-contract/types';
4+
import type { PostgresPlanTargetDetails } from './planner';
5+
import {
6+
buildAddColumnSql,
7+
columnExistsCheck,
8+
columnHasNoDefaultCheck,
9+
columnNullabilityCheck,
10+
qualifyTableName,
11+
} from './planner-sql';
12+
import { buildTargetDetails } from './planner-target-details';
13+
14+
export function buildAddColumnOperationIdentity(
15+
schema: string,
16+
tableName: string,
17+
columnName: string,
18+
): Pick<
19+
SqlMigrationPlanOperation<PostgresPlanTargetDetails>,
20+
'id' | 'label' | 'summary' | 'target'
21+
> {
22+
return {
23+
id: `column.${tableName}.${columnName}`,
24+
label: `Add column ${columnName} to ${tableName}`,
25+
summary: `Adds column ${columnName} to table ${tableName}`,
26+
target: {
27+
id: 'postgres',
28+
details: buildTargetDetails('table', tableName, schema),
29+
},
30+
};
31+
}
32+
33+
export function buildAddNotNullColumnWithTemporaryDefaultOperation(options: {
34+
readonly schema: string;
35+
readonly tableName: string;
36+
readonly columnName: string;
37+
readonly column: StorageColumn;
38+
readonly codecHooks: Map<string, CodecControlHooks>;
39+
readonly temporaryDefault: string;
40+
}): SqlMigrationPlanOperation<PostgresPlanTargetDetails> {
41+
const { schema, tableName, columnName, column, codecHooks, temporaryDefault } = options;
42+
const qualified = qualifyTableName(schema, tableName);
43+
44+
return {
45+
...buildAddColumnOperationIdentity(schema, tableName, columnName),
46+
operationClass: 'additive',
47+
precheck: [
48+
{
49+
description: `ensure column "${columnName}" is missing`,
50+
sql: columnExistsCheck({ schema, table: tableName, column: columnName, exists: false }),
51+
},
52+
],
53+
execute: [
54+
{
55+
description: `add column "${columnName}"`,
56+
sql: buildAddColumnSql(qualified, columnName, column, codecHooks, temporaryDefault),
57+
},
58+
{
59+
description: `drop temporary default from column "${columnName}"`,
60+
sql: `ALTER TABLE ${qualified} ALTER COLUMN ${quoteIdentifier(columnName)} DROP DEFAULT`,
61+
},
62+
],
63+
postcheck: [
64+
{
65+
description: `verify column "${columnName}" exists`,
66+
sql: columnExistsCheck({ schema, table: tableName, column: columnName }),
67+
},
68+
{
69+
description: `verify column "${columnName}" is NOT NULL`,
70+
sql: columnNullabilityCheck({
71+
schema,
72+
table: tableName,
73+
column: columnName,
74+
nullable: false,
75+
}),
76+
},
77+
{
78+
description: `verify column "${columnName}" has no default after temporary default removal`,
79+
sql: columnHasNoDefaultCheck({ schema, table: tableName, column: columnName }),
80+
},
81+
],
82+
};
83+
}

packages/3-targets/3-targets/postgres/src/core/migrations/planner-reconciliation.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,13 @@ import { ifDefined } from '@prisma-next/utils/defined';
1111
import type { PlanningMode, PostgresPlanTargetDetails } from './planner';
1212
import {
1313
buildColumnTypeSql,
14-
buildTargetDetails,
1514
columnExistsCheck,
1615
columnNullabilityCheck,
1716
constraintExistsCheck,
1817
qualifyTableName,
1918
toRegclassLiteral,
20-
} from './planner';
19+
} from './planner-sql';
20+
import { buildTargetDetails } from './planner-target-details';
2121

2222
// ============================================================================
2323
// Public API

0 commit comments

Comments
 (0)