@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 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.0.0`, `private` and unpublished until the publishing plan
8
- (changesets, the `wmind` npm organisation, a manual publish workflow).
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
- the abandoned one writes nothing when it settles.
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` never lands.** Each request past `refreshMaxMs` (60 s)
52
- abandons the running flight and starts another, and an abandoned flight writes nothing, so a
53
- loader that always takes longer keeps restarting and its copy never moves. `httpResource` is
54
- bounded by the transport's 30 s deadline and is not affected; a loader making several calls in a
55
- row is. Set `refreshMaxMs` above the loader's worst case. A later version may let an abandoned
56
- flight still write when the stored copy is older than its start.
57
- - **A store call that never settles blocks its key.** Writes to one key are queued in process, so a
58
- `CacheStore` (or `KvBackend`) call that never settles leaves that key's later refresh writes and
59
- `invalidate()` waiting for good; other keys are unaffected. Give the backend its own timeout. A
60
- later version may bound store calls with a budget.
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 writes nothing and records nothing when it settles.
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. Instances sharing a KV store are not serialised: the
16
- * store has no compare-and-set, so an invalidation written by one instance can be overwritten by a
17
- * refresh of another that re-read before it; the periodic full refresh bounds that (§3.2).
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
- const storeUnavailable = (entry, operation) =>
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
- new Unavailable({
106
- code: 'store_unavailable',
107
- status: 0,
108
- message: `${entry.key}: the cache store could not be ${operation}`,
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
- // An abandoned flight (review I1) hands its value to the requests that joined it and
143
- // writes nothing: a newer flight owns the key.
144
- if (entry.flight !== flight)
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 error
177
- // the SDK minted (store_unserialisable) is kept; a backend's own error is never shown.
178
- refreshFailed(entry, error instanceof SiteSdkError ? error : storeUnavailable(entry, 'written'), now());
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
- entry.error = null;
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
- const flight = { kind, startedAt, promise: Promise.resolve({ ok: true, value: null }) };
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
- const last = Math.max(record.lastFullAt, record.lastPartialAt ?? record.lastFullAt);
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wmind/site-core",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Site SDK core: HTTP transport, request-driven refresh, snapshot store, webhooks, status",
5
5
  "license": "MIT",
6
6
  "repository": {