Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
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 @@ -12,13 +12,13 @@ Here is the canonical case. An app references `auth.users`, a table owned by the

```prisma
types {
Uuid = String @db.Uuid
AuthUserId = Uuid
}

namespace public {
model Profile {
id String @id @default(uuid())
userId Uuid @unique
id String @id @default(uuid())
userId AuthUserId @unique
user supabase:auth.AuthUser @relation(fields: [userId], references: [id], onDelete: Cascade)
}
}
Expand Down Expand Up @@ -125,7 +125,7 @@ This is deliberate. The relationship's value today is the database constraint
## Consequences

- **The carrier is additive.** Contracts with no cross-space references are unaffected, and their serialized form does not change.
- **Native-type matching is the author's responsibility.** The branded column reference carries a space id, not a storage type. When a cross-space FK targets a column with a non-default native type — `auth.users.id` is `uuid` — the author must match that type on the source column, which is why the grounding example declares `types { Uuid = String @db.Uuid }` and types `userId` as `Uuid`. Postgres rejects mismatched FK column types at apply time; the framework does not coerce.
- **Native-type matching is the author's responsibility.** The branded column reference carries a space id, not a storage type. When a cross-space FK targets a column with a non-default native type — `auth.users.id` is `uuid` — the author must match that type on the source column, which is why the grounding example declares `types { AuthUserId = Uuid }` and types `userId` as `AuthUserId`. Postgres rejects mismatched FK column types at apply time; the framework does not coerce.
- **`extensions` carries two meanings at once** (imports and load-order dependency). This is acceptable while every app that imports a space also depends on it, but it leaves no way to depend on a space for ordering without importing its models.
- **Relations are non-navigable.** Declaring a relation the ORM cannot traverse is a partial capability: the constraint and migration work, the query surface does not. This matches how the target tables are used in practice but is a seam that a future cross-space query model will need to fill.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ Notice three things that the rest of this document builds up. The argument value

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)).
The design covers the full spectrum of the current field-, model-, and generic-block-level attribute syntax.

---

Expand Down Expand Up @@ -217,9 +217,9 @@ PSL's expression grammar supports native array and object literals recursively,

The benefit is uniform: a native literal is parsed by the PSL parser, so it carries real spans and diagnostics and supports editor completion, where a quoted-string-encoded list or map is opaque to all of that.

## Out of scope: `@db.*` native types
## Removed storage-type attribute channel

`@db.*` (`@db.Uuid`, `@db.VarChar(255)`, …) is **not** an attribute on a field or a model, and so is outside this design. It is an attribute on a **named-type declaration**: an author writes `type Slug = String @db.VarChar(191)` and references `Slug` from a field. The named-type resolver handles `@db.*` through a dedicated path gated on `allowDbNativeType`, producing a storage descriptor (codec id, native type, type parameters) that fields inherit by type reference. Named-type-declaration attributes are a separate authoring surface from the field/model/block attributes this ADR specifies; a declarative spec for that surface is possible but is not addressed here.
The former `@db.*` named-type attribute channel is removed. Storage types are authored only through type-position constructors: `@db.Uuid` becomes `Uuid`, and `@db.VarChar(191)` becomes `VarChar(191)`. Remaining source that uses the removed spelling receives an actionable diagnostic directing that rewrite. Because storage selection is no longer an attribute surface, this declarative attribute design needs no named-type exception. [ADR 241](ADR%20241%20-%20Scalar%20types%20use%20the%20authoring%20type-constructor%20channel.md) records the unified constructor channel.

---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ const normalizedTypeParams =
typeParams !== undefined && Object.keys(typeParams).length === 0 ? undefined : typeParams;
```

Object-template resolution drops `undefined` values, so `temporal.timestamp()`'s template `{ precision: { kind: 'arg', index: 0 } }` resolves to `{}` when no precision is supplied. Absent and `{}` are equivalent to every consumer that reads them, but they are not equal to a byte comparison — and a contract that carried `{}` would differ from the `@db.Timestamp` spelling that produces the same column.
Object-template resolution drops `undefined` values, so `temporal.timestamp()`'s template `{ precision: { kind: 'arg', index: 0 } }` resolves to `{}` when no precision is supplied. Absent and `{}` are equivalent to every consumer that reads them, but they are not equal to a byte comparison — and a contract that carried `{}` would differ from the bare `Timestamp` type constructor that produces the same column.

### The two rules carry each other

Expand Down Expand Up @@ -244,7 +244,7 @@ An argument object with **required** properties is not weak, and relies on the o

Three facts about the wider system make the presets safe without any adapter or runtime involvement:

- `{ precision }` typeParams on the postgres timestamp codecs flow through DDL rendering; the `@db.Timestamp(3)` spelling produces the same contract shape and exercises the same path.
- `{ precision }` typeParams on the postgres timestamp codecs flow through DDL rendering; the `Timestamp(3)` type-position constructor produces the same contract shape and exercises the same path.
- A parameterized codec with absent `typeParams` is probed with `{}` in [sql-context.ts](../../../packages/2-sql/5-runtime/src/sql-context.ts), and `undefined` is normalized to `{}` in resolve-codec — so omitting `typeParams` entirely is equivalent to every consumer that reads it.
- The `timestampNow` generator — control descriptor, runtime generator, adapter lowering — is target-agnostic and serves both the postgres and sqlite presets.

Expand All @@ -260,7 +260,7 @@ Three facts about the wider system make the presets safe without any adapter or

**A variant-selecting argument** (`temporal.timestamp(withTimezone: true)`). Rejected: it makes an argument change the field's codec. Keeping "one preset per codec, arguments change field properties" means the codec is always readable from the spelling.

**Composing `@db.*` native-type attributes with a behavioral preset.** The two mechanisms do not compose a preset owns the whole column descriptor — and unifying them is a larger question than this decision. `@db.*` is unchanged.
**Composing a type-position constructor with a behavioral preset.** The two mechanisms do not compose because a preset owns the whole column descriptor. Use a constructor such as `Timestamp(3)` when only storage shape is needed; use `temporal.timestamp(3, …)` when the field also needs preset behavior.

**Deriving the convenience presets structurally from the full form.** `temporal.updatedAt()` could be the full-form descriptor with its arguments pre-bound — one construction, equivalence by identity, no parity test needed. Rejected: partial argument binding is a new framework concept on descriptors, and it could only ever cover `updatedAt` — `createdAt` is a storage default, which the per-codec presets deliberately cannot express, so it stays separately authored regardless. Removing one preset's worth of drift risk did not justify the new seam; the equivalence is enforced by the named tests instead.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# ADR 241 — Scalar types use the authoring type-constructor channel

Status: **Accepted**.

Amends: [ADR 170 — Pack-provided type constructors and field presets](ADR%20170%20-%20Pack-provided%20type%20constructors%20and%20field%20presets.md).

Related: [ADR 171 — Parameterized native types in contracts](ADR%20171%20-%20Parameterized%20native%20types%20in%20contracts.md), [ADR 231 — Declarative attribute specifications](ADR%20231%20-%20Declarative%20attribute%20specifications.md).

## At a glance

Every PSL storage type comes from one contributed authoring namespace. A scalar is a type constructor that can be called with no arguments, so bare `String` means `String()`. Parameterized storage types such as `VarChar(191)` use the same constructor descriptor and the same resolution path. Targets and extension packs contribute both forms through `AuthoringContributions.type`; there is no separate scalar-descriptor map or database-attribute channel.

## Grounding example

A target contributes ordinary scalars and parameterized storage types with the same descriptor shape:

```ts
const postgresAuthoringTypes = {
String: {
kind: 'typeConstructor',
output: { codecId: 'pg/text@1', nativeType: 'text' },
},
VarChar: {
kind: 'typeConstructor',
args: [{ kind: 'number', name: 'length', integer: true, minimum: 1, optional: true }],
output: {
codecId: 'sql/varchar@1',
nativeType: 'character varying',
typeParams: { length: { kind: 'arg', index: 0 } },
},
},
} as const satisfies AuthoringTypeNamespace;
```

PSL resolves both through the type position:

```prisma
types {
Slug = VarChar(191)
}

model User {
id Uuid @id
slug Slug
}
```

`String`, `Uuid`, and bare `VarChar` are zero-argument instantiations. `VarChar(191)` uses the same contribution with one argument, producing structured `typeParams` while keeping the base `nativeType` separate as required by [ADR 171](ADR%20171%20-%20Parameterized%20native%20types%20in%20contracts.md).

## Context

The authoring stack previously carried two ways to contribute storage types. Base scalar names came from a dedicated `scalarTypeDescriptors` map, while parameterized and extension-owned types came from `AuthoringContributions.type`. PostgreSQL native types also had a family-owned `@db.*` interpretation path that translated named-type attributes into storage descriptors. These channels described the same decision—selecting a codec, native type, and optional parameters—but assembled, validated, completed, and resolved it differently.

That split made a scalar artificially different from any other storage type. It also placed target-specific native-type knowledge in the SQL family interpreter instead of in the target that owns the codecs and storage vocabulary. A single contribution channel lets the framework treat type names uniformly while targets and extension packs retain ownership of their concrete types.

## Decision

Every authorable storage type is an `AuthoringTypeConstructorDescriptor` contributed through the `type` property of `AuthoringContributions`. Family, target, adapter, and extension descriptors compose their type namespaces through `assembleAuthoringContributions`; duplicate paths fail at composition rather than overriding one another.

A bare type name `T` is semantically the zero-argument instantiation `T()`. The framework derives the scalar view with `collectScalarTypeConstructors`: a top-level constructor with no entity-reference argument and no required arguments is a scalar. `ControlStack.scalarTypes`, language-server scalar names, symbol-table classification, and scalar codec validation are derived from that view. They are consumers of the unified namespace, not contribution channels of their own.

Parameterized storage types use the same descriptor and resolver. Their argument declarations validate the authoring call, and their output templates place resolved values in structured `typeParams`. The contract continues to carry the base `nativeType` separately from those parameters; the codec owner remains responsible for target-specific expansion, as decided by ADR 171.

Storage-type ownership follows the component boundary. Targets contribute their base and native storage types; extension packs contribute namespaced constructors unless the short-name policy permits otherwise. The SQL family interpreter resolves the assembled namespace generically and contains no PostgreSQL-native mapping table.

PSL storage is selected only in type position. The former `@db.X(args)` channel is removed. Remaining source using that spelling fails with actionable migration guidance: rewrite `@db.X` as `X` and `@db.X(args)` as `X(args)` in type position.

## Rationale

One concept should have one registration and resolution path. A scalar differs from a parameterized type only in whether the constructor is invoked with arguments; treating them as different contribution kinds duplicates assembly and validation without expressing a domain distinction.

The unified namespace also enforces thin-family, target-owned storage vocabulary. The framework defines the constructor descriptor and generic composition rules. The target or extension that owns a codec declares the names and output storage descriptors associated with it. Adding a storage type does not require teaching the SQL family interpreter a new database-specific name.

Deriving scalar names from constructors keeps tooling aligned with interpretation. Completion, symbol classification, and codec validation cannot drift onto a scalar map that resolves differently from the constructors used to emit the contract.

## Consequences

- Components contribute scalar and parameterized storage types through `AuthoringContributions.type` only. The `scalarTypeDescriptors` contribution and assembly surfaces are retired.
- Bare type syntax and constructor-call syntax share precedence, collision handling, argument validation, and lowering.
- Targets own native storage names and codec bindings. Family interpreters remain generic across targets.
- `ControlStack.scalarTypes` remains a derived convenience view for consumers that need names, while `collectScalarTypeConstructors` provides the derived name-to-storage-output map.
- TypeScript and PSL authoring helpers can be generated from the same descriptor namespace.
- The contract representation does not change: storage entries still contain codec ids, base native types, and structured type parameters.
- Existing `@db.X(args)` source must be rewritten mechanically to `X(args)` in type position; no compatibility channel remains.

## Alternatives considered

### Keep a separate scalar descriptor map

Base scalars could remain in `scalarTypeDescriptors`, with constructor contributions reserved for types that expose an explicit call surface. Rejected: bare `T` already has the semantics of `T()`, and two contribution paths would continue to duplicate composition, codec validation, completion derivation, and resolution precedence.

### Fold the scalar map into constructors during assembly

The framework could preserve the old component SPI and translate map entries into constructor descriptors when composing a control stack. Rejected: this would make the assembled result uniform while leaving two authoring contracts for component authors. Retiring the map makes the component boundary and the runtime shape agree.

### Keep PostgreSQL native types in the SQL family interpreter

The SQL interpreter could continue translating a dedicated database attribute into storage descriptors from a family-owned table. Rejected: the table embeds PostgreSQL codec and native-type knowledge in a family package, bypasses the shared constructor namespace, and gives one storage decision two authoring positions. Native types belong to the target and resolve in type position like every other type.

### Treat bare scalars as declarations rather than calls

The namespace could contain a scalar leaf kind alongside a constructor leaf kind. Rejected: it would encode an invocation-style distinction as a domain distinction and force every namespace consumer to dispatch between two descriptor shapes that produce the same storage output.

## References

- [ADR 170 — Pack-provided type constructors and field presets](ADR%20170%20-%20Pack-provided%20type%20constructors%20and%20field%20presets.md) — establishes contributed authoring helpers and target/pack ownership.
- [ADR 171 — Parameterized native types in contracts](ADR%20171%20-%20Parameterized%20native%20types%20in%20contracts.md) — keeps base native type names separate from structured parameters in the contract.
- [ADR 231 — Declarative attribute specifications](ADR%20231%20-%20Declarative%20attribute%20specifications.md) — covers attributes after removal of the separate `@db.*` storage channel.
- [Ecosystem Extensions & Packs subsystem](../subsystems/6.%20Ecosystem%20Extensions%20&%20Packs.md) — component contribution and extension authoring boundaries.
- Constructor and contribution types: [`framework-authoring.ts`](../../../packages/1-framework/1-core/framework-components/src/shared/framework-authoring.ts).
- Namespace assembly and derived scalar view: [`control-stack.ts`](../../../packages/1-framework/1-core/framework-components/src/control/control-stack.ts).
- PostgreSQL scalar and native-type contributions: [`control-mutation-defaults.ts`](../../../packages/3-targets/6-adapters/postgres/src/core/control-mutation-defaults.ts).
Original file line number Diff line number Diff line change
Expand Up @@ -368,13 +368,13 @@ A future `use ... as` aliasing directive is reserved as an additive layer on top

```prisma
types {
Uuid = String @db.Uuid // must match auth.users.id native type
AuthUserId = Uuid // must match auth.users.id native type
}

namespace public {
model Profile {
id String @id @default(uuid())
userId Uuid @unique // @unique makes this a 1:1 relationship
id String @id @default(uuid())
userId AuthUserId @unique // @unique makes this a 1:1 relationship
user supabase:auth.AuthUser @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("profile")
}
Expand Down Expand Up @@ -420,7 +420,7 @@ The `AuthUser` handle is branded with `spaceId: 'supabase'` — that brand is wh

The branded column reference carries a space id, not the target column's storage type. If the target column has a non-default native type — `auth.users.id` is `uuid` — you must match that type on the source column. Postgres rejects mismatched FK column types at apply time; the framework does not coerce.

**PSL:** declare a named type alias in a `types {}` block and use it on the source field (as shown above). `@db.X` is a type-constructor attribute — it is valid only inside `types { Name = T @db.X }` declarations, not as a field attribute. Writing `userId String @db.Uuid` is rejected by the PSL parser.
**PSL:** use the matching constructor directly in type position or declare a named alias in a `types {}` block and use it on the source field (as shown above). The `@db.X(args)` channel is removed: rewrite `@db.X` as `X` and `@db.X(args)` as `X(args)`. Remaining uses fail with an actionable diagnostic naming the type-position replacement.

**TS:** use the field builder that produces the matching native type: `field.uuidNative()` for a Postgres native UUID target, `field.uuidString()` for cross-target char(36), `field.text()` for text, etc.

Expand Down
Loading
Loading