feat(cacheable): support per-store TTL overrides via hooks#1657
Conversation
Allow a BEFORE_SET hook to assign a per-store object ({ primary,
secondary }) to item.ttl so the primary and secondary stores can be given
different expirations, mirroring the existing operation-level per-store
TTL. The hook item now uses a tracked ttl accessor, so any assignment
(even the same value) counts as an override; an omitted field of a
per-store object falls back to that store's normal cascade. After
resolution item.ttl is normalized to the effective primary number so
AFTER_SET handlers and sync replication always observe a number.
Extend the same per-store support to setMany / BEFORE_SET_MANY and the
operation-level setMany item ttl. Tag snapshots use the longer of the two
store TTLs (consolidated into a shared maxStoreTtl helper) so a later
invalidation can always reach the longest-lived copy.
Fully document the override precedence, maxTtl capping, AFTER_SET / sync
normalization, and backfill behavior in the README. Add a CacheableHookItem
type for opt-in hook handler typing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018gcwQAcwjCjFP2Myh8kCMz
There was a problem hiding this comment.
Code Review
This pull request introduces support for overriding the time-to-live (TTL) on a per-store basis (primary and secondary) within the BEFORE_SET and BEFORE_SET_MANY hooks, as well as directly in setMany operations. It updates the documentation, types, and implementation to handle per-store TTL objects, ensuring that tag snapshots and sync replication are correctly managed. One critical issue was identified in the review: when checking if the overridden TTL is an object, typeof null will evaluate to "object", which would incorrectly treat an explicit null (immortal) TTL as a per-store configuration object. A suggestion was provided to guard against this by checking hookTtl !== null.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #1657 +/- ##
=========================================
Coverage 100.00% 100.00%
=========================================
Files 29 29
Lines 3379 3410 +31
Branches 762 773 +11
=========================================
+ Hits 3379 3410 +31 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1365336848
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
- Guard the BEFORE_SET object branch against null (typeof null === "object") so a hook clearing ttl with null no longer cascades to store defaults. - Treat a ttl of 0 as immortal when sizing tag snapshots, so a snapshot cannot expire while an immortal (ttl 0) store copy is still cached. - Publish the effective (cascaded + capped) primary ttl from setMany sync, not just the raw per-store field, so subscribers stay consistent when an item omits the primary field. Add a shared resolveStoreTtl helper used by setManyKeyv, setManyKeyTags, and the sync publish. - v8-ignore the fire-and-forget non-blocking setMany secondary error path, matching the existing sibling patterns. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gcwQAcwjCjFP2Myh8kCMz
- Add a strongly-typed onHook overload mapping each CacheableHooks name to its payload (BEFORE_SET/AFTER_SET -> CacheableHookItem, BEFORE_SET_MANY -> CacheableSetItem[], BEFORE_SECONDARY_SETS_PRIMARY -> scalar-ttl item, etc.), with a loose fallback for arbitrary events. Hook handlers now autocomplete and type-check (e.g. assigning a per-store object in BEFORE_SECONDARY_SETS_PRIMARY is a compile error). Export the new payload types (CacheableAfterGetItem, CacheableAfterGetManyItem, CacheableSecondarySetsPrimaryItem) and CacheableHookHandlerMap. - Guard BEFORE_SECONDARY_SETS_PRIMARY: resolve a per-store object / shorthand down to the primary value before writing the primary store, so a JS caller can't hand Keyv a non-numeric ttl. - Publish the in-scope effective primary number to sync from set() instead of the widened item.ttl accessor (fixes a latent body type error). - Document the TTL precedence as one canonical table, and the hook sharp edges: 0/null/undefined clears the ttl (immortal), an invalid shorthand aborts the whole set, AFTER_SET sees a number while AFTER_SET_MANY sees the item as passed, and how per-store ttl interacts with sync. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gcwQAcwjCjFP2Myh8kCMz
Floor invalid per-operation and per-hook TTLs in capTtl: a negative or NaN ttl is normalized to "no ttl" (parity with the instance-level > 0 guards in setTtl/setMaxTtl) instead of flowing into keyv.set as an immediate/NaN expiry. A ttl of 0 is preserved since it means the entry never expires. Covers both set() and setMany since they resolve effective per-store TTLs through capTtl. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gcwQAcwjCjFP2Myh8kCMz
Summary
Follows up the operation-level per-store TTL feature (
set('k','v', { ttl: { primary, secondary } })) by letting the same per-store override be expressed from a hook, with full backwards compatibility and thorough documentation of what happens when you override.A
BEFORE_SEThook can now assignitem.ttla per-store object so the primary and secondary stores expire at different rates:This automatically covers
set(),getOrSet(), andwrap()(they all run throughBEFORE_SET), and the same per-store object is now accepted bysetManyitems and theBEFORE_SET_MANYhook.What changed
set()resolution reworked into three branches — no override (today's behavior, byte-for-byte), scalar (applies to every store), per-store object (each field independent; an omitted field falls back to that store's normal cascade, including any operation-level per-storettl).ttlaccessor on the hook item: any assignment counts as an override, even assigning the same value — removing the old!==value-collision footgun. Leavingttluntouched keeps each store on its own cascade.item.ttlis collapsed to the effective primary number, soAFTER_SEThandlers and sync replication always observe a number, never the per-store object.setManysymmetry:setManyKeyvresolves each store's field per item;setManyKeyTagsnow uses the longest store TTL.maxStoreTtlhelper consolidates the "tag snapshot must outlive the longest-lived copy" rule used by bothset()andsetManyKeyTags.maxTtlcaps each store independently after the hook.CacheableHookItem<T>exported for opt-in hook-handler typing;CacheableSetItem.ttlwidened to acceptPerStoreTtl.hook ?? operation ?? store default ?? instance ttl, thenmaxTtl), the scalar-vs-object semantics, theAFTER_SET/sync normalization, the tag-snapshot rule, and the backfill caveat.BEFORE_SECONDARY_SETS_PRIMARYis documented as primary-only / scalar (intentionally unchanged).Backwards compatibility
The no-override path is unchanged. The one intentional behavior change (approved): a hook assigning a scalar equal to the resolved primary now counts as an override instead of a no-op — obscure, and no existing test depended on the old behavior.
Testing
test/per-store-ttl-hooks.test.ts(20 tests) covering per-store object/scalar/omitted-field/empty-object, maxTtl capping, no-secondary, tag-snapshot longevity (including the immortal-secondary case),AFTER_SETnormalization, sync publish, write-tracking, and the fullsetMany/BEFORE_SET_MANYpath.index.tsat 100% statements/lines (the only flagged branch is the pre-existingset syncsetter, untouched here).Process
The design was audited and validated by adversarial critic sub-agents (correctness/backwards-compat, API design, documentation/semantics); their findings drove the write-tracking accessor, the
setManydata-loss fix, the tag-TTL single-store guard, and the decision to keepBEFORE_SECONDARY_SETS_PRIMARYscalar-only.🤖 Generated with Claude Code
https://claude.ai/code/session_018gcwQAcwjCjFP2Myh8kCMz
Generated by Claude Code