@prezly/theme-kit-nextjs 10.10.1 → 10.11.0

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.
package/README.md CHANGED
@@ -416,6 +416,42 @@ subject to their configured expiration; do not flush a Redis instance that also
416
416
  contains routing data. TTL and query canonicalization are separate policies and
417
417
  are unchanged here.
418
418
 
419
+ ### Memory layer refill and bounds
420
+
421
+ With both `memory` and `redis` enabled the layers form a stack. A hit found in
422
+ Redis is written back into the in-process layer with the entry's own version
423
+ and retention, so the next request on that runtime is served from memory
424
+ without a Redis round trip or a JSON decode. Before this, a memory entry lost to
425
+ a version change was never refilled and every later read on that pod went to
426
+ Redis. Stale entries are never copied: only a value that passed the version
427
+ check is written back, and a not-found entry keeps its short retention. Only
428
+ layers that implement the optional `lookup` method are refill sources; a
429
+ third-party `Cache` that implements `get` alone is read as before and never
430
+ copied from.
431
+
432
+ The in-process store is one `Map` per runtime shared by every adapter and
433
+ namespace, bounded to 128 MiB of estimated payload and 20,000 entries by
434
+ default. It evicts the least recently used entry first, deterministically and
435
+ without sorting; a read moves the entry to the most recent position. Pass
436
+ `memory: { maxBytes, maxRecords }` to `PrezlyAdapter.connect` to change the
437
+ bounds; both must be positive integers. `ContentDelivery.inspectSharedMemoryCache()`
438
+ reports the current occupancy.
439
+
440
+ Redis renewal changed at the same time. Regular content keeps a sliding
441
+ expiry, but instead of an `EXPIRE` on every read, an entry is rewritten with a
442
+ fresh timestamp only once it has consumed half of its lifetime, so a hot key
443
+ costs one write per half-life instead of one per read. The rewrite runs as a
444
+ small Lua script that compares the stored payload with the one that was read,
445
+ so a renewal landing after another pod refreshed the key never puts the older
446
+ payload back. Entries written by older releases carry no timestamp and are
447
+ renewed on their first read. The renewal still reports as the `expire` command
448
+ in telemetry. Not-found entries are never renewed.
449
+
450
+ The in-process store also refuses two kinds of write: an entry older than the
451
+ one it already holds for that key (a refill that lost a race with an origin
452
+ fetch) and an entry larger than `maxBytes` on its own, which would otherwise
453
+ evict every other tenant and still not fit.
454
+
419
455
  ### Not-found results
420
456
 
421
457
  `contentDelivery.story()` and `contentDelivery.gallery()` resolve `null` when the
@@ -26,7 +26,7 @@ function configure(config) {
26
26
  return undefined;
27
27
  }
28
28
  function configureStorage(config) {
29
- var caches = [config.memory ? _themeKitCore.ContentDelivery.createSharedMemoryCache() : undefined, config.redis ? (0, _redis.createRedisCache)(_objectSpread(_objectSpread({
29
+ var caches = [config.memory ? _themeKitCore.ContentDelivery.createSharedMemoryCache('', typeof config.memory === 'object' ? config.memory : undefined) : undefined, config.redis ? (0, _redis.createRedisCache)(_objectSpread(_objectSpread({
30
30
  ttl: DEFAULT_REDIS_CACHE_TTL
31
31
  }, config.redis), {}, {
32
32
  telemetry: config.telemetry
@@ -5,7 +5,12 @@ export interface Configuration {
5
5
  prefix?: string;
6
6
  ttl?: number;
7
7
  };
8
- memory?: boolean;
8
+ /**
9
+ * In-process cache in front of Redis. `true` uses the default bounds
10
+ * (128 MiB of estimated payload, 20,000 entries); pass an object to change
11
+ * them. The store is shared by every adapter in the runtime.
12
+ */
13
+ memory?: boolean | ContentDelivery.MemoryCacheOptions;
9
14
  latestVersion: Resolvable<number>;
10
15
  namespace?: string;
11
16
  /** Extra immutable identity for custom fetch implementations. Enables request sharing. */
@@ -20,7 +20,7 @@ export function configure(config) {
20
20
  return undefined;
21
21
  }
22
22
  function configureStorage(config) {
23
- var caches = [config.memory ? ContentDelivery.createSharedMemoryCache() : undefined, config.redis ? createRedisCache(_objectSpread(_objectSpread({
23
+ var caches = [config.memory ? ContentDelivery.createSharedMemoryCache('', typeof config.memory === 'object' ? config.memory : undefined) : undefined, config.redis ? createRedisCache(_objectSpread(_objectSpread({
24
24
  ttl: DEFAULT_REDIS_CACHE_TTL
25
25
  }, config.redis), {}, {
26
26
  telemetry: config.telemetry
@@ -19,6 +19,13 @@ function _objectWithoutPropertiesLoose(r, e) { if (null == r) return {}; var t =
19
19
  function asyncGeneratorStep(n, t, e, r, o, a, c) { try { var i = n[a](c), u = i.value; } catch (n) { return void e(n); } i.done ? t(u) : Promise.resolve(u).then(r, o); }
20
20
  function _asyncToGenerator(n) { return function () { var t = this, e = arguments; return new Promise(function (r, o) { var a = n.apply(t, e); function _next(n) { asyncGeneratorStep(a, r, o, _next, _throw, "next", n); } function _throw(n) { asyncGeneratorStep(a, r, o, _next, _throw, "throw", n); } _next(void 0); }); }; }
21
21
  var COMMAND_TIMEOUT = 1000;
22
+ /**
23
+ * Renews an entry only if it is still the one that was read, so a renewal
24
+ * that lands after a newer write (another pod refreshing the key from origin)
25
+ * never puts the older payload back. EXPIRE had no such race; a plain SET has.
26
+ */
27
+ var RENEW_IF_UNCHANGED = "\nif redis.call('GET', KEYS[1]) == ARGV[1] then\n return redis.call('SET', KEYS[1], ARGV[2], 'EX', ARGV[3])\nend\nreturn nil\n";
28
+ var now = () => Math.floor(Date.now() / 1000);
22
29
  var CONNECTIONS = new Map();
23
30
  function command(_x, _x2) {
24
31
  return _command.apply(this, arguments);
@@ -75,29 +82,72 @@ function createRedisCache(_ref) {
75
82
  var connection = client;
76
83
  function createCache() {
77
84
  var namespacePrefix = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : '';
85
+ function read(_x3, _x4) {
86
+ return _read.apply(this, arguments);
87
+ }
88
+ function _read() {
89
+ _read = _asyncToGenerator(function* (key, latestVersion) {
90
+ if (!connection.isReady) {
91
+ _themeKitCore.ContentDelivery.emit(telemetry, {
92
+ type: 'redis_unavailable',
93
+ command: 'get'
94
+ });
95
+ return undefined;
96
+ }
97
+ var fullKey = "".concat(namespacePrefix).concat(key);
98
+ var cached = yield command(() => connection.get(fullKey), telemetry, 'get');
99
+ if (!cached) return undefined;
100
+ var entry = JSON.parse(cached);
101
+ if (entry.version < latestVersion) return undefined;
102
+ // Sliding expiry applies to regular content only: an entry with its
103
+ // own short retention must not be renewed to the default lifetime.
104
+ // Renewal used to be an EXPIRE on every read, doubling the command
105
+ // rate. Now an entry is rewritten with a fresh timestamp only once it
106
+ // has consumed half of its lifetime, so a hot key costs one write per
107
+ // half-life instead of one per read. Entries from older releases
108
+ // carry no timestamp and are renewed on their first read. A timestamp
109
+ // in the future (clock skew) is treated as due as well.
110
+ if (ttl && entry.ttl === undefined) {
111
+ var time = now();
112
+ if (entry.written === undefined || entry.written > time || time - entry.written >= ttl / 2) {
113
+ var renewed = _objectSpread(_objectSpread({}, entry), {}, {
114
+ written: time
115
+ });
116
+ void command(() => connection.eval(RENEW_IF_UNCHANGED, {
117
+ keys: [fullKey],
118
+ arguments: [cached, JSON.stringify(renewed), String(ttl)]
119
+ }), telemetry, 'expire').catch(() => undefined);
120
+ }
121
+ }
122
+ return entry;
123
+ });
124
+ return _read.apply(this, arguments);
125
+ }
78
126
  return {
79
127
  get(key, latestVersion, onSource) {
80
128
  return _asyncToGenerator(function* () {
81
- if (!connection.isReady) {
82
- _themeKitCore.ContentDelivery.emit(telemetry, {
83
- type: 'redis_unavailable',
84
- command: 'get'
85
- });
86
- return undefined;
87
- }
88
- var cached = yield command(() => connection.get("".concat(namespacePrefix).concat(key)), telemetry, 'get');
89
- if (!cached) return undefined;
90
- var entry = JSON.parse(cached);
91
- if (entry.version < latestVersion) return undefined;
92
- // Sliding expiry applies to regular content only. An entry with its
93
- // own short retention must not be renewed to the default lifetime.
94
- if (ttl && entry.ttl === undefined) {
95
- void command(() => connection.expire("".concat(namespacePrefix).concat(key), ttl), telemetry, 'expire').catch(() => undefined);
96
- }
129
+ var entry = yield read(key, latestVersion);
130
+ if (!entry) return undefined;
97
131
  if (onSource) _themeKitCore.ContentDelivery.notify(onSource, 'redis');
98
132
  return entry.value;
99
133
  })();
100
134
  },
135
+ lookup(key, latestVersion) {
136
+ return _asyncToGenerator(function* () {
137
+ var entry = yield read(key, latestVersion);
138
+ if (!entry) return undefined;
139
+ // Report what is left of a short retention, so a refilled copy
140
+ // does not outlive this one. Legacy entries have no timestamp
141
+ // and report their original retention.
142
+ var age = entry.written === undefined ? 0 : Math.max(0, now() - entry.written);
143
+ return {
144
+ value: entry.value,
145
+ version: entry.version,
146
+ ttl: entry.ttl === undefined ? undefined : Math.max(1, entry.ttl - age),
147
+ layer: 'redis'
148
+ };
149
+ })();
150
+ },
101
151
  set(key, value, version, options) {
102
152
  return _asyncToGenerator(function* () {
103
153
  if (!connection.isReady) {
@@ -110,11 +160,13 @@ function createRedisCache(_ref) {
110
160
  // SET EX takes whole seconds; round a fractional hint up, never down.
111
161
  var entry = (options === null || options === void 0 ? void 0 : options.ttl) === undefined ? {
112
162
  value,
113
- version
163
+ version,
164
+ written: now()
114
165
  } : {
115
166
  value,
116
167
  version,
117
- ttl: Math.max(1, Math.ceil(options.ttl))
168
+ ttl: Math.max(1, Math.ceil(options.ttl)),
169
+ written: now()
118
170
  };
119
171
  yield command(() => {
120
172
  var _entry$ttl;
@@ -12,6 +12,13 @@ import { ContentDelivery } from '@prezly/theme-kit-core';
12
12
  import stableStringify from 'json-stable-stringify';
13
13
  import { createClient } from 'redis';
14
14
  var COMMAND_TIMEOUT = 1000;
15
+ /**
16
+ * Renews an entry only if it is still the one that was read, so a renewal
17
+ * that lands after a newer write (another pod refreshing the key from origin)
18
+ * never puts the older payload back. EXPIRE had no such race; a plain SET has.
19
+ */
20
+ var RENEW_IF_UNCHANGED = "\nif redis.call('GET', KEYS[1]) == ARGV[1] then\n return redis.call('SET', KEYS[1], ARGV[2], 'EX', ARGV[3])\nend\nreturn nil\n";
21
+ var now = () => Math.floor(Date.now() / 1000);
15
22
  var CONNECTIONS = new Map();
16
23
  function command(_x, _x2) {
17
24
  return _command.apply(this, arguments);
@@ -68,29 +75,72 @@ export function createRedisCache(_ref) {
68
75
  var connection = client;
69
76
  function createCache() {
70
77
  var namespacePrefix = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : '';
78
+ function read(_x3, _x4) {
79
+ return _read.apply(this, arguments);
80
+ }
81
+ function _read() {
82
+ _read = _asyncToGenerator(function* (key, latestVersion) {
83
+ if (!connection.isReady) {
84
+ ContentDelivery.emit(telemetry, {
85
+ type: 'redis_unavailable',
86
+ command: 'get'
87
+ });
88
+ return undefined;
89
+ }
90
+ var fullKey = "".concat(namespacePrefix).concat(key);
91
+ var cached = yield command(() => connection.get(fullKey), telemetry, 'get');
92
+ if (!cached) return undefined;
93
+ var entry = JSON.parse(cached);
94
+ if (entry.version < latestVersion) return undefined;
95
+ // Sliding expiry applies to regular content only: an entry with its
96
+ // own short retention must not be renewed to the default lifetime.
97
+ // Renewal used to be an EXPIRE on every read, doubling the command
98
+ // rate. Now an entry is rewritten with a fresh timestamp only once it
99
+ // has consumed half of its lifetime, so a hot key costs one write per
100
+ // half-life instead of one per read. Entries from older releases
101
+ // carry no timestamp and are renewed on their first read. A timestamp
102
+ // in the future (clock skew) is treated as due as well.
103
+ if (ttl && entry.ttl === undefined) {
104
+ var time = now();
105
+ if (entry.written === undefined || entry.written > time || time - entry.written >= ttl / 2) {
106
+ var renewed = _objectSpread(_objectSpread({}, entry), {}, {
107
+ written: time
108
+ });
109
+ void command(() => connection.eval(RENEW_IF_UNCHANGED, {
110
+ keys: [fullKey],
111
+ arguments: [cached, JSON.stringify(renewed), String(ttl)]
112
+ }), telemetry, 'expire').catch(() => undefined);
113
+ }
114
+ }
115
+ return entry;
116
+ });
117
+ return _read.apply(this, arguments);
118
+ }
71
119
  return {
72
120
  get(key, latestVersion, onSource) {
73
121
  return _asyncToGenerator(function* () {
74
- if (!connection.isReady) {
75
- ContentDelivery.emit(telemetry, {
76
- type: 'redis_unavailable',
77
- command: 'get'
78
- });
79
- return undefined;
80
- }
81
- var cached = yield command(() => connection.get("".concat(namespacePrefix).concat(key)), telemetry, 'get');
82
- if (!cached) return undefined;
83
- var entry = JSON.parse(cached);
84
- if (entry.version < latestVersion) return undefined;
85
- // Sliding expiry applies to regular content only. An entry with its
86
- // own short retention must not be renewed to the default lifetime.
87
- if (ttl && entry.ttl === undefined) {
88
- void command(() => connection.expire("".concat(namespacePrefix).concat(key), ttl), telemetry, 'expire').catch(() => undefined);
89
- }
122
+ var entry = yield read(key, latestVersion);
123
+ if (!entry) return undefined;
90
124
  if (onSource) ContentDelivery.notify(onSource, 'redis');
91
125
  return entry.value;
92
126
  })();
93
127
  },
128
+ lookup(key, latestVersion) {
129
+ return _asyncToGenerator(function* () {
130
+ var entry = yield read(key, latestVersion);
131
+ if (!entry) return undefined;
132
+ // Report what is left of a short retention, so a refilled copy
133
+ // does not outlive this one. Legacy entries have no timestamp
134
+ // and report their original retention.
135
+ var age = entry.written === undefined ? 0 : Math.max(0, now() - entry.written);
136
+ return {
137
+ value: entry.value,
138
+ version: entry.version,
139
+ ttl: entry.ttl === undefined ? undefined : Math.max(1, entry.ttl - age),
140
+ layer: 'redis'
141
+ };
142
+ })();
143
+ },
94
144
  set(key, value, version, options) {
95
145
  return _asyncToGenerator(function* () {
96
146
  if (!connection.isReady) {
@@ -103,11 +153,13 @@ export function createRedisCache(_ref) {
103
153
  // SET EX takes whole seconds; round a fractional hint up, never down.
104
154
  var entry = (options === null || options === void 0 ? void 0 : options.ttl) === undefined ? {
105
155
  value,
106
- version
156
+ version,
157
+ written: now()
107
158
  } : {
108
159
  value,
109
160
  version,
110
- ttl: Math.max(1, Math.ceil(options.ttl))
161
+ ttl: Math.max(1, Math.ceil(options.ttl)),
162
+ written: now()
111
163
  };
112
164
  yield command(() => {
113
165
  var _entry$ttl;
@@ -28,6 +28,9 @@ function connection() {
28
28
  set: jest.fn(/*#__PURE__*/_asyncToGenerator(function* () {
29
29
  return 'OK';
30
30
  })),
31
+ eval: jest.fn(/*#__PURE__*/_asyncToGenerator(function* () {
32
+ return 'OK';
33
+ })),
31
34
  expire: jest.fn(/*#__PURE__*/_asyncToGenerator(function* () {
32
35
  return true;
33
36
  }))
@@ -66,34 +69,136 @@ it('fails open while disconnected without waiting for connection establishment',
66
69
  client.isReady = true;
67
70
  expect(yield cache.get('key', 1)).toBe('cached');
68
71
  }));
69
- it('preserves namespace, version and sliding expiry and observes expiry rejection', /*#__PURE__*/_asyncToGenerator(function* () {
72
+ it('preserves namespace and version, renews legacy entries in place, and observes renewal rejection', /*#__PURE__*/_asyncToGenerator(function* () {
73
+ jest.useFakeTimers({
74
+ now: 1700000000000
75
+ });
70
76
  var _connection2 = connection(),
71
77
  client = _connection2.client,
72
78
  cache = _connection2.cache;
73
- client.expire.mockRejectedValueOnce(new Error('expiry failed'));
79
+ // A legacy entry (no `written`) is renewed on first read even when the write fails.
80
+ client.eval.mockRejectedValueOnce(new Error('renewal failed'));
74
81
  expect(yield cache.namespace('room').get('key', 2)).toBe('cached');
75
82
  expect(client.get).toHaveBeenLastCalledWith('test:room:key');
83
+ expect(client.eval).toHaveBeenCalledTimes(1);
84
+ expect(client.eval).toHaveBeenLastCalledWith(expect.stringContaining("redis.call('GET', KEYS[1]) == ARGV[1]"), {
85
+ keys: ['test:room:key'],
86
+ arguments: [JSON.stringify({
87
+ version: 2,
88
+ value: 'cached'
89
+ }), JSON.stringify({
90
+ version: 2,
91
+ value: 'cached',
92
+ written: 1700000000
93
+ }), '300']
94
+ });
95
+ expect(client.set).not.toHaveBeenCalled();
96
+ expect(client.expire).not.toHaveBeenCalled();
76
97
  expect(yield cache.get('key', 3)).toBeUndefined();
77
- expect(client.expire).toHaveBeenCalledTimes(1);
98
+ expect(client.eval).toHaveBeenCalledTimes(1);
78
99
  yield cache.set('key', 'updated', 3);
79
100
  expect(client.set).toHaveBeenLastCalledWith('test:key', JSON.stringify({
80
101
  value: 'updated',
81
- version: 3
102
+ version: 3,
103
+ written: 1700000000
82
104
  }), {
83
105
  EX: 300
84
106
  });
85
107
  }));
86
- it('stores a short-lived entry with its own expiry and never renews it', /*#__PURE__*/_asyncToGenerator(function* () {
108
+ it('renews a regular entry only once it has consumed half of its lifetime, or when its timestamp is in the future', /*#__PURE__*/_asyncToGenerator(function* () {
109
+ jest.useFakeTimers({
110
+ now: 1700000000000
111
+ });
87
112
  var _connection3 = connection(),
88
113
  client = _connection3.client,
89
114
  cache = _connection3.cache;
115
+ var entry = {
116
+ value: 'cached',
117
+ version: 2,
118
+ written: 1700000000
119
+ };
120
+ client.get.mockResolvedValue(JSON.stringify(entry));
121
+ expect(yield cache.get('key', 2)).toBe('cached');
122
+ jest.setSystemTime(1700000149000); // 149s later, under half of the 300s ttl
123
+ expect(yield cache.get('key', 2)).toBe('cached');
124
+ expect(client.eval).not.toHaveBeenCalled();
125
+ jest.setSystemTime(1700000150000); // exactly half of the lifetime
126
+ expect(yield cache.get('key', 2)).toBe('cached');
127
+ expect(client.eval).toHaveBeenCalledTimes(1);
128
+ expect(client.eval).toHaveBeenLastCalledWith(expect.any(String), {
129
+ keys: ['test:key'],
130
+ arguments: [JSON.stringify(entry), JSON.stringify(_objectSpread(_objectSpread({}, entry), {}, {
131
+ written: 1700000150
132
+ })), '300']
133
+ });
134
+ client.get.mockResolvedValue(JSON.stringify(_objectSpread(_objectSpread({}, entry), {}, {
135
+ written: 1700000999
136
+ }))); // skewed writer
137
+ expect(yield cache.get('key', 2)).toBe('cached');
138
+ expect(client.eval).toHaveBeenCalledTimes(2);
139
+ expect(client.set).not.toHaveBeenCalled();
140
+ expect(client.expire).not.toHaveBeenCalled();
141
+ }));
142
+ it('exposes the entry version and remaining retention through lookup', /*#__PURE__*/_asyncToGenerator(function* () {
143
+ var _cache$lookup, _cache$namespace$look, _cache$namespace, _yield$cache$lookup, _cache$lookup2, _cache$lookup3;
144
+ jest.useFakeTimers({
145
+ now: 1700000000000
146
+ });
147
+ var _connection4 = connection(),
148
+ client = _connection4.client,
149
+ cache = _connection4.cache;
150
+ client.get.mockResolvedValueOnce(JSON.stringify({
151
+ value: 'cached',
152
+ version: 2,
153
+ written: 1700000000
154
+ }));
155
+ expect(yield (_cache$lookup = cache.lookup) === null || _cache$lookup === void 0 ? void 0 : _cache$lookup.call(cache, 'key', 2)).toEqual({
156
+ value: 'cached',
157
+ version: 2,
158
+ ttl: undefined,
159
+ layer: 'redis'
160
+ });
161
+ client.get.mockResolvedValueOnce(JSON.stringify({
162
+ value: null,
163
+ version: 4,
164
+ ttl: 60,
165
+ written: 1699999955
166
+ }));
167
+ expect(yield (_cache$namespace$look = (_cache$namespace = cache.namespace('room')).lookup) === null || _cache$namespace$look === void 0 ? void 0 : _cache$namespace$look.call(_cache$namespace, 'missing', 4)).toEqual({
168
+ value: null,
169
+ version: 4,
170
+ ttl: 15,
171
+ // 45s of the 60s retention consumed
172
+ layer: 'redis'
173
+ });
174
+ client.get.mockResolvedValueOnce(JSON.stringify({
175
+ value: null,
176
+ version: 4,
177
+ ttl: 60
178
+ }));
179
+ expect((_yield$cache$lookup = yield (_cache$lookup2 = cache.lookup) === null || _cache$lookup2 === void 0 ? void 0 : _cache$lookup2.call(cache, 'legacy', 4)) === null || _yield$cache$lookup === void 0 ? void 0 : _yield$cache$lookup.ttl).toBe(60);
180
+ client.get.mockResolvedValueOnce(JSON.stringify({
181
+ value: 'old',
182
+ version: 1
183
+ }));
184
+ expect(yield (_cache$lookup3 = cache.lookup) === null || _cache$lookup3 === void 0 ? void 0 : _cache$lookup3.call(cache, 'key', 2)).toBeUndefined();
185
+ expect(client.eval).not.toHaveBeenCalled();
186
+ }));
187
+ it('stores a short-lived entry with its own expiry and never renews it', /*#__PURE__*/_asyncToGenerator(function* () {
188
+ jest.useFakeTimers({
189
+ now: 1700000000000
190
+ });
191
+ var _connection5 = connection(),
192
+ client = _connection5.client,
193
+ cache = _connection5.cache;
90
194
  yield cache.set('missing', null, 4, {
91
195
  ttl: 60
92
196
  });
93
197
  expect(client.set).toHaveBeenLastCalledWith('test:missing', JSON.stringify({
94
198
  value: null,
95
199
  version: 4,
96
- ttl: 60
200
+ ttl: 60,
201
+ written: 1700000000
97
202
  }), {
98
203
  EX: 60
99
204
  });
@@ -106,6 +211,8 @@ it('stores a short-lived entry with its own expiry and never renews it', /*#__PU
106
211
  expect(yield cache.get('missing', 4, onSource)).toBeNull();
107
212
  expect(onSource).toHaveBeenCalledWith('redis');
108
213
  expect(client.expire).not.toHaveBeenCalled();
214
+ expect(client.eval).not.toHaveBeenCalled();
215
+ expect(client.set).toHaveBeenCalledTimes(1); // the initial write only, never a renewal
109
216
  client.get.mockResolvedValueOnce(JSON.stringify({
110
217
  value: null,
111
218
  version: 4,
@@ -114,42 +221,46 @@ it('stores a short-lived entry with its own expiry and never renews it', /*#__PU
114
221
  expect(yield cache.get('missing', 5)).toBeUndefined();
115
222
  }));
116
223
  it('rounds a fractional ttl hint up to whole seconds for SET EX', /*#__PURE__*/_asyncToGenerator(function* () {
117
- var _connection4 = connection(),
118
- client = _connection4.client,
119
- cache = _connection4.cache;
224
+ jest.useFakeTimers({
225
+ now: 1700000000000
226
+ });
227
+ var _connection6 = connection(),
228
+ client = _connection6.client,
229
+ cache = _connection6.cache;
120
230
  yield cache.set('short', null, 1, {
121
231
  ttl: 0.5
122
232
  });
123
233
  expect(client.set).toHaveBeenLastCalledWith('test:short', JSON.stringify({
124
234
  value: null,
125
235
  version: 1,
126
- ttl: 1
236
+ ttl: 1,
237
+ written: 1700000000
127
238
  }), {
128
239
  EX: 1
129
240
  });
130
241
  }));
131
242
  it.each([false, 0, ''])('returns a stored %p as a hit', /*#__PURE__*/function () {
132
- var _ref8 = _asyncToGenerator(function* (value) {
133
- var _connection5 = connection(),
134
- client = _connection5.client,
135
- cache = _connection5.cache;
243
+ var _ref1 = _asyncToGenerator(function* (value) {
244
+ var _connection7 = connection(),
245
+ client = _connection7.client,
246
+ cache = _connection7.cache;
136
247
  client.get.mockResolvedValueOnce(JSON.stringify({
137
248
  value,
138
249
  version: 2
139
250
  }));
140
251
  expect(yield cache.get('key', 2)).toBe(value);
141
- expect(client.expire).toHaveBeenCalledTimes(1);
252
+ expect(client.eval).toHaveBeenCalledTimes(1); // legacy entry renewed in place
142
253
  });
143
254
  return function (_x) {
144
- return _ref8.apply(this, arguments);
255
+ return _ref1.apply(this, arguments);
145
256
  };
146
257
  }());
147
258
  it.each(['get', 'set'])('times out stalled %s commands without unsafe cancellation and recovers', /*#__PURE__*/function () {
148
- var _ref9 = _asyncToGenerator(function* (method) {
259
+ var _ref10 = _asyncToGenerator(function* (method) {
149
260
  jest.useFakeTimers();
150
- var _connection6 = connection(),
151
- client = _connection6.client,
152
- cache = _connection6.cache;
261
+ var _connection8 = connection(),
262
+ client = _connection8.client,
263
+ cache = _connection8.cache;
153
264
  var rejectOperation;
154
265
  client[method].mockImplementationOnce(() => new Promise((_, reject) => {
155
266
  rejectOperation = reject;
@@ -164,13 +275,13 @@ it.each(['get', 'set'])('times out stalled %s commands without unsafe cancellati
164
275
  expect(jest.getTimerCount()).toBe(0);
165
276
  });
166
277
  return function (_x2) {
167
- return _ref9.apply(this, arguments);
278
+ return _ref10.apply(this, arguments);
168
279
  };
169
280
  }());
170
281
  it('does not fragment Redis connections by observer and distinguishes unavailable from commands', /*#__PURE__*/_asyncToGenerator(function* () {
171
- var _connection7 = connection(false),
172
- client = _connection7.client,
173
- options = _connection7.options;
282
+ var _connection9 = connection(false),
283
+ client = _connection9.client,
284
+ options = _connection9.options;
174
285
  var events = [];
175
286
  var observe = event => {
176
287
  events.push(event);
@@ -26,6 +26,9 @@ function connection() {
26
26
  set: jest.fn(/*#__PURE__*/_asyncToGenerator(function* () {
27
27
  return 'OK';
28
28
  })),
29
+ eval: jest.fn(/*#__PURE__*/_asyncToGenerator(function* () {
30
+ return 'OK';
31
+ })),
29
32
  expire: jest.fn(/*#__PURE__*/_asyncToGenerator(function* () {
30
33
  return true;
31
34
  }))
@@ -64,34 +67,136 @@ it('fails open while disconnected without waiting for connection establishment',
64
67
  client.isReady = true;
65
68
  expect(yield cache.get('key', 1)).toBe('cached');
66
69
  }));
67
- it('preserves namespace, version and sliding expiry and observes expiry rejection', /*#__PURE__*/_asyncToGenerator(function* () {
70
+ it('preserves namespace and version, renews legacy entries in place, and observes renewal rejection', /*#__PURE__*/_asyncToGenerator(function* () {
71
+ jest.useFakeTimers({
72
+ now: 1700000000000
73
+ });
68
74
  var _connection2 = connection(),
69
75
  client = _connection2.client,
70
76
  cache = _connection2.cache;
71
- client.expire.mockRejectedValueOnce(new Error('expiry failed'));
77
+ // A legacy entry (no `written`) is renewed on first read even when the write fails.
78
+ client.eval.mockRejectedValueOnce(new Error('renewal failed'));
72
79
  expect(yield cache.namespace('room').get('key', 2)).toBe('cached');
73
80
  expect(client.get).toHaveBeenLastCalledWith('test:room:key');
81
+ expect(client.eval).toHaveBeenCalledTimes(1);
82
+ expect(client.eval).toHaveBeenLastCalledWith(expect.stringContaining("redis.call('GET', KEYS[1]) == ARGV[1]"), {
83
+ keys: ['test:room:key'],
84
+ arguments: [JSON.stringify({
85
+ version: 2,
86
+ value: 'cached'
87
+ }), JSON.stringify({
88
+ version: 2,
89
+ value: 'cached',
90
+ written: 1700000000
91
+ }), '300']
92
+ });
93
+ expect(client.set).not.toHaveBeenCalled();
94
+ expect(client.expire).not.toHaveBeenCalled();
74
95
  expect(yield cache.get('key', 3)).toBeUndefined();
75
- expect(client.expire).toHaveBeenCalledTimes(1);
96
+ expect(client.eval).toHaveBeenCalledTimes(1);
76
97
  yield cache.set('key', 'updated', 3);
77
98
  expect(client.set).toHaveBeenLastCalledWith('test:key', JSON.stringify({
78
99
  value: 'updated',
79
- version: 3
100
+ version: 3,
101
+ written: 1700000000
80
102
  }), {
81
103
  EX: 300
82
104
  });
83
105
  }));
84
- it('stores a short-lived entry with its own expiry and never renews it', /*#__PURE__*/_asyncToGenerator(function* () {
106
+ it('renews a regular entry only once it has consumed half of its lifetime, or when its timestamp is in the future', /*#__PURE__*/_asyncToGenerator(function* () {
107
+ jest.useFakeTimers({
108
+ now: 1700000000000
109
+ });
85
110
  var _connection3 = connection(),
86
111
  client = _connection3.client,
87
112
  cache = _connection3.cache;
113
+ var entry = {
114
+ value: 'cached',
115
+ version: 2,
116
+ written: 1700000000
117
+ };
118
+ client.get.mockResolvedValue(JSON.stringify(entry));
119
+ expect(yield cache.get('key', 2)).toBe('cached');
120
+ jest.setSystemTime(1700000149000); // 149s later, under half of the 300s ttl
121
+ expect(yield cache.get('key', 2)).toBe('cached');
122
+ expect(client.eval).not.toHaveBeenCalled();
123
+ jest.setSystemTime(1700000150000); // exactly half of the lifetime
124
+ expect(yield cache.get('key', 2)).toBe('cached');
125
+ expect(client.eval).toHaveBeenCalledTimes(1);
126
+ expect(client.eval).toHaveBeenLastCalledWith(expect.any(String), {
127
+ keys: ['test:key'],
128
+ arguments: [JSON.stringify(entry), JSON.stringify(_objectSpread(_objectSpread({}, entry), {}, {
129
+ written: 1700000150
130
+ })), '300']
131
+ });
132
+ client.get.mockResolvedValue(JSON.stringify(_objectSpread(_objectSpread({}, entry), {}, {
133
+ written: 1700000999
134
+ }))); // skewed writer
135
+ expect(yield cache.get('key', 2)).toBe('cached');
136
+ expect(client.eval).toHaveBeenCalledTimes(2);
137
+ expect(client.set).not.toHaveBeenCalled();
138
+ expect(client.expire).not.toHaveBeenCalled();
139
+ }));
140
+ it('exposes the entry version and remaining retention through lookup', /*#__PURE__*/_asyncToGenerator(function* () {
141
+ var _cache$lookup, _cache$namespace$look, _cache$namespace, _yield$cache$lookup, _cache$lookup2, _cache$lookup3;
142
+ jest.useFakeTimers({
143
+ now: 1700000000000
144
+ });
145
+ var _connection4 = connection(),
146
+ client = _connection4.client,
147
+ cache = _connection4.cache;
148
+ client.get.mockResolvedValueOnce(JSON.stringify({
149
+ value: 'cached',
150
+ version: 2,
151
+ written: 1700000000
152
+ }));
153
+ expect(yield (_cache$lookup = cache.lookup) === null || _cache$lookup === void 0 ? void 0 : _cache$lookup.call(cache, 'key', 2)).toEqual({
154
+ value: 'cached',
155
+ version: 2,
156
+ ttl: undefined,
157
+ layer: 'redis'
158
+ });
159
+ client.get.mockResolvedValueOnce(JSON.stringify({
160
+ value: null,
161
+ version: 4,
162
+ ttl: 60,
163
+ written: 1699999955
164
+ }));
165
+ expect(yield (_cache$namespace$look = (_cache$namespace = cache.namespace('room')).lookup) === null || _cache$namespace$look === void 0 ? void 0 : _cache$namespace$look.call(_cache$namespace, 'missing', 4)).toEqual({
166
+ value: null,
167
+ version: 4,
168
+ ttl: 15,
169
+ // 45s of the 60s retention consumed
170
+ layer: 'redis'
171
+ });
172
+ client.get.mockResolvedValueOnce(JSON.stringify({
173
+ value: null,
174
+ version: 4,
175
+ ttl: 60
176
+ }));
177
+ expect((_yield$cache$lookup = yield (_cache$lookup2 = cache.lookup) === null || _cache$lookup2 === void 0 ? void 0 : _cache$lookup2.call(cache, 'legacy', 4)) === null || _yield$cache$lookup === void 0 ? void 0 : _yield$cache$lookup.ttl).toBe(60);
178
+ client.get.mockResolvedValueOnce(JSON.stringify({
179
+ value: 'old',
180
+ version: 1
181
+ }));
182
+ expect(yield (_cache$lookup3 = cache.lookup) === null || _cache$lookup3 === void 0 ? void 0 : _cache$lookup3.call(cache, 'key', 2)).toBeUndefined();
183
+ expect(client.eval).not.toHaveBeenCalled();
184
+ }));
185
+ it('stores a short-lived entry with its own expiry and never renews it', /*#__PURE__*/_asyncToGenerator(function* () {
186
+ jest.useFakeTimers({
187
+ now: 1700000000000
188
+ });
189
+ var _connection5 = connection(),
190
+ client = _connection5.client,
191
+ cache = _connection5.cache;
88
192
  yield cache.set('missing', null, 4, {
89
193
  ttl: 60
90
194
  });
91
195
  expect(client.set).toHaveBeenLastCalledWith('test:missing', JSON.stringify({
92
196
  value: null,
93
197
  version: 4,
94
- ttl: 60
198
+ ttl: 60,
199
+ written: 1700000000
95
200
  }), {
96
201
  EX: 60
97
202
  });
@@ -104,6 +209,8 @@ it('stores a short-lived entry with its own expiry and never renews it', /*#__PU
104
209
  expect(yield cache.get('missing', 4, onSource)).toBeNull();
105
210
  expect(onSource).toHaveBeenCalledWith('redis');
106
211
  expect(client.expire).not.toHaveBeenCalled();
212
+ expect(client.eval).not.toHaveBeenCalled();
213
+ expect(client.set).toHaveBeenCalledTimes(1); // the initial write only, never a renewal
107
214
  client.get.mockResolvedValueOnce(JSON.stringify({
108
215
  value: null,
109
216
  version: 4,
@@ -112,42 +219,46 @@ it('stores a short-lived entry with its own expiry and never renews it', /*#__PU
112
219
  expect(yield cache.get('missing', 5)).toBeUndefined();
113
220
  }));
114
221
  it('rounds a fractional ttl hint up to whole seconds for SET EX', /*#__PURE__*/_asyncToGenerator(function* () {
115
- var _connection4 = connection(),
116
- client = _connection4.client,
117
- cache = _connection4.cache;
222
+ jest.useFakeTimers({
223
+ now: 1700000000000
224
+ });
225
+ var _connection6 = connection(),
226
+ client = _connection6.client,
227
+ cache = _connection6.cache;
118
228
  yield cache.set('short', null, 1, {
119
229
  ttl: 0.5
120
230
  });
121
231
  expect(client.set).toHaveBeenLastCalledWith('test:short', JSON.stringify({
122
232
  value: null,
123
233
  version: 1,
124
- ttl: 1
234
+ ttl: 1,
235
+ written: 1700000000
125
236
  }), {
126
237
  EX: 1
127
238
  });
128
239
  }));
129
240
  it.each([false, 0, ''])('returns a stored %p as a hit', /*#__PURE__*/function () {
130
- var _ref8 = _asyncToGenerator(function* (value) {
131
- var _connection5 = connection(),
132
- client = _connection5.client,
133
- cache = _connection5.cache;
241
+ var _ref1 = _asyncToGenerator(function* (value) {
242
+ var _connection7 = connection(),
243
+ client = _connection7.client,
244
+ cache = _connection7.cache;
134
245
  client.get.mockResolvedValueOnce(JSON.stringify({
135
246
  value,
136
247
  version: 2
137
248
  }));
138
249
  expect(yield cache.get('key', 2)).toBe(value);
139
- expect(client.expire).toHaveBeenCalledTimes(1);
250
+ expect(client.eval).toHaveBeenCalledTimes(1); // legacy entry renewed in place
140
251
  });
141
252
  return function (_x) {
142
- return _ref8.apply(this, arguments);
253
+ return _ref1.apply(this, arguments);
143
254
  };
144
255
  }());
145
256
  it.each(['get', 'set'])('times out stalled %s commands without unsafe cancellation and recovers', /*#__PURE__*/function () {
146
- var _ref9 = _asyncToGenerator(function* (method) {
257
+ var _ref10 = _asyncToGenerator(function* (method) {
147
258
  jest.useFakeTimers();
148
- var _connection6 = connection(),
149
- client = _connection6.client,
150
- cache = _connection6.cache;
259
+ var _connection8 = connection(),
260
+ client = _connection8.client,
261
+ cache = _connection8.cache;
151
262
  var rejectOperation;
152
263
  client[method].mockImplementationOnce(() => new Promise((_, reject) => {
153
264
  rejectOperation = reject;
@@ -162,13 +273,13 @@ it.each(['get', 'set'])('times out stalled %s commands without unsafe cancellati
162
273
  expect(jest.getTimerCount()).toBe(0);
163
274
  });
164
275
  return function (_x2) {
165
- return _ref9.apply(this, arguments);
276
+ return _ref10.apply(this, arguments);
166
277
  };
167
278
  }());
168
279
  it('does not fragment Redis connections by observer and distinguishes unavailable from commands', /*#__PURE__*/_asyncToGenerator(function* () {
169
- var _connection7 = connection(false),
170
- client = _connection7.client,
171
- options = _connection7.options;
280
+ var _connection9 = connection(false),
281
+ client = _connection9.client,
282
+ options = _connection9.options;
172
283
  var events = [];
173
284
  var observe = event => {
174
285
  events.push(event);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prezly/theme-kit-nextjs",
3
- "version": "10.10.1",
3
+ "version": "10.11.0",
4
4
  "description": "Data layer and utility library for developing Prezly themes with NextJS",
5
5
  "type": "module",
6
6
  "main": "build/index.mjs",
@@ -79,7 +79,7 @@
79
79
  "json-stable-stringify": "^1.2.1",
80
80
  "redis": "^4.7.0",
81
81
  "url-pattern": "^1.0.3",
82
- "@prezly/theme-kit-core": "^10.10.1",
82
+ "@prezly/theme-kit-core": "^10.11.0",
83
83
  "@prezly/theme-kit-react": "^10.9.1",
84
84
  "@prezly/theme-kit-intl": "^10.9.1"
85
85
  },