@leejpsd/nextjs-cache-handler 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +46 -15
  3. package/dist/cache-components/index.cjs +59 -37
  4. package/dist/cache-components/index.cjs.map +1 -1
  5. package/dist/cache-components/index.d.cts +2 -2
  6. package/dist/cache-components/index.d.ts +2 -2
  7. package/dist/cache-components/index.js +59 -37
  8. package/dist/cache-components/index.js.map +1 -1
  9. package/dist/cli/index.cjs +4 -3
  10. package/dist/cli/index.cjs.map +1 -1
  11. package/dist/cli/index.js +4 -3
  12. package/dist/cli/index.js.map +1 -1
  13. package/dist/incremental/index.cjs +4 -3
  14. package/dist/incremental/index.cjs.map +1 -1
  15. package/dist/incremental/index.d.cts +2 -2
  16. package/dist/incremental/index.d.ts +2 -2
  17. package/dist/incremental/index.js +4 -3
  18. package/dist/incremental/index.js.map +1 -1
  19. package/dist/index.cjs +63 -40
  20. package/dist/index.cjs.map +1 -1
  21. package/dist/index.d.cts +1 -1
  22. package/dist/index.d.ts +1 -1
  23. package/dist/index.js +63 -40
  24. package/dist/index.js.map +1 -1
  25. package/dist/ops/index.d.cts +1 -1
  26. package/dist/ops/index.d.ts +1 -1
  27. package/dist/otel/index.d.cts +1 -1
  28. package/dist/otel/index.d.ts +1 -1
  29. package/dist/seed/index.cjs +4 -3
  30. package/dist/seed/index.cjs.map +1 -1
  31. package/dist/seed/index.d.cts +1 -1
  32. package/dist/seed/index.d.ts +1 -1
  33. package/dist/seed/index.js +4 -3
  34. package/dist/seed/index.js.map +1 -1
  35. package/dist/shared/client/adapter-ioredis.d.cts +1 -1
  36. package/dist/shared/client/adapter-ioredis.d.ts +1 -1
  37. package/dist/shared/client/adapter-redis.d.cts +1 -1
  38. package/dist/shared/client/adapter-redis.d.ts +1 -1
  39. package/dist/{types-C1vJ_sED.d.cts → types-YbDAllyp.d.cts} +7 -1
  40. package/dist/{types-C1vJ_sED.d.ts → types-YbDAllyp.d.ts} +7 -1
  41. package/package.json +7 -5
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.2
4
+
5
+ ### Patch Changes
6
+
7
+ - [`c165563`](https://github.com/leejpsd/nextjs-cache-handler/commit/c16556385a7fb3e8e95dfddc9015dde7a887311a) Thanks [@leejpsd](https://github.com/leejpsd)! - Align `'use cache'` tag invalidation exactly with upstream semantics.
8
+
9
+ **Fixed: hard/soft polarity was inverted for `updateTags` without durations.** Next.js calls `updateTags(tags)` with NO durations for hard invalidation (`updateTag()` server actions, single-arg `revalidateTag()`) and `updateTags(tags, { expire })` for profile-based soft invalidation (`revalidateTag(tag, "max")`). The handler treated no-durations as soft, so `updateTag()` — which must guarantee read-your-own-writes — could serve stale content. No-durations is now hard.
10
+
11
+ Also in this release:
12
+ - Soft invalidations now honor their hard deadline: entries serve stale until `durations.expire` seconds after the invalidation, then miss (`Infinity` = never). Tag markers store a `"<stale>|<expired>"` pair; 0.4.1 readers `parseInt` the same marker safely, and legacy bare-number markers are treated as hard-at-timestamp, so mixed-version fleets stay correct in both directions.
13
+ - Tag-stale entries are served with the truthful timestamp and `revalidate: -1` (the built-in handler's refresh signal) instead of a backdated timestamp, which could cross the `expire` boundary on tight profiles and confused the implicit-tags discard check.
14
+ - Soft invalidations serve-while-revalidating regardless of `staleWhileRevalidate` (that option now governs time-based staleness only, matching the upstream contract).
15
+ - `getExpiration` reports only the hard deadline, mirroring the built-in handler.
16
+ - `expire: 0` entries are no longer persisted in production (mirrors Next 16.3's built-in handler; they are regenerated on every read anyway).
17
+ - Verified end-to-end against Next.js 16.3.0 stable (both interfaces; build, serve, hard/soft invalidation, ISR SWR).
18
+
19
+ ## 0.4.1
20
+
21
+ ### Patch Changes
22
+
23
+ - [`8555fbe`](https://github.com/leejpsd/nextjs-cache-handler/commit/8555fbeb10c8681f101f13157a078b0a1e3fcea5) Thanks [@leejpsd](https://github.com/leejpsd)! - Fix ISR record serialization writing Buffers as JSON byte arrays instead of base64.
24
+
25
+ `Buffer.prototype.toJSON` runs before a `JSON.stringify` replacer sees the value, so the base64 envelope branch was unreachable and every Buffer field (rendered HTML, RSC payloads, PPR segment data) was stored as `{ type: "Buffer", data: [...] }` — roughly 2.8x larger than base64. The replacer now reads the pre-`toJSON` original via `this[key]`.
26
+
27
+ No migration needed: the reviver has always accepted both shapes, so records written by either version deserialize correctly in both directions.
28
+
3
29
  ## 0.4.0
4
30
 
5
31
  ### Minor Changes
package/README.md CHANGED
@@ -3,18 +3,20 @@
3
3
  [![npm](https://img.shields.io/npm/v/@leejpsd/nextjs-cache-handler.svg)](https://www.npmjs.com/package/@leejpsd/nextjs-cache-handler)
4
4
  [![license](https://img.shields.io/npm/l/@leejpsd/nextjs-cache-handler.svg)](./LICENSE)
5
5
 
6
- > **`v0.3.0`** — install with
7
- > `npm install @leejpsd/nextjs-cache-handler`. Production-validated against
8
- > AWS ECS Fargate with multi-instance Redis (v0.1: 24h live-traffic soak;
9
- > v0.3: fresh multi-instance verification incl. a live Redis-reboot drill
10
- > with zero 5xx — see
11
- > [`docs/staging-verification-2026-08-01.md`](./docs/staging-verification-2026-08-01.md)).
6
+ > **`v0.4.2`** — install with
7
+ > `npm install @leejpsd/nextjs-cache-handler`, or wire everything with
8
+ > `npx nextjs-cache-handler init --yes`. Production-validated against
9
+ > AWS ECS Fargate with multi-instance Redis (24h live-traffic soak and a
10
+ > live Redis-reboot drill with zero 5xx — see
11
+ > [`docs/staging-verification-2026-08-01.md`](./docs/staging-verification-2026-08-01.md)),
12
+ > and exercised end-to-end against **Next.js 16.3** (both cache interfaces).
12
13
  >
13
- > v0.3 adds: **Next.js 15 support** (ISR handler), **reconnect with
14
- > exponential backoff** (no more permanent memory-only latch), **request-scoped
15
- > GET deduplication**, transparent **gzip/brotli compression**, **Redis
16
- > Sentinel** support, a built-in **OpenTelemetry emitter** at `/otel`, and an
17
- > LRU-bounded memory fallback.
14
+ > v0.4 adds: **build-output cache seeding** (first request after deploy is a
15
+ > HIT), **pub/sub tag propagation** (~3ms cross-instance), **Redis Cluster**
16
+ > e2e in CI, a **CLI** (`init` / `doctor` / `seed`), and agent-native assets
17
+ > (skill, rules, [MCP server](https://www.npmjs.com/package/@leejpsd/nextjs-cache-handler-mcp)).
18
+ > 0.4.2 aligns tag invalidation exactly with upstream semantics
19
+ > (`updateTag()` hard vs `revalidateTag(tag, "max")` soft — see CHANGELOG).
18
20
 
19
21
  ## 🤖 For AI agents
20
22
 
@@ -31,6 +33,11 @@ troubleshooting playbook ships in the package (`AGENTS.md`,
31
33
  `skills/nextjs-redis-cache/SKILL.md`) and via
32
34
  `npx skills add leejpsd/nextjs-cache-handler`.
33
35
 
36
+ For cache operations from your agent (health, tag state, safe invalidation),
37
+ the companion MCP server is on the official registry as
38
+ `io.github.leejpsd/nextjs-cache-handler-mcp` — or one command:
39
+ `npx nextjs-cache-handler init --yes` wires handlers, rules, and `.mcp.json` together.
40
+
34
41
  ---
35
42
 
36
43
  The Redis cache handler for **Next.js 15/16** that ships **both** `cacheHandler`
@@ -82,7 +89,7 @@ plural interface as ❌ **"Not yet supported - Help needed"**:
82
89
  > [`nextjs-turbo-redis-cache`](https://github.com/trieb-work/nextjs-turbo-redis-cache#features)
83
90
  > directly before relying on this comparison.
84
91
 
85
- | Feature | this (0.3.0) | @fortedigital 3.2.1 | nextjs-turbo-redis-cache 1.15 |
92
+ | Feature | this (0.4.x) | @fortedigital 3.2.1 | nextjs-turbo-redis-cache 1.15 |
86
93
  |---|---|---|---|
87
94
  | `cacheHandlers` config (plural) | ✅ | ❌ Help needed | ✅ since 1.11 |
88
95
  | `'use cache'` directive | ✅ | ❌ Help needed | ✅ since 1.11 |
@@ -367,8 +374,25 @@ eventual implementation should converge on the same behavior.
367
374
 
368
375
  ---
369
376
 
377
+ ## Migrating from another cache handler
378
+
379
+ Coming from `@neshca/cache-handler` (or its forks like
380
+ `@jadkins89/next-cache-handler`)? There's a dedicated guide with a full
381
+ API/concept mapping, config diff, and a seeding replacement:
382
+ [docs/migrating-from-neshca.md](docs/migrating-from-neshca.md).
383
+
384
+ ---
385
+
370
386
  ## Compatibility
371
387
 
388
+ > **Upgrading to Next.js 16.3?** Two behavioral notes: (1) 16.3 changed the
389
+ > internal fetch-cache key format (`v3` → `v4`), so 16.2-era fetch entries
390
+ > become unreadable orphans — deploy with a new `DEPLOYMENT_VERSION` and let
391
+ > the old namespace age out via TTL. (2) An ISR entry found past its
392
+ > `cacheLife` **expire** now triggers a blocking revalidation instead of
393
+ > serving stale (matching `'use cache'` semantics); within
394
+ > `revalidate..expire` stale-while-revalidate is unchanged.
395
+
372
396
  - **Next.js**: `>=15.0.0 <17`
373
397
  - `cacheHandler` (singular, ISR): Next **15 and 16** — the handler accepts
374
398
  both ctx shapes (Next 15's `ctx.revalidate` / `kindHint`, Next 16's
@@ -399,9 +423,16 @@ ESM and CJS dual-published, full TypeScript types, validated via
399
423
  permanent connect-failure latch), request-scoped GET deduplication,
400
424
  gzip/brotli compression, Redis Sentinel, built-in OpenTelemetry emitter
401
425
  (`/otel`), LRU-bounded memory fallback, ESM peer-loading fixes ✅
402
- - **v0.4.0** — Build-phase cache seeding (prepopulate prebuilt pages),
403
- pub/sub-based tag propagation, Redis Cluster e2e validation, Vercel KV /
404
- Upstash adapter, `'use cache: remote'` multi-tier setup
426
+ - **v0.4.0** *(2026-08)* — Build-output cache seeding (`seed` CLI, NX-safe),
427
+ pub/sub tag propagation, Redis Cluster e2e in CI, `init`/`doctor` CLI,
428
+ agent assets (skill, rules, MCP server) ✅
429
+ - *0.4.1* — Buffer serialization fix (~2.8x smaller ISR payloads) ✅
430
+ - *0.4.2* — upstream-exact tag invalidation semantics: no-durations
431
+ `updateTags` is hard (`updateTag()` read-your-own-writes), profile
432
+ durations are soft with a real hard deadline; tag-stale entries are
433
+ served with `revalidate: -1` instead of a backdated timestamp ✅
434
+ - **v0.5** — Vercel KV / Upstash adapter, `'use cache: remote'` multi-tier
435
+ setup, `neshClassicCache` equivalent
405
436
 
406
437
  ---
407
438
 
@@ -525,7 +525,7 @@ return 1
525
525
  `;
526
526
 
527
527
  // src/shared/lua/revalidate-hard.lua
528
- var revalidate_hard_default = "-- revalidate-hard.lua\n--\n-- Atomically remove every entry referenced by a tag and stamp the tag's\n-- expiration marker. Used by `updateTags(tags, { expire: 0 })` \u2014 the hard\n-- invalidation path triggered by `updateTag()` server actions in Next.js 16.\n--\n-- KEYS[1] : tag set key (e.g. \"next-cache:tag:posts\")\n-- KEYS[2] : tag-expiration marker key (e.g. \"next-cache:tag-expiration:posts\")\n-- ARGV[1] : current timestamp in milliseconds\n-- ARGV[2] : marker TTL in seconds (default: 1 day so cross-deployment\n-- readers can still observe the invalidation)\n--\n-- Returns: number of entries that were deleted.\n\nlocal tagKey = KEYS[1]\nlocal markerKey = KEYS[2]\nlocal nowMs = ARGV[1]\nlocal markerTtl = tonumber(ARGV[2])\n\nlocal entries = redis.call('SMEMBERS', tagKey)\nlocal deleted = 0\n\nif #entries > 0 then\n -- DEL accepts varargs; unpack the entry list.\n redis.call('DEL', unpack(entries))\n deleted = #entries\nend\n\n-- Drop the tag set itself; on next SET it'll be recreated by set-with-tags.\nredis.call('DEL', tagKey)\n\n-- Stamp the invalidation timestamp. Readers compare this against an entry's\n-- timestamp inside `getExpiration` to decide whether soft-tag-only entries\n-- should be considered stale.\nredis.call('SET', markerKey, nowMs, 'EX', markerTtl)\n\nreturn deleted\n";
528
+ var revalidate_hard_default = "-- revalidate-hard.lua\n--\n-- Atomically remove every entry referenced by a tag and stamp the tag's\n-- expiration marker. Used by the hard invalidation path \u2014 `updateTags(tags)`\n-- with no durations (updateTag(), single-arg revalidateTag()) or an explicit\n-- `{ expire: 0 }`.\n--\n-- KEYS[1] : tag set key (e.g. \"next-cache:tag:posts\")\n-- KEYS[2] : tag-expiration marker key (e.g. \"next-cache:tag-expiration:posts\")\n-- ARGV[1] : marker value \"<staleMs>|<expiredMs>\" (hard: both equal)\n-- ARGV[2] : marker TTL in seconds (default: 1 day so cross-deployment\n-- readers can still observe the invalidation)\n--\n-- Returns: number of entries that were deleted.\n\nlocal tagKey = KEYS[1]\nlocal markerKey = KEYS[2]\nlocal marker = ARGV[1]\nlocal markerTtl = tonumber(ARGV[2])\n\nlocal entries = redis.call('SMEMBERS', tagKey)\nlocal deleted = 0\n\nif #entries > 0 then\n -- DEL accepts varargs; unpack the entry list.\n redis.call('DEL', unpack(entries))\n deleted = #entries\nend\n\n-- Drop the tag set itself; on next SET it'll be recreated by set-with-tags.\nredis.call('DEL', tagKey)\n\n-- Stamp the invalidation timestamp. Readers compare this against an entry's\n-- timestamp inside `getExpiration` to decide whether soft-tag-only entries\n-- should be considered stale.\nredis.call('SET', markerKey, marker, 'EX', markerTtl)\n\nreturn deleted\n";
529
529
 
530
530
  // src/shared/lua/refresh-tag-lock.lua
531
531
  var refresh_tag_lock_default = `-- refresh-tag-lock.lua
@@ -869,6 +869,23 @@ var DEFAULT_LOCK_TTL_SEC = 10;
869
869
  var ONE_YEAR_SEC = 60 * 60 * 24 * 365;
870
870
  var FALLBACK_TTL_SEC = 3600;
871
871
  var TAG_EXP_MARKER_TTL_SEC = 60 * 60 * 24 * 7;
872
+ function encodeTagMarker(state) {
873
+ return `${state.s}|${state.e === Infinity ? "inf" : state.e}`;
874
+ }
875
+ function decodeTagMarker(raw) {
876
+ const s = Number.parseInt(raw, 10);
877
+ if (!Number.isFinite(s)) return null;
878
+ const sep = raw.indexOf("|");
879
+ if (sep === -1) return { s, e: s };
880
+ const tail = raw.slice(sep + 1);
881
+ if (tail === "inf") return { s, e: Infinity };
882
+ const e = Number.parseInt(tail, 10);
883
+ return { s, e: Number.isFinite(e) ? e : s };
884
+ }
885
+ function mergeTagState(map, tag, next) {
886
+ const prev = map.get(tag);
887
+ if (!prev || next.s > prev.s) map.set(tag, next);
888
+ }
872
889
  function gateRedis(state) {
873
890
  const fb = state.opts.fallback;
874
891
  const ibp = state.opts.rest.isBuildPhase;
@@ -1053,21 +1070,17 @@ async function getImpl(state, cacheKey, softTags) {
1053
1070
  const freshnessTags = softTags.length > 0 ? [...softTags, ...envelope.tags] : envelope.tags;
1054
1071
  let tagStale = false;
1055
1072
  if (freshnessTags.length > 0) {
1056
- let mostRecent = 0;
1073
+ const now = Date.now();
1057
1074
  for (const t of freshnessTags) {
1058
- const ts = Math.max(
1059
- state.localTagTimestamps.get(t) ?? 0,
1060
- state.memTagExp.get(t) ?? 0
1061
- );
1062
- if (ts > mostRecent) mostRecent = ts;
1063
- }
1064
- if (mostRecent > envelope.timestamp) {
1065
- if (state.opts.staleWhileRevalidate && partition.freshness !== "expired") {
1066
- tagStale = true;
1067
- } else {
1075
+ const local = state.localTagTimestamps.get(t);
1076
+ const mem = state.memTagExp.get(t);
1077
+ const st = local && mem ? local.s >= mem.s ? local : mem : local ?? mem;
1078
+ if (!st || st.s <= envelope.timestamp) continue;
1079
+ if (now >= st.e || partition.freshness === "expired") {
1068
1080
  state.emit({ type: "cache.miss", meta: { reason: "tag-invalidated" } });
1069
1081
  return void 0;
1070
1082
  }
1083
+ tagStale = true;
1071
1084
  }
1072
1085
  }
1073
1086
  const freshness = tagStale ? "stale" : partition.freshness;
@@ -1094,15 +1107,13 @@ async function getImpl(state, cacheKey, softTags) {
1094
1107
  meta: { freshness }
1095
1108
  });
1096
1109
  }
1097
- const revalidateMs = Math.max(0, envelope.revalidate) * 1e3;
1098
- const outTimestamp = tagStale ? Math.min(envelope.timestamp, Date.now() - revalidateMs - 1) : envelope.timestamp;
1099
1110
  return {
1100
1111
  value: bufferToStream(Buffer.from(envelope.value, "base64")),
1101
1112
  tags: envelope.tags,
1102
1113
  stale: envelope.stale,
1103
- timestamp: outTimestamp,
1114
+ timestamp: envelope.timestamp,
1104
1115
  expire: envelope.expire,
1105
- revalidate: envelope.revalidate
1116
+ revalidate: tagStale ? -1 : envelope.revalidate
1106
1117
  };
1107
1118
  }
1108
1119
  async function setImpl(state, cacheKey, pendingEntry) {
@@ -1116,6 +1127,10 @@ async function setImpl(state, cacheKey, pendingEntry) {
1116
1127
  state.emit({ type: "cache.set.failed", meta: { reason: "pending-rejected" } });
1117
1128
  return;
1118
1129
  }
1130
+ if (entry.expire === 0 && !process.env.__NEXT_DEV_SERVER) {
1131
+ state.emit({ type: "cache.set.skipped", meta: { reason: "dynamic-entry" } });
1132
+ return;
1133
+ }
1119
1134
  let buf;
1120
1135
  try {
1121
1136
  buf = await readStreamFully(entry.value);
@@ -1221,11 +1236,13 @@ async function ensureTagSubscription(state) {
1221
1236
  try {
1222
1237
  const parsed = JSON.parse(message);
1223
1238
  if (!Array.isArray(parsed.t) || typeof parsed.ts !== "number") return;
1239
+ const incoming = {
1240
+ s: parsed.ts,
1241
+ e: typeof parsed.e === "number" ? parsed.e : Infinity
1242
+ };
1224
1243
  for (const tag of parsed.t) {
1225
- const prev = state.localTagTimestamps.get(tag) ?? 0;
1226
- if (parsed.ts > prev) state.localTagTimestamps.set(tag, parsed.ts);
1227
- const prevMem = state.memTagExp.get(tag) ?? 0;
1228
- if (parsed.ts > prevMem) state.memTagExp.set(tag, parsed.ts);
1244
+ mergeTagState(state.localTagTimestamps, tag, incoming);
1245
+ mergeTagState(state.memTagExp, tag, incoming);
1229
1246
  }
1230
1247
  pruneOldestByTimestamp(state.localTagTimestamps, MAX_LOCAL_TAG_ENTRIES);
1231
1248
  pruneOldestByTimestamp(state.memTagExp, MAX_LOCAL_TAG_ENTRIES);
@@ -1277,17 +1294,17 @@ async function refreshTagsImpl(state) {
1277
1294
  for (let i = 0; i < keys.length; i += 1) {
1278
1295
  const v = values[i];
1279
1296
  if (!v) continue;
1280
- const ts = Number.parseInt(v, 10);
1281
- if (!Number.isFinite(ts)) continue;
1297
+ const st = decodeTagMarker(v);
1298
+ if (!st) continue;
1282
1299
  const key = keys[i];
1283
1300
  if (key === void 0) continue;
1284
1301
  const tag = stripExpPrefix(state, key);
1285
- if (tag) fresh.set(tag, ts);
1302
+ if (tag) fresh.set(tag, st);
1286
1303
  }
1287
1304
  }
1288
- for (const [tag, ts] of state.localTagTimestamps) {
1289
- if (ts >= scanStartMs && (fresh.get(tag) ?? 0) < ts) {
1290
- fresh.set(tag, ts);
1305
+ for (const [tag, st] of state.localTagTimestamps) {
1306
+ if (st.s >= scanStartMs && (fresh.get(tag)?.s ?? 0) < st.s) {
1307
+ fresh.set(tag, st);
1291
1308
  }
1292
1309
  }
1293
1310
  state.localTagTimestamps = fresh;
@@ -1305,7 +1322,7 @@ function escapeMatch(s) {
1305
1322
  var MAX_LOCAL_TAG_ENTRIES = 1e4;
1306
1323
  function pruneOldestByTimestamp(map, maxEntries) {
1307
1324
  if (map.size <= maxEntries) return;
1308
- const byAge = [...map.entries()].sort((a, b) => a[1] - b[1]);
1325
+ const byAge = [...map.entries()].sort((a, b) => a[1].s - b[1].s);
1309
1326
  const drop = map.size - maxEntries;
1310
1327
  for (let i = 0; i < drop; i += 1) {
1311
1328
  const entry = byAge[i];
@@ -1322,9 +1339,9 @@ async function getExpirationImpl(state, tags) {
1322
1339
  let max = 0;
1323
1340
  for (const t of tags) {
1324
1341
  const local = state.localTagTimestamps.get(t);
1325
- if (local !== void 0 && local > max) max = local;
1342
+ if (local && Number.isFinite(local.e) && local.e > max) max = local.e;
1326
1343
  const mem = state.memTagExp.get(t);
1327
- if (mem !== void 0 && mem > max) max = mem;
1344
+ if (mem && Number.isFinite(mem.e) && mem.e > max) max = mem.e;
1328
1345
  }
1329
1346
  if (max > 0) {
1330
1347
  state.emit({ type: "tag.expiration.read", meta: { source: "local", count: tags.length } });
@@ -1345,8 +1362,8 @@ async function getExpirationImpl(state, tags) {
1345
1362
  let m = 0;
1346
1363
  for (const v of values) {
1347
1364
  if (!v) continue;
1348
- const n = Number.parseInt(v, 10);
1349
- if (Number.isFinite(n) && n > m) m = n;
1365
+ const st = decodeTagMarker(v);
1366
+ if (st && Number.isFinite(st.e) && st.e > m) m = st.e;
1350
1367
  }
1351
1368
  return m;
1352
1369
  }
@@ -1357,11 +1374,14 @@ async function getExpirationImpl(state, tags) {
1357
1374
  }
1358
1375
  async function updateTagsImpl(state, tags, durations) {
1359
1376
  if (tags.length === 0) return;
1360
- const isHardExpire = durations?.expire === 0;
1377
+ const isHardExpire = durations === void 0 || durations.expire === 0;
1361
1378
  const now = Date.now();
1379
+ const deadline = isHardExpire ? now : durations.expire !== void 0 && Number.isFinite(durations.expire) ? now + durations.expire * 1e3 : Infinity;
1380
+ const tagState = { s: now, e: deadline };
1381
+ const marker = encodeTagMarker(tagState);
1362
1382
  for (const t of tags) {
1363
- state.localTagTimestamps.set(t, now);
1364
- state.memTagExp.set(t, now);
1383
+ mergeTagState(state.localTagTimestamps, t, tagState);
1384
+ mergeTagState(state.memTagExp, t, tagState);
1365
1385
  }
1366
1386
  pruneOldestByTimestamp(state.memTagExp, MAX_LOCAL_TAG_ENTRIES);
1367
1387
  if (!gateRedis(state)) {
@@ -1399,8 +1419,8 @@ async function updateTagsImpl(state, tags, durations) {
1399
1419
  client,
1400
1420
  "revalidateHard",
1401
1421
  [tagKey(state, t), tagExpKey(state, t)],
1402
- [String(now), String(TAG_EXP_MARKER_TTL_SEC)]
1403
- ) : client.set(tagExpKey(state, t), String(now), {
1422
+ [marker, String(TAG_EXP_MARKER_TTL_SEC)]
1423
+ ) : client.set(tagExpKey(state, t), marker, {
1404
1424
  EX: TAG_EXP_MARKER_TTL_SEC
1405
1425
  })
1406
1426
  )
@@ -1416,7 +1436,9 @@ async function updateTagsImpl(state, tags, durations) {
1416
1436
  const client = await state.conn.getOrConnect();
1417
1437
  await client?.publish?.(
1418
1438
  invalChannel(state),
1419
- JSON.stringify({ t: tags, ts: now })
1439
+ JSON.stringify(
1440
+ deadline === Infinity ? { t: tags, ts: now } : { t: tags, ts: now, e: deadline }
1441
+ )
1420
1442
  );
1421
1443
  } catch {
1422
1444
  }