Relationship property access now always resolves synchronously - #2006
Conversation
The array-of-foreign-keys resolver used store.get(), which on RocksDB opts into the async read path and returns a Promise on a block-cache miss — leaking an intermittent MaybePromise into user code (a regression from the v4 synchronous relationship contract). Warm keys resolved synchronously, cold keys returned a Promise. Switch it to getSync(), matching the scalar-FK branch, and drop the now-dead Promise.all plumbing. The to-many branch already resolves synchronously (sync range iteration under RocksDB). The select() path (returnEntry) was already sync via getEntry. Adds a contract test that stubs the async get() path to throw, so any resolver regression to the async path fails deterministically regardless of block-cache state. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request enforces a synchronous contract for relationship accessors to align with v4 parity, replacing asynchronous store.get() calls with synchronous store.getSync() or store.getEntry() and removing the obsolete promise-handling logic. It also documents this design decision and adds a corresponding unit test. Feedback on the changes highlights a potential runtime TypeError in Table.ts if a scalar ID is stored instead of an array, which can be resolved by coercing the ID to an array before mapping.
|
Reviewed; no blockers found. |
…calar stored ids Review finding (gemini): attribute.set on an elements attribute stored a scalar id for a single related record, which the reader then misread — and a single composite (array) id would be silently misread as multiple scalar ids. Store a one-element array instead, and coerce scalar stored ids to an array on read for data written before this normalization. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The array-typed-PK case that the relationship setter's normalization guards against — a related record whose primary key is itself an array — had no coverage: wrapping the composite id in a single-element array (vs. spreading it) was only implied by the code comment. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ships Table.ts's array-FK resolver hoisted `txnForContext(context).getReadTxn()` out of the per-id map callback (24c3f35) to build the options object once instead of per element. That means an empty relationship — no ids to resolve — now opens/references a read transaction it never uses, where the old per-id call was skipped entirely when the map ran zero iterations. Normalize and check the id list first so a relationship with nothing to fetch returns before touching the transaction. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Per the PR review comment on this file: pin resolve(), not just set() — add a related record with an actual array primary key and confirm the array-of-FK resolver reads the nested [[...]] shape back to that one record, not two separate lookups. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Addressed the composite-ID test-coverage finding on |
Devin-Holland
left a comment
There was a problem hiding this comment.
Reviewed at head fb50a00. The central claim is that get() on RocksDB is a value-or-Promise and the relationship resolvers must not use it; I verified that end to end in the storage layer rather than from the description, and it holds. Two non-blocking nits, both about the new empty/null shapes rather than the fix itself.
The synchronous contract is real, and both methods the fix uses satisfy it.
PrimaryRocksDatabase.getEntrybranches on the caller's own flag:const raw = options?.async ? super.get(id, getOptions) : super.getSync(id, getOptions)(resources/PrimaryRocksDatabase.ts:123).get()is the only entry point that sets it (:147,{...options, async: true}) — precisely the value-or-Promise this PR removes.getSync(:142) goes throughgetEntrywithout the flag, and so does a baregetEntry, soreturnEntry ? 'getEntry' : 'getSync'is synchronous on both branches and dropping thehasPromises/Promise.allplumbing cannot strand a Promise. That plumbing also covered thegetEntrypath, so this was the part worth checking rather than assuming.- The same holds on LMDB, so
getSyncis not a RocksDB-only method name:handleLocalTimeForGetsdefinesstore.getSyncover the synchronousgetEntryand reservesasync: trueforstore.get(resources/RecordEncoder.ts:547-563). - The new test is a real pin rather than a tautology: stubbing
store.getto throw cannot be satisfied by accident, becausegetSyncreachessuper.getSyncand never routes through the instance's ownget. And it runs on both storage engines in CI —test:unit:allincludestest:unit:resources, andtest:unit:lmdbre-runs that same glob underHARPER_STORAGE_ENGINE=lmdb(package.json:72-75), whichunit-test.yml:62invokes. All three Unit Test legs (Node 22/24/26) are green. - The to-many branch really is untouched and was already synchronous (
.asArrayoversearchByIndex/search,Table.ts:4855-4871), and itsattribute.setis a deliberate no-op — so the setter change is scoped to therelationship.frombranch, as the description says.
-
nit (confirmed) — the empty-array early return hands back the record's own FK array.
if (normalizedIds.length === 0) return normalizedIds;(Table.ts:4896) returnsidsitself whenidsis[], sorecord.manyToMany === record.manyToManyIds— the resolved relation and the stored FK field are the same object.freezeRecordis a shallowObject.freezeof the record (Table.ts:164-167) and never touches the nested array, so arecord.manyToMany.push(related)on an empty relation now writes a record object into the FK field, where before 2a06ca3 the[].map()handed back a throwaway array. Nothing in-tree does that, and skipping the read-transaction acquisition is the whole point of the early return — butreturn []keeps the saving without the aliasing. -
nit (confirmed) — an array-typed relation now has two different empty shapes.
if (ids == null) return ids(:4891) yieldsnullfor an elements attribute, whileids: []yields[](the case the new test pins) and the to-many branch always yields an array. Sorecord.manyToManyis iterable when the FK array is empty and not iterable when it is null, and consumers have to handle both. This is strictly better than what it replaced — a null FK array threw aTypeErroron thefilterMissingbranch and gaveundefinedotherwise — so it is not a regression; it is just that[]is available for free by moving that guard inside the branch, after which the "a relation whose stored field is null now resolves to null" note in the description applies only to scalar FKs. -
residual risk, not a request — the reader's tolerance closes the scalar half of the legacy-data problem but not the composite half.
Array.isArray(ids) ? ids : [ids]recovers a single scalar id written by the old setter, but a single composite id written by the old setter is stored as a bare array (['composite', 42]), which cannot be distinguished from two scalar ids — the exact misread the description describes. The setter fix is the right fix and this half is irreducible; worth knowing the tolerance does not reach it, so if any array-of-FK relationship targets a composite-primary-key table and was ever set with a single record, that data stays ambiguous.
Also verified: the loop-invariant hoist is behavior-preserving (txnForContext(context).getReadTxn() moves from per-id to once, and the early return keeps it from running at all for an empty relation); filterMissing ? results.filter(exists) : results matches the old non-Promise branches exactly; and the DESIGN.md invariant as written is an accurate statement of what I traced.
Verdict: approve.
— Claude & DevAIn (Claude Opus 5)
DavidCockerill
left a comment
There was a problem hiding this comment.
Approving. The core fix is real and I verified it: moving the array-of-FK resolver off the MaybePromise get() to getSync genuinely retires the Array-or-Promise duality on the foreign-key side. That duality has already caused at least one downstream bug — central-manager#487, where a spread over the result index-keyed it — so closing it is worth having.
Approving with comments rather than blocking, because the code is sound and the one substantive item is about the design record rather than behaviour: the new DESIGN.md invariant claims more than the PR enforces. The .to resolver still goes through search(...).asArray, and I can see one route (source revalidation on a caching table) where that still hands back a Promise. That bullet is exactly what someone will grep before writing record.children.filter(...), so it's worth scoping to the FK side and naming .to as a known gap.
Two smaller notes in threads: a test label that reads as a contract it doesn't pin, and a response-shape change (null scalar FK now emits "related": null) that interacts with forceNulls and isn't mentioned in the body.
— Reviewed by DAIvid (Claude Opus 5)
…ed assertion Addresses PR #2006 review feedback (DavidCockerill): the DESIGN.md bullet claimed relationship accessors broadly are synchronous, but the .to resolver (one-to-many/many-to-many via search().asArray) can still return a Promise when a caching table needs source revalidation — same bug class, not yet fixed. Scope the invariant to the FK side and record the gap. Also relabel the childrenOfSelf assertion in the synchronous-contract test: it isn't pinned by the store.get stub (that resolver never calls primaryStore.get), so the label now says what it actually observes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The empty-relationship fast path (2a06ca3) returned `normalizedIds` directly. When the stored FK field was already an array, `normalizedIds === ids` — the record's own backing array — so a caller mutating the resolved relationship (e.g. `record.manyToMany.push(x)`) aliased into the stored data instead of getting an independent empty array. Return a fresh `[]` instead; the non-empty path already produces a fresh array via `.map()`, so this only affects the empty case. Surfaced by an advisory (non-independent — the graded review leg timed out) pre-push pass over the full PR diff. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Addressed @DavidCockerill's three review-comment threads (pushed in 3b7a64c, 8bed1f0):
Also fixed one issue an advisory pre-push pass surfaced on the existing (already-approved) resolver code: the empty array-of-FK fast path returned the record's own stored FK array by reference rather than a fresh one, so One item from that same pass I'm not fixing here, flagging for separate triage: a legacy (pre-this-PR) single composite-key record written via the old single-record setter stores the flat shape (e.g. |
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ed assertion Addresses PR #2006 review feedback (DavidCockerill): the DESIGN.md bullet claimed relationship accessors broadly are synchronous, but the .to resolver (one-to-many/many-to-many via search().asArray) can still return a Promise when a caching table needs source revalidation — same bug class, not yet fixed. Scope the invariant to the FK side and record the gap. Also relabel the childrenOfSelf assertion in the synchronous-contract test: it isn't pinned by the store.get stub (that resolver never calls primaryStore.get), so the label now says what it actually observes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Restores the v4 synchronous contract for schema-defined relationship accessors. The array-of-foreign-keys resolver branch called `primaryStore.get()`, which on RocksDB opts into the async read path: a value on a block-cache hit, a Promise on a miss — an intermittent value-or-Promise leaking into user code touching `record.`. It now uses `getSync()` (matching the scalar-FK branch and the repo's prior `get`→`getSync` conversions), and the dead `hasPromises`/`Promise.all` plumbing is removed. The to-many branch and the `select()` (`returnEntry`) path were already synchronous and are unchanged.
Where to look:
Post-review addition (from Gemini's PR review, commit 24c3f35):
attribute.seton an elements (array-of-FK) attribute stored a scalar id when given a single related record — which the reader misread, and which would silently misread a single composite (array) primary key as multiple scalar ids. The setter now normalizes single records to a one-element id array, and the reader coerces scalar stored ids to an array for pre-existing data.Deliberate tradeoff: `getSync` on a block-cache miss is a blocking read on the worker thread — the same behavior v4/LMDB had via mmap page faults.
Behavioral note: a relation whose stored FK/FK-array field is `null` now resolves to `null` (previously: `undefined` from a wasted store read on the scalar branch, or a throw on the `filterMissing` array branch).
No docs PR needed: the documentation describes relationship access as direct property access and never documented the Promise-returning behavior — this restores the documented contract.
Cross-model review: standard mode (Gemini/agy + Harper-domain adjudication + final-artifact pass) — no unresolved blockers or concerns. Local suites: unit:main 3959 passing, unit:resources green, integration:all green except `ollama-backend.test.ts` (requires a local Ollama instance; environmental). Authored by Claude (Fable).