@craftainer/crud-sdk 0.7.1 → 0.8.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
@@ -127,7 +127,7 @@ await heroes.update(1, { name: "Ada" }, { ifMatch: etag }); // 412 -> Preconditi
127
127
  return next({ ...ctx, request: new Request(ctx.request, { headers }) });
128
128
  };
129
129
  const client = createClient({
130
- baseUrl: "/api/crud",
130
+ baseUrl: `${location.origin}/api/crud`, // absolute: a relative baseUrl throws
131
131
  credentials: "same-origin", // sends the session cookie
132
132
  cacheScope: () => currentUser.sub, // string, or sync/async function
133
133
  onUnauthorized: () => location.assign("/bff/login"),
@@ -164,6 +164,16 @@ await heroes.update(1, { name: "Ada" }, { ifMatch: etag }); // 412 -> Preconditi
164
164
  property.
165
165
  - There is no `headers` option: add static headers such as `X-CSRF` with a
166
166
  middleware, as above.
167
+ - **Logout and user switch.** The SDK does not notice that the session
168
+ changed. On logout, or whenever the `cacheScope` value changes, the app
169
+ must call `cache.clear()` (the `./cache` object; this also drops the
170
+ remembered `If-Match` validators and, with `IndexedDbStore`, the persisted
171
+ entries) and tear down its event streams (call every unsubscribe function
172
+ that `subscribeEvents` / `invalidateOnEvents` returned, or reload the page), so the next user neither reads the previous
173
+ user's data nor receives events from the previous user's stream.
174
+ - `Error.message` is bounded (server `detail` is cut at 200 characters, the
175
+ URL has no query string); the full text stays on `.detail` / `.errors`.
176
+ Treat it as a diagnostic, not as text to show to users.
167
177
 
168
178
  - **Middleware.** > [!WARNING]
169
179
  > Middleware must carry context fields through: build the next context with
@@ -226,6 +236,12 @@ objects, snake_case members).
226
236
  as a read answered `404`/`410` does. A bulk write of unknown outcome clears
227
237
  the whole resource, as its ids are not known. A write aborted before it
228
238
  was sent clears nothing.
239
+ - A `no-store` response (the BFF default) is never stored, but its `ETag` is
240
+ remembered per record and scope (an LRU of 1024) for `If-Match` only:
241
+ `cachedEtag` and `autoIfMatch` use it, a read never does. A newer `ETag`
242
+ replaces it; a write, `412`, `404`/`410`, `DELETE`, an SSE invalidation,
243
+ `clear` or a scope switch drops it (see
244
+ [ADR 0004](docs/adrs/0004-remember-validators-of-no-store-responses.md)).
229
245
  - Wire the stream with `invalidateOnEvents(cache, getEventHub({ fetch, url,
230
246
  events }), "heroes")`; `fetch` must be authenticated. Events invalidate and
231
247
  never apply snapshots. `subscribeEvents` is the lower-level entry.
@@ -237,6 +253,10 @@ events }), "heroes")`; `fetch` must be authenticated. Events invalidate and
237
253
  same manifest `events` object to every subscriber of one stream.
238
254
  - A `keepalive_seconds` that is zero, negative or not finite is treated as
239
255
  unset: the 15 s default applies (a stream silent for three intervals is dead).
256
+ The timer keeps running while handlers do and restarts per event, so a
257
+ handler that takes longer than the dead interval (never settles, say)
258
+ ends the connection (the hub reconnects). A line or event longer than 1 MiB
259
+ also ends it.
240
260
  - Stores: `MemoryStore` (LRU) and `IndexedDbStore` (store failures are a miss).
241
261
  `IndexedDbStore` keeps an `expiresAt` index (database version 2; a version 1
242
262
  database upgrades in place and keeps its entries), deletes every expired
@@ -1,5 +1,5 @@
1
1
  import { n as cacheScope, t as ANON } from "../auth-C5__H0Ln.mjs";
2
- import { a as tanstackCachePolicy, i as cachePolicyFromManifest, n as subscribeEvents, r as resetOnce, t as getEventHub } from "../events-Dfz-rzC8.mjs";
2
+ import { a as tanstackCachePolicy, i as cachePolicyFromManifest, n as subscribeEvents, r as resetOnce, t as getEventHub } from "../events-swCOTQOS.mjs";
3
3
  //#region src/cache/entry.ts
4
4
  const statuses = /* @__PURE__ */ new WeakMap();
5
5
  /**
@@ -189,6 +189,8 @@ async function contextScope(ctx) {
189
189
  //#endregion
190
190
  //#region src/cache/middleware.ts
191
191
  const MIN_RETENTION_MS = 36e5;
192
+ /** Bound on remembered validators (LRU), the same as `MemoryStore`'s default size. */
193
+ const MAX_VALIDATORS = 1024;
192
194
  const MANIFEST_POLICY = cachePolicyFromManifest({});
193
195
  const WRITE_METHODS = /* @__PURE__ */ new Set([
194
196
  "POST",
@@ -249,6 +251,24 @@ function createCache(options) {
249
251
  const inflight = /* @__PURE__ */ new Set();
250
252
  const tasks = /* @__PURE__ */ new Set();
251
253
  const background = new AbortController();
254
+ /**
255
+ * The last ETag seen per record key from any successful response, `no-store`
256
+ * included. RFC 9111 forbids storing such a response, not reading its
257
+ * validator, and the validator is used only for `If-Match` (`cachedEtag`,
258
+ * `autoIfMatch`), never to serve or revalidate content. Map order is the
259
+ * recency order. Keys carry the caller scope, so scopes stay isolated;
260
+ * cleared by the same prefixes as the store.
261
+ */
262
+ const validators = /* @__PURE__ */ new Map();
263
+ const rememberValidator = (key, etag) => {
264
+ validators.delete(key);
265
+ if (etag === void 0) return;
266
+ validators.set(key, etag);
267
+ while (validators.size > MAX_VALIDATORS) validators.delete(validators.keys().next().value);
268
+ };
269
+ const forgetValidators = (prefixes) => {
270
+ for (const key of validators.keys()) if (prefixes === void 0 || prefixes.some((prefix) => key.startsWith(prefix))) validators.delete(key);
271
+ };
252
272
  let globalGeneration = 0;
253
273
  const generation = (resource) => (generations.get(resource ?? "") ?? 0) + globalGeneration;
254
274
  const bump = (resource) => {
@@ -365,6 +385,12 @@ function createCache(options) {
365
385
  ]);
366
386
  return clears([...names].sort().map((name) => kindPrefix(name, "filters")));
367
387
  };
388
+ /** The validator op for a record read or write response (none for other kinds). */
389
+ const validatorOps = (state, response, key) => [{
390
+ type: "validator",
391
+ key,
392
+ etag: state.policy.etag ? response.headers.get("etag") ?? void 0 : void 0
393
+ }];
368
394
  const readPlan = (state, response, body) => {
369
395
  const key = state.key;
370
396
  const kind = state.kind;
@@ -372,6 +398,13 @@ function createCache(options) {
372
398
  const at = now();
373
399
  const { fresh, stale } = entryTimes(state, kind);
374
400
  const old = state.entry;
401
+ const vkey = buildKey({
402
+ resource: state.ctx.resource,
403
+ kind: "record",
404
+ recordId: state.ctx.recordId,
405
+ scope: state.scope,
406
+ apiVersion: state.ctx.apiVersion
407
+ });
375
408
  if (status === 304 && state.validated === true && old !== void 0) {
376
409
  const updated = {
377
410
  ...old,
@@ -388,7 +421,11 @@ function createCache(options) {
388
421
  key,
389
422
  entry: updated,
390
423
  ttlMs: retentionMs(fresh, stale)
391
- }]
424
+ }, ...kind === "record" ? [{
425
+ type: "validator",
426
+ key: vkey,
427
+ etag: updated.etag
428
+ }] : []]
392
429
  };
393
430
  }
394
431
  if (status === 200 && body !== void 0) {
@@ -410,7 +447,7 @@ function createCache(options) {
410
447
  key,
411
448
  entry,
412
449
  ttlMs: retentionMs(fresh, stale)
413
- }];
450
+ }, ...kind === "record" ? validatorOps(state, response, vkey) : []];
414
451
  if (kind === "manifest") ops.push(...manifestOps(old, entry));
415
452
  return {
416
453
  result: response,
@@ -422,7 +459,7 @@ function createCache(options) {
422
459
  ops: [{
423
460
  type: "delete",
424
461
  key
425
- }]
462
+ }, ...kind === "record" && status === 200 ? validatorOps(state, response, vkey) : []]
426
463
  };
427
464
  return {
428
465
  result: response,
@@ -473,7 +510,13 @@ function createCache(options) {
473
510
  ...collections,
474
511
  revisionsPrefix(resource, recordId),
475
512
  recordPrefix(resource, recordId)
476
- ]), ...op === "update" && response !== void 0 && ok ? writeThrough(state, response, body, resource, recordId) : []];
513
+ ]), ...op === "update" && response !== void 0 && ok ? [...writeThrough(state, response, body, resource, recordId), ...validatorOps(state, response, buildKey({
514
+ resource,
515
+ kind: "record",
516
+ recordId,
517
+ scope: state.scope,
518
+ apiVersion: state.ctx.apiVersion
519
+ }))] : []];
477
520
  if (ok && (BULK_OPS.has(op) || op === "restore")) {
478
521
  const ids = idsFromBody(body);
479
522
  if (ids !== void 0) return clears([...collections, ...ids.flatMap((id) => [recordPrefix(resource, id), revisionsPrefix(resource, id)])]);
@@ -482,13 +525,19 @@ function createCache(options) {
482
525
  };
483
526
  /** One pass when the store can, else one `clear` per prefix; a failure is a miss. */
484
527
  const clearPrefixes = async (prefixes) => {
528
+ forgetValidators(prefixes);
485
529
  if (store.clearMany !== void 0) {
486
530
  await safe(() => store.clearMany?.(prefixes));
487
531
  return;
488
532
  }
489
533
  for (const prefix of prefixes) await safe(() => store.clear(prefix));
490
534
  };
491
- const apply = async (op) => {
535
+ const apply = async (op, keepValidator = false) => {
536
+ if (op.type === "validator") {
537
+ rememberValidator(op.key, op.etag);
538
+ return;
539
+ }
540
+ if (op.type === "delete" && !keepValidator) validators.delete(op.key);
492
541
  await safe(() => op.type === "clear" ? clearPrefixes(op.prefixes) : op.type === "set" ? store.set(op.key, op.entry, op.ttlMs) : store.delete(op.key));
493
542
  };
494
543
  /** Plan, then apply, the store changes a response calls for. */
@@ -505,8 +554,12 @@ function createCache(options) {
505
554
  const applyOps = async (state, ops) => {
506
555
  if (state.kind === void 0 && ops.some((op) => op.type === "clear")) bump(state.ctx.resource);
507
556
  const expected = generation(state.ctx.resource);
508
- for (const op of ops) await apply(op);
509
- if (generation(state.ctx.resource) !== expected) for (const op of ops.filter((candidate) => candidate.type === "set")) await apply({
557
+ const stale = state.kind !== void 0 && expected !== state.generation;
558
+ for (const op of ops) {
559
+ if (stale && op.type === "validator") continue;
560
+ await apply(op, stale);
561
+ }
562
+ if (generation(state.ctx.resource) !== expected) for (const op of ops.filter((candidate) => candidate.type !== "clear")) await apply({
510
563
  type: "delete",
511
564
  key: op.key
512
565
  });
@@ -588,13 +641,19 @@ function createCache(options) {
588
641
  return next(ctx);
589
642
  };
590
643
  const lookupEtag = async (resource, recordId, scope, apiVersion) => {
591
- return (await safe(() => store.get(buildKey({
644
+ const key = buildKey({
592
645
  resource,
593
646
  kind: "record",
594
647
  recordId,
595
648
  scope,
596
649
  apiVersion
597
- }))))?.etag;
650
+ });
651
+ const remembered = validators.get(key);
652
+ if (remembered !== void 0) {
653
+ rememberValidator(key, remembered);
654
+ return remembered;
655
+ }
656
+ return (await safe(() => store.get(key)))?.etag;
598
657
  };
599
658
  return {
600
659
  middleware,
@@ -614,6 +673,7 @@ function createCache(options) {
614
673
  },
615
674
  async clear(prefix) {
616
675
  globalGeneration += 1;
676
+ forgetValidators(prefix === void 0 ? void 0 : [prefix]);
617
677
  await safe(() => store.clear(prefix));
618
678
  },
619
679
  cachedEtag: lookupEtag,