Skip to content

feat(metadata): saveMeta persists the operator spellings the spec normalized (objectui#2945) - #4107

Merged
os-zhuang merged 1 commit into
mainfrom
claude/savemeta-normalize-filter-2945
Jul 30, 2026
Merged

feat(metadata): saveMeta persists the operator spellings the spec normalized (objectui#2945)#4107
os-zhuang merged 1 commit into
mainfrom
claude/savemeta-normalize-filter-2945

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Prerequisite 2 of the vocabulary consolidation blocked in objectstack-ai/objectui#2945. Prerequisite 1 (this repo's #3948 item 1 — drivers throw instead of silently skipping) has already landed, so this is what remains before the alias table can be retired at all.

The defect

ViewFilterRuleSchema.operator is z.preprocess(normalizeFilterOperator, z.enum(VIEW_FILTER_OPERATORS)). A stored notEquals / gt / isNull is therefore folded to its canonical form during save-time validation — and the result is thrown away.

saveMetaItem persists the authored body verbatim, and that is deliberate: parsed.data strips the Studio-only auxiliary fields (isPinned, isDefault, sortOrder) that intentionally ride along with an overlay document (ADR-0005§Validation, and the comment on resolveOverlaySchema says so).

The consequence is not that a legacy spelling survives — it is that every save mints new legacy-alias rows.

VIEW_FILTER_OPERATOR_ALIASES describes itself as "a migration bridge [that] may be dropped in a future major". With new alias rows arriving continuously there is no point at which the last one is behind you: a migration that rewrote every existing row would be obsolete the moment the next console personalization PUT landed. That is exactly why objectui#2945 lists this as a prerequisite rather than a nice-to-have.

The fix

graftNormalizedOperators(authored, parsed.data) grafts the normalization back on without giving up the verbatim body. It walks both trees in lockstep by structure and copies across exactly one thing: an operator whose parsed value differs from the authored one.

No key list. ViewFilterRule[] appears at five declared sites today — view filter, ViewTab.filter, page filterBy, and two component.zod.ts block props — and the structural walk covers all of them, plus any added later. Enumerating paths here would have reproduced in this file the exact duplication objectui#2945 exists to remove.

Nothing else moves:

  • only an operator string is rewritten, and only where both sides are strings — so a $-token FilterCondition, a different operator vocabulary entirely, cannot be reshaped by accident;
  • no key is added, removed, reordered or defaulted. The unary {field, operator} form does not acquire a value, even though the schema's own output gives it one;
  • a body already canonical is returned by identity, so the common case allocates nothing.

Behaviour change, stated plainly

A GET after a PUT now returns the canonical spelling rather than the one the author sent. That is the spelling the spec defines; every renderer accepts it (objectstack-ai/objectui#2974 and objectstack-ai/objectui#2989 pinned all three of objectui's operator translation tables to the full vocabulary, in both directions, with parity guards); and it is the point of the change.

Existing rows are untouched. This stops the bleeding — it is not the migration. Prerequisite 3 of objectui#2945 (scan production before writing one) still stands, and is still not something CI can do: sys_saved_report.query_json is unvalidated on both write and read, so its contents cannot be enumerated statically.

Verification

  • 11 new tests. One drives every alias VIEW_FILTER_OPERATOR_ALIASES still folds through the real ViewMetadataSchema — the schema getMetadataTypeSchema('view') returns, so the same one saveMetaItem validates against — and asserts the persisted body comes out canonical in each case.
  • The negative half is pinned just as hard: aux fields survive, key sets are unchanged, filter order is preserved, a $-token object is left alone, a mismatched or longer parsed tree contributes nothing.
  • Full @objectstack/metadata-protocol suite: 110 tests across 18 files, green.
  • tsc --noEmit: zero errors in protocol.ts and the new test (the package has no type-check script; the 21 diagnostics a raw invocation reports are pre-existing, all in seed-loader/mutation-listener test files this PR does not touch).

Refs objectstack-ai/objectui#2945, objectstack-ai/objectui#2901, #3948

🤖 Generated with Claude Code

…malized (objectui#2945)

ViewFilterRuleSchema.operator is z.preprocess(normalizeFilterOperator, …),
so a stored `notEquals`/`gt`/`isNull` is folded to canonical during
save-time validation — and the result was then discarded, because
saveMetaItem persists the authored body verbatim (deliberately:
parsed.data strips the Studio-only aux fields that ride along with an
overlay, ADR-0005 §Validation).

So every save minted new legacy-alias rows. VIEW_FILTER_OPERATOR_ALIASES
documents itself as "a migration bridge [that] may be dropped in a future
major", but with new alias rows arriving continuously there is no point at
which the last one is behind you — a migration rewriting existing rows
would be obsolete at the next personalization PUT. This is prerequisite 2
of the consolidation blocked in objectui#2945.

graftNormalizedOperators walks the authored body and parsed.data in
lockstep BY STRUCTURE and copies exactly one thing: an `operator` whose
parsed value differs. No path list — ViewFilterRule[] has five declared
sites today and the walk covers all of them plus any added later;
enumerating them would reproduce here the duplication #2945 exists to
remove. Guarded on both sides being strings, so a `$`-token
FilterCondition cannot be reshaped. Nothing added, removed, reordered or
defaulted: the unary {field, operator} form does not acquire a `value`
even though the schema's own output would. Returns by identity when
nothing changed.

Behaviour change, stated plainly: a GET after a PUT now returns the
canonical spelling, not the one the author sent. That is the spelling the
spec defines and every renderer accepts (objectui#2974, objectui#2989
pinned all three objectui translation tables to the full vocabulary).
Existing rows are untouched — this stops the bleeding, it is not the
migration.

11 new tests, one driving EVERY alias the spec still folds through the
real ViewMetadataSchema; package suite 110 tests / 18 files green.

Refs objectstack-ai/objectui#2945, objectstack-ai/objectui#2901

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Jul 30, 2026 9:59am

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/m labels Jul 30, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol.

1 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/metadata-protocol)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant