Skip to content

fix(app-shell): DraftChangesPanel addresses /meta item routes in the singular - #5361

Merged
os-support-ai merged 2 commits into
mainfrom
claude/issue-5356-draftchanges-meta-singular
Aug 20, 2026
Merged

fix(app-shell): DraftChangesPanel addresses /meta item routes in the singular#5361
os-support-ai merged 2 commits into
mainfrom
claude/issue-5356-draftchanges-meta-singular

Conversation

@os-support-ai

Copy link
Copy Markdown
Collaborator

Fixes#5356
Part of objectstack-ai/objectstack#9180

The /meta type segment is singular — always (objectstack#9180, maintainer 2026-08-16; step ③ scoped to internal spelling correction by the 2026-08-17 re-weigh). This is the objectui half.

The defect

packages/app-shell/src/preview/DraftChangesPanel.tsx is data-driven, not authored: each entry's type comes straight from the /api/v1/meta/_drafts feed's stored rows, guarded by a typeof check and nothing else (:62 on the branch point, bdf8cf76e). It is then interpolated into two /meta item routes — the published-name list at :76 and the item body read at :112 — so a row stored under a pre-#7894 plural spelling made the Console request /api/v1/meta/objects and /api/v1/meta/objects/ticket.

Re-verified at the branch point: all four measured sites present as stated, and these two remain the only variable-typed /meta route emitters in objectui — every other site interpolates a name into a literal singular type (meta/object/…, meta/dataset/…, meta/app/…).

The fix

One fold, at the one boundary where a stored spelling enters the module — where feed rows become DraftChangeEntry. Everything below (both item routes, the grouping, the type heading) reads the folded value, so a route added later has no raw spelling in scope to interpolate. That is the durable shape rest-server.ts argues for after its own per-gate normalization came back as the same hole eight days later (objectstack#6241).

Which helper, and why that one. objectui has no singular-folding helper of its own — this is the first place that needs one. The ecosystem's is canonicalMetaUrlType from @objectstack/spec/shared (a declared dependency of @object-ui/app-shell), the published fold for exactly a /meta/:type path segment (objectstack#7894, #8424), and the same one RestServer folds incoming requests with.

Deliberately notpluralToSingular: that map's keys are defineStack() collection properties, and field, seed, external_catalog and translation are absent from it because none of them is a stack-level collection. That absence is also how the residue being folded here got written — PUT /meta/fields/… fell through to the permissive plugin-type path and minted rows under type='fields' while PUT /meta/field/… was refused. The spellings this fold accepts are exactly the ones such residue can carry.

Fold, never strip.capabilities maps to capability; a replace(/s$/, '') emits capabilitie. The pin below asserts against that specific wrong answer.

No laundering. Emit-side only: residue stored under a plural type stays exactly as it is on the server. No write path, no migration, no server-side tolerance, and nothing about what the server accepts changes.

A local copy of the table was tried first, and is refused mechanically

The first implementation mirrored the 35-entry spelling table locally with a parity test pinning it against the real export, to avoid the weight below. node scripts/check-spec-symbol-derivation.mjs rejects that: a spec-named symbol must be derived from @objectstack/spec, and the guard's own header explains that a faithful copy is precisely the fork it exists to prevent — reference identity is the only thing distinguishing a re-export from one, and its ALLOW map governs deliberate divergence, which a mirror is not. So the import is the sanctioned spelling, and the gate is green on this branch.

The price, measured, so it is not invisible: +213.4 KB minified / +60.1 KB gzipped on the console's eager graph (esbuild, on a graph already holding the /ui + /kernel entries app-shell imports statically). The panel is reached through static imports from ConsoleLayout, and lazy-loading cannot move those bytes — the console's vendor-objectstack chunk group claims every @objectstack/* module except the linter, and that group's chunk is a static import of the app entry (objectui#5266). Recorded at the import site and filed as #5359; both levers for paying it down live in apps/console/vite.config.ts, outside this card's file surface.

Verification

2efc90d19, all from the repo root (17 packages own a standalone vitest.config.ts, so a package-scoped run would use a different config than CI does):

  • pnpm exec vitest run packages/app-shell/src/preview/__tests__/DraftChangesPanel.test.tsx13 passed, 5 of them new route pins driven by a fixture that carries a plural stored type (a route-spelling fix passes vacuously against an already-singular fixture).
  • pnpm --filter @object-ui/app-shell type-check — green (tsc --noEmit + tsconfig.test.json), after building the dependency closure.
  • eslint on both changed files — 0 errors (2 pre-existing warnings on untouched lines).
  • Gates at this sha: check-spec-symbol-derivation, check-control-bytes, check-changeset-presence, check-changeset-no-major, check-phantom-dependencies, check-package-self-import — all exit 0.

Reverse verification. Reverting the fold to type: d.type as string turns 4 of the 5 pins red, with the plural addresses in the failure text:

AssertionError: expected [ '/api/v1/meta/objects' ] to include '/api/v1/meta/object'
AssertionError: expected [ '/api/v1/meta/capabilities' ] to include '/api/v1/meta/capability'
AssertionError: expected 'objects · 1' to be 'object · 1'

The fifth pin — an already-canonical type and a plugin-registered kind pass through unchanged — stays green in both legs by design; it is the passthrough control. Restoring the fold returns 13/13.

Per leg: no build artifact sits between the edit and the thing under test. The test imports ../DraftChangesPanel by relative path and vitest transforms it from source, so neither leg needed a rebuild. The only prebuilt artifact in play is the installed @objectstack/spec dist providing the fold, which the ablation does not touch.

Changeset: patch for @object-ui/app-shell.

Generated by Claude Code


Generated by Claude Code

…singular (#5356)
The panel is data-driven: each draft's `type` comes straight from the
`/api/v1/meta/_drafts` feed's stored rows with a `typeof` check and no fold, so
a row stored under a pre-#7894 plural spelling made the Console emit
`/api/v1/meta/objects` and `/api/v1/meta/objects/:name`. The `/meta` type
segment is singular — always (objectstack#9180).
Fold the stored spelling once, at the boundary where feed rows become panel
entries; both `/meta` item routes, the grouping and the type heading read that
one value, so a route added later has no raw spelling in scope.
The table mirrors `@objectstack/spec/shared`'s `META_URL_TO_SINGULAR` /
`canonicalMetaUrlType` — the `/meta`-specific contract, not the manifest-level
`PLURAL_TO_SINGULAR` — and a parity test pins the mirror against the real
export key for key. It is mirrored rather than imported because the import
measures +213.4 KB min / +60.1 KB gz on the console's eager graph, which lazy
loading cannot move (the `vendor-objectstack` chunk group claims it); the
table's doc comment carries the measurement.
Emit-side only: at-rest plural residue is untouched, with no write path, no
migration and no change to what the server accepts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RV6yuVCxymHYE16PL9vQkE
…a local copy of its table (#5356)
`scripts/check-spec-symbol-derivation.mjs` refuses a local `META_URL_TO_SINGULAR`
/ `canonicalMetaUrlType`: a spec-named symbol must be derived from
`@objectstack/spec`, and its header explains that a faithful copy is exactly the
fork the guard exists to prevent — reference identity is the only thing that
distinguishes a re-export from one. So the panel imports the real fold.
The measured price is +213.4 KB minified / +60.1 KB gzipped on the console's
eager graph, which lazy loading cannot move (the `vendor-objectstack` chunk
group claims every `@objectstack/*` module but the linter). That is recorded at
the import site and tracked as #5359, whose levers both live in
`apps/console/vite.config.ts`, outside this card's file surface.
The mirror's parity test goes with the mirror; the behavioural route pins,
which assert the addresses the panel actually emits from a plural fixture, are
what carry the fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RV6yuVCxymHYE16PL9vQkE
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)25.3 KB350 KB
Entry fileindex-C_4EfYGM.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)9.83KB3.70KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)29.33KB7.05KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.13KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.64KB2.21KB
auth (SocialSignInButtons.js)9.60KB3.89KB
auth (UserMenu.js)3.40KB1.22KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.79KB
auth (createAuthenticatedFetch.js)6.34KB2.43KB
auth (index.js)2.71KB1.22KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.02KB0.88KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)26.07KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.65KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)505.53KB113.13KB
core (index.js)4.11KB1.62KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)159.80KB44.34KB
fields (index.js)237.07KB59.46KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.42KB1.39KB
i18n (pickLocalized.js)3.69KB1.73KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)30.51KB7.57KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.97KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.74KB
mobile (index.js)1.50KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.71KB0.42KB
mobile (useResponsiveConfig.js)1.36KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.35KB3.31KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.42KB1.42KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.81KB0.83KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.75KB18.37KB
plugin-chatbot (index.js)181.21KB43.14KB
plugin-dashboard (index.js)128.07KB32.77KB
plugin-designer (index.js)212.39KB42.83KB
plugin-detail (index.js)241.46KB60.56KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)124.19KB30.20KB
plugin-gantt (index.js)164.10KB39.87KB
plugin-grid (index.js)197.30KB53.06KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.66KB27.13KB
plugin-map (index.js)20.08KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.68KB7.66KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.52KB20.67KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)36.10KB12.26KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.33KB0.69KB
react (schema-input.js)1.45KB0.83KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (index.js)4.77KB2.16KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)10.76KB3.17KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)6.92KB2.40KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)3.08KB1.53KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-support-aiClaude

Copy link
Copy Markdown
CollaboratorAuthor

Checked: the fold is reachable from /shared and nowhere else

Asked whether canonicalMetaUrlType (or any equivalent /meta spelling fold) is exported by a subpath already on the console's eager graph — @objectstack/spec/ui or /kernel — which would make this import approximately free. It is not. Evidence, on the installed @objectstack/spec@17.0.0:

1. Every entry in the package's exports map, greppped for both symbols (18 subpaths declared: ., data, system, kernel, ai, automation, api, ui, contracts, integration, security, studio, cloud, qa, identity, shared, plus openapi.json / package.json). All 15 code entries return 0 hits in both index.d.ts and index.mjs — except one:

dist/index.d.ts canonicalMetaUrlType=0 META_URL_TO_SINGULAR=0
dist/ui/index.d.ts canonicalMetaUrlType=0 META_URL_TO_SINGULAR=0
dist/ui/index.mjs canonicalMetaUrlType=0 META_URL_TO_SINGULAR=0
dist/kernel/index.d.ts canonicalMetaUrlType=0 META_URL_TO_SINGULAR=0
dist/kernel/index.mjs canonicalMetaUrlType=0 META_URL_TO_SINGULAR=0
…12 more subpaths, all 0/0…
dist/shared/index.d.ts canonicalMetaUrlType=2 META_URL_TO_SINGULAR=2
dist/shared/index.mjs canonicalMetaUrlType=2 META_URL_TO_SINGULAR=5

2. Counter-probe on the zeros — the same files, grepped for symbols objectui imports from them today, so a zero above is a real absence and not a broken read:

dist/ui/index.d.ts expandViewContainer hits=1
dist/ui/index.mjs expandViewContainer hits=5
dist/ui/index.mjs PageSchema hits=4
dist/kernel/index.d.ts deriveNamespaceFromPackageId hits=2
dist/kernel/index.mjs deriveNamespaceFromPackageId hits=2
dist/index.mjs composeStacks hits=10

3. Runtime resolution, which also catches an equivalent fold under a different name — importing each entry and filtering its real export list for anything spelling-shaped:

@objectstack/spec exports=125 fold-shaped: (none)
@objectstack/spec/ui exports=218 fold-shaped: (none)
@objectstack/spec/kernel exports=231 fold-shaped: DEFAULT_METADATA_TYPE_REGISTRY, MetadataTypeSchema, getMetadataTypeSchema, … (the registry, no fold)
@objectstack/spec/shared exports=71 fold-shaped: META_URL_TO_SINGULAR, PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL,
canonicalMetaUrlType, metaUrlSpellingRefusal, pluralToSingular, singularToPlural

Every symbol in this family — the right map, the wrong map, both folds and the refusal verdict — is on /shared exclusively. The root entry does not even re-export pluralToSingular.

Two adjacent things that look like an escape and are not:

  • dist/kernel/index.mjs and dist/index.mjs do containPLURAL_TO_SINGULAR (7 and 10 occurrences) as an inlined internal, but neither exports it — and it is the wrong map anyway: it is missing field, seed, external_catalog and translation, so it would silently fail to fold fields, which is the exact residue spelling objectstack#7894 documents being minted.
  • /kernel does export DEFAULT_METADATA_TYPE_REGISTRY, so a fold could be derived from it on the already-eager baseline. That means writing the pluralization rule locally, and restPluralOfMetaType is module-internal upstream on purpose (objectstack#8424: the published surface carries the verdict, never the predicate parts). A locally reconstructed rule is the "spelling GUESSER" the boundary must not contain — the same answer as the local mirror, arrived at from the other direction.

So there is no zero-cost swap inside this card's file surface, and no deep-path import was attempted (reaching past the exports map was off the table). The +60.1 KB gz stands as measured, and #5359 keeps its two levers — with option C (a finer-grained upstream export) now the only one that removes the cost rather than relocating it.

Nothing in the PR changed: still 2efc90d19, 13/13 pins green, ablation red on 4 of 5.

Generated by Claude Code


Generated by Claude Code

@os-support-ai
os-support-ai marked this pull request as ready for review August 20, 2026 01:54
@os-support-ai
os-support-ai added this pull request to the merge queueAug 20, 2026
Merged via the queue into main with commit f2e11aeAug 20, 2026
22 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-5356-draftchanges-meta-singular branch August 20, 2026 01:54
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@os-support-ai@claude