@prezly/theme-kit-nextjs 10.9.3 → 10.10.1

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
@@ -413,8 +413,30 @@ new retained key namespaces for every token or invalidation version. This create
413
413
  a one-time cold cache on upgrade. Roll out gradually and warm representative
414
414
  newsrooms before an expected traffic spike. Existing unscoped entries remain
415
415
  subject to their configured expiration; do not flush a Redis instance that also
416
- contains routing data. TTL, negative-cache classification and query
417
- canonicalization are separate policies and are unchanged here.
416
+ contains routing data. TTL and query canonicalization are separate policies and
417
+ are unchanged here.
418
+
419
+ ### Not-found results
420
+
421
+ `contentDelivery.story()` and `contentDelivery.gallery()` resolve `null` when the
422
+ API answers 403, 404 or 410. A `null` result is a cache hit like any other value:
423
+ only an absent entry falls back to the API, so `false` and `0` are also valid
424
+ cached values. Not-found entries are kept for `negativeTtl` seconds (default 60,
425
+ `ContentDelivery.DEFAULT_NEGATIVE_TTL`). The deadline is written into the cached
426
+ envelope and checked on every read, so a cache layer that ignores the `ttl`
427
+ hint, or a `null` entry written by an older Theme Kit, is a miss rather than a
428
+ stale hit. Memory and Redis also expire these entries on their own, and Redis
429
+ does not renew that lifetime on read. A cache version change still invalidates
430
+ them at once, so publishing a story makes it visible immediately; a story that
431
+ becomes available without a version change appears within the TTL. Errors such
432
+ as 401, 429, 5xx and transport failures throw and are never stored. `undefined`
433
+ results are not stored either.
434
+
435
+ Set `cache.negativeTtl` in `PrezlyAdapter.connect` to change the retention. It
436
+ must be a positive integer number of seconds. Not-found caching needs the
437
+ source/authorization scope described above; a client created with a custom
438
+ `fetch` and no `requestScope` keeps treating `null` as a miss and does not
439
+ store it.
418
440
 
419
441
  ### Verification and release
420
442
 
@@ -19,7 +19,8 @@ function configure(config) {
19
19
  if (storage) {
20
20
  return {
21
21
  storage: config.namespace ? storage.namespace(config.namespace) : storage,
22
- latestVersion: _themeKitCore.Resolvable.resolve(config.latestVersion)
22
+ latestVersion: _themeKitCore.Resolvable.resolve(config.latestVersion),
23
+ negativeTtl: config.negativeTtl
23
24
  };
24
25
  }
25
26
  return undefined;
@@ -10,9 +10,12 @@ export interface Configuration {
10
10
  namespace?: string;
11
11
  /** Extra immutable identity for custom fetch implementations. Enables request sharing. */
12
12
  requestScope?: string;
13
+ /** Retention in seconds for not-found results. See `ContentDelivery.DEFAULT_NEGATIVE_TTL`. */
14
+ negativeTtl?: number;
13
15
  telemetry?: ContentDelivery.Telemetry;
14
16
  }
15
17
  export declare function configure(config: Configuration): {
16
18
  storage: ContentDelivery.Cache;
17
19
  latestVersion: number;
20
+ negativeTtl: number | undefined;
18
21
  } | undefined;
@@ -13,7 +13,8 @@ export function configure(config) {
13
13
  if (storage) {
14
14
  return {
15
15
  storage: config.namespace ? storage.namespace(config.namespace) : storage,
16
- latestVersion: Resolvable.resolve(config.latestVersion)
16
+ latestVersion: Resolvable.resolve(config.latestVersion),
17
+ negativeTtl: config.negativeTtl
17
18
  };
18
19
  }
19
20
  return undefined;
@@ -89,14 +89,16 @@ function createRedisCache(_ref) {
89
89
  if (!cached) return undefined;
90
90
  var entry = JSON.parse(cached);
91
91
  if (entry.version < latestVersion) return undefined;
92
- if (ttl) {
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) {
93
95
  void command(() => connection.expire("".concat(namespacePrefix).concat(key), ttl), telemetry, 'expire').catch(() => undefined);
94
96
  }
95
97
  if (onSource) _themeKitCore.ContentDelivery.notify(onSource, 'redis');
96
98
  return entry.value;
97
99
  })();
98
100
  },
99
- set(key, value, version) {
101
+ set(key, value, version, options) {
100
102
  return _asyncToGenerator(function* () {
101
103
  if (!connection.isReady) {
102
104
  _themeKitCore.ContentDelivery.emit(telemetry, {
@@ -105,13 +107,21 @@ function createRedisCache(_ref) {
105
107
  });
106
108
  return;
107
109
  }
108
- var entry = {
110
+ // SET EX takes whole seconds; round a fractional hint up, never down.
111
+ var entry = (options === null || options === void 0 ? void 0 : options.ttl) === undefined ? {
109
112
  value,
110
113
  version
114
+ } : {
115
+ value,
116
+ version,
117
+ ttl: Math.max(1, Math.ceil(options.ttl))
111
118
  };
112
- yield command(() => connection.set("".concat(namespacePrefix).concat(key), JSON.stringify(entry), {
113
- EX: ttl
114
- }), telemetry, 'set');
119
+ yield command(() => {
120
+ var _entry$ttl;
121
+ return connection.set("".concat(namespacePrefix).concat(key), JSON.stringify(entry), {
122
+ EX: (_entry$ttl = entry.ttl) !== null && _entry$ttl !== void 0 ? _entry$ttl : ttl
123
+ });
124
+ }, telemetry, 'set');
115
125
  })();
116
126
  },
117
127
  namespace(namespace) {
@@ -82,14 +82,16 @@ export function createRedisCache(_ref) {
82
82
  if (!cached) return undefined;
83
83
  var entry = JSON.parse(cached);
84
84
  if (entry.version < latestVersion) return undefined;
85
- if (ttl) {
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) {
86
88
  void command(() => connection.expire("".concat(namespacePrefix).concat(key), ttl), telemetry, 'expire').catch(() => undefined);
87
89
  }
88
90
  if (onSource) ContentDelivery.notify(onSource, 'redis');
89
91
  return entry.value;
90
92
  })();
91
93
  },
92
- set(key, value, version) {
94
+ set(key, value, version, options) {
93
95
  return _asyncToGenerator(function* () {
94
96
  if (!connection.isReady) {
95
97
  ContentDelivery.emit(telemetry, {
@@ -98,13 +100,21 @@ export function createRedisCache(_ref) {
98
100
  });
99
101
  return;
100
102
  }
101
- var entry = {
103
+ // SET EX takes whole seconds; round a fractional hint up, never down.
104
+ var entry = (options === null || options === void 0 ? void 0 : options.ttl) === undefined ? {
102
105
  value,
103
106
  version
107
+ } : {
108
+ value,
109
+ version,
110
+ ttl: Math.max(1, Math.ceil(options.ttl))
104
111
  };
105
- yield command(() => connection.set("".concat(namespacePrefix).concat(key), JSON.stringify(entry), {
106
- EX: ttl
107
- }), telemetry, 'set');
112
+ yield command(() => {
113
+ var _entry$ttl;
114
+ return connection.set("".concat(namespacePrefix).concat(key), JSON.stringify(entry), {
115
+ EX: (_entry$ttl = entry.ttl) !== null && _entry$ttl !== void 0 ? _entry$ttl : ttl
116
+ });
117
+ }, telemetry, 'set');
108
118
  })();
109
119
  },
110
120
  namespace(namespace) {
@@ -83,12 +83,73 @@ it('preserves namespace, version and sliding expiry and observes expiry rejectio
83
83
  EX: 300
84
84
  });
85
85
  }));
86
+ it('stores a short-lived entry with its own expiry and never renews it', /*#__PURE__*/_asyncToGenerator(function* () {
87
+ var _connection3 = connection(),
88
+ client = _connection3.client,
89
+ cache = _connection3.cache;
90
+ yield cache.set('missing', null, 4, {
91
+ ttl: 60
92
+ });
93
+ expect(client.set).toHaveBeenLastCalledWith('test:missing', JSON.stringify({
94
+ value: null,
95
+ version: 4,
96
+ ttl: 60
97
+ }), {
98
+ EX: 60
99
+ });
100
+ client.get.mockResolvedValueOnce(JSON.stringify({
101
+ value: null,
102
+ version: 4,
103
+ ttl: 60
104
+ }));
105
+ var onSource = jest.fn();
106
+ expect(yield cache.get('missing', 4, onSource)).toBeNull();
107
+ expect(onSource).toHaveBeenCalledWith('redis');
108
+ expect(client.expire).not.toHaveBeenCalled();
109
+ client.get.mockResolvedValueOnce(JSON.stringify({
110
+ value: null,
111
+ version: 4,
112
+ ttl: 60
113
+ }));
114
+ expect(yield cache.get('missing', 5)).toBeUndefined();
115
+ }));
116
+ 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;
120
+ yield cache.set('short', null, 1, {
121
+ ttl: 0.5
122
+ });
123
+ expect(client.set).toHaveBeenLastCalledWith('test:short', JSON.stringify({
124
+ value: null,
125
+ version: 1,
126
+ ttl: 1
127
+ }), {
128
+ EX: 1
129
+ });
130
+ }));
131
+ 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;
136
+ client.get.mockResolvedValueOnce(JSON.stringify({
137
+ value,
138
+ version: 2
139
+ }));
140
+ expect(yield cache.get('key', 2)).toBe(value);
141
+ expect(client.expire).toHaveBeenCalledTimes(1);
142
+ });
143
+ return function (_x) {
144
+ return _ref8.apply(this, arguments);
145
+ };
146
+ }());
86
147
  it.each(['get', 'set'])('times out stalled %s commands without unsafe cancellation and recovers', /*#__PURE__*/function () {
87
- var _ref6 = _asyncToGenerator(function* (method) {
148
+ var _ref9 = _asyncToGenerator(function* (method) {
88
149
  jest.useFakeTimers();
89
- var _connection3 = connection(),
90
- client = _connection3.client,
91
- cache = _connection3.cache;
150
+ var _connection6 = connection(),
151
+ client = _connection6.client,
152
+ cache = _connection6.cache;
92
153
  var rejectOperation;
93
154
  client[method].mockImplementationOnce(() => new Promise((_, reject) => {
94
155
  rejectOperation = reject;
@@ -102,14 +163,14 @@ it.each(['get', 'set'])('times out stalled %s commands without unsafe cancellati
102
163
  yield jest.advanceTimersByTimeAsync(0);
103
164
  expect(jest.getTimerCount()).toBe(0);
104
165
  });
105
- return function (_x) {
106
- return _ref6.apply(this, arguments);
166
+ return function (_x2) {
167
+ return _ref9.apply(this, arguments);
107
168
  };
108
169
  }());
109
170
  it('does not fragment Redis connections by observer and distinguishes unavailable from commands', /*#__PURE__*/_asyncToGenerator(function* () {
110
- var _connection4 = connection(false),
111
- client = _connection4.client,
112
- options = _connection4.options;
171
+ var _connection7 = connection(false),
172
+ client = _connection7.client,
173
+ options = _connection7.options;
113
174
  var events = [];
114
175
  var observe = event => {
115
176
  events.push(event);
@@ -81,12 +81,73 @@ it('preserves namespace, version and sliding expiry and observes expiry rejectio
81
81
  EX: 300
82
82
  });
83
83
  }));
84
+ it('stores a short-lived entry with its own expiry and never renews it', /*#__PURE__*/_asyncToGenerator(function* () {
85
+ var _connection3 = connection(),
86
+ client = _connection3.client,
87
+ cache = _connection3.cache;
88
+ yield cache.set('missing', null, 4, {
89
+ ttl: 60
90
+ });
91
+ expect(client.set).toHaveBeenLastCalledWith('test:missing', JSON.stringify({
92
+ value: null,
93
+ version: 4,
94
+ ttl: 60
95
+ }), {
96
+ EX: 60
97
+ });
98
+ client.get.mockResolvedValueOnce(JSON.stringify({
99
+ value: null,
100
+ version: 4,
101
+ ttl: 60
102
+ }));
103
+ var onSource = jest.fn();
104
+ expect(yield cache.get('missing', 4, onSource)).toBeNull();
105
+ expect(onSource).toHaveBeenCalledWith('redis');
106
+ expect(client.expire).not.toHaveBeenCalled();
107
+ client.get.mockResolvedValueOnce(JSON.stringify({
108
+ value: null,
109
+ version: 4,
110
+ ttl: 60
111
+ }));
112
+ expect(yield cache.get('missing', 5)).toBeUndefined();
113
+ }));
114
+ 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;
118
+ yield cache.set('short', null, 1, {
119
+ ttl: 0.5
120
+ });
121
+ expect(client.set).toHaveBeenLastCalledWith('test:short', JSON.stringify({
122
+ value: null,
123
+ version: 1,
124
+ ttl: 1
125
+ }), {
126
+ EX: 1
127
+ });
128
+ }));
129
+ 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;
134
+ client.get.mockResolvedValueOnce(JSON.stringify({
135
+ value,
136
+ version: 2
137
+ }));
138
+ expect(yield cache.get('key', 2)).toBe(value);
139
+ expect(client.expire).toHaveBeenCalledTimes(1);
140
+ });
141
+ return function (_x) {
142
+ return _ref8.apply(this, arguments);
143
+ };
144
+ }());
84
145
  it.each(['get', 'set'])('times out stalled %s commands without unsafe cancellation and recovers', /*#__PURE__*/function () {
85
- var _ref6 = _asyncToGenerator(function* (method) {
146
+ var _ref9 = _asyncToGenerator(function* (method) {
86
147
  jest.useFakeTimers();
87
- var _connection3 = connection(),
88
- client = _connection3.client,
89
- cache = _connection3.cache;
148
+ var _connection6 = connection(),
149
+ client = _connection6.client,
150
+ cache = _connection6.cache;
90
151
  var rejectOperation;
91
152
  client[method].mockImplementationOnce(() => new Promise((_, reject) => {
92
153
  rejectOperation = reject;
@@ -100,14 +161,14 @@ it.each(['get', 'set'])('times out stalled %s commands without unsafe cancellati
100
161
  yield jest.advanceTimersByTimeAsync(0);
101
162
  expect(jest.getTimerCount()).toBe(0);
102
163
  });
103
- return function (_x) {
104
- return _ref6.apply(this, arguments);
164
+ return function (_x2) {
165
+ return _ref9.apply(this, arguments);
105
166
  };
106
167
  }());
107
168
  it('does not fragment Redis connections by observer and distinguishes unavailable from commands', /*#__PURE__*/_asyncToGenerator(function* () {
108
- var _connection4 = connection(false),
109
- client = _connection4.client,
110
- options = _connection4.options;
169
+ var _connection7 = connection(false),
170
+ client = _connection7.client,
171
+ options = _connection7.options;
111
172
  var events = [];
112
173
  var observe = event => {
113
174
  events.push(event);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prezly/theme-kit-nextjs",
3
- "version": "10.9.3",
3
+ "version": "10.10.1",
4
4
  "description": "Data layer and utility library for developing Prezly themes with NextJS",
5
5
  "type": "module",
6
6
  "main": "build/index.mjs",
@@ -45,10 +45,6 @@
45
45
  "node": ">= 18.18.0",
46
46
  "npm": ">= 8.x"
47
47
  },
48
- "devEngines": {
49
- "node": ">= 18.18.0",
50
- "npm": ">= 8.x"
51
- },
52
48
  "repository": {
53
49
  "type": "git",
54
50
  "url": "git+https://github.com/prezly/theme-kit-js.git",
@@ -78,14 +74,14 @@
78
74
  },
79
75
  "dependencies": {
80
76
  "@noble/hashes": "1.8.0",
81
- "@prezly/theme-kit-core": "^10.9.3",
82
- "@prezly/theme-kit-intl": "^10.9.1",
83
- "@prezly/theme-kit-react": "^10.9.1",
84
77
  "@technically/is-not-undefined": "^1.0.0",
85
78
  "@technically/omit-undefined": "^1.0.4",
86
79
  "json-stable-stringify": "^1.2.1",
87
80
  "redis": "^4.7.0",
88
- "url-pattern": "^1.0.3"
81
+ "url-pattern": "^1.0.3",
82
+ "@prezly/theme-kit-core": "^10.10.1",
83
+ "@prezly/theme-kit-react": "^10.9.1",
84
+ "@prezly/theme-kit-intl": "^10.9.1"
89
85
  },
90
86
  "devDependencies": {
91
87
  "@sentry/nextjs": "7.120.4"
@@ -94,7 +90,7 @@
94
90
  "access": "public"
95
91
  },
96
92
  "sideEffects": false,
97
- "gitHead": "31134742a0ff3f3e669283f94843c3222eac8ebd",
93
+ "gitHead": "51b1ce99cf8ce2c886001077bcaf95cb143f86ce",
98
94
  "scripts": {
99
95
  "prebuild": "rimraf --glob build/* *.tsbuildinfo",
100
96
  "build": "pnpm build:types && pnpm build:esm && pnpm build:cjs",