Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
7e7bc93
docs(spec): whole-object rollup on @agg:collect, and the surface cont…
dmealing Aug 22, 2026
1194032
docs(spec): metamodelVersion rides the existing 0.11 rather than movi…
dmealing Aug 22, 2026
1bb6d18
docs(spec): every new load error needs a corpus fixture that triggers…
dmealing Aug 23, 2026
318b394
docs(spec): bound Half B's breaking-ness instead of just labelling it…
dmealing Aug 23, 2026
c22af2e
docs(plan): implementation plan for the whole-object rollup and the s…
dmealing Aug 23, 2026
8a5c1b3
fix(metamodel): an array field has no filter-operator band either (#335)
dmealing Aug 23, 2026
32be086
fix(metamodel): @sortable gets the subtype validation @filterable alr…
dmealing Aug 23, 2026
98966cb
test(conformance): gate the array filter/sort rules cross-port (#335)
dmealing Aug 23, 2026
b0533eb
fix(metamodel): array filter/sort rules in the remaining three ports …
dmealing Aug 23, 2026
53f9968
docs(plan): correct Task 5's test model and Task 9's file count (#335)
dmealing Aug 23, 2026
451a99f
feat(metamodel): @of is optional on @agg:collect — whole-object rollu…
dmealing Aug 23, 2026
4b998a7
fix(metamodel): pin cardinality + @orderBy-terminal in whole-object c…
dmealing Aug 23, 2026
1046056
feat(metamodel): whole-object rollup resolves VO members against the …
dmealing Aug 23, 2026
bbd1fab
test(conformance): gate the whole-object rollup and its eight error a…
dmealing Aug 23, 2026
e4a80e2
fix(metamodel): whole-object member agreement judges array-ness too (…
dmealing Aug 23, 2026
cfa5126
docs(plan): Task 8 ports EIGHT rules against NINE fixtures, not six/s…
dmealing Aug 23, 2026
2d79f53
refactor(metamodel): one @via walk, not three; extract the whole-obje…
dmealing Aug 23, 2026
3c900ab
chore(metamodel)!: metamodelVersion 0.11 -> 0.12 for #335 (#335)
dmealing Aug 23, 2026
34aa5f0
feat(metamodel): the whole-object arm gets its own error code (#335)
dmealing Aug 23, 2026
03a0c2e
feat(metamodel): whole-object rollup in the remaining three ports (#335)
dmealing Aug 23, 2026
dfcbc0e
docs(metamodel): @of is optional on collect, in all seven byte-gated …
dmealing Aug 23, 2026
d006d55
feat(codegen-ts): lower an @of-less collect to a jsonb whole-object r…
dmealing Aug 23, 2026
f02f132
test(integration): round-trip the whole-object rollup on real PG and …
dmealing Aug 23, 2026
42633ad
docs(migrations): a whole-object origin.collection on a PROJECTION is…
dmealing Aug 23, 2026
d98c0b9
docs(changelog): the whole-object rollup, and teach it where authors …
dmealing Aug 23, 2026
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
80 changes: 80 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,86 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm

## [Unreleased]

### Added — `@of` becomes optional on `@agg: collect`: the whole-object rollup ([#335](https://github.com/metaobjectsdev/metaobjects/issues/335))

**A projection could roll related rows up into an array of one COLUMN, and had no way to
roll them up into an array of OBJECTS.** `origin.aggregate @agg: collect` required `@of`, so
"every supplier's `{id, name}` for this product" was inexpressible — the shape had to be a
second round-trip, or a hand-written view, which is unmanaged and invisible to
`meta verify --db`. When `origin.collection` retired in `0.24.0` this became the one
coverage gap the retirement guide had to state rather than close.

**`@of` is now OPTIONAL on `collect`, and omitting it means a whole-object rollup:** the
carrying `field.object @isArray @objectRef` collects each related row as its declared value
object.

```jsonc
{ "field.object": {
"name": "supplierBriefs", "isArray": true, "@objectRef": "SupplierBrief",
"children": [
{ "origin.aggregate": { "@agg": "collect", "@via": "Product.suppliers" } }
]
}}
```

**The declared value object IS the exposure.** Members bind to the `@via` terminal entity's
fields BY NAME, and a field the entity has but the value object omits is simply not
projected. That is deliberate — it is the [#270](https://github.com/metaobjectsdev/metaobjects/issues/270)
guarantee (a curated value object must not silently become the full entity) carried down to
the DDL tier. Name matching, rather than `extends`, is also deliberate: it keeps one value
object collectable from two different entities, which `extends` would forbid. The convention
is written into the byte-gated `origin.aggregate` registry prose so no port has to infer it.

**Eight load errors, in all five ports.** The carrying field must be a `field.object`
declaring `@objectRef`; that `@objectRef` must name an `object.value`; `@via` is required
(there is no `@of` entity to infer a single hop from); the path must be to-many; `@distinct`
is refused; `@orderBy` keys must resolve against the `@via` **terminal** entity; every value
object member must match a terminal field; and a matched member must agree on **both** type
axes — subtype and array-ness. `@distinct` is refused by CHOICE, not engine limit: it works
on both engines, but it is a guaranteed no-op whenever the value object carries the primary
key, and a silent no-op is worse than a refusal.

**A new error code, `ERR_COLLECT_WHOLE_OBJECT`,** carries the five refusals that would
otherwise have shared `ERR_INVALID_ORIGIN`. That is not taxonomy for its own sake: the shared
corpus compares error **code + source** and never message text, and `ERR_INVALID_ORIGIN` is
exactly what a loader that still *requires* `@of` emits for this same metadata — so five of
the eight negative fixtures passed against three ports containing none of the rules. With the
distinct code they fail, and Task-by-task porting has a real signal.

**View lowering, both dialects.** Postgres emits
`COALESCE(jsonb_agg(jsonb_build_object(…) ORDER BY <pk> ASC) FILTER (WHERE <pk> IS NOT NULL), '[]'::jsonb)`.
`jsonb`, not `json`: PG's `json` type has neither an equality nor an ordering operator, so
the `json_agg(json_build_object(… ORDER BY …))` form does not run at all. Default element
order is the related entity's **primary key** ascending — ordering rows by a serialized
object is meaningless — and an explicit `@orderBy` leads with the PK appended as a tie-break.
The scalar `@of` arm deliberately keeps its existing no-tie-break behaviour, since changing
it would move the emitted SQL of every project already using `@orderBy`.

**SQLite needed a shape nobody would have guessed, and only a real engine found it.** On
SQLite 3.44 (D1's pinned baseline) the in-aggregate `ORDER BY` clause **destroys the JSON
subtype**: `json_group_array(json_object(…) ORDER BY …)` returns an array of quoted STRINGS
rather than objects, and wrapping the argument in `json()` does not survive it either.
Dropping the `ORDER BY` was not an option — element order would stop being deterministic and
an author's `@orderBy` would silently do nothing. So the ordered array is built first and
re-wrapped element-by-element through `json_each`, which iterates in array order. The
emitted SQL text alone could never have shown this; it was caught by the emit → apply →
introspect → re-diff round-trip against a real engine, which is the standing rule that
golden SQL is not evidence for new DDL.

Also worth knowing: inside the rollup a `field.long` member arrives as a JSON **number**,
while the same value as a top-level `BIGINT` column arrives as a string from
node-postgres. That is inherent to JSON, not a codegen choice, and it is lossy above 2^53.

**Also in this change — array fields are not filterable or sortable.** A
`field.<scalar> isArray: true` carrying `@filterable: true` or `@sortable: true` is now a
load error in all five ports (`ERR_FILTERABLE_UNSUPPORTED_SUBTYPE` /
`ERR_SORTABLE_UNSUPPORTED_SUBTYPE`): no operator in the FR-009 scalar band applies to a
collection column, and no dialect can `ORDER BY` one. `@sortable` also gains the subtype
validation `@filterable` already had.

`metamodelVersion` moves `0.11` → `0.12`.


## [0.24.1] — npm `0.24.1` · PyPI `0.24.1` · NuGet `0.24.1` · Maven `7.24.1`

### Fixed — an expression index was undeclarable, and the one spelling that loaded was half-ignored ([#342](https://github.com/metaobjectsdev/metaobjects/issues/342))
Expand Down
12 changes: 10 additions & 2 deletions agent-context/skills/metaobjects-authoring/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -838,8 +838,16 @@ amendment 2026-08-06.)

**Origin vocabulary (#195).** `origin.aggregate @agg` takes `count`/`sum`/`avg`/`min`/`max`
(numeric reduces over `@of`), `any`/`all` (predicate quantifiers over a `@filter`; `@of`
forbidden; empty set → `any=false`, `all=true`), and `collect` (an array rollup of `@of`
into an `isArray` field, with optional `@distinct` / `@orderBy`). Any aggregate may be
forbidden; empty set → `any=false`, `all=true`), and `collect` (an array rollup
into an `isArray` field, with optional `@distinct` / `@orderBy`). **`collect` is the one
`@agg` where `@of` is OPTIONAL (#335):** name a column with `@of` to collect scalars, or
omit `@of` on a `field.object @objectRef` to collect each related row as that declared
value object — a **whole-object rollup**, lowered to `jsonb_agg(jsonb_build_object(…))`
on Postgres. The whole-object form requires an explicit `@via`, refuses `@distinct` (it is
a no-op whenever the value object carries the primary key), and requires every value-object
member to match a field on the `@via` **terminal** entity by name, with the same subtype
and array-ness. The declared value object IS the exposure: a field the entity has and the
value object omits is not projected. Any aggregate may be
row-scoped with `@filter`. `origin.computed` carries a closed structured `@expr` tree (a
derived scalar). `origin.first` picks one related row's column (`@of`) along `@via`,
ordered by a **required `@orderBy`** (`["field:asc|desc", …]`, with the PK as tie-break) —
Expand Down
7 changes: 7 additions & 0 deletions docs/features/downstream-metadata-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ Most "I need a new type" needs die here. Before you register anything:
- **Is it a one-off author-supplied property?** The registered `attr.properties` bag
is the sanctioned escape hatch for arbitrary key/values — it does not require a new
attribute and stays inside strict provenance (ADR-0023).
- **Do you need an array of OBJECTS on a projection?** That is
`origin.aggregate @agg: collect` with **`@of` omitted** — the whole-object rollup
(#335), on a `field.object @isArray @objectRef`. It is the case most often
mistaken for missing vocabulary, because the `@of` form reads as the only form and
the natural next thought is a hand-written view. Its declared value object is the
exposure, and the `CREATE VIEW` is generated — a hand-written one is unmanaged and
therefore invisible to `meta verify --db`.

## Step 1 — check whether core (or the roadmap) already models it

Expand Down
55 changes: 43 additions & 12 deletions docs/features/migrations/origin-collection-retirement.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,16 @@ gain `@filter`, `@orderBy` and `@distinct`, none of which `collection` could exp
`collect` preserves the **element** type: the array field's own `field.<subType>` must equal
the `@of` column's, and the field must be `isArray: true`.

### 2. A whole-object rollup → delete the child
### 2. A whole-object rollup → depends on the HOST

If the field collected whole nested value objects (`field.object @objectRef … isArray: true`),
**delete the `origin.collection` child and change nothing else**:
What to do splits by where the field lives, and getting it wrong on a projection is
**silent** — read both cases before editing.

#### 2a. On a payload / `object.value` host → delete the child

If the field collected whole nested value objects (`field.object @objectRef … isArray: true`)
and its host is a payload or value object, **delete the `origin.collection` child and change
nothing else**:

```jsonc
{ "field.object": {
Expand All @@ -78,15 +84,40 @@ The declared shape is unchanged, and payload typing has been **declared-authorit
0.20.16** (#270) — the type came from `field.object` + `isArray` + `@objectRef`, never from the
origin. Generated payload records, output parsers and render helpers are byte-identical.

**State it plainly, because it is the one real gap:** no surviving origin expresses a
whole-object rollup along a relationship — `@agg: collect` reduces a *column* via `@of`. If
that field was on a `source.rdb @kind: view` **projection** and you were relying on the view
DDL, note that no port ever emitted DDL for `origin.collection` either, so nothing regresses;
but you also cannot now declare the provenance. That shape returns with
[#335](https://github.com/metaobjectsdev/metaobjects/issues/335), which makes `@of` **optional**
on `@agg: collect` (absent = whole-object rollup, typed by the declared `@objectRef` +
`isArray`, never derived from the `@via` target) and ships the view lowering with it. It is
**additive**, so it needs no breaking window.
#### 2b. On a view-kind PROJECTION host → replace the child, never delete it

**Deleting the child here produces metadata that loads clean and generates a broken view.**
A projection field with no `origin.*` is treated as a plain base column, so the emitted DDL
becomes `SELECT base."supplierBriefs"` against a column that does not exist. The load says
nothing; the failure surfaces at apply.

Replace the child instead:

```jsonc
{ "field.object": {
"name": "supplierBriefs", "isArray": true,
"@objectRef": "acme::catalog::SupplierBrief",
"children": [
- { "origin.collection": { "@via": "acme::catalog::Product.suppliers" } }
+ { "origin.aggregate": {
+ "@agg": "collect",
+ "@via": "acme::catalog::Product.suppliers" } }
]
}}
```

That is [#335](https://github.com/metaobjectsdev/metaobjects/issues/335)'s whole-object
rollup: `@of` is **optional** on `@agg: collect`, and omitting it means "collect the related
rows as the declared `@objectRef` value object". The element type comes from the declared
`@objectRef` + `isArray`, never from the `@via` target (#270), and the view lowering ships
with it — `jsonb_agg(jsonb_build_object(…))` on Postgres, `json_group_array(json_object(…))`
on SQLite, `[]` on an empty related set.

Three constraints the old `origin.collection` did not have: `@via` is **required** (there is
no `@of` entity to infer a single hop from), `@distinct` is **refused** (it is a guaranteed
no-op whenever the value object carries the primary key), and every value-object member must
match a field on the `@via` **terminal** entity by name, with the same `field.<subType>` and
array-ness. Each is a load error, so a mistake here fails loudly rather than silently.

### 3. `ASSEMBLY_ORIGIN_SUBTYPES` shrinks to three

Expand Down
Loading
Loading