Skip to content
Merged
Show file tree
Hide file tree
Changes from 42 commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
b47fa35
fix(cli): unify JSON error output for db schema-verify and db sign
jkomyno Mar 10, 2026
089f5e7
chore: snapshot lint
jkomyno Mar 10, 2026
0cca55d
test(cli): implement CLI scenario catalog e2e test suite (Phases 1-4)
jkomyno Mar 10, 2026
1c0b7cf
fix(test): restore full drift-schema journey coverage (M/N)
jkomyno Mar 10, 2026
f76c329
fix(core-control-plane): export errorSchemaVerificationFailed from ba…
jkomyno Mar 10, 2026
50542a9
fix(test): restore P3 chain breakage recovery (find migration dir by …
jkomyno Mar 10, 2026
000a654
fix(test): implement U.01 target mismatch via contract.json tampering
jkomyno Mar 10, 2026
959309c
fix(test): resolve integration-tests typecheck errors
jkomyno Mar 10, 2026
4669eea
test: remove Journey H (brownfield extras) — covered by Journey N
jkomyno Mar 11, 2026
6ae09fe
test: remove Journeys P4 and P5 — covered by cli.migration-apply.e2e.…
jkomyno Mar 11, 2026
ef7e3c6
test: merge Journeys Q, R, X into Journey B; delete migration-edge-cases
jkomyno Mar 11, 2026
6bc3b72
test: remove Journey I (CI pipeline) and Journey J (help)
jkomyno Mar 11, 2026
f85c820
docs: update scenario catalog to reflect journey consolidation
jkomyno Mar 11, 2026
ce21c3e
feat: add test:journeys command for running CLI journey tests
jkomyno Mar 11, 2026
313716e
docs: add journey test README and describe scenarios in test comments
jkomyno Mar 12, 2026
b6a683f
chore: remove plan file from branch (kept locally)
jkomyno Mar 12, 2026
6063273
fix: remove obsolete eslint-plugin snapshot entries
jkomyno Mar 12, 2026
13c57d3
fix(cli): restore main's db-sign and db-schema-verify failure handling
jkomyno Mar 12, 2026
5b60ce5
fix(test): address CodeRabbit review feedback
jkomyno Mar 12, 2026
8e2b11a
refactor(core-control-plane): use ifDefined in errorSchemaVerificatio…
jkomyno Mar 12, 2026
e2ddada
fix(test): use == null to cover undefined from getExitCode()
jkomyno Mar 12, 2026
d5ab105
fix(test): use pathe and remove error-swallowing in journey helpers
jkomyno Mar 13, 2026
8f9982b
fix(core-control-plane): use concrete types in errorSchemaVerificatio…
jkomyno Mar 16, 2026
975e130
refactor(test): unify runCommand and runCommandRaw into runCommandCore
jkomyno Mar 16, 2026
171004f
refactor(test): remove setupJourneyNoDb, use setupJourney({ createTem…
jkomyno Mar 16, 2026
c45b0aa
fix(test): replace dynamic import with static import of runDbUpdate
jkomyno Mar 16, 2026
62bc14d
refactor(test): use static import for withClient in sql() helper
jkomyno Mar 16, 2026
8e936b4
docs(test): document pool: 'forks' as hard requirement in vitest config
jkomyno Mar 16, 2026
01d78b0
refactor(test): use useDevDatabase() and contractFixtures in journey …
jkomyno Mar 16, 2026
8f74861
fix(planner): emit executable DDL for NOT NULL columns on non-empty t…
jkomyno Mar 16, 2026
3eb0629
fix(planner): use unambiguous zero defaults and verify default removal
jkomyno Mar 16, 2026
179a493
fix(planner): add date type coverage and mark getTypeZeroDefault as i…
jkomyno Mar 16, 2026
69d0935
refactor(planner): collapse numeric switch groups and rename to build…
jkomyno Mar 16, 2026
2b43045
refactor(planner,runner): improve readability of skip and precheck co…
jkomyno Mar 16, 2026
3d0a6e0
refactor(test): extract planAddColumn helper in planner behavior tests
jkomyno Mar 16, 2026
a4d4780
docs: document db update reconciliation mode and NOT NULL column stra…
jkomyno Mar 16, 2026
2384066
fix(test): use ColumnDefaultLiteralInputValue instead of unknown in p…
jkomyno Mar 17, 2026
271dfc7
fix(runner): treat undefined origin the same as null in skip gate
jkomyno Mar 17, 2026
98ee266
fix(test): tighten journey test assertions per review feedback
jkomyno Mar 17, 2026
ba28e97
fix(test): correct error code and revert ANSI assertion
jkomyno Mar 17, 2026
c320087
Merge branch 'main' into fix/ddl-not-null
jkomyno Mar 19, 2026
3a57f7b
Merge branch 'main' into fix/ddl-not-null
jkomyno Mar 24, 2026
fefba88
fix(postgres): make temporary defaults extensible
jkomyno Mar 25, 2026
3b77676
fix(postgres): avoid placeholder defaults on constrained columns
jkomyno Mar 25, 2026
fd12d87
fix(postgres): stabilize timetz temporary defaults
jkomyno Mar 25, 2026
99ed751
test(postgres): cover constrained fallback planning
jkomyno Mar 25, 2026
f81fce6
fix(postgres): satisfy planner typecheck
jkomyno Mar 25, 2026
04e721a
Merge branch 'main' into fix/ddl-not-null
jkomyno Mar 26, 2026
837693a
Merge branch 'main' into fix/ddl-not-null
jkomyno Mar 26, 2026
196fd70
refactor: rename temporary-default helpers to identity-value terminology
jkomyno Mar 26, 2026
5a78738
Merge branch 'main' into fix/ddl-not-null
jkomyno Mar 30, 2026
7faaf74
refactor(planner): extract postgres SQL builders and fix json identit…
jkomyno Mar 30, 2026
2aaf712
Merge branch 'main' into fix/ddl-not-null
jkomyno Mar 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/architecture docs/subsystems/7. Migration System.md
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,16 @@ For CLI usage, output formats, and common failure modes, see the canonical comma

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).

### `db update` (live reconciliation)

`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.

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.

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.

**NOT NULL columns without defaults.** When adding a NOT NULL column to a potentially non-empty table, the planner uses a temporary type-appropriate zero default (`''` for text, `0` for integers, `false` for booleans, etc.) so existing rows receive a value. The temporary default is dropped immediately after the column is added. Existing rows permanently retain the zero value; future inserts must provide an explicit value.

## Multi-Service Namespacing

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).
Expand Down
128 changes: 121 additions & 7 deletions packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,16 +346,21 @@ class PostgresMigrationPlanner implements SqlMigrationPlanner<PostgresPlanTarget
const qualified = qualifyTableName(schema, tableName);
const notNull = column.nullable === false;
const hasDefault = column.default !== undefined;
// Only require empty table for NOT NULL columns WITHOUT defaults.
// PostgreSQL allows adding NOT NULL columns with defaults to non-empty tables
// because the default value is applied to existing rows.
const requiresEmptyTable = notNull && !hasDefault;
// For NOT NULL columns without an explicit default, use a temporary type-appropriate
// zero default so PostgreSQL can add the column to non-empty tables. The temporary
// default is dropped immediately after the column is added, leaving the column as
// NOT NULL with no default (matching the contract intent).
const needsTemporaryDefault = notNull && !hasDefault;
const temporaryDefault = needsTemporaryDefault
? buildTypeZeroDefaultLiteral(column.nativeType)
: null;
const requiresEmptyTableCheck = needsTemporaryDefault && temporaryDefault === null;
const precheck = [
{
description: `ensure column "${columnName}" is missing`,
sql: columnExistsCheck({ schema, table: tableName, column: columnName, exists: false }),
},
...(requiresEmptyTable
...(requiresEmptyTableCheck
? [
{
description: `ensure table "${tableName}" is empty before adding NOT NULL column without default`,
Expand All @@ -367,8 +372,17 @@ class PostgresMigrationPlanner implements SqlMigrationPlanner<PostgresPlanTarget
const execute = [
{
description: `add column "${columnName}"`,
sql: buildAddColumnSql(qualified, columnName, column, codecHooks),
sql: buildAddColumnSql(qualified, columnName, column, codecHooks, temporaryDefault),
},
// Drop the temporary default so future inserts must provide an explicit value.
...(temporaryDefault
? [
{
description: `drop temporary default from column "${columnName}"`,
sql: `ALTER TABLE ${qualified} ALTER COLUMN ${quoteIdentifier(columnName)} DROP DEFAULT`,
Comment thread
jkomyno marked this conversation as resolved.
Outdated
},
]
: []),
];
const postcheck = [
{
Expand All @@ -388,6 +402,14 @@ class PostgresMigrationPlanner implements SqlMigrationPlanner<PostgresPlanTarget
},
]
: []),
...(temporaryDefault
? [
{
description: `verify column "${columnName}" has no default after temporary default removal`,
sql: columnHasNoDefaultCheck({ schema, table: tableName, column: columnName }),
},
]
: []),
];

return {
Expand Down Expand Up @@ -980,14 +1002,106 @@ function tableIsEmptyCheck(qualifiedTableName: string): string {
return `SELECT NOT EXISTS (SELECT 1 FROM ${qualifiedTableName} LIMIT 1)`;
}

function columnHasNoDefaultCheck(opts: { schema: string; table: string; column: string }): string {
return `SELECT NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = '${escapeLiteral(opts.schema)}'
AND table_name = '${escapeLiteral(opts.table)}'
AND column_name = '${escapeLiteral(opts.column)}'
AND column_default IS NOT NULL
)`;
}

/**
* Returns a type-appropriate zero-value SQL literal for the given PostgreSQL native type.
* Used as a temporary DEFAULT when adding a NOT NULL column without an explicit default
* to a potentially non-empty table. Existing rows permanently receive this zero value.
*
* Returns null for unrecognized types (e.g. enums, arrays, extensions), which causes
* the planner to fall back to the empty-table precheck.
*
* @internal Exported for testing only.
*/
export function buildTypeZeroDefaultLiteral(nativeType: string): string | null {
switch (nativeType.toLowerCase()) {
// String types
case 'text':
case 'character':
case 'character varying':
return "''";

// Numeric types (integer, float, decimal)
case 'int2':
case 'int4':
case 'int8':
case 'integer':
case 'bigint':
case 'smallint':
case 'float4':
case 'float8':
case 'real':
case 'double precision':
case 'numeric':
case 'decimal':
return '0';

// Boolean
case 'bool':
case 'boolean':
return 'false';

// UUID
case 'uuid':
return "'00000000-0000-0000-0000-000000000000'";

// JSON types — use empty object rather than JSON null to avoid
// ambiguity with JS null when drivers deserialize the value.
case 'json':
case 'jsonb':
return "'{}'::jsonb";
Comment thread
jkomyno marked this conversation as resolved.
Outdated

// Date and timestamp types
case 'date':
case 'timestamp':
case 'timestamptz':
case 'timestamp with time zone':
case 'timestamp without time zone':
return "'epoch'";

// Time types
case 'time':
case 'timetz':
case 'time with time zone':
case 'time without time zone':
return "'00:00:00'";

// Interval
case 'interval':
return "'0'";

// Bit types
case 'bit':
return "B'0'";
case 'bit varying':
return "B''";

default:
return null;
}
}

function buildAddColumnSql(
qualifiedTableName: string,
columnName: string,
column: StorageColumn,
codecHooks: Map<string, CodecControlHooks>,
temporaryDefault?: string | null,
Comment thread
jkomyno marked this conversation as resolved.
Outdated
): string {
const typeSql = buildColumnTypeSql(column, codecHooks);
const defaultSql = buildColumnDefaultSql(column.default, column);
const defaultSql =
buildColumnDefaultSql(column.default, column) ||
(temporaryDefault ? `DEFAULT ${temporaryDefault}` : '');
const parts = [
`ALTER TABLE ${qualifiedTableName}`,
`ADD COLUMN ${quoteIdentifier(columnName)} ${typeSql}`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,11 +117,12 @@ class PostgresMigrationRunner implements SqlMigrationRunner<PostgresPlanTargetDe
return markerCheck;
}

// Apply plan operations or skip if marker already at destination
// db update (origin: null) always applies; migration-apply (origin set) skips if marker matches.
const markerAtDestination = this.markerMatchesDestination(existingMarker, options.plan);
const skipOperations = markerAtDestination && options.plan.origin != null;
let applyValue: ApplyPlanSuccessValue;

if (markerAtDestination) {
if (skipOperations) {
applyValue = { operationsExecuted: 0, executedOperations: [] };
} else {
const applyResult = await this.applyPlan(driver, options);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import type { ColumnDefaultLiteralInputValue } from '@prisma-next/contract/types';
import { coreHash, profileHash } from '@prisma-next/contract/types';
import { INIT_ADDITIVE_POLICY } from '@prisma-next/family-sql/control';
import type { SqlContract, SqlStorage } from '@prisma-next/sql-contract/types';
import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types';
import { describe, expect, it } from 'vitest';
import { createPostgresMigrationPlanner } from '../../src/core/migrations/planner';
import {
buildTypeZeroDefaultLiteral,
createPostgresMigrationPlanner,
} from '../../src/core/migrations/planner';

describe('PostgresMigrationPlanner - subset/superset/conflict handling', () => {
const planner = createPostgresMigrationPlanner();
Expand Down Expand Up @@ -119,6 +123,112 @@ describe('PostgresMigrationPlanner - subset/superset/conflict handling', () => {
});
});

describe('NOT NULL column without default uses temporary default', () => {
it('emits 2-step execute (add with temp default, drop default) for NOT NULL text column', () => {
const addCol = planAddColumn('name', {
nativeType: 'text',
codecId: 'pg/text@1',
nullable: false,
});

// No empty-table precheck
expect(addCol.precheck.map((p) => p.description)).not.toContainEqual(
expect.stringContaining('empty'),
);

// 2-step execute: add with temporary default, then drop default
expect(addCol.execute).toHaveLength(2);
expect(addCol.execute[0]!.sql).toContain("DEFAULT ''");
expect(addCol.execute[0]!.sql).toContain('NOT NULL');
expect(addCol.execute[1]!.sql).toContain('DROP DEFAULT');
Comment thread
jkomyno marked this conversation as resolved.
Outdated

// Postcheck includes verification that temporary default was removed
expect(addCol.postcheck.map((p) => p.description)).toContainEqual(
expect.stringContaining('no default'),
);
});

it('emits 2-step execute for NOT NULL int4 column', () => {
const addCol = planAddColumn('age', {
nativeType: 'int4',
codecId: 'pg/int4@1',
nullable: false,
});

expect(addCol.execute).toHaveLength(2);
expect(addCol.execute[0]!.sql).toContain('DEFAULT 0');
expect(addCol.execute[0]!.sql).toContain('NOT NULL');
expect(addCol.execute[1]!.sql).toContain('DROP DEFAULT');
});

it('skips temporary default for nullable columns', () => {
const addCol = planAddColumn('bio', {
nativeType: 'text',
codecId: 'pg/text@1',
nullable: true,
});

expect(addCol.execute).toHaveLength(1);
expect(addCol.execute[0]!.sql).not.toContain('DEFAULT');
expect(addCol.execute[0]!.sql).not.toContain('NOT NULL');
});

it('skips temporary default for NOT NULL columns with explicit default', () => {
const addCol = planAddColumn('active', {
nativeType: 'bool',
codecId: 'pg/bool@1',
nullable: false,
default: { kind: 'literal', value: true },
});

// Single execute step (explicit default, no temporary default needed)
expect(addCol.execute).toHaveLength(1);
expect(addCol.execute[0]!.sql).toContain('DEFAULT true');
expect(addCol.execute[0]!.sql).toContain('NOT NULL');
});
});

describe('buildTypeZeroDefaultLiteral', () => {
it.each([
['text', "''"],
['character', "''"],
['character varying', "''"],
['int2', '0'],
['int4', '0'],
['int8', '0'],
['integer', '0'],
['bigint', '0'],
['smallint', '0'],
['float4', '0'],
['float8', '0'],
['real', '0'],
['double precision', '0'],
['numeric', '0'],
['decimal', '0'],
['bool', 'false'],
['boolean', 'false'],
['uuid', "'00000000-0000-0000-0000-000000000000'"],
['json', "'{}'::jsonb"],
['jsonb', "'{}'::jsonb"],
['date', "'epoch'"],
['timestamp', "'epoch'"],
['timestamptz', "'epoch'"],
['time', "'00:00:00'"],
['timetz', "'00:00:00'"],
['interval', "'0'"],
['bit', "B'0'"],
['bit varying', "B''"],
] as const)('returns %s → %s', (nativeType, expected) => {
expect(buildTypeZeroDefaultLiteral(nativeType)).toBe(expected);
});

it('returns null for unknown types (enum, array, extension)', () => {
expect(buildTypeZeroDefaultLiteral('my_enum')).toBeNull();
expect(buildTypeZeroDefaultLiteral('int4[]')).toBeNull();
expect(buildTypeZeroDefaultLiteral('tsvector')).toBeNull();
});
});

function createTestContract(overrides?: Partial<SqlContract<SqlStorage>>): SqlContract<SqlStorage> {
return {
schemaVersion: '1',
Expand Down Expand Up @@ -183,6 +293,61 @@ function buildUserTableSchema(): SqlSchemaIR['tables'][string] {
};
}

/**
* Plans adding a single column to the user table and returns the resulting operation.
* The schema contains only the `id` column, so the planner generates an ADD COLUMN for `columnName`.
*/
function planAddColumn(
columnName: string,
columnDef: {
nativeType: string;
codecId: string;
nullable: boolean;
default?: { kind: 'literal'; value: ColumnDefaultLiteralInputValue };
},
) {
const planner = createPostgresMigrationPlanner();
const contract = createTestContract({
storage: {
tables: {
user: {
columns: {
id: { nativeType: 'uuid', codecId: 'pg/uuid@1', nullable: false },
[columnName]: columnDef,
},
primaryKey: { columns: ['id'] },
uniques: [],
indexes: [],
foreignKeys: [],
},
},
},
});
const schema: SqlSchemaIR = {
tables: {
user: {
name: 'user',
columns: { id: { name: 'id', nativeType: 'uuid', nullable: false } },
primaryKey: { columns: ['id'] },
uniques: [],
foreignKeys: [],
indexes: [],
},
},
dependencies: [],
};
const result = planner.plan({
contract,
schema,
policy: INIT_ADDITIVE_POLICY,
frameworkComponents: [],
});
if (result.kind !== 'success') throw new Error('expected planner success');
const op = result.plan.operations.find((o) => o.id === `column.user.${columnName}`);
if (!op) throw new Error(`operation column.user.${columnName} not found`);
return op;
}

function buildPostTableSchema(): SqlSchemaIR['tables'][string] {
return {
name: 'post',
Expand Down
Loading
Loading