Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
c67ef0e
test(psl-parser): specify the declarative attribute-spec engine and i…
SevInf Jun 29, 2026
d3172fc
feat(psl-parser): add declarative attribute-spec engine and core types
SevInf Jun 29, 2026
ce2fa8c
feat(psl-parser): thread the active attribute diagnostic code to leaves
SevInf Jun 29, 2026
7018247
feat(psl-parser): add the str/enumOf/fieldRef/list argument combinators
SevInf Jun 29, 2026
319ee7c
feat(psl-parser): add identifierName leaf and unblock InferAttr on re…
SevInf Jun 29, 2026
98032c8
refactor(sql-contract-psl): lower @relation through the declarative a…
SevInf Jun 29, 2026
de79343
docs(typed-attribute-parsers): project + slice-1 artifacts (spec, pla…
SevInf Jun 30, 2026
3a0b448
fix(psl-parser): reject duplicate attribute arguments unconditionally
SevInf Jun 30, 2026
906e26d
perf(psl-parser): drop the redundant list element copy
SevInf Jun 30, 2026
225d2b7
fix(sql-contract-psl): reject duplicate columns in @relation fields/r…
SevInf Jun 30, 2026
7cb5f75
refactor(sql-contract-psl): validate referential actions via enumOf
SevInf Jun 30, 2026
b917c7c
feat(psl-parser): resolve fieldRef against the scoped symbol table
SevInf Jun 30, 2026
8ff2118
docs(typed-attribute-parsers): record D4 review-round-1 dispatch + pa…
SevInf Jun 30, 2026
d7e05ce
refactor(psl-parser): restrict enumOf to bare identifiers and number …
SevInf Jun 30, 2026
6a28ef8
docs(typed-attribute-parsers): record D5 enumOf restriction + slice-3…
SevInf Jun 30, 2026
003a5c8
Replace enumOf with composable oneOf + identifier combinators
SevInf Jun 30, 2026
d6fb2e5
docs(typed-attribute-parsers): record D6 enumOf->oneOf+identifier; fl…
SevInf Jun 30, 2026
5f87537
docs(adr-231): reconcile with shipped oneOf/identifier design
SevInf Jun 30, 2026
be61351
docs(typed-attribute-parsers): record D7 ADR 231 reconciliation dispatch
SevInf Jun 30, 2026
3ac48c8
refactor(utils): centralize Simplify/UnionToIntersection type helpers
SevInf Jun 30, 2026
e8ee5a9
refactor(psl-parser): require oneOf to have at least one alternative
SevInf Jun 30, 2026
ad54656
refactor(psl-parser): remove variadic positional support
SevInf Jun 30, 2026
8fecd95
refactor(psl-parser): model optional params as a flavoured ArgType
SevInf Jun 30, 2026
c120b85
refactor(psl-parser): interpret attributes in a single pass
SevInf Jun 30, 2026
236fe2d
refactor(sql-contract-psl): consume the relation spec output directly
SevInf Jun 30, 2026
48bcda1
docs(typed-attribute-parsers): record D8 review-round-2 (engine simpl…
SevInf Jun 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
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ const sqlRelation = fieldAttribute('relation', {
fields: optional(list(fieldRef('self'), { nonEmpty: true })),
references: optional(list(fieldRef('referenced'), { nonEmpty: true })),
map: optional(str()),
onDelete: optional(enumOf('NoAction', 'Restrict', 'Cascade', 'SetNull', 'SetDefault')),
onUpdate: optional(enumOf('NoAction', 'Restrict', 'Cascade', 'SetNull', 'SetDefault')),
onDelete: optional(oneOf(identifier('NoAction'), identifier('Restrict'), identifier('Cascade'), identifier('SetNull'), identifier('SetDefault'))),
onUpdate: optional(oneOf(identifier('NoAction'), identifier('Restrict'), identifier('Cascade'), identifier('SetNull'), identifier('SetDefault'))),
},
refine: relationInvariants,
});
Expand All @@ -51,9 +51,9 @@ At runtime, `interpretAttribute(node, sqlRelation, ctx)` turns the parsed AST no
}
```

Notice three things that the rest of this document builds up. The argument value types (`str()`, `enumOf(...)`, `list(fieldRef('self'))`) are **combinators** drawn from a fixed framework kit. A combinator like `fieldRef` carries a **scope** that says which entity a field name resolves against. And cross-argument rules that no single argument can express — `fields` and `references` must appear together — live in a `refine` step.
Notice three things that the rest of this document builds up. The argument value types (`str()`, `oneOf(identifier(...))`, `list(fieldRef('self'))`) are **combinators** drawn from a fixed framework kit. A combinator like `fieldRef` carries a **scope** that says which entity a field name resolves against. And cross-argument rules that no single argument can express — `fields` and `references` must appear together — live in a `refine` step.

That same spec is what the language server reads. Because `onDelete` is declared as `enumOf('NoAction', ...)`, the editor can complete its values; because `fields` is declared as `list(fieldRef('self'))`, the editor knows each entry names a field of the model and can resolve it to a definition or find its other uses — none of which the interpreter's hand-written validation could ever expose.
That same spec is what the language server reads. Because `onDelete` is declared as `oneOf(identifier('NoAction'), ...)`, the editor enumerates the alternatives' pinned values; because `fields` is declared as `list(fieldRef('self'))`, the editor knows each entry names a field of the model and can resolve it to a definition or find its other uses — none of which the interpreter's hand-written validation could ever expose.

The design covers the full spectrum of the current attribute syntax with one exception, `@db.*` native types, which are not attributes on fields or models at all (see [Out of scope](#out-of-scope-db-native-types)).

Expand Down Expand Up @@ -94,9 +94,9 @@ interface ArgType<T> {

The kit divides into four groups.

A combinator owns the work arktype cannot: parsing a PSL AST argument from source, resolving names against the symbol table and registries, anchoring diagnostics to source spans, and carrying the domain metadata (its `kind`, a reference's scope) the language server switches on. Where a leaf reduces to a context-free check on an already-parsed value — a literal set, a numeric range, the shape of a JSON object — it delegates that check, and its type inference, to an arktype `Type` it wraps. So `enumOf` is backed by an arktype literal union, `int({ min, max })` by arktype's numeric constraints, and `json()` by an arktype object schema, while the combinator around them supplies the parse, the context, the spans, and the metadata. This mirrors the existing authoring pattern, where a contributed entity's `validatorSchema` is an arktype `Type` that validates structured input before a factory runs.
A combinator owns the work arktype cannot: parsing a PSL AST argument from source, resolving names against the symbol table and registries, anchoring diagnostics to source spans, and carrying the domain metadata (its `kind`, a reference's scope) the language server switches on. Where a leaf reduces to a context-free check on an already-parsed value — a literal, a numeric range, the shape of a JSON object — it delegates that check, and its type inference, to an arktype `Type` it wraps. So a pinned `identifier(name)` / `str(value)` / `num(value)` is backed by an arktype literal, `int({ min, max })` by arktype's numeric constraints, and `json()` by an arktype object schema, while the combinator around them supplies the parse, the context, the spans, and the metadata. This mirrors the existing authoring pattern, where a contributed entity's `validatorSchema` is an arktype `Type` that validates structured input before a factory runs.

**Scalars** read a single token. `str()` parses a quoted string. `int({ min, max })` parses a number. `bool()` parses a boolean. `enumOf(...values)` parses a member of a fixed literal set — and because a member may be a string or a number, the set may be homogeneous *or* mixed. Mongo's index `type`, which accepts the numbers `1`/`-1` and the strings `"text"`/`"2dsphere"`/`"2d"`/`"hashed"`, is a single `enumOf(1, -1, 'text', '2dsphere', '2d', 'hashed')`. `json()` reads an opaque JSON value from a quoted string; it is the one place a structured value is text-encoded (see [Surface policy](#surface-policy-native-literals-with-one-text-exception)).
**Scalars** read a single token. `str()` parses any quoted string, and `str(value)` pins a specific one. `int({ min, max })` parses a number. `bool()` parses a boolean. `identifier(name)` matches a specific bare identifier, typed `ArgType<name>`, and `num(value)` matches a specific number literal. There is no dedicated enum leaf: a fixed literal set is `oneOf` over these pinned matchers, and because each member is its own matcher the set may be homogeneous *or* mixed — with the quoted-vs-bare surface explicit per member rather than guessed from a value's JS type. Mongo's index `type`, which accepts the numbers `1`/`-1` and the strings `"text"`/`"2dsphere"`/`"2d"`/`"hashed"`, is `oneOf(num(1), num(-1), str('text'), str('2dsphere'), str('2d'), str('hashed'))`. `json()` reads an opaque JSON value from a quoted string; it is the one place a structured value is text-encoded (see [Surface policy](#surface-policy-native-literals-with-one-text-exception)).

**References** resolve a name to an **entity coordinate** — the contract's uniform `(namespace, kind, name)` address for *any* entity, whether a model, an enum, or a pack-contributed entity kind (ADR 221, ADR 224). A reference is not special-cased to models. The coordinate carries one optional extension, a **`field`** element, for a reference that names a field *within* an entity. So `entityRef({ scope })` resolves an entity by name, and its field-bearing form `fieldRef({ scope })` resolves a field within the scoped entity and fills in the coordinate's `field` element; `scope` is `'self'` (the entity declaring the attribute), `'referenced'` (a relation's target entity), or `'document'` (a free path, for wildcard projections). `codecRef()` resolves a registered codec id — a registry reference, not a contract-entity coordinate.

Expand Down Expand Up @@ -201,7 +201,7 @@ The primary reason a spec is *declarative* rather than a parsing function is tha

Today attribute arguments are opaque to the language server. It can offer little more than the attribute name, because everything past the opening parenthesis is validated by interpreter code it cannot introspect. The same spec the interpreters run answers the questions an editor needs to ask:

- **Autocompletion.** The `named` map lists the legal argument names for an attribute, so the editor completes `fie` to `fields:` inside `@relation(...)`. A value typed `enumOf('NoAction', 'Restrict', 'Cascade', 'SetNull', 'SetDefault')` enumerates its own completions, so the editor offers exactly those after `onDelete:`. The combinator's `label` supplies the hover text.
- **Autocompletion.** The `named` map lists the legal argument names for an attribute, so the editor completes `fie` to `fields:` inside `@relation(...)`. A value typed `oneOf(identifier('NoAction'), identifier('Restrict'), identifier('Cascade'), identifier('SetNull'), identifier('SetDefault'))` enumerates its alternatives' pinned values, so the editor offers exactly those after `onDelete:`. The combinator's `label` supplies the hover text.
- **Go-to-definition and find-usages.** A combinator declares not just that an argument is a name, but *what kind of name and where it resolves*. `fields: list(fieldRef('self'))` says each entry names a field of the enclosing model; `references: list(fieldRef('referenced'))` says each names a field of the relation's target model; `@@base`'s `entityRef()` names another entity. From that, the language server resolves the symbol under the cursor to its declaration, and finds every other attribute argument that references the same field or entity — neither of which the interpreter's hand-written validation could ever expose, because it discards that structure as soon as it has checked it.
- **Diagnostics parity.** The editor reports the *same* errors the interpreter would, from the same spec, rather than a thinner approximation maintained separately.

Expand Down Expand Up @@ -243,7 +243,7 @@ The cost is a new layer of indirection. Reading what an attribute accepts means

**Dispatch `oneOf` with a recognition predicate.** Give each combinator a `recognizes(arg)` method so `oneOf` commits to one branch by AST shape before parsing, yielding more targeted errors. Rejected for now: it doubles the leaf contract (a recognizer that must stay in sync with the parser) for a benefit — sharper errors on malformed input — that a small closed grammar does not need. Ordered try-each over diagnostic-pure branches is simpler, and a recognizer can be added later without changing the leaf contract if error quality demands it.

**Separate `enumOf` and `numEnum`.** A string-literal enum and a numeric-literal enum as distinct combinators. Rejected: the only real difference is which token surface each member reads, which a single `enumOf` decides per member from the member's own type. Collapsing them also handles mixed string/number sets — Mongo's index `type` — that two separate combinators cannot express cleanly.
**A dedicated enum leaf.** A single combinator for a fixed literal set, deciding each member's token surface from its JS type. Rejected in favour of `oneOf` over `identifier` / pinned `str` / `num`: composition (principle #4) expresses homogeneous and mixed sets uniformly, makes the quoted-vs-bare surface explicit per member rather than inferred from a value's type, and reuses the `oneOf` sum the design already needs for `@default` and index elements. Mixed string/number sets — Mongo's index `type` — remain expressible, now as `oneOf(num(1), num(-1), str('text'), …)`.

**`json(codecId)` validated by a codec.** Let the JSON leaf decode through a named codec. Rejected: no attribute in scope needs codec-validated JSON — `filter` is opaque pass-through, and `@@type` takes a codec *id* (a `codecRef`), not a codec-validated value. Codec-bound decoding is a separate concern that belongs to generic-block parameters and enum member values, and if those are folded in later they get their own primitive rather than overloading `json()`.

Expand All @@ -268,7 +268,7 @@ The cost is a new layer of indirection. Reading what an attribute accepts means
## References

- [ADR 225 — Three-layer extensibility for pack-contributed entity kinds](ADR%20225%20-%20Three-layer%20extensibility%20for%20pack-contributed%20entity%20kinds.md) — the contribution model this design follows: a framework-defined extension point that families and targets register into, dispatched structurally so the framework learns no per-kind names. Attribute specs are registered the same way entity kinds are.
- [ADR 224 — Control policy: a framework-locked vocabulary with family-owned dispatch](ADR%20224%20-%20Control%20Policy%20—%20framework-locked%20vocabulary%20and%20family-owned%20dispatch.md) — the `@@control(<policy>)` attribute whose value set this design types as `enumOf('managed', 'tolerated', 'external', 'observed')`, and the framework-vocabulary / family-dispatch split this design mirrors, here as a PSL-layer kit with family-owned specs.
- [ADR 224 — Control policy: a framework-locked vocabulary with family-owned dispatch](ADR%20224%20-%20Control%20Policy%20—%20framework-locked%20vocabulary%20and%20family-owned%20dispatch.md) — the `@@control(<policy>)` attribute whose value set this design types as `oneOf(identifier('managed'), identifier('tolerated'), identifier('external'), identifier('observed'))`, and the framework-vocabulary / family-dispatch split this design mirrors, here as a PSL-layer kit with family-owned specs.
- [ADR 221 — Contract IR: two planes with a uniform entity coordinate](ADR%20221%20-%20Contract%20IR%20two%20planes%20with%20uniform%20entity%20coordinate%20and%20pack-contributed%20entity%20kinds.md) — the coordinate model attribute resolution writes into.
- [ADR 126 — PSL top-level block SPI](ADR%20126%20-%20PSL%20top-level%20block%20SPI.md) — the descriptor SPI for generic blocks, whose `key = value` parameters are the subject of an open question above.
- [Pattern: Frozen-class AST + visitor](../patterns/frozen-class-ast.md) — the dispatch pattern for the `ArgType` combinator union across parse, print, and completion sites.
Expand Down
1 change: 1 addition & 0 deletions packages/1-framework/0-foundation/utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"./redact-db-url": "./dist/redact-db-url.mjs",
"./result": "./dist/result.mjs",
"./simplify-deep": "./dist/simplify-deep.mjs",
"./types": "./dist/types.mjs",
"./package.json": "./package.json"
},
"engines": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type { Simplify, UnionToIntersection } from '../types';
9 changes: 9 additions & 0 deletions packages/1-framework/0-foundation/utils/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/** Flattens an intersection of mapped types into a single readable object type. */
export type Simplify<T> = { [K in keyof T]: T[K] } & {};

/** Collapses a union into the intersection of its members. */
export type UnionToIntersection<U> = (U extends unknown ? (k: U) => void : never) extends (
k: infer I,
) => void
? I
: never;
17 changes: 17 additions & 0 deletions packages/1-framework/0-foundation/utils/test/types.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { expectTypeOf, test } from 'vitest';
import type { Simplify, UnionToIntersection } from '../src/types';

test('Simplify flattens an intersection into a single object type', () => {
type Input = { a: number } & { b: string };
expectTypeOf<Simplify<Input>>().toEqualTypeOf<{ a: number; b: string }>();
});

test('Simplify preserves optional modifiers', () => {
type Input = { a: number } & { b?: string };
expectTypeOf<Simplify<Input>>().toEqualTypeOf<{ a: number; b?: string }>();
});

test('UnionToIntersection collapses a union of objects into their intersection', () => {
type Input = { a: number } | { b: string };
expectTypeOf<UnionToIntersection<Input>>().toEqualTypeOf<{ a: number } & { b: string }>();
});
1 change: 1 addition & 0 deletions packages/1-framework/0-foundation/utils/tsdown.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@ export default defineConfig({
'src/exports/result.ts',
'src/exports/redact-db-url.ts',
'src/exports/simplify-deep.ts',
'src/exports/types.ts',
],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { PslDiagnostic } from '@prisma-next/framework-components/psl-ast';
import { nodePslSpan } from '../../resolve';
import type { AstNode } from '../../syntax/ast-helpers';
import type { InterpretCtx } from '../types';

/**
* Builds a leaf diagnostic anchored to the offending `node`, stamped with the
* code threaded through `ctx`. Combinators emit through this helper so every
* leaf carries the active attribute's code rather than a hard-coded generic.
*/
export function leafDiagnostic(ctx: InterpretCtx, node: AstNode, message: string): PslDiagnostic {
return {
code: ctx.diagnosticCode,
message,
sourceId: ctx.sourceId,
span: nodePslSpan(node.syntax, ctx.sourceFile),
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { PslDiagnostic } from '@prisma-next/framework-components/psl-ast';
import { notOk, ok, type Result } from '@prisma-next/utils/result';
import { IdentifierAst } from '../../syntax/ast/identifier';
import type { ArgType } from '../types';
import { leafDiagnostic } from './diagnostic';

/**
* The entity a field name resolves against: the declaring model (`'self'`) or a
* relation's target model (`'referenced'`). Carried for the language server;
* the value parsed at runtime is just the name.
*/
export type FieldRefScope = 'self' | 'referenced';

/** A field-name combinator tagged with the scope its name resolves against. */
export interface FieldRefArgType extends ArgType<string> {
readonly scope: FieldRefScope;
}

/**
* Parses a bare identifier into a field name and resolves it against the scoped
* model: `'self'` against the declaring model, `'referenced'` against a
* relation's target. A name absent from the resolved model emits the
* field-existence diagnostic here, anchored to the identifier. When the
* referenced model is out of scope (e.g. a cross-space target the parser cannot
* see), existence cannot be checked, so the name is carried through unchecked
* and validated where the target is known. The parsed value is always the name.
*/
export function fieldRef(scope: FieldRefScope): FieldRefArgType {
return {
kind: 'fieldRef',
label: 'field name',
scope,
parse: (arg, ctx): Result<string, readonly PslDiagnostic[]> => {
Comment thread
SevInf marked this conversation as resolved.
if (!(arg instanceof IdentifierAst)) {
return notOk([leafDiagnostic(ctx, arg, 'Expected a field name')]);
}
const name = arg.name();
if (name === undefined) {
return notOk([leafDiagnostic(ctx, arg, 'Expected a field name')]);
}
const model = scope === 'self' ? ctx.selfModel : ctx.resolveReferencedModel();
if (model !== undefined && !Object.hasOwn(model.fields, name)) {
return notOk([
leafDiagnostic(ctx, arg, `Field "${name}" does not exist on model "${model.name}"`),
]);
}
return ok(name);
},
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { PslDiagnostic } from '@prisma-next/framework-components/psl-ast';
import { notOk, ok, type Result } from '@prisma-next/utils/result';
import { IdentifierAst } from '../../syntax/ast/identifier';
import type { ArgType } from '../types';
import { leafDiagnostic } from './diagnostic';

/**
* Matches a bare identifier whose name equals `name`, returning that name with
* its literal type preserved. Pinned-only: there is no open form, so several
* `identifier`s composed under `oneOf` infer the precise union of names. A
* non-identifier token, or an identifier with a different name, is rejected with
* the threaded code, anchored to the argument node.
*/
export function identifier<const N extends string>(name: N): ArgType<N> {
return {
kind: 'identifier',
label: name,
parse: (arg, ctx): Result<N, readonly PslDiagnostic[]> => {
if (arg instanceof IdentifierAst && arg.name() === name) return ok(name);
return notOk([leafDiagnostic(ctx, arg, `Expected ${name}`)]);
},
};
}
Loading
Loading