@craftainer/crud-sdk 0.4.0 → 0.5.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
@@ -51,7 +51,9 @@ await heroes.update(1, { name: "Ada" }, { ifMatch: etag }); // 412 -> Preconditi
51
51
 
52
52
  - **Discovery.** The manifest loads on first use through
53
53
  `/.well-known/api-catalog` (the `describedby` link with the manifest
54
- profile), or from `manifestUrl`. Manifest types keep the wire member names
54
+ profile), or from `manifestUrl`. `client.manifest({ refresh: true })`
55
+ reloads it; if the reload fails the error is thrown and the last good
56
+ manifest stays cached. Manifest types keep the wire member names
55
57
  (`skip_param`, `max_age`, ...) and ignore unknown members.
56
58
  - **Same origin.** The client sends the token to every URL it builds, so a
57
59
  manifest `base_path`, `paths.*` or `events.path` that resolves to another
@@ -61,6 +63,20 @@ await heroes.update(1, { name: "Ada" }, { ifMatch: etag }); // 412 -> Preconditi
61
63
  (exact origins: scheme, host and port; an entry that is not an http(s)
62
64
  origin throws at `createClient`). The same rule already applies to the
63
65
  api-catalog's manifest link.
66
+ - **Plain http.** A request that carries an `Authorization` header is
67
+ refused with `InsecureTransportError` (a `ClientError`, thrown before
68
+ anything is sent) when its URL is `http:` and the host is not loopback
69
+ (`localhost`, `*.localhost`, `127.0.0.0/8`, `[::1]`, judged on the parsed
70
+ hostname). The check runs on the request as it is sent, so a middleware that
71
+ rewrites the URL or sets its own `Authorization` is covered. Anonymous
72
+ requests over http are unaffected. To allow it on a trusted network, pass
73
+ `createClient({ baseUrl, allowInsecureHttp: true })`; the codegen CLI has
74
+ `--allow-insecure-http` for `CRUD_SDK_TOKEN`. On a redirect, `fetch` drops
75
+ `Authorization` when the target origin differs (scheme, host or port: so an
76
+ `https:` to `http:` downgrade drops it too, per the WHATWG Fetch
77
+ "HTTP-redirect fetch" step); this relies on the `fetch` you pass behaving
78
+ to spec. `events` streams use the `fetch` you hand to `getEventHub`, which
79
+ the SDK does not wrap: make that `fetch` the client's authenticated one.
64
80
  - **Operations** (each gated on the manifest's capabilities and `paths`,
65
81
  else `CapabilityError`): `list`, `iterAll`, `get`, `create`, `update`,
66
82
  `updateMany`, `delete`, `deleteMany`, `restore({ id } | { filters })`,
@@ -132,10 +148,34 @@ objects, snake_case members).
132
148
  Every connection clears the resource once per cache, including for a
133
149
  subscription that joins an already connected stream, which clears before
134
150
  its gate opens.
151
+ - A write clears what it touches when it succeeds. A write whose outcome is
152
+ unknown (the request threw, or the answer was `5xx`) clears the same, since
153
+ the server may have applied it; a refused write (any other status below `500`) clears
154
+ nothing, except `412`, which evicts the record. A bulk write of unknown outcome clears
155
+ the whole resource, as its ids are not known. A write aborted before it
156
+ was sent clears nothing.
135
157
  - Wire the stream with `invalidateOnEvents(cache, getEventHub({ fetch, url,
136
158
  events }), "heroes")`; `fetch` must be authenticated. Events invalidate and
137
159
  never apply snapshots. `subscribeEvents` is the lower-level entry.
160
+ - One hub serves every subscriber of the same `(fetch, url)`, and the first
161
+ subscriber's `events` options win. A later subscriber's differing
162
+ `resource`, `resume_param`, `replay` or `keepalive_seconds` is ignored; in
163
+ development (`process.env.NODE_ENV !== "production"`, or when there is no
164
+ `process`) it gets a `console.warn` naming the URL and the fields. Pass the
165
+ same manifest `events` object to every subscriber of one stream.
166
+ - A `keepalive_seconds` that is zero, negative or not finite is treated as
167
+ unset: the 15 s default applies (a stream silent for three intervals is dead).
138
168
  - Stores: `MemoryStore` (LRU) and `IndexedDbStore` (store failures are a miss).
169
+ `IndexedDbStore` keeps an `expiresAt` index (database version 2; a version 1
170
+ database upgrades in place and keeps its entries), deletes every expired
171
+ entry once when it opens the database, and takes an optional
172
+ `maxEntries` (a positive integer, default unbounded): a `set` that leaves
173
+ more entries than that deletes the ones with the earliest `expiresAt` until
174
+ the cap holds, a just-written short-ttl entry included. Without
175
+ `maxEntries` nothing but the open-time sweep and per-read expiry removes
176
+ entries. An older release of the package cannot open a version 2 database
177
+ (the browser refuses to downgrade); give the new release a fresh `name` if
178
+ both must coexist.
139
179
  `clearMany(prefixes)` is optional on a custom `CacheStore`.
140
180
  - `cacheStatus(response)` says `hit`, `stale`, `revalidated` or `miss`;
141
181
  `cache.close()` stops background refreshes.
@@ -183,6 +223,15 @@ which returns `{ queryKey }` or `{ predicate }` to spread into
183
223
  `invalidateQueries`/`removeQueries`. Mutations and `bridgeEvents` invalidate
184
224
  every scope of the client's resource, since a write changes what every caller sees.
185
225
 
226
+ The `create`/`update`/`deleteMutationOptions` follow TanStack's "always
227
+ refetch after error or success": `onSettled` invalidates collections (lists,
228
+ stats, predict) and revisions on both outcomes, so a write that failed with a
229
+ `5xx` or a dropped connection (and may have been applied) is refetched too. A
230
+ failed update or delete also invalidates the record's own query. `onSuccess`
231
+ only does `setQueryData` (update) or `removeQueries` (delete), so nothing is
232
+ invalidated twice. If you spread the options into `useMutation` and add your own
233
+ `onSettled`, call the factory's first.
234
+
186
235
  ```ts
187
236
  import { createClient } from "@craftainer/crud-sdk";
188
237
  import {
@@ -16,7 +16,19 @@ async function cacheScope(token) {
16
16
  function staticToken(token) {
17
17
  return () => token;
18
18
  }
19
+ /**
20
+ * Whether `url` is plain `http:` to a host that is not loopback. Loopback is
21
+ * `localhost`, `*.localhost`, `127.0.0.0/8` and `[::1]`, judged on the parsed
22
+ * hostname (so `http://localhost.evil.example` is not loopback). `https:` and
23
+ * any other scheme are never insecure here.
24
+ */
25
+ function isInsecureRemote(url) {
26
+ const { protocol, hostname } = new URL(url);
27
+ if (protocol !== "http:") return false;
28
+ const host = hostname.endsWith(".") ? hostname.slice(0, -1) : hostname;
29
+ return !(host === "localhost" || host.endsWith(".localhost") || host === "[::1]" || /^127(?:\.\d{1,3}){3}$/.test(host));
30
+ }
19
31
  //#endregion
20
- export { cacheScope as n, staticToken as r, ANON as t };
32
+ export { staticToken as i, cacheScope as n, isInsecureRemote as r, ANON as t };
21
33
 
22
- //# sourceMappingURL=auth-DwtNEKrw.mjs.map
34
+ //# sourceMappingURL=auth-C5__H0Ln.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth-C5__H0Ln.mjs","names":[],"sources":["../src/auth.ts"],"sourcesContent":["import type { TokenProvider } from \"./interfaces.ts\";\n\n/** The scope of an anonymous caller. */\nexport const ANON = \"anon\";\n\n/**\n * Cache scope of a caller: `\"anon\"`, or the first 32 hex characters of the\n * SHA-256 of the whole bearer token. Nothing in the token is trusted or\n * decoded (a forged `sub` must not select another caller's entries), and the\n * raw token never leaves this function.\n */\nexport async function cacheScope(\n token: string | null | undefined,\n): Promise<string> {\n if (token === null || token === undefined) {\n return ANON;\n }\n const digest = await crypto.subtle.digest(\n \"SHA-256\",\n new TextEncoder().encode(token),\n );\n return Array.from(new Uint8Array(digest).subarray(0, 16), (byte) =>\n byte.toString(16).padStart(2, \"0\"),\n ).join(\"\");\n}\n\n/** A provider that always returns `token` (a refresh changes nothing). */\nexport function staticToken(token: string | null): TokenProvider {\n return () => token;\n}\n\n/**\n * Whether `url` is plain `http:` to a host that is not loopback. Loopback is\n * `localhost`, `*.localhost`, `127.0.0.0/8` and `[::1]`, judged on the parsed\n * hostname (so `http://localhost.evil.example` is not loopback). `https:` and\n * any other scheme are never insecure here.\n */\nexport function isInsecureRemote(url: string): boolean {\n const { protocol, hostname } = new URL(url);\n if (protocol !== \"http:\") {\n return false;\n }\n // WHATWG URL has already normalised numeric hosts (`127.1` -> `127.0.0.1`)\n // and lower-cased the name; a trailing dot is the same host.\n const host = hostname.endsWith(\".\") ? hostname.slice(0, -1) : hostname;\n return !(\n host === \"localhost\" ||\n host.endsWith(\".localhost\") ||\n host === \"[::1]\" ||\n /^127(?:\\.\\d{1,3}){3}$/.test(host)\n );\n}\n"],"mappings":";;AAGA,MAAa,OAAO;;;;;;;AAQpB,eAAsB,WACpB,OACiB;CACjB,IAAI,UAAU,QAAQ,UAAU,KAAA,GAC9B,OAAO;CAET,MAAM,SAAS,MAAM,OAAO,OAAO,OACjC,WACA,IAAI,YAAY,CAAC,CAAC,OAAO,KAAK,CAChC;CACA,OAAO,MAAM,KAAK,IAAI,WAAW,MAAM,CAAC,CAAC,SAAS,GAAG,EAAE,IAAI,SACzD,KAAK,SAAS,EAAE,CAAC,CAAC,SAAS,GAAG,GAAG,CACnC,CAAC,CAAC,KAAK,EAAE;AACX;;AAGA,SAAgB,YAAY,OAAqC;CAC/D,aAAa;AACf;;;;;;;AAQA,SAAgB,iBAAiB,KAAsB;CACrD,MAAM,EAAE,UAAU,aAAa,IAAI,IAAI,GAAG;CAC1C,IAAI,aAAa,SACf,OAAO;CAIT,MAAM,OAAO,SAAS,SAAS,GAAG,IAAI,SAAS,MAAM,GAAG,EAAE,IAAI;CAC9D,OAAO,EACL,SAAS,eACT,KAAK,SAAS,YAAY,KAC1B,SAAS,WACT,wBAAwB,KAAK,IAAI;AAErC"}
@@ -1,5 +1,5 @@
1
1
  import { a as Middleware, i as MaybePromise, n as CacheStore, r as CrudEvent, t as CacheEntry } from "../interfaces-s73cVfIF.mjs";
2
- import { a as EventsConfig, c as subscribeEvents, d as cachePolicyFromManifest, f as tanstackCachePolicy, i as EventHubOptions, l as CachePolicy, n as EventHandlers, o as SubscribeEventsOptions, r as EventHub, s as getEventHub, t as EventConnection, u as ManifestCacheHints } from "../events-Bp__8_4V.mjs";
2
+ import { a as EventsConfig, c as subscribeEvents, d as cachePolicyFromManifest, f as tanstackCachePolicy, i as EventHubOptions, l as CachePolicy, n as EventHandlers, o as SubscribeEventsOptions, r as EventHub, s as getEventHub, t as EventConnection, u as ManifestCacheHints } from "../events-DdT0AkNE.mjs";
3
3
  //#region src/cache/middleware.d.ts
4
4
  /**
5
5
  * A store may clear several prefixes in one pass (one walk, one transaction);
@@ -98,17 +98,36 @@ interface IndexedDbStoreOptions {
98
98
  /** Database name; default `craftainer-crud-cache`. */
99
99
  readonly name?: string;
100
100
  readonly defaultTtlMs?: number;
101
+ /**
102
+ * Upper bound on stored entries, a positive integer; default unbounded.
103
+ * When a `set` leaves more than this many, the entries with the earliest
104
+ * `expiresAt` are deleted until the cap holds (a just-written entry with a
105
+ * short ttl can be among them). The cap is per database and per store
106
+ * instance: two instances on one `name` with different caps each enforce
107
+ * their own on their writes.
108
+ */
109
+ readonly maxEntries?: number;
101
110
  /** Epoch milliseconds; injectable for tests. */
102
111
  readonly now?: () => number;
103
112
  }
104
113
  export declare class IndexedDbStore implements CacheStore {
105
114
  private readonly name;
106
115
  private readonly defaultTtlMs;
116
+ private readonly maxEntries;
107
117
  private readonly now;
108
118
  private opening;
109
119
  constructor(options?: IndexedDbStoreOptions);
110
120
  private open;
111
- /** Run `work` on the database; a failure drops the handle (so a later call retries) and yields `fallback`. */
121
+ private openAndSweep;
122
+ /** Deletes every entry that has expired (`expiresAt <= now`). */
123
+ private sweep;
124
+ /**
125
+ * Run `work` on the database; a failure yields `fallback`. The handle is
126
+ * shared by every in-flight call, so it is dropped (and closed) only when it
127
+ * is itself unusable: it never opened, or it was closed under us
128
+ * (`InvalidStateError`). Any other failure belongs to one operation (an
129
+ * uncloneable value, a quota error) and leaves the handle to the others.
130
+ */
112
131
  private run;
113
132
  get(key: string): Promise<CacheEntry | undefined>;
114
133
  set(key: string, entry: CacheEntry, ttlMs?: number): Promise<void>;
@@ -1,5 +1,5 @@
1
- import { n as cacheScope, t as ANON } from "../auth-DwtNEKrw.mjs";
2
- import { a as tanstackCachePolicy, i as cachePolicyFromManifest, n as subscribeEvents, r as resetOnce, t as getEventHub } from "../events-DD5dodtF.mjs";
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";
3
3
  //#region src/cache/entry.ts
4
4
  const statuses = /* @__PURE__ */ new WeakMap();
5
5
  /**
@@ -242,7 +242,7 @@ function createCache(options) {
242
242
  let globalGeneration = 0;
243
243
  const generation = (resource) => (generations.get(resource ?? "") ?? 0) + globalGeneration;
244
244
  const bump = (resource) => {
245
- generations.set(resource, generation(resource) + 1);
245
+ generations.set(resource, (generations.get(resource) ?? 0) + 1);
246
246
  };
247
247
  /**
248
248
  * Whether an entry of `kind` may be served without the network right now:
@@ -442,20 +442,29 @@ function createCache(options) {
442
442
  ttlMs: retentionMs(fresh, stale)
443
443
  }];
444
444
  };
445
+ /**
446
+ * The store changes a write calls for. `response` is `undefined` when the
447
+ * request threw: like a `5xx`, the outcome is unknown (the server may have
448
+ * applied the write before the failure), so it clears what a success would
449
+ * have, minus the write-through, and for a bulk op the whole resource (the
450
+ * ids are unknown). Other failures changed nothing.
451
+ */
445
452
  const writeOps = (state, response, body) => {
446
453
  const { op, recordId } = state.ctx;
447
454
  const resource = state.ctx.resource;
448
- const { status } = response;
455
+ const status = response?.status;
449
456
  if (status === 412) return recordId === void 0 ? [] : clears([recordPrefix(resource, recordId)]);
450
- if (status < 200 || status >= 300) return [];
457
+ if ((status === 404 || status === 410) && recordId !== void 0) return clears([recordPrefix(resource, recordId), revisionsPrefix(resource, recordId)]);
458
+ const ok = status !== void 0 && status >= 200 && status < 300;
459
+ if (!ok && status !== void 0 && status < 500) return [];
451
460
  const collections = collectionPrefixes(resource);
452
461
  if (CREATES.has(op)) return clears(collections);
453
462
  if (recordId !== void 0 && (RECORD_WRITES.has(op) || op === "update")) return [...clears([
454
463
  ...collections,
455
464
  revisionsPrefix(resource, recordId),
456
465
  recordPrefix(resource, recordId)
457
- ]), ...op === "update" ? writeThrough(state, response, body, resource, recordId) : []];
458
- if (BULK_OPS.has(op) || op === "restore") {
466
+ ]), ...op === "update" && response !== void 0 && ok ? writeThrough(state, response, body, resource, recordId) : []];
467
+ if (ok && (BULK_OPS.has(op) || op === "restore")) {
459
468
  const ids = idsFromBody(body);
460
469
  if (ids !== void 0) return clears([...collections, ...ids.flatMap((id) => [recordPrefix(resource, id), revisionsPrefix(resource, id)])]);
461
470
  }
@@ -479,6 +488,11 @@ function createCache(options) {
479
488
  result: response,
480
489
  ops: writeOps(state, response, body)
481
490
  } : readPlan(state, response, body);
491
+ await applyOps(state, ops);
492
+ return result;
493
+ };
494
+ /** Apply `ops`, guarding against an invalidation that lands meanwhile. */
495
+ const applyOps = async (state, ops) => {
482
496
  if (state.kind === void 0 && ops.some((op) => op.type === "clear")) bump(state.ctx.resource);
483
497
  const expected = generation(state.ctx.resource);
484
498
  for (const op of ops) await apply(op);
@@ -486,7 +500,6 @@ function createCache(options) {
486
500
  type: "delete",
487
501
  key: op.key
488
502
  });
489
- return result;
490
503
  };
491
504
  const schedule = (state, next) => {
492
505
  const key = state.key;
@@ -498,10 +511,10 @@ function createCache(options) {
498
511
  const signal = background.signal;
499
512
  const headers = new Headers(state.ctx.request.headers);
500
513
  if (entry.etag !== void 0) headers.set("if-none-match", entry.etag);
501
- const request = new Request(state.ctx.request.url, {
502
- method: "GET",
514
+ const request = new Request(state.ctx.request, {
503
515
  headers,
504
- signal
516
+ signal,
517
+ cache: "no-store"
505
518
  });
506
519
  const fresh = {
507
520
  ...state,
@@ -523,7 +536,17 @@ function createCache(options) {
523
536
  const middleware = async (ctx, next) => {
524
537
  const state = await begin(ctx);
525
538
  if (state === void 0) return next(ctx);
526
- if (state.kind === void 0) return finish(state, await next(ctx));
539
+ if (state.kind === void 0) {
540
+ const unsent = ctx.request.signal.aborted;
541
+ let response;
542
+ try {
543
+ response = await next(ctx);
544
+ } catch (error) {
545
+ if (!unsent) await safe(() => applyOps(state, writeOps(state, void 0, void 0)));
546
+ throw error;
547
+ }
548
+ return finish(state, response);
549
+ }
527
550
  state.entry = await safe(() => store.get(state.key));
528
551
  const decision = decide(state, state.entry);
529
552
  const entry = state.entry;
@@ -853,41 +876,102 @@ replaceTraps((oldTraps) => ({
853
876
  //#endregion
854
877
  //#region src/cache/idb.ts
855
878
  /**
856
- * IndexedDB cache store (one object store, out-of-line string keys).
879
+ * IndexedDB cache store (one object store, out-of-line string keys, an
880
+ * `expiresAt` index).
881
+ *
882
+ * Database version 2 adds the `expiresAt` index; upgrading from version 1
883
+ * keeps every entry (the index is built over the stored values). Expired
884
+ * entries are swept once when the database is opened, and `maxEntries` evicts
885
+ * the entries closest to expiry on `set`.
857
886
  *
858
887
  * Every call is wrapped: a blocked database, private mode or a missing
859
888
  * `indexedDB` is a miss (or a no-op), never a failed request. `clear(prefix)`
860
889
  * is one `IDBKeyRange.bound(prefix, prefix + "\uffff")` delete.
861
890
  */
862
891
  const STORE = "entries";
892
+ const BY_EXPIRY = "expiresAt";
893
+ const VERSION = 2;
894
+ /**
895
+ * Observes `tx.done` up front: when an operation throws, the aborted
896
+ * transaction's `done` rejects with nobody left to await it, which would be
897
+ * an unhandled rejection.
898
+ */
899
+ function guarded(tx) {
900
+ tx.done.catch(() => void 0);
901
+ return tx;
902
+ }
903
+ function isInvalidState(error) {
904
+ return typeof error === "object" && error !== null && error.name === "InvalidStateError";
905
+ }
863
906
  var IndexedDbStore = class {
864
907
  name;
865
908
  defaultTtlMs;
909
+ maxEntries;
866
910
  now;
867
911
  opening;
868
912
  constructor(options = {}) {
869
913
  this.name = options.name ?? "craftainer-crud-cache";
870
914
  if (this.name === "") throw new RangeError("IndexedDbStore: name must not be empty");
871
915
  this.defaultTtlMs = options.defaultTtlMs ?? 36e5;
916
+ if (options.maxEntries !== void 0 && !(Number.isInteger(options.maxEntries) && options.maxEntries >= 1)) throw new RangeError("IndexedDbStore: maxEntries must be an integer >= 1");
917
+ this.maxEntries = options.maxEntries;
872
918
  this.now = options.now ?? Date.now;
873
919
  }
874
920
  open() {
875
- this.opening ??= openDB(this.name, 1, { upgrade(db) {
876
- db.createObjectStore(STORE);
877
- } });
921
+ if (this.opening === void 0) {
922
+ const opening = this.openAndSweep(() => {
923
+ if (this.opening === opening) this.opening = void 0;
924
+ });
925
+ this.opening = opening;
926
+ }
878
927
  return this.opening;
879
928
  }
880
- /** Run `work` on the database; a failure drops the handle (so a later call retries) and yields `fallback`. */
929
+ async openAndSweep(onDropped) {
930
+ const db = await openDB(this.name, VERSION, {
931
+ blocking() {
932
+ db.close();
933
+ onDropped();
934
+ },
935
+ terminated: onDropped,
936
+ upgrade(upgrading, oldVersion, _newVersion, tx) {
937
+ if (oldVersion < 1) upgrading.createObjectStore(STORE);
938
+ tx.objectStore(STORE).createIndex(BY_EXPIRY, BY_EXPIRY);
939
+ }
940
+ });
941
+ try {
942
+ await this.sweep(db);
943
+ } catch {}
944
+ return db;
945
+ }
946
+ /** Deletes every entry that has expired (`expiresAt <= now`). */
947
+ async sweep(db) {
948
+ const tx = guarded(db.transaction(STORE, "readwrite"));
949
+ let cursor = await tx.store.index(BY_EXPIRY).openCursor(IDBKeyRange.upperBound(this.now()));
950
+ while (cursor !== null) {
951
+ await cursor.delete();
952
+ cursor = await cursor.continue();
953
+ }
954
+ await tx.done;
955
+ }
956
+ /**
957
+ * Run `work` on the database; a failure yields `fallback`. The handle is
958
+ * shared by every in-flight call, so it is dropped (and closed) only when it
959
+ * is itself unusable: it never opened, or it was closed under us
960
+ * (`InvalidStateError`). Any other failure belongs to one operation (an
961
+ * uncloneable value, a quota error) and leaves the handle to the others.
962
+ */
881
963
  async run(fallback, work) {
882
964
  let used;
965
+ let db;
883
966
  try {
884
967
  used = this.open();
885
- return await work(await used);
886
- } catch {
887
- if (this.opening === used) this.opening = void 0;
888
- try {
889
- (await used)?.close();
890
- } catch {}
968
+ db = await used;
969
+ return await work(db);
970
+ } catch (error) {
971
+ if (db === void 0 || isInvalidState(error)) {
972
+ if (this.opening === used) this.opening = void 0;
973
+ db?.close();
974
+ }
891
975
  return fallback;
892
976
  }
893
977
  }
@@ -908,7 +992,18 @@ var IndexedDbStore = class {
908
992
  expiresAt: this.now() + (ttlMs ?? this.defaultTtlMs),
909
993
  entry
910
994
  };
911
- await db.put(STORE, stored, key);
995
+ const tx = guarded(db.transaction(STORE, "readwrite"));
996
+ await tx.store.put(stored, key);
997
+ if (this.maxEntries !== void 0) {
998
+ let excess = await tx.store.count() - this.maxEntries;
999
+ let cursor = excess > 0 ? await tx.store.index(BY_EXPIRY).openCursor() : null;
1000
+ while (cursor !== null && excess > 0) {
1001
+ await cursor.delete();
1002
+ excess -= 1;
1003
+ cursor = await cursor.continue();
1004
+ }
1005
+ }
1006
+ await tx.done;
912
1007
  });
913
1008
  }
914
1009
  delete(key) {