Repository navigation
Keep computed resolver output out of durable records - #2360
kylebernhardy wants to merge 33 commits into
Conversation
Release cherry-pick
|
There was a problem hiding this comment.
Code Review
This pull request implements mechanisms to prevent read-only resolver fields from being serialized or included in durable record encodings, ensuring that toJSON acts strictly as a response projection. It introduces tracking for durable encoding depth, filters out read-only resolver fields during decoding, and throws errors on attempts to mutate read-only attributes. The review feedback suggests improving error handling by logging caught exceptions in noteReadOnlyResolverCollision rather than swallowing them, and using a dedicated plain-object validation helper instead of a simple typeof check in removeReadOnlyResolverFields.
|
1 blocker found: the resolver-contract cache invalidation (resources/Table.ts:5354) only clears RocksDB's record cache, not LMDB's -- see inline comment. The latest commit only makes the regression test avoid throwing on LMDB; it doesn't add the missing invalidation there. All other prior review threads (mine, @kriszyp's, and gemini's) are addressed by this push. |
|
Nice work on this — the decode-side cleanup in I had independently investigated #2359 before finding this PR, so rather than re-review the diff I ran my reproducers against this head ( Confirmed working on this branch
Worth noting that third row, because it is a route the issue does not mention and this PR has no test for: One gap I would like to see closedAn Fixture: a
That last row is the one I would flag hardest:
Three smaller notes
OfferI have a seven-test integration suite covering the routes this PR's unit tests do not reach — cache fill over REST, a raw-stored-keys probe that asserts the durable bytes directly (with a non-vacuity check that the probe can see a planted collision), legacy-record recovery, the invalidation route, re-encode of an existing record, and the source-collision case. Six of the seven fail on — KrAIs (Claude Opus 5), on behalf of Kris |
kriszyp
left a comment
There was a problem hiding this comment.
Thank you for catching this, this looks really good. A couple things to address and then let's get this merged/patched.
🤖 Reviewed with Claude
|
Unfortunately, I think this is fundamentally the wrong approach. The durable serialization of data should be the default and the external projection to include computed properties should be the opt-in, which completely reverses the entire direction of this, sorry. I've started on a PR to fix this, and will probably need to close this PR. |
| primaryStore.encoder.readOnlyResolverNames.length !== readOnlyResolverNames.size || | ||
| primaryStore.encoder.readOnlyResolverNames.some((name) => !readOnlyResolverNames.has(name)); | ||
| primaryStore.encoder.setReadOnlyResolverNames(readOnlyResolverNames, resolverNames); | ||
| if (resolverContractChanged) primaryStore.clearRecordCache?.(); |
There was a problem hiding this comment.
Blocker: resolver-contract cache invalidation only covers the RocksDB engine
What: primaryStore.clearRecordCache?.() is called here whenever a schema reload changes which attributes are classified as read-only resolvers, so already-decoded/cleaned records don't keep serving a stale classification. clearRecordCache is only implemented on PrimaryRocksDatabase (resources/PrimaryRocksDatabase.ts:206, this.#cache?.clear()). For LMDB-backed tables (HARPER_STORAGE_ENGINE=lmdb, still supported per this repo's conventions), the primary store has its own equivalent decode cache — primaryStore.cache (populated per utility/lmdb/OpenDBIObject.ts:53, this.cache = LMDB_CACHING && { validated: true }, and already .clear()-able elsewhere, e.g. unitTests/resources/caching.test.js:808). clearRecordCache?.() silently no-ops there since the method doesn't exist on the LMDB store.
Why it matters: updatedAttributes() reconfigures structPrototype accessors in place on schema reload; it never reopens primaryStore, so any pre-existing decoded/cached record on an LMDB table survives the reload untouched. If a reload adds/removes @computed/@enumerable/relationship attributes (changing readOnlyResolverNames), a record already sitting in lmdb-js's cache from before the reload keeps serving its old field classification/values indefinitely (until evicted or rewritten) — the exact staleness this cache-clear was added to prevent, just not on LMDB. The new unit test unitTests/resources/crud.test.js:205-224 ("clears the record cache when the resolver contract changes") would itself throw under HARPER_STORAGE_ENGINE=lmdb: it captures const clearRecordCache = primaryStore.clearRecordCache (undefined for LMDB), wraps it, and the wrapper's clearRecordCache.call(this) throws TypeError: Cannot read properties of undefined (reading 'call') once the reload triggers the real call site.
Suggested fix: Either add a clearRecordCache() on the LMDB store path (primaryStore.cache?.clear()), or make the call site in Table.ts engine-agnostic, e.g. primaryStore.clearRecordCache?.() ?? primaryStore.cache?.clear?.().
|
Closing this in favor of #2368, per Kris — with genuine thanks: this PR's diagnosis was right on the money, and its decode-side cleanup ( What tipped the decision to the other implementation:
The invalidation route ( — Claude (Opus 5), on behalf of Kris |
… that writes them (#2368) * Keep resolved attribute values out of durable records, at every layer that writes them A record resolved from a cache source carries the record prototype, whose response projection (toJSON, #1484) surfaces scalar @computed values — and @enumerable relationships since 5.1.0. msgpackr consults an instance's toJSON when encoding it, so the durable encode ran the response projection and wrote resolved values as stored fields. Materializing such a record assigns the stored value back through the resolver accessor: a computed attribute has no setter, so every read, query, invalidate and delete threw `attribute.set is not a function` (5.2.0-5.2.6); a relationship setter dereferences the stale value, so a dangling foreign key crashed the same way (since 5.1.0) and a scalar collision silently destroyed the foreign key. The write side enforces the invariant at the owning layers: - recordUpdater projects the record — and the audit entry's own record, gated so message/publish payloads stay verbatim — to its stored fields before anything durable is written, dropping any name a resolver owns whether it arrived through the response projection or as an own key from a source or peer payload. A source returning a related object instead of its foreign key still gets the key derived through the writable resolver's setter before the name is dropped. - structon (1.1.0, pinned here) now writes own properties only, matching msgpackr's object writers, which closes the prototype-walk route for every struct encode. msgpackr 2.1.0 adds a useToJSON opt-out; harper deliberately does not set it, because an encoder-wide opt-out also silences the legitimate toJSON of nested values. The read side makes recovery possible for the records affected releases already wrote, which were unreadable and undeletable: the four paths that promote a plain decode to a record instance skip resolver-owned names instead of assigning them through the accessors, and the accessor itself drops such an assignment (warning once per table) rather than calling an absent setter. A schema reload clears attribute.set alongside attribute.resolve so a @relationship-to-@computed change cannot retain a stale setter. A user assignment to a computed attribute now throws a client error naming the attribute rather than a TypeError. * Document the durable-vs-response projection convention in DESIGN.md * loadGQLSchema: give inline schemas a filename so computed expressions compile vm.Script requires a string filename; a null one made any @computed(from:) expression in an inline-loaded schema throw. From #2360 (kylebernhardy). * assignStoredFields: own keys only, matching its fallback and storedFieldsOnly for..in walked the prototype chain, so an inherited enumerable (a polluted Object.prototype included) could be copied into a materialized and then durable record on exactly the paths the resolved===undefined Object.assign fallback and storedFieldsOnly's Object.keys already restrict to own properties. Surfaced by the harper-pro#772 takeover review. --------- Co-authored-by: Kris Zyp <kris-review@harperdb.io>
Summary
toJSON()implementationsVerification
8f4977716agyremains unavailable because the local CLI is not authenticated; no Gemini coverage is claimedFor the human reviewer
Fixes #2359
Comment generated by kAIle (Codex GPT-5.6)
Review-Coverage: authored=codex; ran=claude,harper-domain; unavailable=gemini; declined=cursor-grok,cursor-composer; rounds=26 @ 8f49777
Human-Review-Need: 3 @ 8f49777