Skip to content

refactor(data-objectstack): delete the subsumed CONCURRENT_UPDATE guard and align the predicate's doc - #6422

Merged
os-support-ai merged 1 commit into
mainfrom
claude/issue-6375-concurrent-update-guard
Aug 25, 2026
Merged

refactor(data-objectstack): delete the subsumed CONCURRENT_UPDATE guard and align the predicate's doc#6422
os-support-ai merged 1 commit into
mainfrom
claude/issue-6375-concurrent-update-guard

Conversation

@claude

@claude claude Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Fixes #6375

What changed

1. The subsumed guard is gone. normaliseClientError carried two stacked guards before its CONCURRENT_UPDATE re-wrap. Re-derived on this branch against origin/main 9602dc820 (lines 1150-1151 at the time of writing):

if (e.code !== 'CONCURRENT_UPDATE' && e.httpStatus !== 409) return error;
if (e.code !== 'CONCURRENT_UPDATE') return error;

The first line's condition is strictly stronger than the second's, so it can never be the line that decides an outcome — anything it would have returned is returned one line later anyway. Its httpStatus !== 409 half advertised a second acceptance path (a bare 409 still getting re-wrapped) that does not exist, on the one function whose whole job is deciding what gets re-wrapped. Deleted, with the effective rule — the wire code is the sole discriminator — written where the dead line used to sit.

2. The doc moved, not the predicate. The charter asked that isConcurrentUpdateError's doc and its code || name predicate stop disagreeing, and said which way to go depends on whether the name === limb is deliberate. It is deliberate, so the doc is the half that moved. The evidence, in the order it decides the question:

  • The rationale is already written down in this same file, above the sibling isViewConfigPermissionDeniedError, and it cites this function as its precedent:

    Duck-checks code/name rather than using instanceof, matching {@link isConcurrentUpdateError}: a host that bundles this package twice (or re-throws across a worker boundary) still gets the right verdict.

    Deleting the limb would leave that doc citing a predicate that no longer does what it says it does.

  • A consumer census is the wrong measurement here, and it also comes back empty. The case the limb exists for — a host holding two copies of the class, where instanceof fails and the name string is the only discriminator left — is out of tree by construction. For the record the census was run anyway: no in-repo consumer imports this predicate at all. plugin-detail imports a same-named local copy from ./ConcurrentUpdateDialog, not this one. Zero consumers is therefore not evidence against the limb.

  • Two sibling packages carry the same two-limb check, each documenting that it duck-types in order not to depend on the adapter: packages/plugin-form/src/occSave.tsx:78 and packages/plugin-detail/src/ConcurrentUpdateDialog.tsx:223. plugin-form's own test already pins the name limb (occSave.test.tsx:86: expect(isConcurrentUpdateError({ name: 'ConcurrentUpdateError' })).toBe(true)). It is a house pattern with a live pin, not drift.

So both limbs stay, and the reason now sits beside the predicate where the next reader meets it. The doc also records the deliberate width difference: the predicate accepts code or name and reads httpStatus for neither, while the re-wrap keys on the wire code alone.

3. The truth table is pinned in packages/data-objectstack/src/occ.test.ts (see below for why it is not a vacuous pin).

The pin is about this deletion, not about the function existing

The deletion is a no-op by construction, so "behaviour unchanged" would pass before and after and prove nothing. What earns these rows their keep is an asymmetry: two of them go red under the other possible deletion — dropping the code !== 'CONCURRENT_UPDATE' line and keeping the conjunction, under which a 409 whose code says something else falls through and gets re-wrapped as a conflict it never was.

Three runs, each with its direction predicted first:

run tree predicted observed
A pin + unmodified origin/main source green Test Files 1 passed (1) / Tests 18 passed (18)
B pin + this change green, identical Test Files 1 passed (1) / Tests 18 passed (18)
C pin + the other deletion (ablation) red on exactly the two 409-passthrough rows `Tests 2 failed

Run C's two failures were exactly the predicted rows — "passes through a 409 whose code is NOT CONCURRENT_UPDATE" and "passes through a bare 409 carrying no code at all" — and nothing else moved.

The ablation mutated source only (no dist involvement: the test imports ./index inside the package, and vitest resolves it from source), and the mutation was confirmed on disk before the run rather than from an editor exit code: injected text grep -c = 1, removed text grep -cx = 0, and the working-tree git hash-object moved off the HEAD blob hash (fd389f5c… to b019a597…). The restore was git checkout HEAD -- <abs path> under a trap … EXIT INT TERM, and is proven by hash rather than by exit code: working tree back to fd389f5c974ef83138d5008495b1b14c87e3d8b7, byte-identical to the HEAD blob, git diff HEAD empty.

Verification — union run on 3d85c0cd9 (the final commit)

  • pnpm exec vitest run packages/data-objectstack/Test Files 47 passed (47) / Tests 640 passed (640)
  • pnpm --filter @object-ui/data-objectstack run type-checktsc --noEmit, exit 0. Confirmed it actually covers the edited files rather than excluding tests: tsc --listFiles lists both src/index.ts and src/occ.test.ts.
  • node scripts/check-control-bytes.mjsOK (scanned 5314 tracked text file(s))
  • node scripts/check-changeset-presence.mjs — declared with an empty frontmatter changeset (this repo's explicit "releases nothing" form; the skip-changeset label is the other repo's mechanism and was deliberately not used here). Gate prints: "2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s) … Every one of them has an EMPTY frontmatter".
  • node scripts/check-changeset-no-major.mjsNo changeset declares a major bump.
  • node scripts/check-changeset-fixed.mjs — OK
  • node scripts/check-vi-mock-specifiers.mjs — OK
  • node scripts/check-shell-escape-residue.mjs — OK

Two declared narrowings, so the difference between measured and not run stays visible:

  1. eslint was run on the two edited files, not repo-wide. pnpm lint is turbo run lint across 47 packages — CI runs it either way. Targeted result: 0 errors, 123 warnings, all @typescript-eslint/no-explicit-any and all pre-existing (the two in occ.test.ts are at lines 29 and 86, both in assertions that predate this branch; the added block introduces no any). This repo's eslint config carries no type-aware linting and no formatting rules, so nothing in this diff can move a verdict on a file it does not touch.
  2. check-readme-exports could not be fully measured locally — prerequisite, not a red gate. It needs every package's dist/*.d.ts on disk and says so itself ("its type entry ./dist/index.d.ts is not on disk -- run pnpm build first"). In a fresh worktree 34 of 40 packages are unbuilt. After building the package this PR touches, every unjudged item naming packages/data-objectstack disappeared (356 to 348 unjudged — exactly its 8 entries; judged self-imports went 22 to 30 real, 0 wrong-path, 0 fabricated). The remaining 348 belong to the other 33 unbuilt packages. CI builds first and measures the rest.

Scope

The guard deletion, the doc alignment, and the pin. This file's other error paths and its 8+ normaliseClientError call sites were not touched.

One observation-class item found while reading the boundary was recorded as a separate unassigned card rather than patched here — objectui#6421, plugin-detail's same-named local predicate narrows to a required code: 'CONCURRENT_UPDATE' while accepting name-only errors that lack it. No consumer reads .code off the narrowed result, so there is no symptom today. Out of scope for this PR and intentionally left open: #6421 is not addressed here.

Release impact

None. Comment, doc and test changes plus one deleted line that decided nothing; declared as releasing nothing via the empty-frontmatter changeset.


Draft on purpose: the PM lands this one. Not marked ready, no auto-merge, not queued.

Generated by Claude Code


Generated by Claude Code

`normaliseClientError` carried two stacked guards before its re-wrap:

    if (e.code !== 'CONCURRENT_UPDATE' && e.httpStatus !== 409) return error;
    if (e.code !== 'CONCURRENT_UPDATE') return error;

The first can never decide an outcome — its condition is strictly stronger
than the second's, so every input it would have returned is returned one line
later anyway. Its `httpStatus !== 409` half advertised a second acceptance
path (a bare 409 still being re-wrapped) that does not exist, on the one
function whose whole job is deciding what gets re-wrapped. Deleted, with the
effective rule written where it used to sit.

Also aligned the doc above the exported `isConcurrentUpdateError` with the
predicate below it: the doc named only the wire shape while the code accepts
`name === 'ConcurrentUpdateError'` too. The `name` limb is KEPT — it is the
deliberate cross-realm discriminator that `isViewConfigPermissionDeniedError`
already documents and cites this function as its precedent for — and the doc
now records that instead of leaving it to read as drift.

Both accepted sets are pinned as an explicit truth table in `occ.test.ts`,
including the rows that go red under the other possible deletion.

Cleanup for objectui#6375.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 52 chunks) 3233.6 KB 3266.6 KB
Main entry chunk (gzip) 157.4 KB 350 KB
Entry file index-BcO2J-dm.js
Status PASS

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

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 11.30KB 4.28KB
app-shell (runtime-config.js) 18.10KB 6.51KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
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.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 5.13KB 2.35KB
collaboration (CommentThread.js) 26.08KB 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.68KB 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) 505.86KB 114.58KB
core (index.js) 5.30KB 2.13KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 173.10KB 47.96KB
fields (index.js) 238.89KB 60.02KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.44KB 1.39KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 33.40KB 8.71KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.95KB 10.97KB
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.75KB
mobile (index.js) 1.55KB 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.72KB 0.42KB
mobile (useResponsiveConfig.js) 1.37KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 9.53KB 3.38KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 4.64KB 1.50KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 1.93KB 0.88KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.66KB 12.84KB
plugin-charts (index.js) 64.66KB 18.32KB
plugin-chatbot (index.js) 188.60KB 44.82KB
plugin-dashboard (index.js) 133.46KB 34.48KB
plugin-designer (index.js) 211.90KB 42.74KB
plugin-detail (index.js) 245.10KB 62.31KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 131.78KB 32.19KB
plugin-gantt (index.js) 164.14KB 39.87KB
plugin-grid (index.js) 201.79KB 54.60KB
plugin-kanban (index.js) 52.87KB 14.57KB
plugin-list (index.js) 112.63KB 27.45KB
plugin-map (index.js) 20.09KB 6.62KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 43.49KB 11.93KB
plugin-timeline (index.js) 26.70KB 7.69KB
plugin-tree (index.js) 9.26KB 3.13KB
plugin-view (index.js) 84.55KB 20.74KB
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.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 54.84KB 18.43KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.35KB 0.70KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 5.41KB 2.34KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 4.93KB 2.24KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 12.13KB 3.65KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 7.54KB 2.63KB
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) 2.74KB 1.41KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 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-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.72KB 2.24KB
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 (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
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

@os-support-ai
os-support-ai marked this pull request as ready for review August 25, 2026 23:45
@os-support-ai
os-support-ai added this pull request to the merge queue Aug 25, 2026
Merged via the queue into main with commit 3279b48 Aug 25, 2026
29 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-6375-concurrent-update-guard branch August 25, 2026 23:58
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.

[finding] normaliseClientError's first CONCURRENT_UPDATE guard is subsumed by the line under it — the httpStatus === 409 half decides nothing

2 participants