Repository navigation
fix(metadata-protocol): a page saved without type is stored and served with PageSchema's declared default (#20101) - #20133
Conversation
…ved with PageSchema's declared default
PageSchema declares `type: PageTypeSchema.default('record')`. The save gate
parsed with that default and then stored the authored body verbatim, and every
read served stored rows unparsed, so a record page authored without `type` was
served without it and never picked as the object's record page.
One helper, withDeclaredPageTypeDefault, reads the default from the registered
`page` schema and fills it only when the key is absent. It runs at two seams:
saveMetaItem, after the schema gate accepts the body, so new rows store the
key and a GET then PUT round-trip stays byte-identical; and convertStoredItem,
the rehydration seam, so rows stored earlier are served with the default and
never rewritten.
Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
Co-authored-by: Claude <noreply@anthropic.com>
…fault Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 37c03b5e6d4dd152a69f38b9ad59f2882218c1d9 && git checkout 37c03b5e6d4dd152a69f38b9ad59f2882218c1d9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin bc80e16260597d52b062b7e1ee810c7b9a99aed2 e3b83df825bb8e15242aae7fb317d3dd8774f4d2 && git checkout -B drift-repro bc80e16260597d52b062b7e1ee810c7b9a99aed2 && git merge --no-ff e3b83df825bb8e15242aae7fb317d3dd8774f4d2
node scripts/docs-audit/affected-docs.mjs --json bc80e16260597d52b062b7e1ee810c7b9a99aed2
|
Contract reviewServed-tier: Scope: 5 files on merge base
No spec, docs or governed file is touched. Measured at base and head through two stacks:
objectui is unreachable from this session, so every renderer-side claim is UNMEASURED. ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
Fixes #20101
Clause-②: no
What this changes
PageSchemadeclarestype: PageTypeSchema.default('record')(packages/spec/src/ui/page.zod.ts), so a page authored withouttypeis a record page.saveMetaItemparsed the body with that default, then stored the authored body verbatim, and every/metaread serves a stored row unparsed. A page saved withouttypewas therefore stored and served without it.One helper,
withDeclaredPageTypeDefaultinpackages/metadata-protocol/src/protocol.ts, reads the default from the registeredpageschema (getMetadataTypeSchema('page').shape.type, the schema the save gate validates with). It fills the default only whentypeis absent, so no'record'literal is added. It runs at two seams:saveMetaItemafter the schema gate accepts the body, beside the two existing grafts. New rows store the key, on draft and publish saves alike.convertStoredItem, the rehydration seam every stored row passes through. Rows stored before this change are served with the default. The row itself is never rewritten.The read fill sits in
convertStoredItemand not inconvertStoredItemDetailed. It is not an ADR-0087 conversion and emits no notice, somigrateStoredMetadata(the Detailed caller) has nothing to rewrite.Scope is
PageSchema.typeonly. The regions ofprotocol.tsthat draft PR #20125 edits are not touched: this diff's hunks are at theresolveOverlaySchemadocblock, aftergraftFoldedFormSections, inconvertStoredItemand in thesaveMetaItemschema-gate block.Why both seams (H2, measured)
Serve paths: base
226e00c038vs head (H1)The measurement used a typeless page, an explicit
type: 'app'control, a typeless draft, and a typeless row seeded directly into the store (the "old row"). It ran at three levels: the realRestServerover the real protocol, the protocol alone, and the real composition (bootStack(showcase)plusMetadataPluginover a build-shaped artifact, over real HTTP).appcontrolappcontrolsaveMetaItempublish: stored bodyapprecordappsaveMetaItemdraft: stored draft bodyrecordpublishMetaItemof that draft: stored and servedrecordapprecordappGET /meta/page(getMetaItems), cache on and off, new and old rowapprecordappGET /meta/page?preview=draftrecordGET /meta/page/:name, cached arm (getMetaItemCached)7811092dapp, ETag6164defarecord, ETag69c54eb6app, ETag6164defa(unchanged)GET /meta/page/:name, uncached arm (getMetaItem)apprecordappGET /meta/page/:name?state=draftrecordgetMetaItemwithpreviewDraftsrecordgetMetaItemLayered, old row (overlay / effective)record/recordsearchAllpage hitpageTypeapprecordapploadMetaFromDbboot hydration into the registryapprecordappmigrateStoredMetadatapreview?state=draft, published draftapp(ETag100766d6)recordapp(ETag100766d6, unchanged)Consumers of the served page body (H3)
Every measured reading below used the explicit
apppage as its control.type: none in this repo. A grep of non-testpackages/**for reads of a page'stypefinds two readers. One is thesearchAllpage sweep (pageType, measured). The other is the lint rule invalidate-page-visualization-bindings.ts, which askspage.type !== 'list'; its verdict cannot change, becauserecordis notlist. Record-page selection lives in objectui'susePageAssignment, per the card, and that is UNMEASURED here because objectui is not reachable from this session.searchAllsweep. See the table above.convertStoredItemorgetMetaItems. They are the runtime authoring gate's stored universe (foldStoredCollection), theduplicatePackagecopy (which is then saved throughsaveMetaItem), and the runtime package-export sweep (getMetaItems).historyMetaItemanddiffMetaItemhistory bodies, which are a record of what was written, andgetEffectiveLock, which reads protection only.metadataservice'sDatabaseLoader.rowToData(packages/metadata). In the composition measured (showcase plusMetadataPlugin), every read of the test pages served the typed body.sha256:tokenIf-Matchcompares) is computed over the stored body, so a read cannot move it. The cached read's ETag is a hash of the served content. It changes once for a typeless page and is unchanged for theappcontrol.typeonce, and the served body is identical before and after. That is the author's re-save, not a migration.Tests
All runs are at head
e3b83df825. The probes ran atc2a29b4573, andprotocol.tsis byte-identical between the two commits.pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2: 188 files passed, 3 skipped; 2686 tests passed, 19 skipped; exit 0.pnpm --filter @objectstack/metadata-protocol typecheck: exit 0.tsc --listFilesincludes all 191 test files, among them the three edited here./metaroute tests: everypackages/rest/src/*meta*.test.ts, 47 files, 667 tests passed, exit 0. They read the rebuiltmetadata-protocoldist/, which containswithDeclaredPageTypeDefault(4 hits).dashboard-designer-roundtrip,meta-published-and-state-routes,package-first-authoring,meta-types-create-seed,showcase-object-extension-meta-readandshowcase-object-extension-scalar-divergence. 6 files, 33 tests passed, exit 0.New pins:
protocol.stored-conversions.test.ts: a stored typeless page row is served with the declared default by the list and single reads, the draft read and the list preview, and boot hydration. An explicitapprow is untouched. The row's bytes survive the reads, andmigrateStoredMetadatapreview and apply report it canonical, withrewritten: 0.protocol.read-decorations.test.ts: publish and draft saves store the default. An explicitappis stored and served unchanged. GET → PUT gives the same stored body, checksum and version.protocol.search-published-pages.test.ts: a typeless page's hit carriespageTypeequal to the declared default.Every pin reads the expected value from the schema. A precondition asserts that value is
record.Ablation. Each leg ran through
scripts/ablation-replace.mjs(anchor hit 1 → 0, blob changed) with a trap restore, and restore was proven each time (blob1df09c2eb4equals HEAD,git diff HEADempty). These suites import./protocol.jsfrom source, so no rebuild was involved.ablation-dist-preflight --absentconfirms the markers never reacheddist/.Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, derived ate3b83df825: 61 commands. All 61 were run with exit codes recorded, and--ranreports 61 run, 0 NOT-MEASURED (a derived zero).pnpm check:dual-build-cjs-loadsfirst exited 3 (PREREQUISITE NOT MET: 8 packages had nodist/). It passed after those were built.node scripts/check-issue-citations.mjs --base 226e00c038: 5 citations, all resolve.eslint --no-inline-config --format jsonover the 4 changed.tsfiles reports 4 files, 0 errors, 0 warnings.isPathIgnoredis false for them.calculateConfigForFileshows neitherparserOptions.projectnorprojectServiceset, so linting is not type-aware and this diff cannot change the verdict on any file it does not touch. The fullpnpm lintis left to CI.Acceptance notes
226e00c038: 22 stored metadata types were walked (types withallowRuntimeCreateorallowOrgOverride). 15 of them declare 39 top-level defaults. The positive controlpage.typeis found, and the negative controlpage.object(optional, no default) is not.external_cataloghas no registered schema. The save path stores all of these verbatim. Onlypage.typewas measured through the serve paths. The 38 besidespage.type:object:isSystem,datasourcehook:priority,async,onError,runAsseed:externalId,mode,envmapping:sourceFormat,modeview:type(simple)page:template,regions,isDefault,kind(full)app:active,isDefaultaction:type(script),refreshAfterreport:type(tabular),drilldownflow:version,status,runAsdatasource:active,autoConnect,schemaMode,originemail_template:category,locale,variables,active,isSystempermission:isDefaultposition:delegatableskill:surface,activeusePageAssignment, and it is UNMEASURED from this session. What this PR pins is the served body:typeequal to the declared default.typechanges once, because the served content changed. Theappcontrol's ETag is unchanged.typeonce, with one history row. From then on its round-trip writes nothing.PageSchemarefuses (for example thepageTypealias key, which is reachable only by a write that went around the save gate) is now read withtype: 'record'beside that key. Such a row fails the schema either way and carries_diagnostics. No such row was measured in any data.Generated by Claude Code