Skip to content

feat(app-shell,data-objectstack): the designer states its package on the publish step (#5420) - #6115

Merged
yinlianghui merged 3 commits into
mainfrom
claude/issue-5420-designer-publish-package-binding
Aug 24, 2026
Merged

feat(app-shell,data-objectstack): the designer states its package on the publish step (#5420)#6115
yinlianghui merged 3 commits into
mainfrom
claude/issue-5420-designer-publish-package-binding

Conversation

@yinlianghui

@yinlianghuiyinlianghui commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Fixes#5420

📝 Body re-posted once to repair a sanitizer strip: the first version wrote the parameter as
?package= followed by an angle-bracketed id placeholder, and the eslint note spelled the
TypeScript generic argument in angle brackets. The stored bytes lost all five placeholders,
one of which left a sentence meaningless. Spelled PKG_ID and "generic any argument"
below instead. No other edit.

What was broken

Studio's metadata designer (MetadataResourceEditPage) runs a save→publish loop: Save
writes a draft bound to the software package the editor is scoped to
(PUT /meta/:type/:name?mode=draft&package=PKG_ID), and Publish promotes that draft
(POST /meta/:type/:name/publish). Verified on the merge-base f471b4f73, both halves
exactly as the card asserted:

stepcall sitestates the package?
saveResourceEditPage.doSaveMetadataClient.saveyesif (options.packageId) params.push(...)
publishResourceEditPage.doPublishMetadataClient.publishno — the URL was built with no query string at all

Until objectstack-ai/objectstack#10354 the publish route did not accept the parameter, so
the silence was correct. It has landed, so the silence is now a gap: objectstack#9612's
package-closure narrowing at the runtime publish gate could not fire on an HTTP-driven
promotion, which is precisely this designer on every edit.

What this changes

packages/data-objectstackMetadataClient.publish() accepts packageId and emits
?package=PKG_ID, deliberately the same wire spelling, the same encodeURIComponent
treatment and the same conditional shape save() already uses one door over. The sibling
layered() door was already written this way, so this is the file's existing idiom, not a
new one.

packages/app-shellResourceEditPage now reads the binding for both steps from
one derivation, readActivePackageBinding(). That function is the old inline IIFE from
doSave, hoisted to module scope and called by doSave and doPublish alike. One value,
one spelling, and — the part that matters for drift — one derivation: a second inline copy in
the publish path would have been free to disagree with the save path, most easily about the
?package=all fold, while both looked correct in isolation.

Omit, never empty

?package= with an empty value and no package key at all are different request bytes even
though today's normaliser folds them (all and the empty value both mean "env-local overlay,
no package"). The parameter is omitted when the designer holds no binding, because that
is the shape the save door already followed and the two calls of one loop must not disagree.
It also matches how the framework reads it downstream: promoteDraftForPublish branches on
the key being present, where a present-but-null package pins the draft lookup to unbound
rows and a packaged draft stops being found.

The acceptance criterion

"The designer states the binding it already knows, so the narrowing is reachable."
Explicitly not "publishing got faster". Package-closure narrowing has a second,
independent gate this does not touch: narrowObjectsToPackageClosure keeps any object
carrying no _packageId provenance unconditionally, and a tenant-authored overlay corpus
carries none — so on such a corpus stating the package narrows nothing at all. Nothing in
this PR asserts, or should be accepted against, a latency claim.

The framework version this was measured against

@objectstack/rest17.2.0. The card's warning — "the parameter is ignored by any
framework build older than it, silently"
— was discharged by evidence, not by the PR being
merged:

  1. objectstack#10354 is MERGED into main (merged_at 2026-08-21T02:14:01Z, base main).
  2. The source on framework origin/main carries it: packages/rest/src/rest-server.ts,
    the POST /meta/:type/:name/publish handler, reads req.query?.package, folds all and
    the empty value to undefined, and guards it with
    refuseRepeatedQueryParams(req, res, ['package']) — the whole block tagged [#10063].
  3. The release that carries it is named by packages/rest/CHANGELOG.md under ## 17.2.0:
    "Additive:POST /meta/:type/:name/publish now accepts the package query parameter,
    so a single-item draft→active promotion can state the package it belongs to (#10063)."
  4. This repo's lockfile resolves the @objectstack/* line to 17.2.0 from the registry,
    i.e. the framework line objectui builds against is at or past the first release carrying it.

Tests

New, both directions pinned in the same run — a lone "publish now sends the package" test
is trivially satisfiable by always sending it, so the unbound counter-probe is not optional:

  • packages/data-objectstack/src/metadata-client.publishPackageBinding.test.ts — request
    bytes: bound → .../publish?package=com.example.showcase; unbound → .../publish, with
    absence asserted as URLSearchParams.has('package') === false, never as === ''. Also pins
    that the encoded id is byte-identical to what the save door emits for the same value.
  • packages/app-shell/src/views/metadata-admin/ResourceEditPage.publishPackageBinding.test.tsx
    — the loop: one render dirties the draft, lets the real save door fire, and then clicks
    Publish, asserting the publish states the same id the save just stated. ?package=all and a
    bare URL both pin key absence on the publish.

Which assertions would still pass on a revert

The bound-direction cases fail on a revert (reverted, doPublish passes no third argument at
all). The key-absence assertions would also pass on a revert — absence is exactly what the
old door did, and no absence assertion can separate those two worlds by itself. They are not
aimed at the revert; they are the counter-probe for always-sending. Their revert-sensitive
companion sits beside them in the same case (expect(publishOpts).toBeTypeOf('object'), red on
undefined), so the pair separates all three worlds: correct, reverted, always-send.

Reverse verification — two ablations, disjoint red sets

Run on the shipping tree c3e51ceb3; each leg proved on disk by grepping injected and
removed text separately, restored under trap … EXIT INT TERM with a cwd-independent
git -C … checkout --, and confirmed clean (git diff HEAD --stat empty) afterwards. The
implementation was committed before any ablation ran, so no restore could take an
uncommitted edit with it. No rebuild stands between a mutation and a run here: this repo's
vitest.config.mts aliases every @object-ui/* to src, and each suite imports its subject
by a same-package relative specifier — which the ablations themselves demonstrate by flipping
the verdict with no build in between.

ablationprediction (stated first)measured
A — revert doPublish's third argument (readActivePackageBinding refs 3 → 2, bare-publish line 0 → 1)all 3 app-shell cases red, the 5 client cases green3 failed / 5 passed — exactly the 3 app-shell cases
B — always-send in MetadataClient.publish (marker 0 → 1, conditional-qs lines 2 → 1)only the absent-direction client cases red, bound-direction and app-shell green2 failed / 6 passed — exactly the 2 absent-direction client cases

The two red sets are disjoint. B is the exact mistake objectstack#10354's own comment
warns about, and the suite catches it.

Gates

Derived by enumerating each CI job's own step list (ci.yml, lint.yml, changeset-*.yml,
control-bytes.yml), not from top-level script names. All run on c3e51ceb3, exit codes
captured before any pipe:

gateexitverdict quoted from the gate itself
turbo run type-check --filter=@object-ui/app-shell --filter=@object-ui/data-objectstack (script name type-check, hyphenated; ^build runs first, so this is not an unbuilt closure)0"Tasks: 31 successful, 31 total"
vitest run — the two new suites + the pre-existing metadata-client.test.ts0"Test Files 3 passed (3) · Tests 29 passed (29)"
scripts/check-changeset-presence.mjs0"4 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)"
scripts/check-changeset-no-major.mjs0"No changeset declares a major bump."
scripts/check-changeset-fixed.mjs0"All workspace packages are in the changeset fixed group."
scripts/check-control-bytes.mjs0"OK (scanned 5058 tracked text file(s); skipped 85 binary)"
scripts/check-lint-coverage.mjs0"46/46 packages linted, 0 with outstanding errors (0 total)"
scripts/check-type-check-coverage.mjs0"41/41 packages compile their tests, 0 declared debt"

eslint — declared narrowing. Run path-filtered over the merge-base delta rather than
repo-wide, and the narrowing is measured rather than asserted: --format json reports 4 file
entries, 0 errors, 66 warnings
, so all four changed source files are inside eslint's own
configured population (not ignored) and the green is non-vacuous for this diff. Cross-checked
against the added-line ranges: both new test files are 0 warnings / 0 errors, and exactly
one warning falls on an added line — @typescript-eslint/no-explicit-any on the moved
client.publish call, which carries a generic any type argument. That argument is not new:
it is the same one that call already had before this PR, and it matches the surrounding
client.layered and client.save calls, which are written the same way. eslint is not
type-aware in this config, so this diff cannot move the verdict on any file it does not touch.
The repo-wide pnpm lint (turbo run lint, 46 packages) is CI's run.

Not done here

⛔ No change to MetadataClient.publishDraft or usePublishAllDrafts. That is the
"publish everything pending" path, not the designer's loop, and it already routes
package-bound drafts through POST /packages/:id/publish-drafts — the by-reference door is
used there only for drafts whose packageId is null, i.e. exactly the unbound case.
Out of this card's file surface.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3221.0 KB3990.2 KB
Main entry chunk (gzip)153.7 KB350 KB
Entry fileindex-4WuYL09Q.js
StatusPASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)10.38KB3.90KB
app-shell (runtime-config.js)18.10KB6.51KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
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.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.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.15KB114.53KB
core (index.js)4.92KB1.97KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)166.95KB46.08KB
fields (index.js)238.40KB59.89KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.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.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.21KB44.67KB
plugin-dashboard (index.js)133.35KB34.45KB
plugin-designer (index.js)212.30KB42.80KB
plugin-detail (index.js)244.08KB61.86KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)125.63KB30.64KB
plugin-gantt (index.js)164.15KB39.88KB
plugin-grid (index.js)200.79KB54.26KB
plugin-kanban (index.js)52.89KB14.59KB
plugin-list (index.js)111.86KB27.22KB
plugin-map (index.js)20.11KB6.64KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.49KB7.59KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)84.57KB20.74KB
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.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)52.40KB17.45KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.35KB0.70KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)7.54KB2.63KB
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)2.74KB1.41KB
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-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.49KB2.14KB
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 (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
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

@yinlianghui
yinlianghui marked this pull request as ready for review August 24, 2026 18:10
@yinlianghui
yinlianghui added this pull request to the merge queueAug 24, 2026
Merged via the queue into main with commit b470e91Aug 24, 2026
23 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-5420-designer-publish-package-binding branch August 24, 2026 18:22
Sign up for freeto 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.

[cross-repo] Studio's designer save→publish loop should state ?package= on publish, now that the framework publish route accepts it

2 participants

@yinlianghui@os-litant