|
| 1 | +# Persistence attributes — consolidated cross-language vocabulary (per level, per source subtype) |
| 2 | + |
| 3 | +- **Date:** 2026-05-23 |
| 4 | +- **Status:** Design — plan-of-record for the full persistence-attribute set. |
| 5 | +- **Companion to:** [source v2 spec](2026-05-23-source-v2-paradigm-subtypes-multisource-design.md) + [ADR-0007](../../../spec/decisions/ADR-0007-source-v2-paradigm-subtypes-multisource.md). Obeys [ADR-0006](../../../spec/decisions/ADR-0006-reserved-keywords-vs-inline-attributes.md) (reserved words bare; everything here is an `@`-attr). |
| 6 | +- **Goal:** pull together every persistence concept already in TS (Drizzle/migrate/runtime), Java (ObjectManagerDB), and C# (EF Core) into **one normalized vocabulary**, fill the gaps (explicit referential actions, soft-delete, concurrency), and pin which attrs apply per source subtype. |
| 7 | + |
| 8 | +> **Notation:** attribute names are shown in their **canonical JSON** spelling (`@`-prefixed: `@column`, `@onDelete`, `@storage`). In **YAML authoring** they're written sigil-free (`column:`, `onDelete:`, …); the desugar re-adds the `@` ([ADR-0006](../../../spec/decisions/ADR-0006-ai-first-yaml-authoring.md)). Reserved structural keys stay bare in both. |
| 9 | +
|
| 10 | +## 1. Archaeology — what already exists (and diverges) |
| 11 | + |
| 12 | +| Concept | TS | Java (OMDB) | C# | normalized | |
| 13 | +|--|--|--|--|--| |
| 14 | +| field physical name | `@dbColumn` | `dbColumn` | `@dbColumn` | **`@column`** (per-subtype: `@field`/`@property`/…) | |
| 15 | +| not-null | `@required` | `required`/`dbNullable` | `@required` | **`@required`** | |
| 16 | +| max length | `@maxLength` | `dbLength` | `@maxLength` | **`@maxLength`** | |
| 17 | +| precision/scale | `@precision`/`@scale` | `dbPrecision`/`dbScale` | `@precision`/`@scale` | **`@precision`/`@scale`** | |
| 18 | +| unique | `@unique` | `dbUnique`/`isUnique` | `@unique` | **`@unique`** | |
| 19 | +| index | `@db.indexed` | `dbIndex`/`isIndex` | `@db.indexed` | **`@indexed`** | |
| 20 | +| default | `@default` | `defaultValue` | `@default` | **`@default`** | |
| 21 | +| auto timestamp | `@autoSet: onCreate\|onUpdate` | `auto: create\|update` | `@autoSet` | **`@autoSet`** | |
| 22 | +| PK generation | identity `@generation` | identity `generation` + `dbSequenceName` | `@generation` | **`@generation`** (+ `@sequence`) | |
| 23 | +| db type override | (`@db.*`) | `dbType` (e.g. jsonb) | — | **`@columnType`** | |
| 24 | +| object storage | `@storage`+`@objectRef` | `dbType=jsonb` | `@storage`+`@objectRef` (EF owned) | **`@storage`+`@objectRef`** | |
| 25 | +| rename hint | (migrate) | `previousName` | `@previousName` | **`@previousName`** | |
| 26 | +| relationship lifecycle | subtype assoc/aggr/comp | subtype + `jpaCascade` | subtype | subtype **+ explicit `@onDelete`/`@onUpdate`** | |
| 27 | +| FK enforcement | (implicit) | `enforce` | (implicit) | **`@enforce`** | |
| 28 | +| fetch strategy | — | `jpaFetch` | — | **`@fetch`** (eager/lazy) | |
| 29 | +| table name | `@name`→`@table` | `dbTable` | `@name`→`@table` | **`@table`** (source v2) | |
| 30 | +| composite index/unique | (per-field) | object `dbIndex`/`dbUnique` | — | **source `@indexes`/`@uniques`** | |
| 31 | +| concurrency | — | `dbAllowDirtyWrite`/`dbDirtyWriteCheckField` | — | **`@version` field** | |
| 32 | +| read-only | dbView subtype | `isViewOnly`/`dbView` | dbView subtype | **`@kind`-derived** (source v2) | |
| 33 | + |
| 34 | +**Normalization rules:** drop the `db`/`jpa` prefixes and the `@db.*` namespace (legacy Java/TS) — they fragment one concept across spellings. The *paradigm* is already carried by the source subtype, so attrs are bare and paradigm-neutral where the concept generalizes, and per-subtype only where the concept genuinely differs (physical name). |
| 35 | + |
| 36 | +## 2. Field-level attributes (`field.*`) |
| 37 | + |
| 38 | +| attr | values | applies to | meaning | |
| 39 | +|--|--|--|--| |
| 40 | +| `@column` / `@field` / `@property` / … | string | all | physical address in the record (per source subtype; §source-v2) | |
| 41 | +| `@required` | bool | all | NOT NULL / required | |
| 42 | +| `@maxLength` | int | string | varchar(N) / max length | |
| 43 | +| `@precision` / `@scale` | int | decimal | numeric(p,s) | |
| 44 | +| `@default` | literal \| sqlExpr (`now`, `CURRENT_TIMESTAMP`, `uuid()`) | all | column default | |
| 45 | +| `@unique` | bool | all | single-column unique | |
| 46 | +| `@indexed` | bool \| index-name | all | create an index | |
| 47 | +| `@autoSet` | `onCreate` \| `onUpdate` | date/timestamp | auto-managed timestamp (createdAt/updatedAt) | |
| 48 | +| `@columnType` | string | all | escape hatch: explicit physical type (e.g. `jsonb`, `citext`) | |
| 49 | +| `@storage` | `flattened` \| `jsonb` \| `subdocument` | object (`@objectRef`) | nested-object storage strategy (see §6) | |
| 50 | +| `@objectRef` | object name | object | the nested/referenced object | |
| 51 | +| `@filterable` / `@sortable` | bool | all | query-layer (Project D) | |
| 52 | +| `@previousName` | string | all | migration rename hint | |
| 53 | + |
| 54 | +## 3. Identity-level attributes (`identity.*`) |
| 55 | + |
| 56 | +| attr | values | meaning | |
| 57 | +|--|--|--| |
| 58 | +| `@fields` | string[] | the field(s) forming the identity | |
| 59 | +| `@generation` | `increment` \| `uuid` \| `assigned` \| `sequence` \| `identity` | PK generation strategy | |
| 60 | +| `@sequence` | string | sequence name (when `@generation: sequence`) | |
| 61 | +| `@unique` | bool | secondary identity ⇒ unique constraint | |
| 62 | + |
| 63 | +Subtypes: `primary` (one; the PK), `secondary` (business/alt keys), **`reference`** (FK fields → another entity; from Java) with `@references` (target `Entity`/`Entity.id`) + `@enforce` (physical FK on/off). |
| 64 | + |
| 65 | +## 4. Relationship-level attributes (`relationship.*`) — incl. the new referential actions |
| 66 | + |
| 67 | +Subtypes carry **default** lifecycle; explicit attrs **override**: |
| 68 | + |
| 69 | +| attr | values | default (from subtype) | meaning | |
| 70 | +|--|--|--|--| |
| 71 | +| `@objectRef` | object name | — | target entity | |
| 72 | +| `@cardinality` | `one` \| `many` | — | relationship cardinality | |
| 73 | +| **`@onDelete`** | `cascade` \| `set-null` \| `restrict` \| `no-action` | composition→`cascade`, aggregation→`set-null`, association→`restrict` | **referential action on parent delete (NEW)** | |
| 74 | +| **`@onUpdate`** | (same `FkAction` set) | `cascade` | **referential action on key update (NEW)** | |
| 75 | +| `@enforce` | bool | `true` | physical FK constraint vs logical-only | |
| 76 | +| `@fetch` | `eager` \| `lazy` | `lazy` | load strategy (codegen/runtime hint) | |
| 77 | +| `@joinEntity` / `@joinFields` | string / string[] | — | N:M join table | |
| 78 | + |
| 79 | +**The value set is the existing `FkAction` union — `cascade | set-null | restrict | no-action`** ([migrate-ts `types.ts:65`](../../../server/typescript/packages/migrate-ts/src/types.ts)), kebab-case, **no `setDefault`** (the emitters don't carry it; MySQL doesn't support it). The authoring value === the `FkDescriptor` value, so it threads straight through with no translation layer. |
| 80 | + |
| 81 | +**`@onDelete` and `@autoSet` are different axes on different metatypes — do NOT unify them.** `@autoSet: onCreate|onUpdate` is a one-off *field* write-fill on a timestamp column; `@onDelete`/`@onUpdate` are *relationship* referential actions. There is no general "behavior on write" infrastructure and we are not inventing one — folding these into a single lifecycle hook would be over-engineering. |
| 82 | + |
| 83 | +**Defaults-from-subtype** keep the common case zero-config (a `composition` cascades; an `association` restricts) while `@onDelete`/`@onUpdate` expose and override that intent. Note this is a *small new inference* — today the metadata→schema side leaves the action unset (FKs emit no action clause); we add the derive-from-subtype default + the explicit override. Caveat: `set-null` requires an optional (nullable) relation; `verify`/migrate flags violations. |
| 84 | + |
| 85 | +## 5. Source/object-level attributes |
| 86 | + |
| 87 | +| attr | level | meaning | |
| 88 | +|--|--|--| |
| 89 | +| `@table`/`@collection`/… | source | physical object name (source v2) | |
| 90 | +| `@schema` | source.rdb | DB schema/namespace | |
| 91 | +| `@kind` | source | object kind; drives read-only (source v2) | |
| 92 | +| `@role` | source | multi-source role (source v2) | |
| 93 | +| `@indexes` | source/object | composite indexes (multi-column) | |
| 94 | +| `@uniques` | source/object | composite unique constraints | |
| 95 | +| `@version` | field/object | optimistic-lock version column (replaces Java dirty-write) | |
| 96 | +| `@softDelete` | object | soft-delete mode: a `deletedAt` timestamp (or `deleted` flag) + read-filter; distinct from DB `@onDelete` | |
| 97 | + |
| 98 | +## 6. Object storage (`@storage`) — answer to "the C# jsonb thing" |
| 99 | + |
| 100 | +On a `field.object` (with `@objectRef`): |
| 101 | + |
| 102 | +| `@storage` | relational (rdb) | document | meaning | |
| 103 | +|--|--|--|--| |
| 104 | +| `flattened` | nested columns expand into the parent table, prefixed (EF Core `OwnsOne`); `isArray` forbidden | n/a | owned/embedded, one set of columns | |
| 105 | +| `jsonb` | a single `jsonb` column holds the structured value (or array when `isArray`) | native nested | typed-by-metadata, opaque storage | |
| 106 | +| `subdocument` | (no relational column emitted) | native nested document | document-store-native | |
| 107 | + |
| 108 | +Absent ⇒ default single-jsonb-column (back-compat). Validation: `flattened` + `isArray` ⇒ `ERR_STORAGE_FLATTENED_ARRAY`; `@storage` without `@objectRef` ⇒ `ERR_STORAGE_WITHOUT_OBJECT_REF` (both exist). |
| 109 | + |
| 110 | +## 7. Per-source-subtype applicability |
| 111 | + |
| 112 | +Most attrs are **rdb**-centric. Applicability deltas: |
| 113 | + |
| 114 | +| attr group | rdb | document | event | keyValue | graph | search | vector | timeSeries | |
| 115 | +|--|--|--|--|--|--|--|--|--| |
| 116 | +| `@required`/`@default`/`@unique` | ✓ | ✓ | (schema) | ✓ | ✓ | ✓ | ✓ | ✓ | |
| 117 | +| `@maxLength`/`@precision`/`@scale` | ✓ | — | — | — | — | — | — | — | |
| 118 | +| `@indexed`/`@indexes`/`@uniques` | ✓ | ✓ | — | `@gsi` | ✓ | (mapping) | — | ✓ | |
| 119 | +| `@autoSet` | ✓ | ✓ | (event time) | ✓ | ✓ | ✓ | — | `@timeColumn` | |
| 120 | +| `@generation` | ✓ | ✓ (`_id`) | — | `@partitionKey` | ✓ | ✓ | ✓ | — | |
| 121 | +| `@onDelete`/`@onUpdate`/`@enforce` | ✓ | (app-level) | — | — | ✓ (edges) | — | — | — | |
| 122 | +| `@storage` | ✓ (jsonb/flattened) | subdocument | — | — | — | — | — | — | |
| 123 | +| `@version`/`@softDelete` | ✓ | ✓ | — | ✓ | ✓ | — | — | — | |
| 124 | +| paradigm-specific | `@schema` | `@database` | `@keySchema`/`@valueSchema`/`@partitions` | `@partitionKey`/`@sortKey`/`@ttl` | `@from`/`@to` | `@mappingsRef` | `@dimensions`/`@metric` | `@retention`/`@tags` | |
| 125 | + |
| 126 | +## 8. What's new vs. today (the gaps we're filling) |
| 127 | + |
| 128 | +1. **Explicit `@onDelete`/`@onUpdate` referential actions** — **low effort, high value: the plumbing already exists end-to-end and only the authoring attribute is missing.** The `FkAction` union (`cascade|set-null|restrict|no-action`), `FkDescriptor.onDelete/onUpdate`, the DDL **emit** (`migrate-ts/src/emit/{postgres,sqlite}.ts`), **introspect**, and **diff** are all built (TS confirmed; C# `PostgresEmit.cs` per the same design). What's absent is the `@onDelete`/`@onUpdate` attr on the relationship schema and threading its value into the expected-schema `FkDescriptor` (today that field is left unset → FKs emit no action clause). So the task is: add the two attrs (`allowedValues = FkAction`), thread them in, add a conformance fixture. Exposing intent, not building plumbing. |
| 129 | +2. **`@enforce`** (physical vs logical FK) and **`@fetch`** promoted from Java-only to the cross-language vocabulary. |
| 130 | +3. **`@softDelete`** and **`@version`** as first-class declarations (today: ad-hoc / Java-only dirty-write). |
| 131 | +4. **`@generation: sequence`/`identity`** + `@sequence` (Java had `dbSequenceName`; generalize). |
| 132 | +5. **Normalized names** — Java `db*`/`jpa*` and TS `@db.*` collapse into the bare cross-language set above. |
| 133 | + |
| 134 | +## 9. Open questions |
| 135 | + |
| 136 | +- `@onDelete` default for **aggregation** — `set-null` vs `restrict`? (Prisma defaults required→restrict; our aggregation = shared ownership ⇒ `set-null` feels right but needs the relation optional.) Resolve in review. |
| 137 | +- `@softDelete` granularity — object-level mode only, or per-relationship cascade-of-soft-delete? (Start object-level; defer cascade-soft-delete.) |
| 138 | +- `@version` representation — a dedicated `@version` field marker vs an object-level `@optimisticLock: <field>`. (Lean: a field-level `@version: true`.) |
| 139 | +- Which attrs are **conformance-gated** (loader/serializer round-trip) vs **codegen-only** (golden tests). Referential actions + storage round-trip in metadata; physical-type escape hatches are codegen-only. |
| 140 | + |
| 141 | +## 10. Research |
| 142 | + |
| 143 | +- Prisma referential actions: `Cascade`/`Restrict`/`NoAction`/`SetNull`/`SetDefault`; defaults ON DELETE RESTRICT (required) / ON UPDATE CASCADE; `SetNull` needs optional relation; `SetDefault` unsupported on MySQL. ([Prisma referential actions](https://www.prisma.io/docs/orm/prisma-schema/data-model/relations/referential-actions)) — **we adopt the existing 4-value `FkAction` (`cascade|set-null|restrict|no-action`), omitting `setDefault`** to match what the emitters already carry. |
| 144 | +- Soft delete is an application/middleware pattern (`deletedAt`/`deleted` + read-filter), not a DB referential action. ([Prisma soft-delete](https://www.prisma.io/docs/orm/prisma-client/client-extensions/middleware/soft-delete-middleware)) |
| 145 | +- JPA cascade types (ALL/PERSIST/MERGE/REMOVE/…) + fetch (EAGER/LAZY) informed `@onDelete`/`@fetch` (Java `jpaCascade`/`jpaFetch`). |
0 commit comments