|
| 1 | +--- |
| 2 | +name: metaobjects-authoring |
| 3 | +description: Use when authoring or modifying MetaObjects metadata — fields, entities, relationships, sources, enums, abstracts/inheritance — in YAML or canonical JSON. |
| 4 | +--- |
| 5 | + |
| 6 | +# Authoring MetaObjects metadata |
| 7 | + |
| 8 | +MetaObjects metadata is the durable spine of your app: typed entity declarations |
| 9 | +that drive code generation, runtime behavior, and drift detection. You author it, |
| 10 | +the loader reads it, the codegen emits idiomatic per-language code from it. This |
| 11 | +skill is the procedure for writing it correctly. |
| 12 | + |
| 13 | +Metadata lives in files under `metaobjects/` at project root, one file per domain |
| 14 | +concept (`meta.commerce.json`, `meta.users.yaml`, …). Each file declares a |
| 15 | +`package` on its root node. Files in the same `package` with the same object |
| 16 | +`name` are merged by the loader. |
| 17 | + |
| 18 | +Two on-disk formats, one shape: |
| 19 | + |
| 20 | +- **Canonical JSON** — the on-disk interchange. Every node is a single-key map |
| 21 | + whose key fuses the type and subtype. |
| 22 | +- **YAML** — the sigil-free authoring front-end. Lowered to canonical JSON at load |
| 23 | + time, so it shares the entire downstream pipeline. |
| 24 | + |
| 25 | +Author in whichever fits the project. Prefer YAML for new hand-authored metadata |
| 26 | +(it's less noisy); JSON is the format conformance fixtures and tooling pin. |
| 27 | + |
| 28 | +## The fused-key encoding (non-negotiable) |
| 29 | + |
| 30 | +Every node is `{ "<type>.<subType>": { <body> } }`. The wrapper key fuses type and |
| 31 | +subtype — there is **no** separate `subType` body key. |
| 32 | + |
| 33 | +```json |
| 34 | +{ "object.entity": { "name": "User" } } |
| 35 | +{ "field.string": { "name": "email", "@required": true } } |
| 36 | +{ "field.enum": { "name": "status", "@values": ["OPEN", "CLOSED"] } } |
| 37 | +{ "identity.primary": { "@fields": ["id"] } } |
| 38 | +``` |
| 39 | + |
| 40 | +A complete entity in canonical JSON: |
| 41 | + |
| 42 | +```json |
| 43 | +{ |
| 44 | + "metadata.root": { |
| 45 | + "package": "acme::blog", |
| 46 | + "children": [ |
| 47 | + { |
| 48 | + "object.entity": { |
| 49 | + "name": "Author", |
| 50 | + "children": [ |
| 51 | + { "source.rdb": { "@table": "authors" } }, |
| 52 | + { "field.long": { "name": "id" } }, |
| 53 | + { "field.string": { "name": "name", "@required": true, "@maxLength": 200 } }, |
| 54 | + { "field.string": { "name": "bio", "@maxLength": 2000 } }, |
| 55 | + { "identity.primary": { "@fields": ["id"], "@generation": "increment" } } |
| 56 | + ] |
| 57 | + } |
| 58 | + } |
| 59 | + ] |
| 60 | + } |
| 61 | +} |
| 62 | +``` |
| 63 | + |
| 64 | +The same entity in sigil-free YAML: |
| 65 | + |
| 66 | +```yaml |
| 67 | +metadata: |
| 68 | + package: acme::blog |
| 69 | + children: |
| 70 | + - object.entity: |
| 71 | + name: Author |
| 72 | + children: |
| 73 | + - source.rdb: { table: authors } |
| 74 | + - field.long: { name: id } |
| 75 | + - field.string: { name: name, required: true, maxLength: 200 } |
| 76 | + - field.string: { name: bio, maxLength: 2000 } |
| 77 | + - identity.primary: { fields: id, generation: increment } |
| 78 | +``` |
| 79 | +
|
| 80 | +## Reserved structural keys vs. attributes |
| 81 | +
|
| 82 | +There is one closed set of **reserved structural keys**. Everything else is an |
| 83 | +attribute. |
| 84 | +
|
| 85 | +``` |
| 86 | +name package extends abstract overlay isArray children value |
| 87 | +``` |
| 88 | + |
| 89 | +- In **canonical JSON**: reserved keys are bare (`"name"`, `"extends"`); every |
| 90 | + other key is `@`-prefixed (`"@required"`, `"@maxLength"`, `"@table"`). |
| 91 | +- In **YAML**: reserved keys are bare AND attributes are bare too — the desugar |
| 92 | + re-adds the `@` when lowering. |
| 93 | +- `@`-prefixing a reserved word (e.g. `"@isArray": true`) is invalid and fails the |
| 94 | + load with `ERR_RESERVED_ATTR`. Use the bare `isArray: true` (YAML) or the `[]` |
| 95 | + key-suffix sugar (`field.long[]: weekIds`). |
| 96 | + |
| 97 | +## Two violation rules — internalize these |
| 98 | + |
| 99 | +1. **Attribute-name uniqueness within a node.** A node body must not declare the |
| 100 | + same attribute name twice. `{ "field.string": { "name": "x", "@maxLength": 10, |
| 101 | + "@maxLength": 20 } }` is malformed. |
| 102 | + |
| 103 | +2. **An inline `@attr` IS an `attr` child — never both.** An inline attribute and |
| 104 | + a child `attr.*` node with the same name are the same slot expressed two ways. |
| 105 | + Declare a given attribute once, in one form. Don't set `@required` inline AND |
| 106 | + also add an `attr.boolean` child named `required` — that's a double-declaration. |
| 107 | + |
| 108 | +## Field subtypes (closed vocabulary) |
| 109 | + |
| 110 | +| Subtype | Stores | Notes | |
| 111 | +|---|---|---| |
| 112 | +| `field.string` | text | `@maxLength` drives `varchar(N)` | |
| 113 | +| `field.int` | 32-bit integer | | |
| 114 | +| `field.long` | 64-bit integer | | |
| 115 | +| `field.double` | float | | |
| 116 | +| `field.boolean` | true/false | | |
| 117 | +| `field.date` | calendar date | ISO 8601 `YYYY-MM-DD` on the wire | |
| 118 | +| `field.timestamp` | instant | ISO 8601 with timezone on the wire | |
| 119 | +| `field.decimal` | exact decimal | `@precision` / `@scale`; lossless money/quantity | |
| 120 | +| `field.currency` | integer minor units | see Currency below | |
| 121 | +| `field.enum` | string member | `@values` required; see Enum below | |
| 122 | +| `field.uuid` | UUID | canonical lowercase hex on the wire | |
| 123 | +| `field.object` | embedded value object | `@objectRef` + `@storage`; see below | |
| 124 | + |
| 125 | +Common field attributes: `@required`, `@maxLength`, `@column` (physical column |
| 126 | +name), `@default`, `@filterable`, `@sortable`. |
| 127 | + |
| 128 | +### Currency |
| 129 | + |
| 130 | +`field.currency` stores money as **integer minor units** (cents for USD, yen for |
| 131 | +JPY) — never a float. `@currency` is ISO 4217; `@locale` (on a `view.currency` |
| 132 | +child) is BCP 47. The server never formats currency; formatting is client-side. |
| 133 | + |
| 134 | +```json |
| 135 | +{ "field.currency": { |
| 136 | + "name": "priceCents", "@currency": "USD", "@required": true, |
| 137 | + "children": [ { "view.currency": { "@locale": "en-US" } } ] |
| 138 | +}} |
| 139 | +``` |
| 140 | + |
| 141 | +### Enum |
| 142 | + |
| 143 | +`field.enum` is string-backed. `@values` is **required**: a non-empty set of |
| 144 | +unique members, each matching `^[A-Za-z_][A-Za-z0-9_]*$`. Missing `@values` → |
| 145 | +`ERR_MISSING_REQUIRED_ATTR`; a bad member → `ERR_BAD_ATTR_VALUE`. |
| 146 | + |
| 147 | +```json |
| 148 | +{ "field.enum": { "name": "status", "@required": true, |
| 149 | + "@values": ["DRAFT", "PUBLISHED", "ARCHIVED"] } } |
| 150 | +``` |
| 151 | + |
| 152 | +Reuse a constraint set across entities with an abstract `field.enum` + `extends`. |
| 153 | + |
| 154 | +### Embedded value objects — `field.object` + `@storage` |
| 155 | + |
| 156 | +`field.object` embeds another `object` declaration. `@objectRef` names it; |
| 157 | +`@storage` controls persistence: |
| 158 | + |
| 159 | +- `flattened` — one DB column per sub-field (`address_street`, `address_city`, …). |
| 160 | + Illegal on array fields. |
| 161 | +- `jsonb` — one `jsonb` column. |
| 162 | +- `subdocument` (default, back-compat) — single jsonb column. |
| 163 | + |
| 164 | +```json |
| 165 | +{ "field.object": { "name": "address", "@objectRef": "Address", "@storage": "flattened" } } |
| 166 | +``` |
| 167 | + |
| 168 | +## YAML sigil-free authoring + the coercion footgun |
| 169 | + |
| 170 | +In YAML, write the fused `type.subType` key with a **map body**, bare reserved |
| 171 | +keys, bare attributes. Two house-style rules: |
| 172 | + |
| 173 | +1. **Always write the explicit `type.subType`** (`field.string`, not `field`). |
| 174 | + Defaults change; the explicit form survives registry edits. |
| 175 | + |
| 176 | +2. **Quote any scalar that looks like a boolean, number, date, or null.** YAML |
| 177 | + silently coerces unquoted `yes` / `no` / `on` / `off` to booleans and bare |
| 178 | + `2026-05-25` to a date. The loader's coercion guard rejects a coerced value in |
| 179 | + a slot that declares a different type (`ERR_YAML_COERCION`) — but quoting is how |
| 180 | + you *prevent* the surprise. Enum members are the classic trap: |
| 181 | + |
| 182 | + ```yaml |
| 183 | + # Rejected — Y and N coerce to booleans |
| 184 | + field.enum: { name: flag, values: [Y, N] } |
| 185 | + # Correct — quote domain-data members |
| 186 | + field.enum: { name: flag, values: ["Y", "N"] } |
| 187 | + ``` |
| 188 | +
|
| 189 | +The `[]` key-suffix declares an array field: `field.long[]: weekIds` lowers to |
| 190 | +`{ "field.long": { "name": "weekIds", "isArray": true } }`. |
| 191 | + |
| 192 | +## Identities |
| 193 | + |
| 194 | +| Subtype | Purpose | Key attrs | |
| 195 | +|---|---|---| |
| 196 | +| `identity.primary` | the PK field(s) | `@fields`, `@generation` | |
| 197 | +| `identity.secondary` | a unique secondary index | `@fields` | |
| 198 | +| `identity.reference` | an inbound FK from this entity to another | `@fields`, `@references`, `@enforce` | |
| 199 | + |
| 200 | +`@generation` on a primary controls value generation (e.g. `increment`). |
| 201 | +`@fields` accepts a single string in authoring; it normalizes to an array in |
| 202 | +canonical JSON. `@enforce` on a reference (default `true`) controls whether the |
| 203 | +backend physically enforces it (a SQL FK constraint); set `false` for a logical |
| 204 | +reference for navigation/typing/codegen only. Referential actions |
| 205 | +(`@onDelete`/`@onUpdate`) are NOT on `identity.reference` — they live on the |
| 206 | +`relationship.*` node (see Relationships below). |
| 207 | + |
| 208 | +```json |
| 209 | +{ "identity.primary": { "@fields": ["id"], "@generation": "increment" } } |
| 210 | +{ "identity.secondary": { "@fields": ["email"] } } |
| 211 | +{ "identity.reference": { "name": "fkAuthor", "@fields": ["authorId"], "@references": "Author", "@enforce": true } } |
| 212 | +``` |
| 213 | + |
| 214 | +## Relationships |
| 215 | + |
| 216 | +`relationship.composition` is the "this entity owns / aggregates instances of |
| 217 | +that entity" side; `identity.reference` (above) is the FK-column side. They are |
| 218 | +the two halves of one FK. |
| 219 | + |
| 220 | +| Attr | On | Values | |
| 221 | +|---|---|---| |
| 222 | +| `@objectRef` | composition | target entity name | |
| 223 | +| `@cardinality` | composition | `one` / `many` | |
| 224 | +| `@onDelete` / `@onUpdate` | `relationship.*` only | `cascade` / `set-null` / `restrict` / `no-action` | |
| 225 | + |
| 226 | +```json |
| 227 | +{ "relationship.composition": { |
| 228 | + "name": "posts", "@objectRef": "Post", |
| 229 | + "@cardinality": "many", "@onDelete": "cascade" } } |
| 230 | +``` |
| 231 | + |
| 232 | +## Sources — `source.rdb` + `@kind` |
| 233 | + |
| 234 | +`source.rdb` declares where an entity's data lives. Read-only-ness derives from |
| 235 | +`@kind` (it is NOT a separate subtype): |
| 236 | + |
| 237 | +| `@kind` | Read-only | Default? | |
| 238 | +|---|---|---| |
| 239 | +| `table` | no | yes (when `@kind` omitted) | |
| 240 | +| `view` | yes | – | |
| 241 | +| `materializedView` | yes | – | |
| 242 | +| `storedProc` | yes | – | |
| 243 | +| `tableFunction` | yes | – | |
| 244 | + |
| 245 | +The physical name is `@table` (NOT `@name`). The physical column name on a field |
| 246 | +is `@column`. `@schema` namespaces the DB schema (Postgres default `public`; |
| 247 | +SQLite rejects non-default values). Multi-source: multiple `source.rdb` children, |
| 248 | +each with a `@role`, exactly one `primary`. |
| 249 | + |
| 250 | +```json |
| 251 | +{ "source.rdb": { "@kind": "view", "@table": "v_author", "@schema": "blog" } } |
| 252 | +``` |
| 253 | + |
| 254 | +A `view`-kind entity's fields carry `origin.*` children (`passthrough` / |
| 255 | +`aggregate` / `collection`) declaring where each value comes from. |
| 256 | + |
| 257 | +## Abstracts + `extends` (deferred resolution) + `overlay` |
| 258 | + |
| 259 | +An **abstract** node (`abstract: true`) describes a shape but is never emitted as |
| 260 | +a concrete entity. A concrete node references it via `extends:` to inherit its |
| 261 | +children + attrs. This is the lightest reuse mechanism — pure data, no codegen |
| 262 | +change. |
| 263 | + |
| 264 | +```yaml |
| 265 | +- object.entity: |
| 266 | + name: BaseEntity |
| 267 | + abstract: true |
| 268 | + children: |
| 269 | + - field.long: { name: id } |
| 270 | + - field.timestamp: { name: createdAt, required: true } |
| 271 | +
|
| 272 | +- object.entity: |
| 273 | + name: Author |
| 274 | + extends: BaseEntity |
| 275 | + children: |
| 276 | + - source.rdb: { table: authors } |
| 277 | + - field.string: { name: name, required: true } |
| 278 | + - identity.primary: { fields: id } |
| 279 | +``` |
| 280 | + |
| 281 | +Resolution facts: |
| 282 | + |
| 283 | +- **Deferred.** `extends:` resolves *after all files load* — abstracts can live in |
| 284 | + any file, forward references are fine. |
| 285 | +- **Multi-level chains flatten** (`Author extends BaseEntity extends Auditable`). |
| 286 | +- **Cross-package** refs use the fully-qualified name (`extends: "shared::auditable"`); |
| 287 | + same-package refs use the bare name. |
| 288 | +- An unresolved reference fails with `ERR_UNKNOWN_EXTENDS`. |
| 289 | + |
| 290 | +`abstract` and `extends` are **structural keys** (bare, no `@`). |
| 291 | + |
| 292 | +**`overlay` is a different concept.** `extends:` is an IS-A relationship between |
| 293 | +two distinct nodes. `overlay: true` re-opens the *same* named node to amend it |
| 294 | +across files (same `package` + same `name` → merged; last-writer-wins on attr |
| 295 | +conflicts, structural children accumulate). Use `extends` to share shape between |
| 296 | +distinct entities; use `overlay` to split one entity's declaration across files. |
| 297 | + |
| 298 | +--- |
| 299 | + |
| 300 | +For non-trivial schema design, use `/superpowers:brainstorming` if installed; |
| 301 | +otherwise proceed. |
0 commit comments