Skip to content

feat(cacheable): support per-store TTL overrides via hooks#1657

Merged
jaredwray merged 5 commits into
mainfrom
claude/epic-fermat-pz59cj
Jun 20, 2026
Merged

feat(cacheable): support per-store TTL overrides via hooks#1657
jaredwray merged 5 commits into
mainfrom
claude/epic-fermat-pz59cj

Conversation

@jaredwray

Copy link
Copy Markdown
Owner

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_SET hook can now assign item.ttl a per-store object so the primary and secondary stores expire at different rates:

cache.onHook(CacheableHooks.BEFORE_SET, (data) => {
  data.ttl = { primary: '10s', secondary: '5m' };
});

This automatically covers set(), getOrSet(), and wrap() (they all run through BEFORE_SET), and the same per-store object is now accepted by setMany items and the BEFORE_SET_MANY hook.

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-store ttl).
  • Write-tracking ttl accessor on the hook item: any assignment counts as an override, even assigning the same value — removing the old !== value-collision footgun. Leaving ttl untouched keeps each store on its own cascade.
  • Normalization: after resolution item.ttl is collapsed to the effective primary number, so AFTER_SET handlers and sync replication always observe a number, never the per-store object.
  • Full setMany symmetry: setManyKeyv resolves each store's field per item; setManyKeyTags now uses the longest store TTL.
  • Shared maxStoreTtl helper consolidates the "tag snapshot must outlive the longest-lived copy" rule used by both set() and setManyKeyTags.
  • maxTtl caps each store independently after the hook.
  • CacheableHookItem<T> exported for opt-in hook-handler typing; CacheableSetItem.ttl widened to accept PerStoreTtl.
  • README documents the override precedence (hook ?? operation ?? store default ?? instance ttl, then maxTtl), the scalar-vs-object semantics, the AFTER_SET/sync normalization, the tag-snapshot rule, and the backfill caveat. BEFORE_SECONDARY_SETS_PRIMARY is 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

  • New 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_SET normalization, sync publish, write-tracking, and the full setMany / BEFORE_SET_MANY path.
  • Full suite green with Redis services running: 281 passed, lint clean, index.ts at 100% statements/lines (the only flagged branch is the pre-existing set sync setter, 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 setMany data-loss fix, the tag-TTL single-store guard, and the decision to keep BEFORE_SECONDARY_SETS_PRIMARY scalar-only.

🤖 Generated with Claude Code

https://claude.ai/code/session_018gcwQAcwjCjFP2Myh8kCMz


Generated by Claude Code

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

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/cacheable/src/index.ts Outdated
@codecov

codecov Bot commented Jun 17, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (51f6cea) to head (c34337d).

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread packages/cacheable/src/index.ts Outdated
Comment thread packages/cacheable/src/index.ts Outdated
claude and others added 4 commits June 17, 2026 18:06
- 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
@jaredwray jaredwray merged commit 0be0680 into main Jun 20, 2026
12 checks passed
@jaredwray jaredwray deleted the claude/epic-fermat-pz59cj branch June 20, 2026 18:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants