Skip to content

Commit d53f230

Browse files
committed
Merge origin/main (f8178ff) into claude/issue-20912-native-multivalue-door
Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
2 parents df5ab8d + f8178ff commit d53f230

27 files changed

Lines changed: 1616 additions & 282 deletions

‎.changeset/20802-nested-relation-filter-served.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,4 @@ Measured through `POST /api/v1/data/:object/query` on SQLite and PostgreSQL 16 (
2525
| `{ $not: { owner: { region: "NA" } } }` | `INVALID_FILTER` / 400 | `d2`, `d4` |
2626
| `{ owner: { region: "APAC" } }` (no owner matches) | `INVALID_FILTER` / 400 | no rows |
2727

28-
On the in-memory driver, a multi-valued relation's `$contains` still matches a stored id by substring per element, so there an id that is a substring of another stored id (`u1` inside `u10`) also matches; SQLite and PostgreSQL match the element.
28+
SQLite, PostgreSQL and the in-memory driver match the element of a multi-valued relation, so an id that is a substring of another stored id (`u1` inside `u10`) does not match it.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@objectstack/driver-memory": minor
3+
---
4+
5+
fix(driver-memory): `$contains` / `$notContains` on a multi-valued or JSON-stored field answer by membership, as the SQL drivers do
6+
7+
Clause-②: yes (widening) — one new public method on the exported `InMemoryDriver` class, `filterContainsTest`; its return type `MemoryContainsTest` is not re-exported from the package entry. No accepted filter key or operator is added: `$contains` and `$notContains` keep their declared shape.
8+
9+
On a field whose declaration makes it JSON-stored (`multiple: true` on a `lookup`, `user`, `select`, `radio`, `file` or `image` field, a `multiselect`, `checkboxes` or `tags` field, or a structured type such as `json`), the in-memory driver now answers `{ field: { $contains: v } }` by whole-element membership: some element of the stored array equals `v`. It used to match each element by substring, so `u1` matched a row storing `['u10']` and `'red'` matched a row storing `['redwood']`. A number member answered nothing: `{ nums: { $contains: '1' } }` missed `[1, 2]`. `$notContains` is the exact complement, and a row with no value still satisfies it. A scalar text column keeps the case-exact substring test.
10+
11+
`driver-sql` gives the same answer on SQLite, PostgreSQL and MySQL; the two drivers were measured over the same fixture. The answer holds on every face of this driver:
12+
13+
- `find()` and `count()`, in both filter spellings;
14+
- the nested-relation filter on a multi-valued relation, which the engine lowers to one `$contains` per related id;
15+
- `MemoryAnalyticsService`'s query, and its SQL echo, which now renders SQLite's `json_each` membership construct for such a column.
16+
17+
The comparand is still a string. A number or boolean member is named by its text: `'1'` matches the stored number `1` (and `'1.50'` the number `1.5`), `'true'` matches the boolean `true`, and `'null'` matches a `null` member. A field the driver holds no declaration for, such as a field on an object never passed through `syncSchema`, keeps the substring reading.
18+
19+
New: `InMemoryDriver.filterContainsTest(object, field, value)` returns the one test every face above lowers `$contains` to. It is a narrow seam for the analytics face, beside `filterSubstringPattern` and `filterComparandStorageForm`. The added public method is why this entry is `minor`.
20+
21+
**If your tests relied on the old answer:** on the in-memory driver, a filter that matched an id by prefix or a tag by substring now returns only the member rows. That is what SQL already returned in production. Write `$contains` with the whole member value.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/lint': patch
4+
'@objectstack/cli': patch
5+
---
6+
7+
feat(spec): one list of page-component slot positions, derived from the component rows and read by every page walk — `page:card`'s `footer` is now walked by all three (#20940)
8+
9+
The platform has three walks that descend into a page component's `properties` bag, and each kept its own list of where child components hang: the ADR-0087 conversion walker (`children`, `body`, `footer`, `items[].children`), `@objectstack/lint`'s `walkPageComponents` (the same four) and the exported `walkAddressedPageComponents` (`children`, `items[].children`). So a node in a card's `footer` — a declared, rendered slot ("Card footer components (slot)") — was judged by `os lint` and skipped by every consumer of the exported walk: `translatePage` left its copy untranslated, `os i18n extract` offered no key for it, and objectui's validator passed it unjudged.
10+
11+
**`@objectstack/spec` — new exports `pageComponentSlotPositions()` and `PageComponentSlotPosition` (`@objectstack/spec/ui`).** The component rows now mark each composition slot at its declaration, and `pageComponentSlotPositions()` derives the one list from `ComponentPropsMap`: `children`, `footer` and the panel position `items[].children`, plus the tombstoned `body` flagged `retired: true`. The marker changes nothing about the schema it marks — the parse, the JSON Schema and the authorable surface are unchanged. The list is derived on first call and memoized, never at import. `minor` because the package's public surface grows by these two exports.
12+
13+
**`walkAddressedPageComponents` descends `properties.footer`.** It reads the list's authorable entries, in the list's order (`children`, `footer`, then `items[].children`); signature and return shape are unchanged. What follows from it:
14+
15+
- `translatePage` translates the copy of a component in a card footer through `pages.<name>.components.<id>`, like any other nested component.
16+
- `os i18n extract` offers those keys, and `os i18n check` counts them, for a stack whose card footers hold components with an `id` and copy.
17+
- objectui's validator, which judges the nodes this walk visits, now judges a card footer's nodes.
18+
19+
`page:card.body` stays undescended, as #5775 ruled: it is not an authorable spelling.
20+
21+
**The conversion walker reads every entry, the retired one included.** Its reach does not change: it descends `children`, `body`, `footer` and `items[].children`, as before. Stored documents still carry `body`, the renderers still draw it, and a conversion that runs before `page-card-body-to-children` meets the sub-tree there. Within one component the visit order is now `children`, `body`, `footer`, then the panels. That order is observable only as the order of the notices for a component that carries both a direct slot and panels.
22+
23+
**`@objectstack/lint` — `walkPageComponents` reads the list's authorable entries.** It walks `footer` as before, and it stops walking the retired `body` spelling. The walk matches by shape, so this drops a `body` array on any component, not only on `page:card`. #5775 (maintainer ruling 2026-08-06, direction A) made `children` the one composition key. The renderers keep reading `body` only as a back-compat fallback for stored documents. On `page:card` the tombstone's rename prescription still refuses `body`, and so does the thin containers' guidance; the sub-tree is judged once it sits under `children`. So the rules built on this walk no longer report findings about nodes under any component's `body` array. The conversion walker keeps reaching them for stored documents.
24+
25+
**`@objectstack/cli`:** no code change. `os i18n extract` and `os i18n check` pick up the `footer` component keys through the shared walk. The extractor's object-section pass stops reading `record:details` sections under a retired `body`, through lint's walk.
26+
27+
**Why no ADR-0087 ledger entry.** Nothing an author writes moves: no spec key is retired or renamed, no stored `sys_metadata` shape changes, and no conversion or migration id is touched. `objectstack migrate meta` has nothing to act on.

‎packages/cli/src/utils/i18n-extract.ts‎

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -932,9 +932,10 @@ function walkObjectSections(config: any, out: ExpectedEntry[]): void {
932932
// private copy. That walk exists precisely because duplicating it produced a
933933
// dead rule once already (#3583): components hang off `regions[].components`
934934
// AND `slots.<slot>` (which may be a bare component, not an array), sub-trees
935-
// live inside the untyped `properties` bag (`page:tabs` →
936-
// `properties.items[].children`, `page:card` → `properties.body`/`.footer`),
937-
// and source-authored pages (`kind: 'html' | 'react' | 'jsx'`) hold only a
935+
// live inside the untyped `properties` bag at the positions spec's
936+
// `pageComponentSlotPositions()` derives (`page:tabs` →
937+
// `properties.items[].children`, `page:card` → `properties.footer`, every
938+
// container → `properties.children`), and source-authored pages (`kind: 'html' | 'react' | 'jsx'`) hold only a
938939
// DERIVED region cache that the author never wrote — scaffolding translation
939940
// keys off that cache would invent an authoring surface.
940941
//
@@ -1099,8 +1100,10 @@ export function authorWarnedTranslationGroups(): ReadonlySet<string> {
10991100
* WALK — which components carry those keys — is `walkAddressedPageComponents`,
11001101
* the same traversal `translatePage` itself runs (commit c45d8e6b4, completing the key
11011102
* list's precedent). The walk owns the roots (`regions[].components[]` AND
1102-
* `slots.<slot>`), the descent (`properties.children` AND a panel's
1103-
* `properties.items[].children`, depth-capped, cycle-guarded) and the ruled
1103+
* `slots.<slot>`), the descent (the authorable slot positions spec derives
1104+
* from the component rows — `properties.children`, a card's
1105+
* `properties.footer`, a panel's `properties.items[].children` — depth-capped,
1106+
* cycle-guarded) and the ruled
11041107
* collision arbitration (commit 901355c3b: root level wins outright; among nested
11051108
* components, document-order first sighting) — this function used to
11061109
* hand-mirror all five and now owns none of them. What it still owns:
@@ -1113,15 +1116,14 @@ export function authorWarnedTranslationGroups(): ReadonlySet<string> {
11131116
* - the `label` either/or: `label` may be authored on the component itself
11141117
* or in its props — the same either/or `translatePage` resolves back onto.
11151118
*
1116-
* ⛔ Deliberately NOT `@objectstack/lint`'s `walkPageComponents`, which is
1117-
* WIDER than the resolver in two ways (`properties.body`, `properties.footer`
1118-
* — `page:card`'s slots, which the resolver leaves undescended as a renderer
1119-
* back-compat fallback rather than an authorable spelling; `slots.<slot>`
1120-
* roots and `properties.items[].children` were the other two until #16772
1121-
* brought both into the shared walk) and NARROWER in one (it skips
1122-
* `kind: 'html' | 'react' | 'jsx'` pages, which `translatePage` walks) —
1123-
* either direction of that mismatch is one half of the failure pair
1124-
* `PAGE_COMPONENT_COPY_KEYS`' own JSDoc names.
1119+
* ⛔ Deliberately NOT `@objectstack/lint`'s `walkPageComponents`. Its descent
1120+
* is no longer WIDER than the resolver's — since #20940 both read spec's one
1121+
* derived slot list, and `page:card`'s `properties.body` / `.footer` were the
1122+
* last positions lint walked and the resolver did not (`slots.<slot>` roots
1123+
* and `properties.items[].children` closed at #16772) — but it is still
1124+
* NARROWER in one way (it skips `kind: 'html' | 'react' | 'jsx'` pages, which
1125+
* `translatePage` walks), and a mismatch in either direction is one half of
1126+
* the failure pair `PAGE_COMPONENT_COPY_KEYS`' own JSDoc names.
11251127
*/
11261128
function emitPageComponentCopy(out: ExpectedEntry[], page: any, name: string): void {
11271129
walkAddressedPageComponents(page, (component, { id, nested, addressed }) => {

‎packages/cli/test/platform-page-i18n-parity.test.ts‎

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -288,10 +288,13 @@ const walkParityPage = (): Record<string, any> => ({
288288
'bare-component-id-string',
289289
null,
290290
],
291-
// NOT descended by `translatePage`: `body`/`footer` are a
292-
// renderer-side back-compat fallback, not an authorable
293-
// composition spelling.
291+
// NOT descended by `translatePage`: `body` is the retired
292+
// spelling (#5775) — a renderer-side back-compat fallback, not an
293+
// authorable composition spelling.
294294
body: [{ id: 'card_body_child', type: 'object-metric', properties: { title: 'Body child' } }],
295+
// DESCENDED since #20940 — `footer` is a declared, rendered slot,
296+
// one of the positions spec's `pageComponentSlotPositions()`
297+
// derives from the component rows.
295298
footer: [{ id: 'card_footer_child', type: 'object-metric', properties: { title: 'Footer child' } }],
296299
// DESCENDED since #16772 — a `page:tabs` / `page:accordion`
297300
// panel's `items[].children`, one level below the container.
@@ -375,19 +378,21 @@ describe('i18n-extract ↔ translatePage walk parity (#13109)', () => {
375378
it('pins the two sets by name, so a shape that stops being reachable is visible', () => {
376379
const page = walkParityPage();
377380
expect([...idsExtractorOffers(page)].sort()).toEqual([
378-
'card', 'inner_flex', 'kpi_1', 'kpi_deep', 'kpi_label', 'nested_header', 'region_metric',
379-
'slot_child', 'slot_list_child', 'tab_child',
381+
'card', 'card_footer_child', 'inner_flex', 'kpi_1', 'kpi_deep', 'kpi_label', 'nested_header',
382+
'region_metric', 'slot_child', 'slot_list_child', 'tab_child',
380383
]);
381-
// `card_body_child` and `card_footer_child` are absent from BOTH sides —
382-
// the shapes `translatePage` does not descend. `tab_child`, `slot_child`
383-
// and `slot_list_child` are present on BOTH sides since #16772 widened
384-
// the shared walk to `items[].children` and to the `slots.<slot>` roots.
384+
// `card_body_child` is absent from BOTH sides — the retired spelling
385+
// `translatePage` does not descend. `card_footer_child` is present on BOTH
386+
// sides since #20940 moved the shared walk onto the rows' one derived slot
387+
// list, which names `footer`. `tab_child`, `slot_child` and
388+
// `slot_list_child` are present on BOTH sides since #16772 widened the
389+
// shared walk to `items[].children` and to the `slots.<slot>` roots.
385390
// `hdr` — the region-level `page:header` — is absent from BOTH sides since
386391
// the ruling. `nested_header` stays: a `page:header` inside a container is
387392
// reached by the id route only, so the id key is the only key it has.
388393
expect([...idsResolverApplies(page)].sort()).toEqual([
389-
'card', 'inner_flex', 'kpi_1', 'kpi_deep', 'kpi_label', 'nested_header', 'region_metric',
390-
'slot_child', 'slot_list_child', 'tab_child',
394+
'card', 'card_footer_child', 'inner_flex', 'kpi_1', 'kpi_deep', 'kpi_label', 'nested_header',
395+
'region_metric', 'slot_child', 'slot_list_child', 'tab_child',
391396
]);
392397
});
393398

0 commit comments

Comments
 (0)