Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
80b6971
fix(cli): unify JSON error output for db schema-verify and db sign
jkomyno Mar 10, 2026
d6e33e1
test(cli): implement CLI scenario catalog e2e test suite (Phases 1-4)
jkomyno Mar 10, 2026
28b5b24
test: merge Journeys Q, R, X into Journey B; delete migration-edge-cases
jkomyno Mar 11, 2026
e2fa559
test: remove Journey I (CI pipeline) and Journey J (help)
jkomyno Mar 11, 2026
a9ffb00
docs: update scenario catalog to reflect journey consolidation
jkomyno Mar 11, 2026
76940e8
chore: remove plan file from branch (kept locally)
jkomyno Mar 12, 2026
e4b6d21
fix: remove obsolete eslint-plugin snapshot entries
jkomyno Mar 12, 2026
7cbf118
fix(cli): restore main's db-sign and db-schema-verify failure handling
jkomyno Mar 12, 2026
eaad5a5
fix(planner): emit executable DDL for NOT NULL columns on non-empty t…
jkomyno Mar 16, 2026
f9efde2
docs: document db update reconciliation mode and NOT NULL column stra…
jkomyno Mar 16, 2026
7405a26
fix(test): tighten journey test assertions per review feedback
jkomyno Mar 17, 2026
4df38a8
fix(test): correct error code and revert ANSI assertion
jkomyno Mar 17, 2026
d98b37d
fix(postgres): make temporary defaults extensible
jkomyno Mar 25, 2026
cfc0c96
refactor: extract planner-types.ts from migration planner
jkomyno Mar 27, 2026
f0e3023
refactor: extract planner-sql-checks.ts from migration planner
jkomyno Mar 27, 2026
ad649a7
refactor: extract planner-ddl-builders.ts from migration planner
jkomyno Mar 27, 2026
4cb84a8
refactor: extract planner-schema-lookup.ts from migration planner
jkomyno Mar 27, 2026
3891dbb
test: add unit tests for planner-sql-checks and planner-ddl-builders
jkomyno Mar 27, 2026
c45c325
fix(postgres): address CodeRabbit planner review
jkomyno Mar 31, 2026
a2dcb13
fix(postgres): address PR #260 code review feedback
wmadden Apr 2, 2026
a39caf6
fix(postgres): resolve rebase conflicts with modularized planner imports
wmadden Apr 2, 2026
971f80b
fix: resolve remaining rebase artifacts
wmadden Apr 2, 2026
0e24b55
test(postgres): add buildExpectedFormatType unit tests
wmadden Apr 2, 2026
707ec60
refactor(postgres): rename planner-types back to planner-target-details
wmadden Apr 2, 2026
6d64683
fix(postgres): make test mock match ExpandNativeTypeInput signature
wmadden Apr 2, 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
2 changes: 1 addition & 1 deletion docs/architecture docs/subsystems/7. Migration System.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@ Unlike `migration apply`, which replays serialized edges, `db update` always wor

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 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.
**NOT NULL columns without defaults.** The planner first resolves a temporary default by consulting codec hooks (for parameterized or extension-owned types) and then built-in fallbacks (`''` for text, `0` for integers, `false` for booleans, `'{}'` for arrays, `''::tsvector` for `tsvector`, length-aware zero literals for `bit(n)`, etc.). When a safe literal is available, it emits a reusable 2-step recipe: add the column with the temporary default, then drop the default immediately after. Existing rows permanently retain the backfilled value; future inserts must provide an explicit value. When no safe temporary default is known, the planner falls back to the empty-table precheck path.

## Multi-Service Namespacing

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`ESLint Plugin Rule Validation > should handle 'ignored' case: 'mock-sql-builder.ts' > ignored-mock-sql-builder 1`] = `
{
"errorCount": 0,
"filePath": "mock-sql-builder.ts",
"messages": [],
"warningCount": 0,
}
`;

exports[`ESLint Plugin Rule Validation > should handle 'invalid' case: 'query-with-large-limit.ts' > invalid-query-with-large-limit 1`] = `
{
"errorCount": 1,
"filePath": "query-with-large-limit.ts",
"messages": [
{
"column": 1,
"line": 12,
"message": "Query build() call has a limit() value that exceeds the maximum allowed of 1000.",
"ruleId": "@prisma-next/lint-build-call",
"severity": 2,
},
],
"warningCount": 0,
}
`;

exports[`ESLint Plugin Rule Validation > should handle 'invalid' case: 'select-without-limit.ts' > invalid-select-without-limit 1`] = `
{
"errorCount": 1,
"filePath": "select-without-limit.ts",
"messages": [
{
"column": 1,
"line": 12,
"message": "Query build() call may result in unbounded query. Consider adding .limit() to prevent fetching too many rows.",
"ruleId": "@prisma-next/lint-build-call",
"severity": 2,
},
],
"warningCount": 0,
}
`;

exports[`ESLint Plugin Rule Validation > should handle 'valid' case: 'delete-query.ts' > valid-delete-query 1`] = `
{
"errorCount": 0,
"filePath": "delete-query.ts",
"messages": [],
"warningCount": 0,
}
`;

exports[`ESLint Plugin Rule Validation > should handle 'valid' case: 'insert-query.ts' > valid-insert-query 1`] = `
{
"errorCount": 0,
"filePath": "insert-query.ts",
"messages": [],
"warningCount": 0,
}
`;

exports[`ESLint Plugin Rule Validation > should handle 'valid' case: 'select-with-limit.ts' > valid-select-with-limit 1`] = `
{
"errorCount": 0,
"filePath": "select-with-limit.ts",
"messages": [],
"warningCount": 0,
}
`;

exports[`ESLint Plugin Rule Validation > should handle 'valid' case: 'update-query.ts' > valid-update-query 1`] = `
{
"errorCount": 0,
"filePath": "update-query.ts",
"messages": [],
"warningCount": 0,
}
`;
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
StorageTable,
} from '@prisma-next/sql-contract/types';
import type { PostgresColumnDefault } from '../types';
import { qualifyTableName } from './planner-sql-checks';

export function buildCreateTableSql(
qualifiedTableName: string,
Expand Down Expand Up @@ -126,6 +127,7 @@ function renderParameterizedTypeSql(
return expanded !== column.nativeType ? expanded : null;
}

/** Autoincrement columns use SERIAL types, so this returns empty for them. */
export function buildColumnDefaultSql(
columnDefault: PostgresColumnDefault | undefined,
column?: StorageColumn,
Expand All @@ -145,7 +147,7 @@ export function buildColumnDefaultSql(
return `DEFAULT (${columnDefault.expression})`;
}
case 'sequence':
return `DEFAULT nextval(${quoteIdentifier(columnDefault.name)}::regclass)`;
return `DEFAULT nextval('${escapeLiteral(quoteIdentifier(columnDefault.name))}'::regclass)`;
}
}

Expand Down Expand Up @@ -180,214 +182,17 @@ export function renderDefaultLiteral(value: unknown, column?: StorageColumn): st
return `'${escapeLiteral(json)}'`;
}

export function qualifyTableName(schema: string, table: string): string {
return `${quoteIdentifier(schema)}.${quoteIdentifier(table)}`;
}

export function toRegclassLiteral(schema: string, name: string): string {
const regclass = `${quoteIdentifier(schema)}.${quoteIdentifier(name)}`;
return `'${escapeLiteral(regclass)}'`;
}

export function constraintExistsCheck({
constraintName,
schema,
table,
exists = true,
}: {
constraintName: string;
schema: string;
table: string;
exists?: boolean;
}): string {
const existsClause = exists ? 'EXISTS' : 'NOT EXISTS';
return `SELECT ${existsClause} (
SELECT 1 FROM pg_constraint c
JOIN pg_namespace n ON c.connamespace = n.oid
WHERE c.conname = '${escapeLiteral(constraintName)}'
AND n.nspname = '${escapeLiteral(schema)}'
AND c.conrelid = to_regclass(${toRegclassLiteral(schema, table)})
)`;
}

export function columnExistsCheck({
schema,
table,
column,
exists = true,
}: {
schema: string;
table: string;
column: string;
exists?: boolean;
}): string {
const existsClause = exists ? '' : 'NOT ';
return `SELECT ${existsClause}EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = '${escapeLiteral(schema)}'
AND table_name = '${escapeLiteral(table)}'
AND column_name = '${escapeLiteral(column)}'
)`;
}

export function columnNullabilityCheck({
schema,
table,
column,
nullable,
}: {
schema: string;
table: string;
column: string;
nullable: boolean;
}): string {
const expected = nullable ? 'YES' : 'NO';
return `SELECT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = '${escapeLiteral(schema)}'
AND table_name = '${escapeLiteral(table)}'
AND column_name = '${escapeLiteral(column)}'
AND is_nullable = '${expected}'
)`;
}

/**
* Maps contract native type names to the display form returned by PostgreSQL's
* `format_type()`. Base types use short names in the contract (e.g., `int4`)
* but `format_type()` returns SQL-standard names (e.g., `integer`).
*
* NOTE: The inverse mapping lives in `normalizeFormattedType` in control-adapter.ts.
* These two maps must stay in sync. A shared bidirectional map in
* @prisma-next/adapter-postgres would eliminate the drift risk.
*/
const FORMAT_TYPE_DISPLAY: ReadonlyMap<string, string> = new Map([
['int2', 'smallint'],
['int4', 'integer'],
['int8', 'bigint'],
['float4', 'real'],
['float8', 'double precision'],
['bool', 'boolean'],
['timestamp', 'timestamp without time zone'],
['timestamptz', 'timestamp with time zone'],
['time', 'time without time zone'],
['timetz', 'time with time zone'],
]);

/**
* Builds the string that `format_type(atttypid, atttypmod)` would return for a
* contract column. Used for postchecks — separate from `buildColumnTypeSql` which
* produces DDL-safe strings (e.g., quoted identifiers, SERIAL).
*/
export function buildExpectedFormatType(
column: StorageColumn,
codecHooks: Map<string, CodecControlHooks>,
): string {
// Parameterized types: expand with typeParams.
// format_type() returns the same form (e.g., 'character varying(255)').
if (column.typeParams && column.codecId) {
const hooks = codecHooks.get(column.codecId);
if (hooks?.expandNativeType) {
return hooks.expandNativeType({
nativeType: column.nativeType,
codecId: column.codecId,
typeParams: column.typeParams,
});
}
}

// User-defined types (enums, composites): format_type() double-quotes names
// that contain uppercase characters (e.g., "StatusType") but returns lowercase
// names bare (e.g., status_type). We can't use quoteIdentifier() here because
// it always quotes, which would break the lowercase case.
if (column.typeRef) {
const needsQuoting = column.nativeType !== column.nativeType.toLowerCase();
return needsQuoting ? `"${column.nativeType}"` : column.nativeType;
}

// Base types: map contract short names to format_type() display names.
return FORMAT_TYPE_DISPLAY.get(column.nativeType) ?? column.nativeType;
}

/** Checks that the column's full type (including typmods) matches the expected type via `format_type()`. */
export function columnTypeCheck({
schema,
table,
column,
expectedType,
}: {
schema: string;
table: string;
column: string;
expectedType: string;
}): string {
return `SELECT EXISTS (
SELECT 1
FROM pg_attribute a
JOIN pg_class c ON c.oid = a.attrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = '${escapeLiteral(schema)}'
AND c.relname = '${escapeLiteral(table)}'
AND a.attname = '${escapeLiteral(column)}'
AND format_type(a.atttypid, a.atttypmod) = '${escapeLiteral(expectedType)}'
AND NOT a.attisdropped
)`;
}

/** Checks that a column default exists (or does not exist) via `information_schema.columns.column_default`. */
export function columnDefaultExistsCheck({
schema,
table,
column,
exists = true,
}: {
schema: string;
table: string;
column: string;
exists?: boolean;
}): string {
const nullCheck = exists ? 'IS NOT NULL' : 'IS NULL';
return `SELECT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = '${escapeLiteral(schema)}'
AND table_name = '${escapeLiteral(table)}'
AND column_name = '${escapeLiteral(column)}'
AND column_default ${nullCheck}
)`;
}

export function tableIsEmptyCheck(qualifiedTableName: string): string {
return `SELECT NOT EXISTS (SELECT 1 FROM ${qualifiedTableName} LIMIT 1)`;
}

export 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
)`;
}

export function buildAddColumnSql(
qualifiedTableName: string,
columnName: string,
column: StorageColumn,
codecHooks: Map<string, CodecControlHooks>,
defaultLiteral?: string | null,
temporaryDefault?: string | null,
): string {
const typeSql = buildColumnTypeSql(column, codecHooks);
const defaultSql =
buildColumnDefaultSql(column.default, column) ||
(defaultLiteral != null ? `DEFAULT ${defaultLiteral}` : '');
(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
@@ -1,15 +1,14 @@
import { quoteIdentifier } from '@prisma-next/adapter-postgres/control';
import type { CodecControlHooks, SqlMigrationPlanOperation } from '@prisma-next/family-sql/control';
import type { StorageColumn } from '@prisma-next/sql-contract/types';
import type { PostgresPlanTargetDetails } from './planner';
import { buildAddColumnSql } from './planner-ddl-builders';
import {
buildAddColumnSql,
columnExistsCheck,
columnHasNoDefaultCheck,
columnNullabilityCheck,
qualifyTableName,
} from './planner-sql';
import { buildTargetDetails } from './planner-target-details';
} from './planner-sql-checks';
import { buildTargetDetails, type PostgresPlanTargetDetails } from './planner-target-details';

export function buildAddColumnOperationIdentity(
schema: string,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,8 @@ import type {
import type { SqlContract, SqlStorage, StorageColumn } from '@prisma-next/sql-contract/types';
import { invariant } from '@prisma-next/utils/assertions';
import { ifDefined } from '@prisma-next/utils/defined';
import type { PlanningMode, PostgresPlanTargetDetails } from './planner';
import { buildColumnDefaultSql, buildColumnTypeSql } from './planner-ddl-builders';
import {
buildColumnDefaultSql,
buildColumnTypeSql,
buildExpectedFormatType,
columnDefaultExistsCheck,
columnExistsCheck,
Expand All @@ -21,8 +19,12 @@ import {
constraintExistsCheck,
qualifyTableName,
toRegclassLiteral,
} from './planner-sql';
import { buildTargetDetails } from './planner-target-details';
} from './planner-sql-checks';
import {
buildTargetDetails,
type PlanningMode,
type PostgresPlanTargetDetails,
} from './planner-target-details';

// ============================================================================
// Public API
Expand Down
Loading
Loading