feat(adapter-store): add extend config hook for consumer-defined store methods#120
Conversation
Deploying fs-packages with
|
| Latest commit: |
556fd80
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://4ab097cf.fs-packages.pages.dev |
| Branch Preview URL: | https://feat-adapter-store-extend-ho.fs-packages.pages.dev |
Goosterhof
left a comment
There was a problem hiding this comment.
⚠️ Concerns
0 blockers · 1 concern · 0 nits · 1 praise · 0 inline
CI: required check job is red — caps this below APPROVE.
Adds a generic extend capability-injection hook to createAdapterStoreModule, modelled on the existing broadcast slot, so consumers can attach store-level methods (e.g. kendo's retrieveBySlug) that close over the internal setById/deleteById tier with no as unknown as cast. The type-level mechanism is correct — X extends object = {}, & X identity for omitted extend, extend(storeModule) at packages/adapter-store/src/adapter-store.ts:94 references the legitimately-defined internal storeModule (:54), and the base rename is faithful. Backward-compatible, no public-surface change. The substance of the diff is clean.
Cross-file findings
The verification table is contradicted by CI, and the "green main" framing has gone stale. The PR body's table asserts "all 8 CI gates run locally" with the audit row green ("0 production vulns; the 3 dev-only vitepress highs are pre-existing on green main"). In CI the check job is red: the bare npm audit step (.github/workflows/ci.yml:18 — no --omit=dev) exited 1, which short-circuited the job — format:check, lint, build, validate:dist, typecheck, lint:pkg, test:coverage, test:mutation all show skipped in the run, so 7 of the 8 gates never executed in CI. The local run predates the failure.
Root cause is not this PR's code. main's check passed at 2026-06-12T17:11; this PR's ran at 2026-06-13T11:42; the esbuild advisory (GHSA-gv7w-rqvm-qjhr, Deno RCE) was published in that window and lands on a pre-existing transitive vitepress → vite → esbuild dev dependency that main's lockfile also carries. So the vuln is genuinely dev-only and not introduced here — but the gate is a hard, non---omit=dev npm audit, so it is now red on every branch including main's next push, and it will block merge until the audit posture is addressed (either bump vitepress, or scope the gate to production with npm audit --omit=dev). The body should stop claiming "green main"; main is one push away from the same red.
Two paths: (1) rebase onto a main that fixes the audit gate (separate chore — out of scope for this feature), or (2) fold an npm audit --omit=dev / --audit-level scoping change into this PR. Either way, don't merge over a red check on the assumption it's cosmetic — the skip means the real gates (typecheck, coverage, mutation) are unverified in CI, even though they passed locally.
Praise: the extend design earns its place — it generalizes the existing broadcast trust model into one narrow construction-time door rather than exposing setById on the public surface, and the test at tests/adapter-store.spec.ts asserts the no-cast contract by calling the extended method without a cast (the call site is the type assertion). That is the right way to test a type-safety claim.
Automated war-room agent review — posted because this PR carries the Agent Review Requested label.
This comment has been minimized.
This comment has been minimized.
…sories GHSA-gv7w-rqvm-qjhr + GHSA-g7r4-m6w7-qqqr (high) are transitive via vitepress→vite→esbuild (dev-only; npm audit --omit=dev was already clean). Both are first-patched in esbuild 0.28.1. Forces the patched version via the existing root overrides block; keeps the strict CI audit gate intact. This is not a regression from #120 — main fails the same audit since the advisory was published. Merging this also un-reds main's audit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This comment has been minimized.
This comment has been minimized.
Engineer · feedback claimed
|
Engineer · update
|
This comment has been minimized.
This comment has been minimized.
Engineer · feedback claimed
|
Engineer · update
|
This comment has been minimized.
This comment has been minimized.
Goosterhof
left a comment
There was a problem hiding this comment.
Verdict: REQUEST_CHANGES — the blocker here is governance, not code. The implementation is genuinely clean (careful F-bounded typing, a real runtime backstop behind the compile-time guard, honest tests, mutation-tested). On a private app I'd wave it through. This is the Armory, and a permanent generic API surface on a package every territory installs has to clear a higher bar. Three things stop it.
1. The design fork in #119 was never decided — this PR decides it unilaterally
#119 lays out four options and closes, verbatim: "Which direction fits the package best? Happy to PR whichever option we agree on." It explicitly flags the load-bearing question — "store-level custom methods become a thing. Whether that's a direction the package wants is the main question here." There are zero comments on #119; no agreement was recorded. This PR ships Option 2 — the most expansive of the four — as a finished thing. That inverts the order of operations for a shared package: a doctrine-level decision got made by the implementation rather than before it.
And Option 2 isn't the obvious pick. The motivating need is one method (retrieveBySlug) for one consumer (kendo). Against that, Option 1 (retrieveByKey(key: string)) is a first-class sibling of retrieveById — Laravel route-model binding with string keys is a framework concept with a legitimate claim to live in the package, it's narrow, typed, and adds no escape hatch. Option 2's selling point is "future-proof, no further package changes" — but that's the argument behind every escape hatch ever regretted, and the Armory's value is its constraint.
Please settle Option 1 vs Option 2 on #119 with the Commander before this merges. That's the actual open question, and #119 asked for exactly that decision. Everything below is conditional on Option 2 surviving that debate.
2. Unrelated scope is bundled in, and the PR body misdescribes the audit state
The diff carries an esbuild 0.27.7 → 0.28.1 override + full lockfile regeneration. The commit explains it (clears dev-only advisories via vitepress→vite→esbuild). The PR body never mentions it — and worse, the verification table says "audit | 0 production vulns (the 3 dev-only vitepress highs are pre-existing on green main)", framing those highs as left untouched while the PR actually fixes them. That row is stale and self-contradictory. Independent of merit, an audit fix that un-reds main on its own should be its own PR, not hidden inside a feature change with a body that misstates the audit. Split it out and correct the row. (Inline notes anchor the remaining points.)
This comment has been minimized.
This comment has been minimized.
|
§1 (design fork): Settled. Option 2 was agreed with the Commander over Mattermost; #119's open question is closed. I'll record that on #119. #3 (the The premise of
We're keeping the store intact: §2 (esbuild override): Agreed — lifting the override + lockfile into a standalone chore PR so #4 (collision guard keys on the current built-in set): Fair — adding a built-in later is a breaking change for extend-consumers. Adding a doc line; the mechanism stays. |
|
Design direction settled on #119 — see #119 (comment). Short version: we're taking Option 2, hardened. The |
…at note Address PR #120 review (Goosterhof, inline on types.ts / adapter-store.ts): - Stop overstating extend as "the same trust model as broadcast, generalized." State the open-vs-closed asymmetry instead: broadcast handlers are consumed internally (never public), whereas extend's output IS the public surface, so a renamed re-export ({save: setById}) publishes the bare setter and the name-collision guard can't catch it. Document the footgun and direct extend methods to wrap an HTTP fetch (as retrieveBySlug does) rather than surface the setter. - Note the forward-compat consequence: the collision guard keys on the current built-in set, so adding a built-in later collides with any extend method already shipping that name -- a new built-in is a breaking change for extend-consumers. JSDoc + docs only; no behavior change. Design stays aligned with broadcast (extend keeps the same raw {setById, deleteById} tier) per the Commander decision to ship Option 2 on #119. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks for the thorough reply — and agreed on §2 (esbuild split) and #4 (doc line). On the re-export thread we're not aligned yet, and one factual correction up front: the Mattermost agreement settled Option 2 over Option 1 (hook vs. first-class method). It did not cover the raw-mutator-vs-constrained-capability sub-question — that's the part still open, and it's what #119's "Option 2, hardened" decision lands on. So this isn't re-litigating a closed call; it's the one piece that was never decided. You're right about one thing, and I'll say it plainly: the bypass-HTTP capability already exists via But "same trust tier, so keep the raw mutator" is a false dilemma. And the blast radius isn't symmetric. With The concrete question that decides this: name one legitimate extend?: (cap: ExtendCapabilities<T>) => X;
type ExtendCapabilities<T extends Item> = {
retrieveInto: (endpoint: string, options?: AxiosRequestConfig) => Promise<void>;
// generalizes to requestInto({method, url, data}, map?) for write-verb actions
};
extend: ({retrieveInto}) => ({
retrieveBySlug: (slug) => retrieveInto(`${domainName}/${slug}`),
})
Holding |
|
Esbuild split is up as #127 (off latest |
This comment has been minimized.
This comment has been minimized.
|
Concrete follow-up on the "same trust tier as #128 wraps the That's exactly the difference with
So "parity with (#128 is independent of this PR and merge-ready on its own; heads-up that it and #120 both target |
`broadcast` applies server-pushed events to the store without an HTTP
round-trip — that non-HTTP path is the feature. The cost was that the bare
internal `setById`/`deleteById` were handed straight to consumer-authored
`subscribe`, and any payload (the WebSocket gives whatever it gives) landed
in frozen state unvalidated: a malformed event (`{id: undefined}`, a
non-object, a non-numeric id) silently corrupted the store, keyed under
"undefined"/"null".
The store now passes validating wrappers instead of the raw mutators:
- `onUpdate` requires an object with a numeric `id`
- `onDelete` requires a numeric id
- a bad payload throws the new `BroadcastPayloadError` rather than mutating
Two consequences beyond the validation itself: the raw `setById`/`deleteById`
no longer leave the factory (only the wrappers do), and the closed-contract
discipline (don't re-export the handlers onto your own public surface) is now
documented. `AdapterStoreBroadcast<T>`'s public shape is unchanged.
This is the broadcast half of the trust-surface review on #120/#119. Unlike
`extend`, broadcast's leak is irreducible — its purpose *is* a non-HTTP write
path — so the lever here is payload validation, not capability constraint.
Version cascade (pre-1.0): fs-adapter-store 0.1.7 -> 0.2.0 (new public export +
behavior change); fs-cached-adapter-store widens its fs-adapter-store range
^0.1.0 -> ^0.1.0 || ^0.2.0 (peer + dev) and patch-bumps 0.2.0 -> 0.2.1.
Lockfile regenerated; all @script-development/* resolve to the workspace.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`broadcast` applies server-pushed events to the store without an HTTP
round-trip — that non-HTTP path is the feature. The cost was that the bare
internal `setById`/`deleteById` were handed straight to consumer-authored
`subscribe`, and any payload (the WebSocket gives whatever it gives) landed
in frozen state unvalidated: a malformed event (`{id: undefined}`, a
non-object, a non-numeric id) silently corrupted the store, keyed under
"undefined"/"null".
The store now passes validating wrappers instead of the raw mutators:
- `onUpdate` requires an object with a numeric `id`
- `onDelete` requires a numeric id
- a bad payload throws the new `BroadcastPayloadError` rather than mutating
Two consequences beyond the validation itself: the raw `setById`/`deleteById`
no longer leave the factory (only the wrappers do), and the closed-contract
discipline (don't re-export the handlers onto your own public surface) is now
documented. `AdapterStoreBroadcast<T>`'s public shape is unchanged.
This is the broadcast half of the trust-surface review on #120/#119. Unlike
`extend`, broadcast's leak is irreducible — its purpose *is* a non-HTTP write
path — so the lever here is payload validation, not capability constraint.
Version cascade (pre-1.0): fs-adapter-store 0.1.7 -> 0.2.0 (new public export +
behavior change); fs-cached-adapter-store widens its fs-adapter-store range
^0.1.0 -> ^0.1.0 || ^0.2.0 (peer + dev) and patch-bumps 0.2.0 -> 0.2.1.
Lockfile regenerated; all @script-development/* resolve to the workspace.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Goosterhof
left a comment
There was a problem hiding this comment.
🚫 Blockers
Round 2 (round 1: COMMENT prsweep:6c9f535; an interim CHANGES_REQUESTED raised the #119-design and esbuild-scope blockers).
1 blocker · 0 concerns · 0 nits · 2 praise · 1 inline
CI: required check is red — stale (esbuild advisory, fixed by #126 which merged 10:00Z); a rebase clears it. mergeStateStatus: DIRTY / CONFLICTING confirms the branch predates the #126 merge.
Round-2 tally — the interim blockers are resolved: esbuild override lifted to #126 (merged 2026-06-15T10:00:23Z) and reverted here (61e6b3da); the audit row is corrected; the ally MAJOR 3-arg-bypass got a real runtime backstop (7e33b179); trust-model wording and the forward-compat hazard are now documented honestly (7ab4904e). The collision-guard work and tests are genuinely good. But the round-2 base moved: the #119 design question this PR was blocked on has been decided, and the decision changes the contract this PR ships.
The implementation is clean — F-bounded ExtendShape, runtime backstop behind the compile guard, mutation-tested. It does not matter, because it implements the wrong contract.
Cross-file findings
Blocker — the #119 decision is "Option 2 hardened", and this PR ships un-hardened Option 2. The Commander's decision on #119 (2026-06-15T09:01, two hours before this head) is explicit: extend ships, but it must receive a response-backed ingest primitive (ExtendCapabilities<T> with retrieveInto), not the raw setById/deleteById tier. The decision spells out exactly why a runtime guard is insufficient and names this PR by number:
- The hazard isn't
{save: sm.setById}(a guard catches reference identity) — it's the one-line wrapper{save: (item) => sm.setById(item)}, which is a new function indistinguishable at runtime from a legitimate fetch-then-set. Quoting #119: "A guard is real protection against accidents and theater against intent." - The decided fix is structural, not policed:
extend?: (cap: ExtendCapabilities<T>) => Xwhere the only ingest path isretrieveInto(endpoint, options?) => Promise<void>.setById/deleteByIdnever leave the factory, so the raw-mutator re-export becomes unexpressible, not guarded-against. - This PR's head still does the opposite —
extend?: (storeModule: AdapterStoreModule<T>) => Xhands the full internal module (anchored inline atpackages/adapter-store/src/types.ts:113), andadapter-store.ts:102invokes it with the rawstoreModule. The motivatingretrieveBySlugitself calls baresetByIdin this PR's docs and tests — exactly the surface the decision removes.
Regression-against-decision, not against prior code. #119 step 1: "Rework extend to pass ExtendCapabilities<T> (start with retrieveInto)... Widen the store's Pick<HttpService, ...> only as far as the shipped primitives require." Steps 2–3 confirm the collision guard, version cascade, docs, and test structure carry over — so this is a rework of the capability surface, not a rewrite. The author's own thread reply (3412235728) argued for keeping the raw tier and documenting the footgun; the #119 decision overrides that choice on ISO 27001 / NEN 7510 grounds (the HTTP path is where authz/audit live for emmie / ublgenie / codebook).
Fix: implement #119 as written — ExtendCapabilities<T> with retrieveInto as the sole ingest path; keep the collision guard, cascade, and test scaffold. Add a test asserting setById/deleteById are not reachable from the extend callback argument (the structural-impossibility assertion the decision is buying).
Praise: two real wins survive the rework. The @ts-expect-error-locked compile guard plus the construction-time ExtendKeyCollisionError backstop is the right belt-and-suspenders, and the forward-compat note (a new built-in is a breaking change for extend-consumers) is an honest call-out most authors miss. The four-option framing in #119 is what made the decision possible — that analysis carries weight.
Findings (detail inline)
packages/adapter-store/src/types.ts:113—extendsignature passes the raw module, notExtendCapabilities<T>(the #119-decided contract).
Automated war-room agent review — posted because this PR carries the Agent Review Requested label.
…at note Address PR #120 review (Goosterhof, inline on types.ts / adapter-store.ts): - Stop overstating extend as "the same trust model as broadcast, generalized." State the open-vs-closed asymmetry instead: broadcast handlers are consumed internally (never public), whereas extend's output IS the public surface, so a renamed re-export ({save: setById}) publishes the bare setter and the name-collision guard can't catch it. Document the footgun and direct extend methods to wrap an HTTP fetch (as retrieveBySlug does) rather than surface the setter. - Note the forward-compat consequence: the collision guard keys on the current built-in set, so adding a built-in later collides with any extend method already shipping that name -- a new built-in is a breaking change for extend-consumers. JSDoc + docs only; no behavior change. Design stays aligned with broadcast (extend keeps the same raw {setById, deleteById} tier) per the Commander decision to ship Option 2 on #119. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
61e6b3d to
ef2cd04
Compare
Rebased onto #128 + extend now mirrors the broadcast hardeningForce-pushed a rebase onto 1. Version cascade (the 0.2.0 collision resolved in #128's favour)
2. #128 moved So extend is brought to parity:
Gates (all 8 green locally): build · typecheck · lint · format · lint:pkg (11 pkgs + root) · coverage 100% (533 tests) · mutation 100% on |
Goosterhof
left a comment
There was a problem hiding this comment.
🚫 Blockers
Round 3 (round 1: COMMENT prsweep:6c9f535; interim CHANGES_REQUESTED raised #119-design + esbuild-scope; round 2: prsweep:61e6b3da raised the #119-decision blocker).
1 blocker · 0 concerns · 0 nits · 1 praise · 1 inline
CI: required check is red — mergeStateStatus: BEHIND, branch trails main; the esbuild advisory was cleared by #126, a rebase re-runs green. Stale, not this PR's code.
Round-3 tally — one new commit since round 2 (ef2cd045, the feat!: validate extend payloads hardening). It is real hardening — setById/deleteById wrappers now throw ExtendPayloadError on a malformed payload, mutation-tested. But it hardens the wrong contract. The round-2 blocker is carried, unaddressed: this head still ships the raw-mutator extend door the #119 decision rejected. Adding payload validation to the raw tier is not the same as removing the raw tier.
Cross-file findings
Blocker (carried from round 2) — head still ships un-hardened Option 2, not the #119-decided ExtendCapabilities<T>. Verified directly against refs/pull/120/head:
packages/adapter-store/src/types.ts:127—extend?: (storeModule: AdapterStoreModule<T>) => X. Still the full internal module.packages/adapter-store/src/adapter-store.ts:152—extend(extendStoreModule), whereextendStoreModule(:137) exposessetById+deleteByIdto the callback.- The #119 decision (2026-06-15T09:01) names this PR and is explicit:
extend?: (cap: ExtendCapabilities<T>) => Xwhere the only ingest path isretrieveInto(endpoint, options?) => Promise<void>.setById/deleteByIdnever leave the factory, so the raw-mutator re-export becomes unexpressible, not policed.
What ef2cd045 changed is orthogonal to the blocker. The hazard the decision removes is {save: (item) => sm.setById(item)} — a new function indistinguishable at runtime from a legitimate fetch-then-set. Payload validation does not touch that: a well-formed payload passes the new wrapper and reaches state through a non-HTTP write path all the same. Quoting #119: "A guard is real protection against accidents and theater against intent." The decision buys structural impossibility; this head still buys a guard.
Fix is unchanged from round 2: implement #119 as written — ExtendCapabilities<T> with retrieveInto as the sole ingest path; widen the store's Pick<HttpService, ...> only as far as retrieveInto requires; keep the collision guard, version cascade, and test scaffold (the decision confirms they carry over). Add a test asserting setById/deleteById are not reachable from the extend callback argument — that structural-impossibility assertion is the whole point of the rework.
Praise: the payload-validation hardening in ef2cd045 is well-built on its own terms — the shared isStorableItem predicate (adapter-store.ts:51) factored out of the duplicated broadcast check, the integer-keyspace rationale captured in the comment, and the it.each table covering NaN / Infinity / non-integer-float / non-object all exercised. None of it is wasted; most of it survives the rework onto retrieveInto.
Findings (detail inline)
packages/adapter-store/src/types.ts:127—extendstill passes the rawAdapterStoreModule<T>, not the #119-decidedExtendCapabilities<T>.
Automated war-room agent review — posted because this PR carries the Agent Review Requested label.
|
Jasper — the rework is well-built and most of it stays. But it ships the wrong contract, and the contract isn't open anymore. I decided this on #119 (09:01): Why this isn't negotiable: for emmie / ublgenie / codebook the HTTP path is where authz and audit live. "Document the footgun" is the weakest rung of our enforcement ladder; a shared package gets the control pushed to structural, not parked in a doc comment. Validation closes corruption; it does nothing about a deliberate non-HTTP write path. "Parity with broadcast" doesn't carry it: broadcast is a closed contract (handlers never returned), None of your work is wasted. Concretely: |
This comment has been minimized.
This comment has been minimized.
|
@Goosterhof — done in
No use case that All 8 gates green locally: build · typecheck · lint · format · lint:pkg · coverage 100% (531 tests) · mutation 100% (adapter-store, 0 survived). |
There was a problem hiding this comment.
✅ Approved — ships the decided contract
Clearing my standing CHANGES_REQUESTED. d07b27f implements #119 as written, and the adoption is clean.
Resolved:
extend?: (cap: ExtendCapabilities) => X, andExtendCapabilitiesexposes onlyretrieveInto.setById/deleteByIdare not reachable from the callback, so{save: cap.setById}(and the wrapper variant) is unexpressible, not guarded — the structural property the decision was buying.- The structural-impossibility test is there (
'setById' in cap/'deleteById' in capbothfalse) — that assertion is the whole point, good that it's pinned. retrieveIntoGETs, validates each item (isStorableItem, integer-id), upserts via the internalsetById, single-or-array,ExtendPayloadErroron a malformed response.Pick<HttpService>unchanged; options typed viaParameters<...>, no direct axios import.- Carried over exactly as anticipated:
isStorableItem, the collision guard, the 0.3.0 cascade, the test scaffold. None of the earlier hardening was wasted — it moved inside the ingest boundary.
The use-case question that ran three rounds is settled: retrieveBySlug is (slug) => retrieveInto(...) over the domain endpoint, and the write-verb requestInto is correctly deferred until a real case lands rather than built ahead of need.
One non-blocker before merge: the branch is BEHIND main (a Dependabot happy-dom bump merged after it) — MERGEABLE, but rebase to current so CI runs on the merge result.
Verified locally on d07b27f: build, typecheck, lint, format, audit all green; check green on CI. Good work turning this around.
…e methods Option 2 of #119: a generic capability-injection hook on fs-adapter-store, modelled on the existing broadcast config slot. Consumers pass extend?: (storeModule) => X and the returned store is typed StoreModuleForAdapter<T,E,N> & X, carrying their custom methods which can call the internal setById/deleteById without any type-system abuse. Additive and backward-compatible: X defaults to {} (the identity element for & X), so an omitted extend is identical to current behaviour and the public surface is unchanged. setById stays internal. Version cascade (pre-1.0 discipline): fs-adapter-store 0.1.7 -> 0.2.0 (minor, additive feature); fs-cached-adapter-store takes only the dep-range/version cascade (peer + dev range ^0.1.0 -> ^0.1.0 || ^0.2.0, version 0.2.0 -> 0.2.1) — no extend hook is added there, it intentionally narrows its surface. Refs #119 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…mpile error
Constrain the `extend` return type X so a returned key that collides with a
built-in store method (getAll/getById/getOrFailById/generateNew/retrieveById/
retrieveAll) fails the type constraint instead of silently shadowing the base
method via the `{...base, ...extended}` spread. Type-only: the runtime spread
is unchanged, so coverage + mutation are unaffected.
ExtendShape<T,E,N,X> maps each base-colliding key to `never` (so it can't be
satisfied) while admitting arbitrary NEW keys — the legit extend cases
(pingCount, retrieveBySlug) still compile. Locked by a @ts-expect-error
negative test on a colliding explicit type arg, and the extend? JSDoc now
states the new-name requirement.
Addresses the review-loop MINOR on adapter-store.ts:95.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…y collisions
The ExtendShape compile-time constraint only catches a colliding extend key
when X is supplied/inferred (e.g. the explicit 4th type arg). On the idiomatic
<T,E,N> call X defaults to {}, so the collision goes unseen and {...base,
...extended} silently shadows the built-in at runtime — contradicting the
JSDoc/docs promise of an unconditional compile error.
Add a construction-time runtime guard that throws ExtendKeyCollisionError on
any base-key collision (holds on every call form, incl. the 3-type-arg path),
make the JSDoc + docs honest (throws at construction always; compile error
additionally when X is supplied/inferred), and keep the ExtendShape constraint
as the compile-time bonus. Add runtime + error-class tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…at note Address PR #120 review (Goosterhof, inline on types.ts / adapter-store.ts): - Stop overstating extend as "the same trust model as broadcast, generalized." State the open-vs-closed asymmetry instead: broadcast handlers are consumed internally (never public), whereas extend's output IS the public surface, so a renamed re-export ({save: setById}) publishes the bare setter and the name-collision guard can't catch it. Document the footgun and direct extend methods to wrap an HTTP fetch (as retrieveBySlug does) rather than surface the setter. - Note the forward-compat consequence: the collision guard keys on the current built-in set, so adding a built-in later collides with any extend method already shipping that name -- a new built-in is a breaking change for extend-consumers. JSDoc + docs only; no behavior change. Design stays aligned with broadcast (extend keeps the same raw {setById, deleteById} tier) per the Commander decision to ship Option 2 on #119. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ast hardening #128 moved broadcast off the raw mutator tier onto validating wrappers, so a malformed server-pushed payload throws BroadcastPayloadError instead of corrupting frozen state. After that, `extend` was the only construction hook still handed the bare setById/deleteById — its own setById(data) path had the identical keyspace-corruption vector ({id: NaN} -> state.value[NaN] -> "NaN" key, undeletable). This brings extend to parity: - extend now receives validating wrappers, not the raw mutators. setById requires an object with an integer id; deleteById requires an integer id. A malformed payload throws the new ExtendPayloadError rather than mutating. - The integer-id check is lifted into a shared isStorableItem predicate used by both the broadcast wrappers (DRY with #128's inline check) and the new extend wrappers. Raw setById/deleteById never leave the factory through either door. - Only `adapter` (trusted, package-internal) still gets the raw tier. Trust-model docs corrected: extend is no longer "the raw tier, same as broadcast" — it's validating wrappers like broadcast. The residual asymmetry stays documented (broadcast is closed; extend is open, so a re-exported wrapper still permits non-HTTP writes of *well-formed* data — validation stops corruption, not deliberate re-export). Tests: ExtendPayloadError unit coverage + extend-integration matrix asserting setById/deleteById reject non-object / missing-id / non-numeric / NaN / non-integer payloads without corrupting state, and that well-formed payloads still pass through both wrappers. Version cascade (rebased onto #128, which took fs-adapter-store 0.2.0): - fs-adapter-store 0.2.0 -> 0.3.0 (new ExtendPayloadError export + behaviour change on malformed extend writes). - fs-cached-adapter-store widens fs-adapter-store peer/dev range to add `|| ^0.3.0` and patch-bumps 0.2.1 -> 0.2.2. Lockfile regenerated; workspace links intact, 0 registry copies. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tators (#119) Implements the #119 "Option 2, hardened" decision. `extend` no longer receives the raw `{setById, deleteById}` tier; it receives `ExtendCapabilities` whose sole ingest path is `retrieveInto(endpoint, options?)` — a GET that upserts the validated response into the store. Why: extend's return value IS the public store surface. Handed the raw setById, a consumer could re-export a non-HTTP write path — `extend: (cap) => ({save: cap.setById})`, or a `(item) => cap.setById(item)` wrapper that no runtime guard can distinguish from a legitimate fetch-then-set. Payload validation (the prior approach) closes corruption but does nothing about a deliberate non-HTTP write of well-formed data. Routing the only ingest through retrieveInto makes that structurally unexpressible, not guarded — which matters for consumer territories under ISO 27001 / NEN 7510 where the HTTP path carries authz/audit. The asymmetry with broadcast (per #128): broadcast's non-HTTP write path is irreducible (it is the feature), so it can only be validated; extend's isn't, so it is designed out. Carries over from the prior approach: the `isStorableItem` predicate (now validates retrieveInto's response, still shared with broadcast), `ExtendPayloadError` (reworded for the retrieveInto context), the collision guard, version cascade, and test scaffold. retrieveInto upserts a single item or an array, validating each. - types.ts: `ExtendCapabilities` type; `extend?: (cap) => X`. Raw mutator tier gone from the extend surface (still used by adapter/broadcast). Options type derived via `Parameters<HttpService['getRequest']>[1]` — no direct axios import. - New test asserts `setById`/`deleteById` are unreachable from the extend argument — the structural-impossibility guarantee, not just a behavioural one. Version unchanged at 0.3.0 (already breaking); cascade unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
d07b27f to
556fd80
Compare
Review Loop · 8/10 · PASS — 🟡 1fs-packages #120 · AC anchor: none — no Kendo issue / plan dir / PR AC section (acceptance pass skipped); reviewed against fs-packages root CLAUDE.md + consumer-contract discipline · head Tip 1 finding(s) posted inline — see the review comments on the changed lines. Actionmerge-ready |
| ): Promise<void> => { | ||
| const {data} = await httpService.getRequest<T | T[]>(endpoint, options); | ||
| const items = Array.isArray(data) ? data : [data]; | ||
| for (const item of items) { |
There was a problem hiding this comment.
🟡 MINOR — retrieveInto array ingest is non-atomic — valid items before a malformed one are committed before the throw
The validate-and-upsert loop calls setById on each valid item as it goes, so for a partly-malformed array (e.g. [{id:1},{id:NaN}]) item 1 is already persisted when item 2 throws ExtendPayloadError.
Fix: Either (a) validate all items first, then upsert (pre-pass items.every(isStorableItem) before any setById) to make the array ingest all-or-nothing, or (b) soften the docs/test name to "rejects the malformed item" and add an assertion documenting that earlier valid items are committed. Pick one so code, docs, and test agree.
Evidence and reasoning
The docs and the array-rejection test name both promise "without corrupting state", but the array path leaves the earlier valid items in the store.
adapter-store.ts:144-149 — for (const item of items) { if (!isStorableItem(item)) throw new ExtendPayloadError(...); setById(item); }. setById (line 148) mutates state + persists per item, before the next item is validated. The single-item path is fine — a lone bad item never lands, and the single-item tests do assert store.getAll.value === []. But the array test rejects a retrieveInto array response if any item is malformed, without corrupting state (adapter-store.spec.ts:561) only asserts .rejects.toThrow(ExtendPayloadError) — it does NOT assert the store is empty / item 1 was rolled back, so the partial-upsert behaviour is unverified and the test name overclaims. docs/packages/adapter-store.md:39 makes the same "rather than corrupting state" claim. The keyspace invariant (NaN/non-integer id never lands) IS upheld — only valid items are committed — so this is prose/test drift + non-atomicity, not data corruption; hence MINOR.
Implements Option 2, hardened per the #119 decision: a generic
extendcapability-injection hook onfs-adapter-store, where the sole ingest path is a response-backedretrieveInto— not the raw mutator tier.What
Consumers pass
extend?: (cap: ExtendCapabilities) => XtocreateAdapterStoreModule. The returned store is typedStoreModuleForAdapter<T, E, N> & Xand carries the consumer's custom methods. The capability handed in isretrieveInto(endpoint, options?)— a GET that upserts the validated response into the store. The rawsetById/deleteByIdnever leave the factory through this door, so a non-HTTP write path (extend: (cap) => ({save: cap.setById}), or a wrapper around it) is structurally unexpressible, not policed by a runtime guard. The motivating kendo case stays a one-liner:Why
retrieveIntoand not rawsetById:extend's return value IS the public store surface, so a raw mutator could be re-exported as a non-HTTP write path that no runtime guard can distinguish from a legitimate fetch-then-set. Routing the only ingest through HTTP designs that out. (broadcast, by contrast, keeps validated raw handlers — its non-HTTP path is irreducible;extend's isn't. See #128.)retrieveIntovalidates every response item (single or array) viaisStorableItem— a non-object / non-integer-iditem throwsExtendPayloadErrorrather than corrupting the keyspace.Additive / backward-compatible
X extends object = {}—{}is the identity element for the& Xintersection (A & {}≡A). An omittedextendis identical to prior behaviour.setById/deleteByIdare never reachable from theextendargument (asserted by a structural-impossibility test).Version cascade (pre-1.0 discipline)
fs-adapter-store0.1.7→0.3.0(rebased onto feat(adapter-store): validate broadcast payloads before they touch state #128, which took0.2.0; newExtendPayloadErrorexport + behaviour change on malformed extend writes).fs-cached-adapter-storewidens@script-development/fs-adapter-storeto^0.1.0 || ^0.2.0 || ^0.3.0in bothpeerDependenciesanddevDependencies; own version →0.2.2. Noextendhook is added there — it intentionally narrows its surface.package-lock.jsonregenerated vianpm install; verified everynode_modules/@script-development/*resolves to the workspace (symlink →packages/*), no nested registry copies.Verification (all 8 CI gates run locally, green)
main; esbuild override merged via #126)adapter-store(0 survived — extend + broadcast validating wrappers, guards, and error call-sites all exercised)New tests in the
describe('extend (capability injection)')block: (1)extendcalled once at construction with aretrieveInto-only capability — andsetById/deleteByIdasserted unreachable from the argument (the structural-impossibility guarantee); (2) extend-returned methods exposed and callable without a cast; (3)retrieveBySlugdrives the store viaretrieveInto; (4)retrieveIntovalidation matrix (non-object / missing-id / non-numeric /NaN/ non-integer-float, single and array) throwingExtendPayloadErrorwithout corrupting state; (5) options forwarding togetRequest.Refs #119
🤖 Generated with Claude Code