Skip to content

Commit 5e72365

Browse files
dmealingclaude
andcommitted
docs(adr+specs): ADR-0018 per-kind physical-name attrs; FR-013..FR-016 metamodel additions
ADR-0018 (proposed) — revises ADR-0007's universal @table within rdb. Per-kind aliases (@table/@view/@materializedView/@proc/@function) all write to a single internal physicalName slot; canonical serializer emits the kind-matching alias. Industry-standard naming (matches SQL itself + every major ORM). Pre-1.0 status lets @table stay valid as a legacy alias with WARN_LEGACY_PHYSICAL_NAME_ALIAS; corpus regenerates to canonical per-kind names. Same per-kind-when-kinds-differ rule applied to other paradigms (event per-kind; document/keyValue single). FR-013 — @readonly on field. Logical/core (no setter in native binding). Drives codegen + omitted from INSERT/UPDATE. Inheritance rule: upgrade allowed, downgrade rejected. 6 fixtures (4 positive + 2 error). FR-014 — TPH discriminator. Supersedes prior @discriminator/@discriminatorColumn proposals. Both attrs (@Discriminator on base, @discriminatorValue on subtype) live on object.entity (logical) — the field-name-on-the-object is logical; column-name is already handled by the field's own @column. DB introspection can't recover "this column is the discriminator," confirming the layer. 9 fixtures (6 positive + 3 error). Largest of the batch — migrate-ts TPH emit + drift detection. FR-015 — @parameterRef on source.rdb. Reuses object.value via @parameterRef, mirrors FR-004 @payloadRef. Replaces the proposed new parameter.<subtype> node type (rejected as overfitting — duplicates field.* vocabulary). 10 fixtures (6 positive + 4 error). pg_proc introspection + drift gate in migrate-ts. FR-016 — source.rdb cleanup: realizes ADR-0007's name + default-resolution rule (currently bypassed by entity-name fallback); implements ADR-0018's per-kind aliases for the rdb paradigm. Single FR because both touch the same files. 9 fixtures (6 positive + 3 error). Corpus regeneration migrates existing @table-for-view/proc usage to canonical per-kind names. Total batch: ~3-4 weeks elapsed if 4 ports fan out in parallel after TS reference lands; ~5 weeks single-track. Five language ports × four FRs + TS-only migrate-ts work for TPH DDL emit and pg_proc introspect. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 872e059 commit 5e72365

6 files changed

Lines changed: 870 additions & 0 deletions
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# FR-013 — Field-level `@readOnly` attribute design
2+
3+
**Status:** Design (ready for implementation plan)
4+
**Applies to:** all five language ports (TypeScript reference + Java / Kotlin / C# / Python).
5+
**Related ADRs:** [ADR-0001](../../../spec/decisions/ADR-0001-cross-language-type-binding.md) (cross-language native-type binding), [ADR-0002](../../../spec/decisions/ADR-0002-open-closed-typed-nodes.md) (open-closed typed nodes), [ADR-0013](../../../spec/decisions/ADR-0013-logical-field-types-vs-physical-column-attributes.md) (logical/physical layer split).
6+
7+
## Why this doc exists
8+
9+
The metamodel already expresses *entity-level* read-only-ness through `source.rdb @kind``view` / `materializedView` / `storedProc` / `tableFunction` make the whole entity read-only. There is no *field-level* read-only mechanism. A writable `@kind: "table"` entity is all-or-nothing today, which is wrong for five common shapes every adopter eventually needs:
10+
11+
1. **Computed / generated columns** — Postgres `GENERATED ALWAYS AS (...) STORED`, SQL Server computed columns, MySQL generated columns. The database computes the value; the application must not write it.
12+
2. **Audit timestamps populated by triggers**`created_at` / `updated_at` written by DB triggers, not by application code.
13+
3. **Server-generated identity columns on update** — secondary fields populated by `DEFAULT` clauses on update that the application must treat as read-after-write.
14+
4. **View / projection extension** — an `object.entity` with `@kind: "view"` extending a writable `object.entity` should propagate read-only-ness to inherited fields through the projection, even when the projection re-publishes only some of them.
15+
5. **Replication / ETL inputs** — columns populated by external systems where the application must treat them as inputs only.
16+
17+
Every adopter today either hand-codes per-field read-only-ness in `partial class`-style escape hatches or accepts that codegen emits writable setters for fields that must not be written. Adding a single logical attr — `@readOnly: true` — closes the gap cleanly.
18+
19+
## Layer placement (ADR-0013 litmus test)
20+
21+
`@readOnly` changes the field's **native binding** in every language (presence or absence of a setter). That is a *logical* concern, not a physical one. It is **not** introspectable from `information_schema` — DB introspection sees a column with a default expression but cannot know whether the application is supposed to treat it as read-only. The application's contract is the source of truth.
22+
23+
Therefore: **`@readOnly` lives in `core/field/field-schema.ts`**, registered on `commonFieldAttrs` alongside `@required` / `@unique` / `@default` / `@objectRef`. It is *not* a `dbProvider` attribute.
24+
25+
## Decision
26+
27+
Add a single boolean attribute `@readOnly` to every `field.<subtype>`. Default: `false` (writable; existing behavior preserved).
28+
29+
### Schema declaration
30+
31+
In `server/typescript/packages/metadata/src/core/field/field-schema.ts`, append to `commonFieldAttrs`:
32+
33+
```typescript
34+
{
35+
name: FIELD_ATTR_READ_ONLY,
36+
valueType: ATTR_SUBTYPE_BOOLEAN,
37+
required: false,
38+
description:
39+
"When true, the field is read-only: codegen emits no setter / writable property, " +
40+
"and the persistence layer skips the column on INSERT / UPDATE. The value is " +
41+
"populated by the database (computed column, default expression, trigger), by " +
42+
"replication, or by another external owner. Conceptually orthogonal to @required.",
43+
}
44+
```
45+
46+
Constant in `field-constants.ts`:
47+
48+
```typescript
49+
/** Field-level read-only flag. When true, codegen emits no setter and persistence
50+
* skips the column on writes. Default false. */
51+
export const FIELD_ATTR_READ_ONLY = "readOnly";
52+
```
53+
54+
### Semantics
55+
56+
- `@readOnly: true` → the field is read-only from the application's perspective.
57+
- Codegen emits a getter only (no setter / no writable property).
58+
- The persistence layer omits the column from generated INSERT and UPDATE statements.
59+
- Zod / Pydantic / class-validator schemas mark the field read-only on input variants (create / update) and present on output variants (read).
60+
- `@readOnly: false` (or omitted) → writable. Existing behavior. No change.
61+
- The attribute applies to fields of any subtype (`field.string`, `field.int`, `field.timestamp`, ...). It does not interact with `field.object @storage` differently from other subtypes — a read-only object-field is read-only as a whole.
62+
63+
### Cross-attribute validation rules
64+
65+
1. **`@readOnly: true` with `identity.primary @generation: "assigned"`** is rejected: the application has no path to populate the identity value (no setter; not generated; not defaulted). Emit `ERR_READONLY_ASSIGNED_PRIMARY`.
66+
67+
2. **`@readOnly: true` inside `object.value`** is currently a no-op (value-objects do not expose mutability semantics at the field level). The loader accepts the metadata without error and emits `WARN_READONLY_VALUE_OBJECT` for visibility — codegen may use the attribute for record / struct treatment in some languages (e.g., Kotlin `val` instead of `var`) but the persistence implication does not apply.
68+
69+
### Inheritance / `extends:` interaction
70+
71+
Inherited fields carry their declared `@readOnly` value through `extends:` resolution. A concrete subtype may **upgrade** a writable inherited field to read-only (`@readOnly: true`), but **cannot downgrade** a read-only inherited field to writable. Attempting `@readOnly: false` on a field whose base declares `@readOnly: true` produces `ERR_READONLY_DOWNGRADE` at the effective-tree resolution step.
72+
73+
Special case for view projections: when an `object.entity` extends a writable base AND its `source.rdb @kind` is in `SOURCE_READ_ONLY_KINDS`, the source's read-only-ness wins entity-wide. Every inherited field becomes read-only at the persistence layer regardless of per-field `@readOnly`. The codegen contract is "max-restrictive read-only-ness wins."
74+
75+
## Per-port codegen mapping
76+
77+
| Port | Emission for `@readOnly: true` |
78+
|---|---|
79+
| **TypeScript** | `readonly` property on the entity type; Zod schema's `omit(...)` removes the field from the create/update input variants; Drizzle insert/update column lists exclude it. |
80+
| **Java** | Private field + public getter only (no setter); Lombok `@Setter` excludes the field; JPA `@Column(insertable = false, updatable = false)`. |
81+
| **Kotlin** | `val` instead of `var` in the data class; Exposed table omits from `insert`/`update` builders. |
82+
| **C#** | `public T Field { get; private set; }`; EF Core `.Property(x => x.Field).ValueGeneratedOnAddOrUpdate().Metadata.SetAfterSaveBehavior(PropertySaveBehavior.Ignore)`. |
83+
| **Python** | `@property` getter only (no setter); Pydantic `Field(frozen=True)` or `Final[...]`; SQLAlchemy column marked `Column(..., readonly=True)` or excluded from mutation paths. |
84+
85+
### `migrate-ts` impact
86+
87+
**No DDL emission.** `@readOnly` is a runtime / native-binding concern, not a DDL concern. The underlying mechanism (computed column, trigger, replication target, etc.) is declared independently — by hand-coded migration SQL, by a future `@externalSql` reference, or by an `@dbColumnType` physical attribute. `migrate-ts` does not infer DDL from `@readOnly`.
88+
89+
**Optional verify-time warning** (post-implementation polish): `migrate-ts verify` may emit `WARN_READONLY_NO_GENERATOR` when a column declared `@readOnly: true` has neither a `GENERATED ALWAYS` clause, nor a `DEFAULT` expression, nor a documented external owner. This is a quality-of-life check, not a load-time error.
90+
91+
## Conformance fixtures
92+
93+
Under `fixtures/conformance/`:
94+
95+
1. **`field-readonly-basic/`** — single field with `@readOnly: true`; canonical round-trip preserves the attr.
96+
2. **`field-readonly-inherited/`** — concrete entity extends an abstract base; `@readOnly` carried through inheritance without re-declaration on the subtype.
97+
3. **`field-readonly-upgrade-on-subtype/`** — subtype upgrades a writable inherited field to `@readOnly: true`; effective tree shows read-only.
98+
4. **`field-readonly-on-view-projection/`** — entity with `source.rdb @kind: "view"` declaring `@readOnly: true` on every projection column. Legal; canonical round-trip preserves.
99+
5. **`error-field-readonly-downgrade/`** — subtype attempts `@readOnly: false` over a parent's `@readOnly: true`. Expect `ERR_READONLY_DOWNGRADE`.
100+
6. **`error-field-readonly-assigned-primary/`**`identity.primary @generation: "assigned"` on a `@readOnly: true` field. Expect `ERR_READONLY_ASSIGNED_PRIMARY`.
101+
102+
Fixture format follows the existing `field-object-storage-*` precedent (`input/meta.demo.json` + `expected.json`).
103+
104+
## Effort estimate
105+
106+
- TS reference (constants + schema + loader validation + 6 fixtures): ~1 day.
107+
- Per-port fanout (Java / Kotlin / C# / Python) — register the attr, wire codegen, run shared corpus: ~1 day each, parallel.
108+
- Total elapsed if ports fan out in parallel: **~2-3 days.**
109+
110+
Smallest of the four FRs in the current metamodel batch.
111+
112+
## Out of scope
113+
114+
- The DDL machinery that *makes* a column read-only (computed column generation, trigger emission, replication target binding). That is downstream of this metadata attribute and lives in dialect-specific migration code or hand-written SQL.
115+
- Auto-detection of read-only-ness from introspection. DB introspection cannot reliably distinguish "computed by database" from "computed once at insert" — the application's contract is the source of truth, not the schema.
116+
- Method-level read-only semantics (e.g., a method that doesn't mutate). Not a field concern.
117+
118+
## Cross-references
119+
120+
- ADR-0013 — logical/physical placement (this addition is logical; lives in `core/field/`).
121+
- ADR-0002 — open-closed; this is one attr declaration + one canonical-serializer entry, no central edits.
122+
- Conformance corpus README at `fixtures/conformance/README.md` for fixture format.

0 commit comments

Comments
 (0)