@wmind/site-core 0.1.0 → 0.1.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 +19 -16
- package/dist/site.d.ts +7 -0
- package/dist/site.js +100 -32
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,8 +4,8 @@ The product-independent core of the site SDK: an HTTP transport, request-driven
|
|
|
4
4
|
cache model, an atomic-swap snapshot, webhooks and status. Product modules (`@wmind/site-entitycms`,
|
|
5
5
|
`@wmind/site-legal`) build on it; a site normally installs a module, not this package alone.
|
|
6
6
|
|
|
7
|
-
- **Status:** MIT, final name. Version `0.
|
|
8
|
-
|
|
7
|
+
- **Status:** MIT, final name, public on npm. Version `0.1.1`: the two known limits of `0.1.0`
|
|
8
|
+
fixed, one option added (`storeTimeoutMs`), nothing removed or changed in shape.
|
|
9
9
|
- **Runs on:** Node 22, browsers and edge runtimes (Cloudflare Workers, Vercel, Netlify). Web
|
|
10
10
|
platform APIs only (`fetch`, `Request`, `Response`, `crypto.subtle`, `AbortController`); zero
|
|
11
11
|
runtime dependencies.
|
|
@@ -18,8 +18,11 @@ cache model, an atomic-swap snapshot, webhooks and status. Product modules (`@wm
|
|
|
18
18
|
1. **Requests drive refreshes.** No timer ever starts a refresh or an HTTP call. A copy past its
|
|
19
19
|
freshness is refreshed when a request asks for it; the flight is shared by every concurrent
|
|
20
20
|
request for the key (one per key and process) and is never cancelled. A flight that has not
|
|
21
|
-
settled after `refreshMaxMs` (60 s) is abandoned by the next request, which starts a new one
|
|
22
|
-
|
|
21
|
+
settled after `refreshMaxMs` (60 s) is abandoned by the next request, which starts a new one.
|
|
22
|
+
The abandoned one records no error when it settles, and its value is stored only when no newer
|
|
23
|
+
flight of the key has landed and the stored copy is older than its start: a loader slower than
|
|
24
|
+
`refreshMaxMs` still lands, and never over a newer copy or a copy a 404 deleted. Set
|
|
25
|
+
`refreshMaxMs` above the loader's worst case anyway, or a slow loader runs twice at a time.
|
|
23
26
|
2. **Budgets, not waits.** A request with a copy waits for the refresh at most `refreshBudgetMs`
|
|
24
27
|
(default 0: it answers with the copy at once); a request without one waits at most
|
|
25
28
|
`coldBudgetMs` (10 s), then gets `Unavailable` `refresh_budget_exceeded`. The refresh goes on and
|
|
@@ -48,16 +51,16 @@ cache model, an atomic-swap snapshot, webhooks and status. Product modules (`@wm
|
|
|
48
51
|
|
|
49
52
|
## Known limits
|
|
50
53
|
|
|
51
|
-
- **A loader slower than `refreshMaxMs`
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
54
|
+
- **Fixed in 0.1.1.** A loader slower than `refreshMaxMs` no longer restarts forever without
|
|
55
|
+
landing (rule 1), and a store call that never settles no longer blocks its key: every
|
|
56
|
+
`CacheStore` call (`get`, `set`, `delete`) is bounded by `storeTimeoutMs` (5 s), after which it
|
|
57
|
+
fails as `store_unavailable` (`details.reason: 'timeout'`) and the key's write queue moves on.
|
|
58
|
+
- **A store call cannot be cancelled.** A write that takes longer than `storeTimeoutMs` and then
|
|
59
|
+
completes can land after a later write of the same key: until the next refresh (at most
|
|
60
|
+
`fullEveryMs` later) it can leave an older value in place, bring back a copy a 404 had deleted, or
|
|
61
|
+
undo an invalidation. Keep `storeTimeoutMs` well above the store's normal latency (it is capped at
|
|
62
|
+
2,147,483,647 ms, the largest delay a platform timer honours).
|
|
63
|
+
- **Instances sharing a KV store are not serialised** (rule 7).
|
|
61
64
|
|
|
62
65
|
## Quick start
|
|
63
66
|
|
|
@@ -153,8 +156,8 @@ Codes this package mints:
|
|
|
153
156
|
| `network_error`, `timeout` | `Unavailable` | no HTTP answer (status 0) |
|
|
154
157
|
| `unexpected_response` | by status (`Unavailable` for a 404) | an answer that is not the JSON error envelope, a 2xx that is not JSON, a 304 to a request without `If-None-Match` |
|
|
155
158
|
| `refresh_budget_exceeded` | `Unavailable` | no servable copy within the budget; `details.reason` `cold`, `max_stale` or `preview` |
|
|
156
|
-
| `store_unavailable` | `Unavailable` | the cache store failed |
|
|
157
|
-
| `refresh_abandoned` | `Unavailable` (status only) | a flight older than `refreshMaxMs` was replaced |
|
|
159
|
+
| `store_unavailable` | `Unavailable` | the cache store failed, or a call did not settle within `storeTimeoutMs` (`details.reason: 'timeout'`) |
|
|
160
|
+
| `refresh_abandoned` | `Unavailable` (status only) | a flight older than `refreshMaxMs` was replaced (its late value may still land) |
|
|
158
161
|
| `store_unserialisable` | `SiteSdkError` | a value a KV store cannot hold as JSON |
|
|
159
162
|
| `unexpected_not_modified` | `SiteSdkError` (304) | a loader answered `not_modified` with no copy |
|
|
160
163
|
| `preview_unsupported` | `SiteSdkError` | a preview of an `httpResource` with no `preview` |
|
package/dist/site.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { type CacheRecord, type CacheStore } from './store.js';
|
|
|
4
4
|
export declare const DEFAULT_COLD_BUDGET_MS = 10000;
|
|
5
5
|
export declare const DEFAULT_REFRESH_MAX_MS = 60000;
|
|
6
6
|
export declare const DEFAULT_MAX_ERROR_PAUSE_MS = 600000;
|
|
7
|
+
export declare const DEFAULT_STORE_TIMEOUT_MS = 5000;
|
|
7
8
|
export interface SiteCacheOptions {
|
|
8
9
|
tenant: string;
|
|
9
10
|
/** Default `new MemoryCacheStore()`. */
|
|
@@ -29,6 +30,12 @@ export interface SiteCacheOptions {
|
|
|
29
30
|
* cannot freeze it. `errorPauseMs` itself is never cut.
|
|
30
31
|
*/
|
|
31
32
|
maxErrorPauseMs?: number;
|
|
33
|
+
/**
|
|
34
|
+
* How long one store call (`get`, `set`, `delete`) may take (5_000). A call that has not settled
|
|
35
|
+
* by then fails as `Unavailable` `store_unavailable` (`details.reason: 'timeout'`) and the key's
|
|
36
|
+
* write queue moves on; the call itself cannot be cancelled and runs on.
|
|
37
|
+
*/
|
|
38
|
+
storeTimeoutMs?: number;
|
|
32
39
|
/** A product prefix for every store key (`legal`, `cms`), so modules can share a store. */
|
|
33
40
|
namespace?: string;
|
|
34
41
|
}
|
package/dist/site.js
CHANGED
|
@@ -7,14 +7,19 @@
|
|
|
7
7
|
*
|
|
8
8
|
* A flight never rejects: it resolves to `{ ok, value | error }`, and its failure goes to
|
|
9
9
|
* `status()` and starts the error pause. A flight older than `refreshMaxMs` is abandoned by the
|
|
10
|
-
* next request (review I1): it
|
|
10
|
+
* next request (review I1): it records nothing when it settles, and its success is written only
|
|
11
|
+
* when no newer flight of the key has landed and the stored copy is older than its start
|
|
12
|
+
* (re-review M-a, 0.1.1), so a loader slower than `refreshMaxMs` still lands and never overwrites
|
|
13
|
+
* a newer copy.
|
|
11
14
|
*
|
|
12
15
|
* Writes to one key are queued in process (review I3): a refresh's re-read and write, an
|
|
13
16
|
* invalidation's read and write and a NotFound's delete never interleave, so an invalidation is
|
|
14
17
|
* never lost and a deleted copy is never written back. An invalidation is also kept in process
|
|
15
|
-
* from the moment `invalidate()` is called.
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
+
* from the moment `invalidate()` is called. Every store call is bounded by `storeTimeoutMs`
|
|
19
|
+
* (re-review M-d, 0.1.1): one that has not settled by then fails as `store_unavailable` and the
|
|
20
|
+
* queue moves on. Instances sharing a KV store are not serialised: the store has no
|
|
21
|
+
* compare-and-set, so an invalidation written by one instance can be overwritten by a refresh of
|
|
22
|
+
* another that re-read before it; the periodic full refresh bounds that (§3.2).
|
|
18
23
|
*/
|
|
19
24
|
import { freshForMs, isStorable, parseCacheControl } from './cache-control.js';
|
|
20
25
|
import { NotFound, SiteSdkError, Unavailable } from './errors.js';
|
|
@@ -25,6 +30,12 @@ import { MemoryCacheStore, storeKey } from './store.js';
|
|
|
25
30
|
export const DEFAULT_COLD_BUDGET_MS = 10_000;
|
|
26
31
|
export const DEFAULT_REFRESH_MAX_MS = 60_000;
|
|
27
32
|
export const DEFAULT_MAX_ERROR_PAUSE_MS = 600_000;
|
|
33
|
+
export const DEFAULT_STORE_TIMEOUT_MS = 5_000;
|
|
34
|
+
/** A store call that did not settle within `storeTimeoutMs` (re-review M-d); never leaves the module. */
|
|
35
|
+
class StoreTimeout extends Error {
|
|
36
|
+
}
|
|
37
|
+
/** The start of the refresh that last produced or confirmed the record, full or partial. */
|
|
38
|
+
const lastRefreshOf = (record) => Math.max(record.lastFullAt, record.lastPartialAt ?? record.lastFullAt);
|
|
28
39
|
/** A positive finite number, else a TypeError naming the option. */
|
|
29
40
|
function positiveMs(name, value) {
|
|
30
41
|
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
|
@@ -32,6 +43,16 @@ function positiveMs(name, value) {
|
|
|
32
43
|
}
|
|
33
44
|
return value;
|
|
34
45
|
}
|
|
46
|
+
/** The largest delay a platform timer honours: above it Node fires after 1 ms. */
|
|
47
|
+
const MAX_TIMER_MS = 2_147_483_647;
|
|
48
|
+
/** A duration that backs a timer: positive, finite and at most `MAX_TIMER_MS`. */
|
|
49
|
+
function timerMs(name, value) {
|
|
50
|
+
const ms = positiveMs(name, value);
|
|
51
|
+
if (ms > MAX_TIMER_MS) {
|
|
52
|
+
throw new TypeError(`createSiteCache: ${name} must be at most ${MAX_TIMER_MS} milliseconds`);
|
|
53
|
+
}
|
|
54
|
+
return ms;
|
|
55
|
+
}
|
|
35
56
|
export function createSiteCache(options) {
|
|
36
57
|
const { tenant } = options;
|
|
37
58
|
if (typeof tenant !== 'string' || tenant === '') {
|
|
@@ -44,6 +65,7 @@ export function createSiteCache(options) {
|
|
|
44
65
|
const maxStaleMs = options.maxStaleMs;
|
|
45
66
|
const refreshMaxMs = positiveMs('refreshMaxMs', options.refreshMaxMs ?? DEFAULT_REFRESH_MAX_MS);
|
|
46
67
|
const maxErrorPauseMs = positiveMs('maxErrorPauseMs', options.maxErrorPauseMs ?? DEFAULT_MAX_ERROR_PAUSE_MS);
|
|
68
|
+
const storeTimeoutMs = timerMs('storeTimeoutMs', options.storeTimeoutMs ?? DEFAULT_STORE_TIMEOUT_MS);
|
|
47
69
|
const { namespace } = options;
|
|
48
70
|
if (namespace !== undefined && (typeof namespace !== 'string' || namespace === '')) {
|
|
49
71
|
throw new TypeError('createSiteCache: namespace must be a non-empty string when set');
|
|
@@ -100,13 +122,32 @@ export function createSiteCache(options) {
|
|
|
100
122
|
entry.error = { at, error };
|
|
101
123
|
noteError(entry, error, at);
|
|
102
124
|
};
|
|
103
|
-
|
|
125
|
+
/**
|
|
126
|
+
* One store call, bounded by `storeTimeoutMs` (re-review M-d): a call that has not settled by
|
|
127
|
+
* then throws `StoreTimeout`, so the key's queue moves on. A synchronous throw is a rejection.
|
|
128
|
+
*/
|
|
129
|
+
const storeCall = async (call) => {
|
|
130
|
+
const settled = await within(new Promise((resolve) => resolve(call())), storeTimeoutMs);
|
|
131
|
+
if (settled === null)
|
|
132
|
+
throw new StoreTimeout();
|
|
133
|
+
if (settled.status === 'rejected')
|
|
134
|
+
throw settled.reason;
|
|
135
|
+
return settled.value;
|
|
136
|
+
};
|
|
137
|
+
const storeUnavailable = (entry, operation, cause) =>
|
|
104
138
|
// The backend's own message is left out: it can name a host or a credential of the store.
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
139
|
+
cause instanceof StoreTimeout
|
|
140
|
+
? new Unavailable({
|
|
141
|
+
code: 'store_unavailable',
|
|
142
|
+
status: 0,
|
|
143
|
+
message: `${entry.key}: the cache store could not be ${operation} within ${storeTimeoutMs} ms`,
|
|
144
|
+
details: { reason: 'timeout', storeTimeoutMs },
|
|
145
|
+
})
|
|
146
|
+
: new Unavailable({
|
|
147
|
+
code: 'store_unavailable',
|
|
148
|
+
status: 0,
|
|
149
|
+
message: `${entry.key}: the cache store could not be ${operation}`,
|
|
150
|
+
});
|
|
110
151
|
const budgetExceeded = (entry, reason, budgetMs) => new Unavailable({
|
|
111
152
|
code: 'refresh_budget_exceeded',
|
|
112
153
|
status: 0,
|
|
@@ -139,14 +180,20 @@ export function createSiteCache(options) {
|
|
|
139
180
|
const freshFor = cacheControl ? freshForMs(cacheControl) : (previous?.freshForMs ?? null);
|
|
140
181
|
const storable = !cacheControl || isStorable(cacheControl);
|
|
141
182
|
await locked(entry, async () => {
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
|
|
183
|
+
// The flight that owns the key writes its answer. An abandoned one (review I1) hands its
|
|
184
|
+
// value to the requests that joined it, records nothing, and writes only when no newer
|
|
185
|
+
// flight has landed and the stored copy is older than its start (re-review M-a): a loader
|
|
186
|
+
// slower than `refreshMaxMs` still lands, never over a newer copy or a NotFound's delete.
|
|
187
|
+
const owner = entry.flight === flight;
|
|
188
|
+
if (!owner && flight.gen <= entry.landedGen)
|
|
145
189
|
return;
|
|
146
190
|
const t = now();
|
|
147
191
|
/** An invalidation counts when it came at or after this flight's start, and not from the future. */
|
|
148
192
|
const counts = (at) => typeof at === 'number' && at >= flight.startedAt && at <= t;
|
|
149
193
|
if (!storable) {
|
|
194
|
+
if (!owner)
|
|
195
|
+
return;
|
|
196
|
+
entry.landedGen = flight.gen;
|
|
150
197
|
entry.error = null;
|
|
151
198
|
absorbInvalidation(entry, flight.startedAt, t);
|
|
152
199
|
return;
|
|
@@ -155,11 +202,16 @@ export function createSiteCache(options) {
|
|
|
155
202
|
// survives it (R6, review I3).
|
|
156
203
|
let current = null;
|
|
157
204
|
try {
|
|
158
|
-
current = await store.get(entry.storeKey);
|
|
205
|
+
current = await storeCall(() => store.get(entry.storeKey));
|
|
159
206
|
}
|
|
160
207
|
catch {
|
|
208
|
+
// The owner writes anyway; an abandoned flight cannot tell whether the copy is newer.
|
|
209
|
+
if (!owner)
|
|
210
|
+
return;
|
|
161
211
|
current = null;
|
|
162
212
|
}
|
|
213
|
+
if (!owner && current !== null && lastRefreshOf(current) >= flight.startedAt)
|
|
214
|
+
return;
|
|
163
215
|
const marks = [current?.invalidatedAt, entry.pendingInvalidationAt].filter(counts);
|
|
164
216
|
const record = {
|
|
165
217
|
value,
|
|
@@ -169,16 +221,23 @@ export function createSiteCache(options) {
|
|
|
169
221
|
invalidatedAt: marks.length > 0 ? Math.max(...marks) : null,
|
|
170
222
|
freshForMs: freshFor,
|
|
171
223
|
};
|
|
224
|
+
// Before the write: a write that fails or times out may still land, and no older flight
|
|
225
|
+
// may follow it.
|
|
226
|
+
entry.landedGen = flight.gen;
|
|
172
227
|
try {
|
|
173
|
-
await store.set(entry.storeKey, record);
|
|
228
|
+
await storeCall(() => store.set(entry.storeKey, record));
|
|
174
229
|
}
|
|
175
230
|
catch (error) {
|
|
176
|
-
// The waiting requests still get the value; the failure pauses like any other. An
|
|
177
|
-
// the SDK minted (store_unserialisable) is kept; a backend's own error is never shown.
|
|
178
|
-
|
|
231
|
+
// The waiting requests still get the value; the owner's failure pauses like any other. An
|
|
232
|
+
// error the SDK minted (store_unserialisable) is kept; a backend's own error is never shown.
|
|
233
|
+
if (owner) {
|
|
234
|
+
refreshFailed(entry, error instanceof SiteSdkError ? error : storeUnavailable(entry, 'written', error), now());
|
|
235
|
+
}
|
|
179
236
|
return;
|
|
180
237
|
}
|
|
181
|
-
|
|
238
|
+
// A late landing clears only an error older than its start: a newer flight's pause stays.
|
|
239
|
+
if (owner || (entry.error !== null && entry.error.at < flight.startedAt))
|
|
240
|
+
entry.error = null;
|
|
182
241
|
absorbInvalidation(entry, flight.startedAt, t);
|
|
183
242
|
mirror(entry, record);
|
|
184
243
|
});
|
|
@@ -194,7 +253,13 @@ export function createSiteCache(options) {
|
|
|
194
253
|
entry.pendingInvalidationAt = null;
|
|
195
254
|
};
|
|
196
255
|
const startFlight = (entry, kind, previous, startedAt, ctx) => {
|
|
197
|
-
|
|
256
|
+
entry.flights += 1;
|
|
257
|
+
const flight = {
|
|
258
|
+
kind,
|
|
259
|
+
startedAt,
|
|
260
|
+
gen: entry.flights,
|
|
261
|
+
promise: Promise.resolve({ ok: true, value: null }),
|
|
262
|
+
};
|
|
198
263
|
const { definition } = entry;
|
|
199
264
|
const load = () => {
|
|
200
265
|
if (kind === 'partial') {
|
|
@@ -219,10 +284,12 @@ export function createSiteCache(options) {
|
|
|
219
284
|
// The source says the resource is gone: a withdrawn text must not stay online (R5).
|
|
220
285
|
// Every other error keeps the copy.
|
|
221
286
|
if (kind === 'full' && error instanceof NotFound) {
|
|
287
|
+
// The answer landed even if the delete fails: no older flight writes after it.
|
|
288
|
+
entry.landedGen = flight.gen;
|
|
222
289
|
await locked(entry, async () => {
|
|
223
290
|
if (entry.flight !== flight)
|
|
224
291
|
return;
|
|
225
|
-
await store.delete(entry.storeKey);
|
|
292
|
+
await storeCall(() => store.delete(entry.storeKey));
|
|
226
293
|
entry.pendingInvalidationAt = null;
|
|
227
294
|
mirror(entry, null);
|
|
228
295
|
}).catch(() => {
|
|
@@ -287,18 +354,17 @@ export function createSiteCache(options) {
|
|
|
287
354
|
const servable = (record, t) => {
|
|
288
355
|
if (maxStaleMs === undefined)
|
|
289
356
|
return true;
|
|
290
|
-
|
|
291
|
-
return Math.max(0, t - last) <= maxStaleMs;
|
|
357
|
+
return Math.max(0, t - lastRefreshOf(record)) <= maxStaleMs;
|
|
292
358
|
};
|
|
293
359
|
const get = async (entry, ctx = {}) => {
|
|
294
360
|
if (ctx.preview !== undefined)
|
|
295
361
|
return previewGet(entry, ctx.preview?.token);
|
|
296
362
|
let record;
|
|
297
363
|
try {
|
|
298
|
-
record = await store.get(entry.storeKey);
|
|
364
|
+
record = await storeCall(() => store.get(entry.storeKey));
|
|
299
365
|
}
|
|
300
|
-
catch {
|
|
301
|
-
const error = storeUnavailable(entry, 'read');
|
|
366
|
+
catch (cause) {
|
|
367
|
+
const error = storeUnavailable(entry, 'read', cause);
|
|
302
368
|
noteError(entry, error, now());
|
|
303
369
|
throw error;
|
|
304
370
|
}
|
|
@@ -382,10 +448,10 @@ export function createSiteCache(options) {
|
|
|
382
448
|
await locked(entry, async () => {
|
|
383
449
|
let record;
|
|
384
450
|
try {
|
|
385
|
-
record = await store.get(entry.storeKey);
|
|
451
|
+
record = await storeCall(() => store.get(entry.storeKey));
|
|
386
452
|
}
|
|
387
|
-
catch {
|
|
388
|
-
const error = storeUnavailable(entry, 'read');
|
|
453
|
+
catch (cause) {
|
|
454
|
+
const error = storeUnavailable(entry, 'read', cause);
|
|
389
455
|
noteError(entry, error, now());
|
|
390
456
|
throw error;
|
|
391
457
|
}
|
|
@@ -396,10 +462,10 @@ export function createSiteCache(options) {
|
|
|
396
462
|
invalidatedAt: Math.max(record.invalidatedAt ?? at, at),
|
|
397
463
|
};
|
|
398
464
|
try {
|
|
399
|
-
await store.set(entry.storeKey, marked);
|
|
465
|
+
await storeCall(() => store.set(entry.storeKey, marked));
|
|
400
466
|
}
|
|
401
|
-
catch {
|
|
402
|
-
const error = storeUnavailable(entry, 'written');
|
|
467
|
+
catch (cause) {
|
|
468
|
+
const error = storeUnavailable(entry, 'written', cause);
|
|
403
469
|
noteError(entry, error, now());
|
|
404
470
|
throw error;
|
|
405
471
|
}
|
|
@@ -438,6 +504,8 @@ export function createSiteCache(options) {
|
|
|
438
504
|
storeKey: k,
|
|
439
505
|
definition: definition,
|
|
440
506
|
flight: null,
|
|
507
|
+
flights: 0,
|
|
508
|
+
landedGen: 0,
|
|
441
509
|
error: null,
|
|
442
510
|
chain: Promise.resolve(),
|
|
443
511
|
pendingInvalidationAt: null,
|