Skip to content

Commit 041be46

Browse files
dmealingclaude
andcommitted
docs: add abstracts-and-inheritance feature page + "when to use which" guide
Two complementary additions: docs/features/abstracts-and-inheritance.md (new) - Author-side reference for `abstract: true` + `extends:` — the lightest mechanism for shape reuse (entities, fields, etc.) - Two canonical patterns: shared base entities (audit columns) and shared field shapes (slug constraint reuse) - Resolution semantics: cross-file forward refs, multi-level chains, ERR_UNKNOWN_EXTENDS contract - Distinguishes extends (IS-A reuse) vs overlay (same-instance amendment) - Cross-port emission note (loader merges into extenders before any generator runs — codegen sees the flattened shape) - Verified-by list pointing at the existing extends-* conformance fixtures docs/features/extending-with-providers.md (extended) - New section "When to add a subtype vs. an attr vs. an abstract" codifies the design principle: default to attrs over subtypes; default to abstracts over both when shape can be expressed in existing subtypes - Decision flow + decision table with marginal-cost ordering: abstract (free) < attr-extension (cheap) < subtype-registration (expensive) - Concrete reapplication examples: template.toolcall and field.slug, both expressible without new subtypes docs/README.md - Layout outline + "When to read which" table add the new feature page Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ca0655a commit 041be46

3 files changed

Lines changed: 403 additions & 10 deletions

File tree

docs/README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ docs/
2525
│ ├── loaders.md
2626
│ ├── migrations-and-drift.md
2727
│ ├── api-contract.md # cross-port REST contract (the universal browser client speaks it)
28-
│ ├── extending-with-providers.md # custom metamodel subtypes via MetaDataTypeProvider
28+
│ ├── abstracts-and-inheritance.md # abstract: true + extends: — author-side shape reuse
29+
│ ├── extending-with-providers.md # custom metamodel subtypes/attrs via MetaDataTypeProvider
2930
│ └── yaml-authoring.md
3031
└── ports/ # one file per language/framework port
3132
├── typescript.md
@@ -45,7 +46,8 @@ docs/
4546
| Compare what TS vs Java vs Kotlin vs C# vs Python emit for the same metadata | any [`features/*.md`](features/) — every feature shows all five ports side-by-side |
4647
| Author metadata in YAML instead of JSON | [`features/yaml-authoring.md`](features/yaml-authoring.md) |
4748
| Wire prompt construction (FR-004) | [`features/templates-and-payloads.md`](features/templates-and-payloads.md) |
48-
| Add a custom metamodel subtype to a downstream project | [`features/extending-with-providers.md`](features/extending-with-providers.md) + [`recipes/extending-metaobjects-with-providers.md`](recipes/extending-metaobjects-with-providers.md) |
49+
| Share a metadata shape across multiple instances (abstracts, `extends:`) | [`features/abstracts-and-inheritance.md`](features/abstracts-and-inheritance.md) |
50+
| Add a custom metamodel subtype or attribute to a downstream project | [`features/extending-with-providers.md`](features/extending-with-providers.md) + [`recipes/extending-metaobjects-with-providers.md`](recipes/extending-metaobjects-with-providers.md) |
4951
| Wire the universal browser client (React + TanStack) to any backend | [`ports/typescript-client.md`](ports/typescript-client.md) + [`features/api-contract.md`](features/api-contract.md) |
5052
| Know which feature is supported in which port today | the capability matrix in the root [`README.md`](../README.md) or the per-port "Capability snapshot" table |
5153
| See which conformance fixtures gate each feature (and which port passes which) | [`CONFORMANCE.md`](CONFORMANCE.md) |
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
# Abstracts and inheritance
2+
3+
Metadata can declare **abstract** instances — nodes that describe a shape but
4+
are never instantiated themselves. Concrete instances reference an abstract
5+
via `extends:` to inherit its children and attrs. This is the **author-side**
6+
mechanism for reuse: no provider code, no new subtype, no codegen change.
7+
8+
Abstracts and `extends:` are the lightest possible way to share metadata
9+
shape. When you find yourself copy-pasting the same field/validator combo
10+
across multiple entities, an abstract is the right answer.
11+
12+
For the **provider-side** mechanism (adding brand-new subtypes or
13+
attributes), see [`extending-with-providers.md`](extending-with-providers.md).
14+
The decision table at the bottom of this page compares both.
15+
16+
## What is an abstract?
17+
18+
An **abstract node** is any metadata node with `abstract: true`. It loads
19+
normally but the loader / codegen treat it specially:
20+
21+
- It contributes shape but is **never emitted as a concrete entity** — no
22+
table, no API endpoint, no runtime class. Codegen skips it.
23+
- It **cannot be instantiated directly** — every concrete instance must
24+
declare its own `name` and reference the abstract via `extends:`.
25+
- It can be `extended` by any number of concrete instances, which all inherit
26+
its children + attrs.
27+
28+
`abstract` and `extends` are **structural keys** (bare, no `@` prefix) — the
29+
same kind as `name`, `package`, `children`. They live alongside the node's
30+
type/subtype, not as `@`-prefixed attributes.
31+
32+
## Two canonical use cases
33+
34+
### 1. Shared base entities (`object.entity` abstracts)
35+
36+
Common audit fields (`id`, `createdAt`, `updatedAt`) belong on one abstract
37+
entity that all concrete entities extend. Change them in one place; every
38+
table updates on the next `meta gen`.
39+
40+
```yaml
41+
# metaobjects/abstracts/meta-base-entity.yaml
42+
metadata:
43+
package: acme
44+
children:
45+
- object.entity:
46+
name: BaseEntity
47+
abstract: true
48+
children:
49+
- field.long:
50+
name: id
51+
- field.timestamp:
52+
name: createdAt
53+
"@required": true
54+
- field.timestamp:
55+
name: updatedAt
56+
```
57+
58+
```yaml
59+
# metaobjects/meta-author.yaml
60+
- object.entity:
61+
name: Author
62+
extends: BaseEntity
63+
children:
64+
- source.rdb: { "@table": authors }
65+
- field.string:
66+
name: name
67+
"@required": true
68+
- identity.primary: { "@fields": id }
69+
```
70+
71+
The codegen emits `authors` with `id`, `created_at`, `updated_at`, AND
72+
`name` columns. `BaseEntity` itself is never a table — it has no
73+
`source.rdb`, no identity, and `abstract: true`.
74+
75+
This is the pattern entities.md introduces — see
76+
[`entities.md § extends`](entities.md#extends-for-shared-abstract-bases) for
77+
the full Author example with per-port emission.
78+
79+
### 2. Shared field shapes (`field.<subtype>` abstracts)
80+
81+
When the same constraint set shows up on multiple entity fields — e.g., a
82+
URL-safe opaque slug ID — declare it as an abstract field once.
83+
84+
```yaml
85+
# metaobjects/abstracts/meta-slug-fields.yaml
86+
metadata:
87+
package: acme
88+
children:
89+
- field.string:
90+
name: shortSlug
91+
abstract: true
92+
"@maxLength": 8
93+
children:
94+
- validator.regex:
95+
"@pattern": "^[A-Z2-9]{8}$"
96+
- validator.required: {}
97+
```
98+
99+
```yaml
100+
# metaobjects/meta-council.yaml
101+
- object.entity:
102+
name: Council
103+
children:
104+
- source.rdb: { "@table": councils }
105+
- field.string:
106+
name: id
107+
extends: shortSlug # ← inherits maxLength + regex + required
108+
- identity.primary: { "@fields": id }
109+
```
110+
111+
Every port's codegen propagates the constraint:
112+
113+
- Drizzle column: `text("id").notNull()` + a CHECK constraint matching the
114+
regex
115+
- Zod validator: `z.string().length(8).regex(/^[A-Z2-9]{8}$/)`
116+
- Migration SQL: `CHECK (length(id) = 8 AND id REGEXP '^[A-Z2-9]{8}$')`
117+
118+
Change the alphabet or length in `shortSlug` once; every field that extends
119+
it updates on the next regen. **No provider code, no codegen change in MO
120+
itself — pure authored metadata.**
121+
122+
## How resolution works
123+
124+
The loader runs in passes (see [`loaders.md`](loaders.md)):
125+
126+
1. **Parse** every metadata file in deterministic ordinal order.
127+
2. **Build tree** — every node exists in memory with its declared shape.
128+
3. **Resolve `extends`** — walk every node; if it has `extends: "<name>"`,
129+
look up that name in the loaded tree (current package first, then via
130+
fully-qualified ref). Merge the abstract's children + attrs into the
131+
concrete node.
132+
4. **Validate** — the merged node is checked against its subtype's rules
133+
(required attrs, child constraints, etc.) just like a flat node would be.
134+
135+
This means:
136+
137+
- **Abstracts can live in any file** in the corpus. The loader sees the
138+
whole tree before resolving extends — order doesn't matter.
139+
- **Forward references are fine.** A field that `extends: shortSlug` can
140+
appear in a file the loader processes before the file that declares
141+
`shortSlug`.
142+
- **Multi-level chains work.** `Author extends BaseEntity extends Auditable`
143+
flattens to one effective shape. Each level can add children or override
144+
attrs.
145+
- **Cross-package references** use fully-qualified names:
146+
`extends: "shared::auditable"`. Same-package references can use the bare
147+
name.
148+
149+
If `extends:` references a name that doesn't resolve, the loader emits
150+
`ERR_UNKNOWN_EXTENDS` with the unresolved reference and the source location.
151+
The fixture
152+
[`extends-nonexistent`](../../fixtures/conformance/error-extends-nonexistent/)
153+
covers this.
154+
155+
## Overlays vs. extends — different concepts
156+
157+
Both look like they're "merging shape" but they do different things:
158+
159+
| Mechanism | Purpose | Wire key |
160+
|---|---|---|
161+
| `extends:` | Concrete inherits from abstract — IS-A relationship | `extends: "BaseName"` |
162+
| `overlay:` | Re-open an existing same-named node and add/override children — same-instance amendment across files | `overlay: true` |
163+
164+
Use `extends:` when two distinct entities share a shape. Use `overlay:` when
165+
the same entity's declaration is split across files (e.g., domain code in
166+
one file, persistence overlay in another). See
167+
[`loaders.md`](loaders.md) for the overlay merge semantics.
168+
169+
## When to use abstracts vs. new subtypes vs. attr extensions
170+
171+
This is the same decision as
172+
[`extending-with-providers.md § When to add a subtype vs. an attr vs. an
173+
abstract`](extending-with-providers.md#when-to-add-a-subtype-vs-an-attr-vs-an-abstract),
174+
viewed from the metadata author's side:
175+
176+
### Use **abstracts + `extends`** when:
177+
178+
- ✅ Multiple metadata instances should share a constraint set that changes
179+
together (slug shape across entities; audit columns across tables).
180+
- ✅ The shape is expressible in existing subtypes — you don't need new
181+
attrs or new node classes.
182+
- ✅ The author wants to opt into the shape per-instance (a Council `id`
183+
field extends `shortSlug`; a non-shareable Internal `id` doesn't).
184+
- ✅ Zero code changes needed in MO itself.
185+
186+
### Use **attr extension** (via a provider's `registry.extend`) when:
187+
188+
- ✅ You need a new **configuration knob** that applies wherever an existing
189+
subtype is used (`@toolName` on `template.output`; `@audit-trail` on
190+
`field.*`).
191+
- ✅ The attr is universally accepted — every instance of that subtype CAN
192+
set it, even if most don't.
193+
- ✅ Codegen needs to branch on the attr's presence (e.g., emit a tool-call
194+
wrapper if `@toolName` is set).
195+
196+
### Use **new subtype registration** (via a provider's `registry.register`) when:
197+
198+
- ✅ The new concept needs a **different runtime node class** (different
199+
`factory:` returning a subclass of `MetaData`).
200+
- ✅ It needs **different `childRules`** that can't be expressed as
201+
additional attrs (e.g., `source.rdb` accepts `@table`/`@kind`/`@role`
202+
but not arbitrary attrs).
203+
- ✅ The concept is so universal it deserves a name in the closed core
204+
vocabulary — added to one of the core providers, not consumer-local.
205+
206+
### Decision flow
207+
208+
```
209+
Need to share shape across multiple instances?
210+
├─ YES → Is the shape expressible with existing subtypes?
211+
│ ├─ YES → Abstract + extends ← lightest
212+
│ └─ NO → New attrs on existing subtype (extend)
213+
│ ├─ Configuration only? → registry.extend
214+
│ └─ Structural divergence? → registry.register
215+
└─ NO → Don't make it shareable. Author it directly.
216+
```
217+
218+
The marginal cost goes up sharply at each step:
219+
**abstract (free) < attr-extension (cheap) < subtype-registration (expensive)**.
220+
A new subtype is a permanent commitment in every port's registry; an
221+
abstract is pure data the loader resolves at parse time. **Default to the
222+
lightest mechanism that fits.**
223+
224+
## What each port emits
225+
226+
Abstracts are **invisible to codegen output** — the loader merges them into
227+
their extenders before any generator runs. Each port emits exactly what it
228+
would emit for the merged shape, as if the author had written all the
229+
inherited children and attrs inline.
230+
231+
Cross-port reading:
232+
233+
```ts
234+
// TypeScript: the loader's effective view
235+
import { MetaDataLoader } from "@metaobjectsdev/metadata";
236+
const loader = await MetaDataLoader.fromDirectory("app", "./metadata");
237+
238+
const author = loader.metaObject("acme::Author");
239+
// author.fields() includes "id" + "createdAt" + "updatedAt" (from BaseEntity)
240+
// AND "name" (declared directly). The abstract origin is transparent.
241+
```
242+
243+
```java
244+
// Java
245+
MetaObject author = loader.getMetaObjectByName("acme::Author");
246+
// Same — author.getMetaFields() returns the merged set.
247+
```
248+
249+
```python
250+
# Python
251+
author = loader.meta_object("acme::Author")
252+
# author.fields() returns the merged set.
253+
```
254+
255+
The `extends:` chain itself is queryable via `node.superRef` (TS / Python)
256+
/ `node.getSuperRef()` (Java / C#) for tooling that needs to walk the
257+
inheritance chain.
258+
259+
## Verified by
260+
261+
- [`extends-single-level`](../../fixtures/conformance/extends-single-level/) — one
262+
level of inheritance, basic resolution
263+
- [`extends-multi-level`](../../fixtures/conformance/extends-multi-level/) — A
264+
extends B extends C, full chain flattening
265+
- [`extends-cross-file`](../../fixtures/conformance/extends-cross-file/) — forward
266+
reference across files
267+
- [`extends-abstract-base`](../../fixtures/conformance/extends-abstract-base/)
268+
abstract entities aren't instantiable; codegen skips them
269+
- [`error-extends-nonexistent`](../../fixtures/conformance/error-extends-nonexistent/)
270+
`ERR_UNKNOWN_EXTENDS` when the reference doesn't resolve
271+
- [`enum-abstract-extends`](../../fixtures/conformance/enum-abstract-extends/)
272+
abstracts on enum fields specifically
273+
274+
## See also
275+
276+
- [`entities.md`](entities.md) — the canonical `BaseEntity` extends example
277+
- [`extending-with-providers.md`](extending-with-providers.md) — the
278+
provider-side reference (when abstracts aren't enough)
279+
- [`field-types.md`](field-types.md) — the field subtypes you can abstract over
280+
- [`loaders.md`](loaders.md) — load order, deferred resolution, overlay semantics

0 commit comments

Comments
 (0)