Skip to content

test(plugin-designer): the field designer's save body is pinned to the object body, never the envelope (#4546) - #4556

Merged
yinlianghui merged 2 commits into
mainfrom
claude/issue-4546-field-designer-save-pin
Aug 13, 2026
Merged

test(plugin-designer): the field designer's save body is pinned to the object body, never the envelope (#4546)#4556
yinlianghui merged 2 commits into
mainfrom
claude/issue-4546-field-designer-save-pin

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #4546

Test-only. No product change — MetadataFieldsPage.tsx is untouched.

What is being pinned

MetadataFieldsPage reads an object through MetadataClient.get() and writes it straight back:

client.save('object', objectName, { ...state.raw, fields: nextFields })

so whatever shape get() hands back is the shape that lands in the database. Before #4271 / PR #4545 that was the response ENVELOPE — { type, name, item, …ADR-0010 protection carriers } — which made a routine field edit persist the envelope OVER the object body. That was the highest-severity consumer in #4545's census: silent data corruption on a save, not merely an empty render.

#4545 repaired the contract at the producer, so this page was healed without being edited — and that is exactly why nothing pinned the save path's wire shape. This adds that pin.

Shape of the pin

One new file, packages/plugin-designer/src/MetadataFieldsPage.saveEnvelope.test.tsx, written the way #4545's suite is:

  • a REAL MetadataClient, constructed in the test and handed to the page as its client prop. Nothing mocks get(), save(), or anything else on it;
  • a fetch double answering the real server shapes — the single-item read answers the envelope objectstack#5563 collapsed that route to, not the bare { fields } body the repo's older doubles invented from the docblock. A double written against documentation instead of the wire is what let this defect hide inside 3,628 green tests;
  • assertions on the captured PUT body, parsed back from the bytes the transport was handed — not on the argument the page passed the client.

The only double besides the transport is FieldDesigner, the presentational leaf, recording its props. The unit under test is the page's read/merge/save chain, which runs for real; the grid-and-drawer UI that produces the edit is not where the corruption lives. Same shape as #4545's PermissionAdvancedFacets stub, and it keeps the file in the light dom project.

The enumerated envelope keys

Measured off MetadataLayered in packages/data-objectstack/src/metadata-client.ts (the ADR-0010 protection-envelope block) and #4545's A2 fixture, not guessed:

item, lock, lockReason, lockSource, lockDocsUrl, provenance, packageId, packageVersion, editable, deletable, resettable, _diagnostics

Two keys are handled apart from that list, deliberately:

  • name is not in it. The envelope's name and the body's name carry the same string, which is precisely why name can never detect this corruption.
  • type is checked separately. It is the envelope's discriminator, and an object document has no top-level type of its own — but a view does ({ name, type: 'grid', … }), so the general rule asserted here is the identity TRIPLE (type string + name string + an item slot — the same presence test MetadataClient.isMetaItemEnvelope uses), not type alone.

Enumeration is the readable half; the load-bearing half is S1, which deep-equals the whole non-fields remainder of the body against the object document, so any stray envelope key fails without having been named in advance.

The five cases

Case Asserts
S0 (control) a field edit fires exactly one PUT at /api/v1/meta/object/showcase_project
S1 the PUT body IS the object body: the non-fields remainder deep-equals the object document (name, label, description, indexes, hooks), the edit landed, and the per-field helpText the designer knows nothing about survived the merge
S2 the body carries none of the enumerated envelope keys, does not carry the type discriminator, and does not itself satisfy the envelope identity triple
S3 the designer is handed the object's real fields, not an envelope's empty ones (the read half of the same defect)
S4 the save AFTER the post-save reload is still the body — the first save could be clean and every later one corrupt if the re-read regressed

Discrimination proof

Ruling 2 asks the pin to be demonstrably capable of catching the #4271 corruption. The split was predicted in writing before running (1 green / 4 red), and it came out exactly as predicted.

Method: git checkout c0f9a4bd5 -- packages/data-objectstack/src/metadata-client.tsc0f9a4bd5 is 479cc7b46^, the commit immediately before PR #4545 landed the unwrap. Working-tree only, never git stash, then @object-ui/data-objectstack rebuilt so no stale artifact could be blamed. (Vitest resolves @object-ui/data-objectstack through the root config's source alias, so the revert is what the run reads either way; the rebuild removes the question.)

 ❯ packages/plugin-designer/src/MetadataFieldsPage.saveEnvelope.test.tsx (5 tests | 4 failed)
     × S1: the PUT body IS the object body, with the edited fields merged in
     × S2: the saved body carries no envelope key and is not itself envelope-shaped
     × S3: the designer is handed the object's real fields, not an envelope's empty ones
     × S4: the save after the post-save reload is still the body

S1's failure is the #4271 corruption captured verbatim — the whole real object demoted into item, the protection carriers spread at the top level, and fields written alongside them:

AssertionError: expected { type: 'object', …(12) } to deeply equal { name: 'showcase_project', …(4) }

- Expected
+ Received

  {
+   "deletable": true,
+   "editable": true,
+   "item": {
      "description": "A customer project.",
+     "fields": {
+       "amount": { "label": "Amount", "type": "currency" },
+       "name": { "label": "Name", "required": true, "type": "text" },
+       "stage": { "helpText": "Pipeline stage.", "label": "Stage", "type": "select" },
+     },
      "hooks": { "beforeInsert": "stampOwner" },
      "indexes": [ { "fields": [ "stage" ], "name": "by_stage" } ],
      "label": "Project",
+     "name": "showcase_project",
+   },
+   "lock": "none",
+   "lockDocsUrl": "https://docs.objectstack.ai/locks",
+   "lockReason": "",
+   "lockSource": "artifact",
    "name": "showcase_project",
+   "packageId": "app.showcase",
+   "packageVersion": "1.4.0",
+   "provenance": "org",
+   "resettable": false,
+   "type": "object",
  }

S2 and S4 name the leaking carrier one at a time (expected { key: 'item', present: true } to deeply equal { key: 'item', present: false }), and S3 reports expected [] to deeply equal [ 'name', 'stage', 'amount' ] — the read half.

S0 stayed green in both directions, deliberately. It is the guard that proves S1–S4's red is about the payload and not about a save that silently never fired: the envelope defect moves the body only, never the route, the verb, or whether a save happens.

One authoring correction, reported rather than papered over: S4 first went red on a setup wait (waitFor on the reloaded field list) instead of on its own second-PUT assertion, because under the broken client the reload also yields an empty designer. That made the case red for the right reason at the wrong line — it would have hidden the shape S4 exists to pin. The wait was replaced with a plain commit flush, and the re-run puts S4's red on its own assertion, as shown above.

Restore: git checkout HEAD -- packages/data-objectstack/src/metadata-client.ts, verified byte-identical by sha256 (68bb36d59717a1f936e1d1d26c134959a03241742c58d6446ec46d4df27608b3 before and after), data-objectstack rebuilt, and git status --porcelain confirmed empty of any data-objectstack entry before the first commit. The branch diff is two files: the test and the changeset.

Premise

Verified, and it holds: on current origin/main (fa2125400) the save path sends the object body. Ruling 3's inversion condition did not fire — nothing envelope-shaped is on the wire today, which is what #4545 promised and what this now pins.

Verification

Gate Result
Dependency-closure build (@object-ui/plugin-designer^... + itself) green, run first, under the shared flock
New file 5/5 green
Full plugin-designer suite 7 files, 46 tests, all passing (was 6 / 41)
Both tsc passes (tsc --noEmit and tsconfig.test.json) clean
ESLint the new file contributes 0 errors and 0 warnings, so the package total (0 errors / 67 warnings) is identical to the origin/main baseline
check:control-bytes OK, 4297 tracked files; plus a direct self-scan of the new file for the wider control-byte range — no hits
check-changeset-presence demanded a file; satisfied by an EMPTY-frontmatter changeset, its own stated exemption for a test-only change
check-changeset-fixed / check-changeset-no-major green; nothing scored, never major
check:phantom-deps green

Changeset

Ruling 4 said none was expected and to use the presence gate's own verdict. The gate demanded a file (1 source file(s) of 1 released package(s) changed, and this change adds no changeset), so .changeset/field-designer-save-body-pin-4546.md was added with an empty frontmatter — the gate's explicit exemption, releasing nothing, never major. objectui has no skip-changeset label escape hatch on that workflow; the file is the only way to answer it.

Surface

packages/plugin-designer/src/MetadataFieldsPage.saveEnvelope.test.tsx and the changeset — nothing else. MetadataFieldsPage.tsx, data-objectstack, app-shell, plugin-grid, plugin-gantt, the CelPredicateField family and content/docs/releases/ are all untouched.


Generated by Claude Code

claude added 2 commits August 13, 2026 08:47
…ct body, never the envelope (#4546)

Part of #4546.

MetadataFieldsPage reads an object through MetadataClient.get() and writes it
straight back with `client.save('object', name, {...state.raw, fields})`, so
whatever shape get() returns is the shape that lands in the database. Before
#4271 / PR #4545 that was the response ENVELOPE, which made a routine field
edit persist the envelope over the object body — the highest-severity consumer
in #4545's census, and silent corruption rather than an empty render.

#4545 repaired the contract at the producer, so this page was healed without
being edited and nothing pinned the save path's wire shape. This adds that pin:
five cases driving the page's real read/merge/save chain through a REAL
MetadataClient over a fetch double answering the real server envelope. Nothing
mocks get() or save(); the only double besides the transport is the
presentational FieldDesigner leaf, recording its props.

No product change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
…ntmatter)

The changeset-presence gate demands a file for any change under a released
package's src/; an empty frontmatter is its explicit exemption for a test-only
change, which this is.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
@vercel

vercel Bot commented Aug 13, 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)
objectui Ignored Ignored Aug 13, 2026 8:51am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 24.7 KB 350 KB
Entry file index-BChsguAa.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 9.56KB 3.59KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 8.92KB 3.41KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 25.13KB 5.40KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 38.46KB 10.17KB
auth (createAuthenticatedFetch.js) 6.34KB 2.43KB
auth (index.js) 2.35KB 1.07KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.02KB 0.88KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.07KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.65KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 489.32KB 108.45KB
core (index.js) 3.37KB 1.34KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 158.47KB 43.13KB
fields (index.js) 230.18KB 57.13KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 3.35KB 1.38KB
i18n (pickLocalized.js) 3.69KB 1.73KB
i18n (provider.js) 23.12KB 7.62KB
i18n (useDisplayLocale.js) 2.84KB 1.45KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 7.77KB 3.13KB
layout (index.js) 38.98KB 10.85KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.74KB
mobile (index.js) 1.50KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.86KB 12.91KB
plugin-charts (index.js) 62.10KB 17.67KB
plugin-chatbot (index.js) 181.21KB 43.14KB
plugin-dashboard (index.js) 120.95KB 31.53KB
plugin-designer (index.js) 212.58KB 42.83KB
plugin-detail (index.js) 239.88KB 59.99KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 114.58KB 27.68KB
plugin-gantt (index.js) 164.27KB 40.01KB
plugin-grid (index.js) 189.36KB 50.33KB
plugin-kanban (index.js) 52.74KB 14.53KB
plugin-list (index.js) 111.13KB 27.12KB
plugin-map (index.js) 18.16KB 5.81KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 41.16KB 10.96KB
plugin-timeline (index.js) 26.68KB 7.66KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.08KB 20.55KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 23.73KB 7.96KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.23KB 0.66KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 3.05KB 1.52KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

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

Copy link
Copy Markdown
Collaborator Author

PM step-7 复核 — ACCEPT (session_017Qqyix2QcnpUC9XeYVDzx3)

Auto-merge armed (squash) — landing verified per the merge-queue discipline.


Generated by Claude Code


Generated by Claude Code

@yinlianghui
yinlianghui marked this pull request as ready for review August 13, 2026 09:04
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 13, 2026
Merged via the queue into main with commit 122e73b Aug 13, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4546-field-designer-save-pin branch August 13, 2026 09:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Field Designer save path needs a regression pin: the PUT body must be the object BODY, never the response envelope

2 participants