Skip to content
Merged
Changes from all 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 @@ -37,15 +37,19 @@ Rationale: aware-by-default is the unanimous best-practice (Postgres "Don't Do T

The "string formats" set does not survive the [ADR-0037](ADR-0037-metamodel-vocabulary-expansion-decision-framework.md) behavioral test (own native type / behavior β†’ subtype; plain validated string β†’ attribute), so it splits three ways:

- **`field.uri` subtype** β€” `url`/`uri` have a native type (`URI`/`Uri`/`urllib`) and behavior (scheme/authority/path, absolute-vs-relative), so they are a subtype, with **`@kind` = `url` | `urn`** distinguishing the structural variants (a URL locates, a URN names). Native binding + parse behavior per port.
- **`field.inet` subtype** β€” `ipv4`/`ipv6` have a native type (`InetAddress`/`IPAddress`/`ipaddress`) and a Postgres-native `inet`/`cidr` column, so they are a subtype, with **`@kind` = `ipv4` | `ipv6`**.
- **`field.uri` subtype** β€” `url`/`uri` have a native type (`URI`/`Uri`/`urllib`) and behavior (scheme/authority/path, absolute-vs-relative), so they are a subtype. Native binding + parse behavior per port; `text` column + URI validation. Ships **without `@kind` in v1** β€” the native type subsumes url/urn; `@kind = url | urn` is *reserved* (the charter example in ADR-0037) for when a value-constraint is actually needed.
- **`field.inet` subtype** β€” `ipv4`/`ipv6` have a native type (`InetAddress`/`IPAddress`/`ipaddress`) and a Postgres-native `inet` column, so they are a subtype. Ships **without `@kind` in v1** β€” a single `inet` accepts both versions and the version is a property of the *value*, not the schema; `@kind = ipv4 | ipv6` is *reserved* for constraining a column to one version.
- **`@stringFormat` attribute** on `field.string` β€” only the genuinely behavior-less, native-type-less validated strings: **`email | hostname`** (additive β€” more can be added later). The canonical matcher per format lives in each port (conformance-gated), *not* author `validator.regex` (cross-language regex engines diverge and break byte-identity). Named `@stringFormat` (not `@format`) to avoid colliding with the existing `template.* @format` (output format) β€” ADR-0037's no-overload corollary. Codegens idiomatically (Zod `z.email()`, Jakarta `@Email`, PG `CHECK`/`citext`).

`uuid` is **not** in this set β€” it is already the `field.uuid` subtype (native UUID type), the original instance of this same rule.

### 3. Multi-edge-same-pair navigation β†’ `@relationName`, fail-closed
### 3. Reverse-navigation naming β†’ **derive** it; `@reverseName` optional (deferred to Wave 4)

When an entity declares two relationships to the same target (the same-pair case β€” e.g. `sender` + `recipient` both β†’ `User`), generated navigation names collide. Add a **`@relationName`** attribute on `relationship.*` that names the navigation/inverse explicitly. **1.0 codegen errors (`ERR_AMBIGUOUS_RELATION`) on unnamed same-pair edges** rather than silently colliding β€” a later rename of generated nav properties would be breaking in adopters' repos, so the ambiguity must be surfaced at build time now. (Confirmed live: one adopter has 8 such entities.)
_Revised after research._ The original plan (a required `@relationName`, fail-closed on same-pair) rested on two premises that didn't survive investigation:
- **The collision is not cross-port.** Reverse 1:N navigation is generated **only by TS today** (and there it's a *bug* β€” the second relationship silently overwrites the first under `User.messages`); Java/Spring emits none, and Kotlin/C#/Python emit reverse only for M:N, named from the (distinct) relationship name. So the same-pair collision is TS-only, not a frozen cross-port contract β€” and the Python and JVM adopters never hit it.
- **A unique reverse name is always derivable.** Relationship names are unique within their source entity, so `(source entity + relationship name)` is a guaranteed-unique key. Unlike Django (`<model>_set`, model-name only β†’ collides) we fold the relationship name into the derivation: no collision β†’ `User.messages`; collision β†’ `User.senderMessages` / `User.recipientMessages`. No author input required.

So the decision is **derive, don't require**: codegen derives the reverse accessor (pretty `<sourcePlural>` by default; qualified with the relationship name on collision), and **`@reverseName` is an *optional* override** for aesthetics or to pin a stable name β€” *not* `@relationName` (that imports Prisma's *pairing* concept, redundant since MetaObjects already pairs sides structurally via `@objectRef`/`@through`/`identity.reference`). The known cost (adding a colliding relationship re-derives the names) is acceptable for regen-able generated code. **Deferred to Wave 4** with the TS reverse-nav bugfix; `@reverseName` is reserved-and-optional, never a required 1.0 attr.

### 4. Finish the `@dbColumnType` slim β€” keep `uuid`

Expand All @@ -60,16 +64,17 @@ The earlier "slim-and-derive" landed the *derive* half (native `text[]`/`uuid[]`

## Consequences

- Decisions 1 and 4 are **breaking** and must land inside the consolidation window; 2, 3, 5 are additive (3 adds a build-time error only for already-ambiguous models).
- Decision 1 is **breaking** and lands inside the consolidation window; 2 is additive (new subtypes/attr); 3 is a codegen-derivation change (no required vocab); 4 (the slim) was already complete from Phase 1; 5 (the gate) is mechanism.
- Each ships cross-port with a `registry-conformance` fixture (ADR-0023 strict provenance).
- Adopters migrate once, to the final shapes: drop ~180 `timestamp_with_tz` annotations, add `@localTime: true` to genuinely-naive timestamps, move 5 array fields to `isArray`, and name same-pair relationships. `@dbColumnType:uuid` and `@dbColumnType:jsonb` continue working unchanged.
- Adopters migrate once, to the final shapes: drop ~180 `timestamp_with_tz` annotations, add `@localTime: true` to genuinely-naive timestamps, move 5 array fields to `isArray`. Reverse-nav names become deterministic (derived) β€” no annotation needed. `@dbColumnType:uuid` and `@dbColumnType:jsonb` continue working unchanged.

## Deferred to a later wave (recorded so they are reservations, not gaps)

`field.json` (the jsonb open-bag already works β€” sugar), `field.bytes` (the one genuine missing type β€” no adopter need yet), `@onDelete: set-default`, additional filter operators (`notIn`/`ilike`/`between`), polymorphic associations (reserve as out-of-scope), ordered relationships, all `view.*`/`layout.*` UI vocabulary (incl. cutting the dead `view.*` constants), and the db-physical attr-naming + base-attr-inheritance unification. None blocks the two adopters; all are additive or decidable before the freeze.

## Implementation waves

1. **Wave 1 β€” gate + slim** (decisions 5 + 4): lower-risk, mechanical; the gate enforces the slim.
2. **Wave 2 β€” timestamp** (decision 1): the native-type-changing split; shared persistence/api fixtures + ADR-0019 temporal clause updated.
3. **Wave 3 β€” `@format` + `@relationName`** (decisions 2 + 3): additive, each gated.
1. **Wave 1 β€” gate + slim** (decisions 5 + 4): lower-risk, mechanical; the gate enforces the slim. βœ… merged.
2. **Wave 2 β€” timestamp** (decision 1): the native-type-changing split (`field.timestamp` instant-default + `@localTime`); shared persistence/api fixtures + ADR-0019 updated. βœ… merged.
3. **Wave 3 β€” `field.uri` + `field.inet` subtypes + `@stringFormat`** (decision 2, post-split): additive, each gated. The "string formats" set resolved by behavior per ADR-0037 (url/ip β†’ subtypes; email/hostname β†’ `@stringFormat`).
4. **Wave 4 β€” reverse-navigation derivation** (decision 3): fix the TS reverse-nav collision bug (derive a unique name, qualifying with the relationship name on collision); reserve `@reverseName` as an optional override. Not a required-vocab change.
Loading