Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
8274a8f
feat(sql-contract): shared index lowering carries expression, where, …
wmadden-electric Jul 24, 2026
5afef24
fix(sql-contract): batch the D9 warning per contract build
wmadden-electric Jul 24, 2026
622475b
feat(psl): contributed diagnostic codes and attribute-anchored refines
wmadden-electric Jul 24, 2026
27e2d76
feat(contract-psl): @@index authors the full parameter matrix with sp…
wmadden-electric Jul 24, 2026
a5dd5bc
feat(contract-ts): constraints.index authors the full parameter matrix
wmadden-electric Jul 24, 2026
803fe72
feat(target-sqlite): reject expression and partial indexes at namespa…
wmadden-electric Jul 24, 2026
71e56fd
test(contract-psl): PSL/TS parity across the index parameter matrix
wmadden-electric Jul 24, 2026
11e88d2
test(integration): ciphers expression-index journey authored through …
wmadden-electric Jul 24, 2026
44cf817
test: index-drift scenarios and additive-only body-edit degradation
wmadden-electric Jul 24, 2026
4b77417
docs: index parameter matrix in the authoring references
wmadden-electric Jul 24, 2026
8b71487
fix(sql-schema-ir): normalize the btree default at SqlIndexIR constru…
wmadden-electric Jul 24, 2026
7fdf4e7
docs: btree spelling change converges as a phase-2 rename, not create…
wmadden-electric Jul 24, 2026
c42cd95
project(functional-indexes): slice-3 spec + plan (rls-exact-names)
wmadden-electric Jul 24, 2026
800c336
refactor(sql-authoring): authoring index types carry the disjoint ele…
wmadden-electric Jul 24, 2026
07b3a48
docs(framework-components): the diagnostic-code union's two arms are …
wmadden-electric Jul 24, 2026
71b6066
fix(contract-ts): the options erasure cast is an audited blindCast; t…
wmadden-electric Jul 24, 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/reference/error-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ The migration-file CLI (`prisma-next-migration`) received a flag it does not rec

### CONTRACT.ARGUMENT_INVALID

A builder or helper on the contract-authoring surface is called with a bad argument: a composed authoring helper receives too many arguments or a malformed trailing options object, `field.sql({ id })` / `field.sql({ unique })` is used without a matching inline `.id(...)` / `.unique(...)` declaration, `model("Name", ...)` is called without a model definition, or a nanoid ID generator is given a size outside 2–255. Raised while authoring/building the contract, before emit. Meta: `helperPath`, `expected`, `received`.
A builder or helper on the contract-authoring surface is called with a bad argument: a composed authoring helper receives too many arguments or a malformed trailing options object, `field.sql({ id })` / `field.sql({ unique })` is used without a matching inline `.id(...)` / `.unique(...)` declaration, `model("Name", ...)` is called without a model definition, a nanoid ID generator is given a size outside 2–255, or an authored index combines its cross-field parameters invalidly (fields and an expression together or neither, an expression without `name:`/`map:`, or `map:` combined with `name:`). Also raised when a contract targets SQLite and declares an expression or partial index — SQLite's namespace construction rejects `expression:`/`where:` because the target does not support them. Raised while authoring/building the contract, before emit. Meta: varies per site.

### CONTRACT.CODEC_DESCRIPTOR_MISSING

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export type { AuthoringPslBlockDescriptorNamespace } from '../shared/framework-authoring';
export type {
ContributedPslDiagnosticCode,
PslBlockParam,
PslBlockParamList,
PslBlockParamOption,
Expand All @@ -22,10 +23,25 @@ export type {
import { blindCast } from '@prisma-next/utils/casts';
import type { CodecLookup } from '../shared/codec-types';
import type { AuthoringPslBlockDescriptorNamespace } from '../shared/framework-authoring';
import type { PslDiagnosticCode, PslExtensionBlock, PslSpan } from '../shared/psl-extension-block';
import type {
ContributedPslDiagnosticCode,
PslDiagnosticCode,
PslExtensionBlock,
PslSpan,
} from '../shared/psl-extension-block';

export interface PslDiagnostic {
readonly code: PslDiagnosticCode;
/**
* The closed {@link PslDiagnosticCode} set exists for IDE completion and
* as documentation of the framework's own codes; the
* {@link ContributedPslDiagnosticCode} pattern arm is the seam for
* package-contributed codes, minted where the domain vocabulary lives
* (e.g. contract-psl). The two arms are extensionally equal — every
* framework code matches the pattern — and nothing switches
* exhaustively over the union, deliberately: consumers treat the code
* as an opaque identifier.
*/
readonly code: PslDiagnosticCode | ContributedPslDiagnosticCode;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this? Why do we have a union here at all?

readonly message: string;
readonly sourceId: string;
readonly span: PslSpan;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,18 @@ export type PslDiagnosticCode =
*/
| 'PSL_DUPLICATE_DECLARATION';

/**
* A PSL diagnostic code contributed by a family or target package (e.g. an
* attribute-spec refine in a family's authoring layer). The framework union
* above stays the parser's own vocabulary; contributed codes share the
* `PSL_` pattern but their names are owned by the contributing package —
* no family or target vocabulary enters this module. Contributed codes
* must not reuse a framework code name; pick a domain-scoped prefix
* segment (e.g. `PSL_INDEX_`, `PSL_POLICY_`) to keep the namespaces
* collision-free.
*/
export type ContributedPslDiagnosticCode = `PSL_${string}`;

/**
* Descriptor vocabulary for a single parameter on a declared block.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,14 @@ import type { InterpretCtx } from '../types';

export const ATTRIBUTE_DIAGNOSTIC_CODE: PslDiagnosticCode = 'PSL_INVALID_ATTRIBUTE_SYNTAX';

export function leafDiagnostic(ctx: InterpretCtx, node: AstNode, message: string): PslDiagnostic {
export function leafDiagnostic(
ctx: InterpretCtx,
node: AstNode,
message: string,
code: PslDiagnostic['code'] = ATTRIBUTE_DIAGNOSTIC_CODE,
): PslDiagnostic {
return {
code: ATTRIBUTE_DIAGNOSTIC_CODE,
code,
message,
sourceId: ctx.sourceId,
span: nodePslSpan(node.syntax, ctx.sourceFile),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { PslDiagnostic } from '@prisma-next/framework-components/psl-ast';
import type { AstNode } from '../syntax/ast-helpers';
import type { AttributeOut, AttributeSpec, InterpretCtx, Param, PositionalParam } from './types';

interface FieldAttributeConfig<
Expand All @@ -10,6 +11,7 @@ interface FieldAttributeConfig<
readonly refine?: (
parsed: AttributeOut<Pos, Named>,
ctx: InterpretCtx,
attributeNode: AstNode,
) => readonly PslDiagnostic[];
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ export function interpretAttribute<Out>(
'The engine builds the output object structurally from the spec; TypeScript cannot relate the dynamically-keyed record to the spec-inferred output type.'
>(bound.value);
if (spec.refine !== undefined) {
const refineDiagnostics = spec.refine(value, ctx);
const refineDiagnostics = spec.refine(value, ctx, attrNode);
if (refineDiagnostics.length > 0) {
return notOk<readonly PslDiagnostic[]>(refineDiagnostics);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { PslDiagnostic } from '@prisma-next/framework-components/psl-ast';
import type { AstNode } from '../syntax/ast-helpers';
import type { AttributeOut, AttributeSpec, InterpretCtx, Param, PositionalParam } from './types';

interface ModelAttributeConfig<
Expand All @@ -10,6 +11,7 @@ interface ModelAttributeConfig<
readonly refine?: (
parsed: AttributeOut<Pos, Named>,
ctx: InterpretCtx,
attributeNode: AstNode,
) => readonly PslDiagnostic[];
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Simplify, UnionToIntersection } from '@prisma-next/utils/types';
import type { SourceFile } from '../source-file';
import type { FieldSymbol, ModelSymbol } from '../symbol-table';
import type { ExpressionAst } from '../syntax/ast/expressions';
import type { AstNode } from '../syntax/ast-helpers';

export type AttributeLevel = 'field' | 'model' | 'block';

Expand Down Expand Up @@ -43,7 +44,16 @@ export interface AttributeSpec<Out> {
readonly name: string;
readonly positional: readonly PositionalParam[];
readonly named: Readonly<Record<string, Param<unknown>>>;
readonly refine?: (parsed: Out, ctx: InterpretCtx) => readonly PslDiagnostic[];
/**
* Cross-argument validation after all arguments parse. `attributeNode` is
* the attribute's own AST node so refines can span-anchor their
* diagnostics at the attribute rather than the enclosing model.
*/
readonly refine?: (
parsed: Out,
ctx: InterpretCtx,
attributeNode: AstNode,
) => readonly PslDiagnostic[];
}

export type OutOf<P> = P extends ArgType<infer T> ? T : never;
Expand Down
6 changes: 5 additions & 1 deletion packages/2-sql/1-core/contract/src/contract-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import { structuredError } from '@prisma-next/utils/structured-error';

type ContractCode = `CONTRACT.${ContractSubcode}`;

type ContractSubcode = 'PACK_CONTRIBUTION_INVALID' | 'TABLE_AMBIGUOUS' | 'VALIDATION_FAILED';
type ContractSubcode =
| 'ARGUMENT_INVALID'
| 'PACK_CONTRIBUTION_INVALID'
| 'TABLE_AMBIGUOUS'
| 'VALIDATION_FAILED';

export function contractError(
code: ContractCode,
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1,6 @@
export { type AuthoredIndexInput, lowerAuthoredIndex } from '../index-naming';
export {
type AuthoredIndexInput,
type ExactNameBodyWarning,
flushExactNameBodyWarnings,
lowerAuthoredIndex,
} from '../index-naming';
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ export function materializeForeignKeysAndIndexes(
synthesizedIndexes.push(
lowerAuthoredIndex(tableName, {
columns: reference.source.columns,
where: undefined,
unique: undefined,
map: undefined,
name: undefined,
type: undefined,
Expand Down
141 changes: 118 additions & 23 deletions packages/2-sql/1-core/contract/src/index-naming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,62 +4,157 @@ import {
defaultIndexName,
formatWireName,
} from '@prisma-next/sql-schema-ir/naming';
import { InternalError } from '@prisma-next/utils/internal-error';
import { contractError } from './contract-errors';
import type { IndexInput } from './ir/sql-index';

/**
* The authored element structure: a fields index carries `columns`, an
* expression index carries `expression` — exactly one, never both.
*/
export type AuthoredIndexElements =
| { readonly columns: readonly string[]; readonly expression?: never }
| { readonly columns?: never; readonly expression: string };

/**
* An index as authored, before naming: `map` is an exact physical name
* (adopted verbatim, PSL `map:`); `name` is a managed wire-name prefix
* (TS `name:`). With neither, the managed prefix defaults to
* `defaultIndexName(table, columns)`.
* (adopted verbatim); `name` is a managed wire-name prefix. With neither,
* the managed prefix defaults to `defaultIndexName(table, columns)`.
* `where`, `unique`, `type`, and `options` participate in the content hash
* alongside the elements.
*/
export interface AuthoredIndexInput {
readonly columns: readonly string[];
export type AuthoredIndexInput = AuthoredIndexElements & {
readonly where: string | undefined;
readonly unique: boolean | undefined;
readonly map: string | undefined;
readonly name: string | undefined;
readonly type: string | undefined;
readonly options: Record<string, unknown> | undefined;
};

/**
* One exact-name warning hit: a `map:`-named object carrying a
* hand-authorable SQL body. `subject` is `index` here and `policy` when
* policies adopt the same warning.
*/
export interface ExactNameBodyWarning {
readonly subject: 'index' | 'policy';
readonly exactName: string;
}

const EXACT_NAME_BODY_GUIDANCE =
"Drift detection compares the authored SQL text byte-for-byte against Postgres's reprinted form, which is only reliable when the text was captured by contract infer. For hand-authored definitions, use name: and let Prisma Next manage the physical name; to migrate an adopted object to managed naming, replace map: with name: (keeping the body text unchanged) and apply the resulting rename migration.";

const EXACT_NAME_BODY_WARNING_CODE = 'PN_EXACT_NAME_BODY_COMPARISON';

const WARNING_BATCH_THRESHOLD = 5;

function formatExactNameBodyWarning(warning: ExactNameBodyWarning): string {
return `${warning.subject} "${warning.exactName}" uses map: with a SQL body. ${EXACT_NAME_BODY_GUIDANCE}`;
}

/**
* Flushes collected exact-name-body warnings once per contract build: per-item warnings
* (each naming its object) up to the threshold, one summary with the name
* list above it — an adopted contract re-emit (which carries `map:` + body
* for every adopted object once infer emits them) must not wall-of-text.
*/
export function flushExactNameBodyWarnings(warnings: readonly ExactNameBodyWarning[]): void {
if (warnings.length === 0) {
return;
}
if (warnings.length <= WARNING_BATCH_THRESHOLD) {
for (const warning of warnings) {
process.emitWarning(formatExactNameBodyWarning(warning), {
code: EXACT_NAME_BODY_WARNING_CODE,
});
}
return;
}
process.emitWarning(
`${warnings.length} objects use map: with a SQL body. ${EXACT_NAME_BODY_GUIDANCE}\n` +
warnings.map((warning) => ` - ${warning.subject} "${warning.exactName}"`).join('\n'),
{ code: EXACT_NAME_BODY_WARNING_CODE },
);
}

/**
* Lowers an authored index into the name-identified entity `contract.json`
* persists: exact mode adopts `map` verbatim (no prefix, no hash); managed
* mode appends the content-hash suffix to the authored or default prefix.
* `unique` always lowers `false` — no authoring surface sets it yet.
* The cross-field guards are the shared enforcement backstop for both
* authoring surfaces (PSL pre-empts them with span-anchored diagnostics).
*/
export function lowerAuthoredIndex(tableName: string, authored: AuthoredIndexInput): IndexInput {
export function lowerAuthoredIndex(
tableName: string,
authored: AuthoredIndexInput,
warnings?: { push(warning: ExactNameBodyWarning): void },
): IndexInput {
if ((authored.columns === undefined) === (authored.expression === undefined)) {
throw contractError(
'CONTRACT.ARGUMENT_INVALID',
`Index on table "${tableName}": an index takes either fields (columns) or an expression — exactly one, not both.`,
);
}
if (authored.map !== undefined && authored.name !== undefined) {
throw contractError(
'CONTRACT.ARGUMENT_INVALID',
`Index "${authored.map}" on table "${tableName}": map and name are mutually exclusive — map adopts an exact physical name, name is a managed prefix.`,
);
}
if (
authored.expression !== undefined &&
authored.name === undefined &&
authored.map === undefined
) {
throw contractError(
'CONTRACT.ARGUMENT_INVALID',
`Index on table "${tableName}": an expression index requires an explicit name (name:) or exact physical name (map:) — a default name cannot be derived from an expression.`,
);
}

const unique = authored.unique ?? false;

if (authored.map !== undefined) {
if (authored.name !== undefined) {
throw new InternalError(
`Index "${authored.map}" on table "${tableName}": map and name are mutually exclusive.`,
);
if (authored.expression !== undefined || authored.where !== undefined) {
const warning: ExactNameBodyWarning = { subject: 'index', exactName: authored.map };
if (warnings !== undefined) {
warnings.push(warning);
} else {
flushExactNameBodyWarnings([warning]);
}
}
return {
const carried = {
name: authored.map,
prefix: undefined,
columns: authored.columns,
where: undefined,
unique: false,
where: authored.where,
unique,
type: authored.type,
options: authored.options,
};
return authored.expression !== undefined
? { ...carried, expression: authored.expression }
: { ...carried, columns: authored.columns ?? [] };
}

const prefix = authored.name ?? defaultIndexName(tableName, authored.columns);
const prefix = authored.name ?? defaultIndexName(tableName, authored.columns ?? []);
assertWireNamePrefixLength(prefix, 'index prefix');
const hash = computeIndexContentHash({
columns: authored.columns,
unique: false,
...(authored.columns !== undefined && { columns: authored.columns }),
...(authored.expression !== undefined && { expression: authored.expression }),
...(authored.where !== undefined && { where: authored.where }),
unique,
...(authored.type !== undefined && { type: authored.type }),
...(authored.options !== undefined && { options: authored.options }),
});
return {
const carried = {
name: formatWireName(prefix, hash),
prefix,
columns: authored.columns,
where: undefined,
unique: false,
where: authored.where,
unique,
type: authored.type,
options: authored.options,
};
return authored.expression !== undefined
? { ...carried, expression: authored.expression }
: { ...carried, columns: authored.columns ?? [] };
}
Loading
Loading