Repository navigation
fix(data-objectstack,plugin-detail): concurrent record reads and explain questions share one request (objectui#11699) - #11745
Merged
objectstack-fleet[bot] merged 4 commits intoOct 6, 2026
Conversation
…nce and asks each explain question once (objectui#11699) Concurrent callers now share the in-flight request at the two sources the record page duplicates measured to: - ObjectStackAdapter.findOne shares an in-flight read by resource + id + params, through the same helper find already used (generalised, find's behaviour unchanged). The entry goes when the read settles; a rejection is shared and not remembered; a write to the resource drops its entries. - useRecordEditable shares an unanswered explain question by the verdict memo's key. A staled question is dropped from the map, and a principal change empties it. Claude-Session: https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju Co-authored-by: Claude <noreply@anthropic.com>
…light reads (objectui#11699) The record-open pin types its metadata stand-in without an explicit any. Claude-Session: https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju Co-authored-by: Claude <noreply@anthropic.com>
Contributor
✅ Console Performance Budget
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
Size Limits
|
Claude-Session: https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju Co-authored-by: Claude <noreply@anthropic.com>
A record open no longer sends the same record read twice at once; the record page's own $expand re-read is named as unchanged. The write-drop sentence names findOne, the only read it applies to. Claude-Session: https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju Co-authored-by: Claude <noreply@anthropic.com>
Contributor
✅ Console Performance Budget
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
Size Limits
|
objectstack-fleet
Bot
deleted the
claude/issue-11699-record-open-shared-reads
branch
October 6, 2026 19:54
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of #11699
Stays open on the card:
RecordDetailView's sequential?populate=re-read of the record whenobjectDefchanges identity. Removing it needsRecordDetailView.tsx, which is held by objectui#11695's claim.Clause-②: no
What this does
Opening a record page sent
GET /api/v1/data/OBJ/IDtwice at once andPOST /api/v1/security/explainfour times. Measured, both duplications come from concurrent callers of one shared source. So the in-flight request is shared at those two sources, and neither host page is edited.@object-ui/data-objectstack:ObjectStackAdapter.findOneshares an in-flight read keyed by resource + id + params.find's sharing code moved into one private helper,shareInFlight, that both methods now call;find's behaviour is unchanged (its own pin is in the new test file). The entry is dropped when the read settles, so this is not a response cache. A rejection reaches every caller that shared it and is not remembered. A write through the adapter (emitMutation, which every create / update / delete / bulk / batch write goes through) drops the pendingfindOnereads of the resource it wrote, so a read asked after a save is never answered by one sent before it.@object-ui/plugin-detail:useRecordEditableshares an unanswered question. A pending map sits besideverdictCache, keyed the same way (principal, object, record, operation). A question whose record changes while it is pending is removed from the map, so the re-ask is a fresh request. A principal change empties the map, inretainForPrincipal, whereverdictCacheis cleared.Measurement: who sent the duplicates
Environment: objectstack
mainat299a2c62, the showcase app (objectstack dev --seed-admin --fresh, own port, temporary SQLite). The console is a productionvite buildof this branch, served byvite previewwith/apiproxied to that backend. Chromium headless 1440x900, seeded admin, full reload of ashowcase_taskrecord page. Requests were counted with Playwrightrequest/requestfinishedevents, 4 runs per build.5a2a6ab1c5b67bsource)GET /api/v1/data/showcase_task/IDPOST /api/v1/security/explain,updatePOST /api/v1/security/explain,deleteGET /api/v1/data/showcase_task?populate=…&top=1&filter=…The callers were named from one instrumented, unminified build. The instrumentation was reverted before anything was committed (
git diff HEADempty, marker count 0 in all four files).GET .../IDcome fromDetailView's load effect insiderecord:details, from oneDetailViewinstance. The effect re-runs becauseRecordDetailsRendererbuilds a newsectionsarray on every render, and the re-run happens while the first read is still on the wire. That read has no$expand, because the highlights strip's fields are de-duplicated out of the body. Sharing atfindOneabsorbs it.RecordDetailViewasksupdateanddelete.record:details'DetailViewasks the same two about 20 to 35 ms later, while the first answers are still pending. The principal was the same for all four, because permissions had loaded before the page mounted. Sharing inuseRecordEditableabsorbs it.?populate=pair isRecordDetailView's own record read,findOnewith$expand, not a list. It is sent again after the first one settled, becauseobjectDefarrives as a new object. Requests that are sequential cannot be shared in flight, so this pair is what keeps the card open. See acceptance note 1.The PM's mechanism assumptions, as measured
findOne" is falsified in detail. It is ONE caller,DetailView, whose effect runs again while its first read is pending. The predicted remedy holds: the calls are concurrent on one adapter with the same key, and sharing atfindOneabsorbs them.retainForPrincipal.purgeSignedOutClientCachesis in@object-ui/authand does not reach this module-scope map.?populate=re-read. It is a host's own sequential call, and it stays open on the card (acceptance note 1).Tests
packages/data-objectstack/src/findOne.inflightShare-11699.test.ts(new, 7 tests). This test runs the real adapter and the real client over a transport that holds each request until the test answers it. It covers: one GET for two concurrent calls; a new GET after settle; a different id, resource or params is not merged; a rejection is the same error for both callers (httpStatus500,codeINTERNAL_ERROR) and the next call asks again; a write to the resource makes the next read ask again; a write to another resource does not;findstill shares.packages/plugin-detail/src/useRecordEditable.inflightShare-11699.test.tsx(new, 5 tests). The explain endpoint holds each request. It covers: two mounts send one POST and both show the verdict; distinct questions are not merged; a fail-open answer is shared but not remembered; a data change while pending makes the re-ask a fresh request; a principal change empties the map, so an unknown-principal ask after it does not join one from before.packages/app-shell/src/views/RecordDetailView.recordOpenRequests-11699.test.tsx(new, the integration pin). It mounts the realRecordDetailViewwith a showcase-shaped record page over the real adapter and the real hook. It releases held requests in the order the measurement saw, then counts: recordGET .../IDis 1, and each of the 2 distinct explain questions is asked once. This is a test-only addition to app-shell, beside the host and outside the claim's file surface.RecordDetailView.tsxitself is untouched.Runs (from the worktree root, through the shared verify lock):
pnpm exec vitest run --maxWorkers=2 packages/data-objectstack/ packages/plugin-detail/:Test Files 321 passed | 1 skipped (322),Tests 3470 passed | 8 skipped (3478).pnpm exec vitest run --maxWorkers=3 packages/app-shell/src/views/RecordDetailView:Test Files 42 passed (42),Tests 257 passed (257). These are the host suites objectui#11713 once broke, plus the new pin.useRecordEditable,__clearRecordEditableCacheorsecurity/explain, or constructsObjectStackAdapter):Test Files 52 passed (52),Tests 445 passed (445).pnpm --filter @object-ui/data-objectstack --filter @object-ui/plugin-detail run type-check: bothDone.--listFilesshows both new test files in those programs.packages/app-shell:tsc -p tsconfig.test.jsonexit 0 atd1d1e87, and--listFilesincludes the new pin. These ran after the closure buildturbo run build --filter="@object-ui/app-shell^..."(28/28 tasks).pnpm --filter @object-ui/data-objectstack --filter @object-ui/plugin-detail run lintreports 0 errors. Each touched source has the same warning count as at5a2a6ab(index.ts123,useRecordEditable.ts1), and both new test files have 0. For app-shell,eslint --format jsonon the one added file reports 1 file, 0 errors, 0 warnings. That is a declared narrowing: no other app-shell file changed, andeslint.config.jshas no type-aware parser options (projectService/parserOptions.project).Ablations
Each ablation mutated committed
HEAD(1c5b67b) throughscripts/ablation-replace.mjsin WRAP mode. The anchor hit exactly once, the blob changed, and the restore was proven by blob == HEAD and an emptygit diff HEAD. Vitest resolves all three packages tosrcthroughvitest.config.mtsaliases, so nodistwas involved.findOne's sharing removed (return this.shareInFlight(this.inflightFindOnes, …)replaced by a directreadOne): 5 red. These are the fourfindOneshare pins, plus the integration pin withexpected … to have a length of 1 but got 2, which is the measured double GET.if (pending) return pending.answer;replaced): 3 red. The integration pin receivedupdate: 2, delete: 2, which is the measured four explains.Gates (exit codes captured before any pipe)
At
1078df3(after mergingmainatd39ac2eand correcting the changeset prose):check:changeset-claims0 ·check:pending-changeset-literals0 ·check-changeset-no-major0 ·check-changeset-presence0 (5 source file(s) of 3 released package(s) changed, and this change declares 1 changeset(s)) ·check-changeset-overwrite0 ·check:control-bytes0 ·check:new-line-citations0 (VERDICT … 0 new citation(s)).At
d1d1e87, and still standing:check:vi-mock-specifiers0 ·check:vi-mock-inherit0 ·check:vi-mock-override-shape0 ·check:test-path-roots0 ·check-governed-queue-guard.mjs --testover the 7 paths:NOT GOVERNED, exit 0. The tests and ablations above were run atd1d1e87and its source, and they stand at1078df3. All six non-changeset files of this change have the same blob at both heads. The only file that differs outside the merge is the changeset. Every file the merge brought in equalsd39ac2e, and none of them names the record page,findOneoruseRecordEditable.NOT MEASURED:
check:readme-exports, reason: it needs every package built, and it judges only the import bindings a README writes, while the README edit here adds a prose bullet and no import.check:doc-snippets, reason: no code fence was added. The repo-widepnpm lintand the fullpnpm testfarm are left to CI.Declaration delta
ObjectStackAdapterindist/index.d.tsgains three private member names (inflightFindOnes,shareInFlight,readOne) and a JSDoc paragraph onfindOne. No export, public member, prop, type member or i18n key changes.useRecordEditable's new code is module-private. The changeset declarespatchfor both packages (.changeset/11699-record-open-shared-reads.md). Its prose claims only what was measured: the same record read is no longer sent twice at once, and the page's own$expandre-read is named as unchanged.Acceptance notes
Part of #11699.DetailViewreadsGET .../IDonce (shared now).RecordDetailViewreads?populate=twice in sequence, re-reading whenobjectDefchanges identity. That pair is the page's own$expandrecord read, not a list, so the card's Done-when (oneGETof the record per open) still covers it. The second read is sent after the first settles, so no in-flight sharing can absorb it. Removing it means an edit toRecordDetailView.tsx, which waits behind objectui#11695's claim.DetailViewinsiderecord:detailsreads the record itself while the page's record (ctx.data) is still loading, in parallel to the page's own read. This is a host-coordination question, not a sharing one, because the two requests carry different params.RecordDetailsRendererbuilds a newsectionsarray on every render, soDetailView's load effect re-runs on unrelated re-renders. That is the root of the double GET, now absorbed by sharing. The schema read it repeats is served by the metadata cache.findhas no write drop. The same staleness windowfindOnenow closes is still open for lists. This is an inference, not reproduced.GET /api/v1/runtime/configandGET /api/v1/auth/get-sessioneach go out twice, in sequence, on every reload (4 of 4 runs, both builds).Generated by Claude Code