@craftainer/crud-sdk 0.4.0 → 0.5.0

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,28 @@ 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
+ const ok = status !== void 0 && status >= 200 && status < 300;
458
+ if (!ok && status !== void 0 && status < 500) return [];
451
459
  const collections = collectionPrefixes(resource);
452
460
  if (CREATES.has(op)) return clears(collections);
453
461
  if (recordId !== void 0 && (RECORD_WRITES.has(op) || op === "update")) return [...clears([
454
462
  ...collections,
455
463
  revisionsPrefix(resource, recordId),
456
464
  recordPrefix(resource, recordId)
457
- ]), ...op === "update" ? writeThrough(state, response, body, resource, recordId) : []];
458
- if (BULK_OPS.has(op) || op === "restore") {
465
+ ]), ...op === "update" && response !== void 0 && ok ? writeThrough(state, response, body, resource, recordId) : []];
466
+ if (ok && (BULK_OPS.has(op) || op === "restore")) {
459
467
  const ids = idsFromBody(body);
460
468
  if (ids !== void 0) return clears([...collections, ...ids.flatMap((id) => [recordPrefix(resource, id), revisionsPrefix(resource, id)])]);
461
469
  }
@@ -479,6 +487,11 @@ function createCache(options) {
479
487
  result: response,
480
488
  ops: writeOps(state, response, body)
481
489
  } : readPlan(state, response, body);
490
+ await applyOps(state, ops);
491
+ return result;
492
+ };
493
+ /** Apply `ops`, guarding against an invalidation that lands meanwhile. */
494
+ const applyOps = async (state, ops) => {
482
495
  if (state.kind === void 0 && ops.some((op) => op.type === "clear")) bump(state.ctx.resource);
483
496
  const expected = generation(state.ctx.resource);
484
497
  for (const op of ops) await apply(op);
@@ -486,7 +499,6 @@ function createCache(options) {
486
499
  type: "delete",
487
500
  key: op.key
488
501
  });
489
- return result;
490
502
  };
491
503
  const schedule = (state, next) => {
492
504
  const key = state.key;
@@ -498,10 +510,10 @@ function createCache(options) {
498
510
  const signal = background.signal;
499
511
  const headers = new Headers(state.ctx.request.headers);
500
512
  if (entry.etag !== void 0) headers.set("if-none-match", entry.etag);
501
- const request = new Request(state.ctx.request.url, {
502
- method: "GET",
513
+ const request = new Request(state.ctx.request, {
503
514
  headers,
504
- signal
515
+ signal,
516
+ cache: "no-store"
505
517
  });
506
518
  const fresh = {
507
519
  ...state,
@@ -523,7 +535,17 @@ function createCache(options) {
523
535
  const middleware = async (ctx, next) => {
524
536
  const state = await begin(ctx);
525
537
  if (state === void 0) return next(ctx);
526
- if (state.kind === void 0) return finish(state, await next(ctx));
538
+ if (state.kind === void 0) {
539
+ const unsent = ctx.request.signal.aborted;
540
+ let response;
541
+ try {
542
+ response = await next(ctx);
543
+ } catch (error) {
544
+ if (!unsent) await safe(() => applyOps(state, writeOps(state, void 0, void 0)));
545
+ throw error;
546
+ }
547
+ return finish(state, response);
548
+ }
527
549
  state.entry = await safe(() => store.get(state.key));
528
550
  const decision = decide(state, state.entry);
529
551
  const entry = state.entry;
@@ -853,41 +875,102 @@ replaceTraps((oldTraps) => ({
853
875
  //#endregion
854
876
  //#region src/cache/idb.ts
855
877
  /**
856
- * IndexedDB cache store (one object store, out-of-line string keys).
878
+ * IndexedDB cache store (one object store, out-of-line string keys, an
879
+ * `expiresAt` index).
880
+ *
881
+ * Database version 2 adds the `expiresAt` index; upgrading from version 1
882
+ * keeps every entry (the index is built over the stored values). Expired
883
+ * entries are swept once when the database is opened, and `maxEntries` evicts
884
+ * the entries closest to expiry on `set`.
857
885
  *
858
886
  * Every call is wrapped: a blocked database, private mode or a missing
859
887
  * `indexedDB` is a miss (or a no-op), never a failed request. `clear(prefix)`
860
888
  * is one `IDBKeyRange.bound(prefix, prefix + "\uffff")` delete.
861
889
  */
862
890
  const STORE = "entries";
891
+ const BY_EXPIRY = "expiresAt";
892
+ const VERSION = 2;
893
+ /**
894
+ * Observes `tx.done` up front: when an operation throws, the aborted
895
+ * transaction's `done` rejects with nobody left to await it, which would be
896
+ * an unhandled rejection.
897
+ */
898
+ function guarded(tx) {
899
+ tx.done.catch(() => void 0);
900
+ return tx;
901
+ }
902
+ function isInvalidState(error) {
903
+ return typeof error === "object" && error !== null && error.name === "InvalidStateError";
904
+ }
863
905
  var IndexedDbStore = class {
864
906
  name;
865
907
  defaultTtlMs;
908
+ maxEntries;
866
909
  now;
867
910
  opening;
868
911
  constructor(options = {}) {
869
912
  this.name = options.name ?? "craftainer-crud-cache";
870
913
  if (this.name === "") throw new RangeError("IndexedDbStore: name must not be empty");
871
914
  this.defaultTtlMs = options.defaultTtlMs ?? 36e5;
915
+ if (options.maxEntries !== void 0 && !(Number.isInteger(options.maxEntries) && options.maxEntries >= 1)) throw new RangeError("IndexedDbStore: maxEntries must be an integer >= 1");
916
+ this.maxEntries = options.maxEntries;
872
917
  this.now = options.now ?? Date.now;
873
918
  }
874
919
  open() {
875
- this.opening ??= openDB(this.name, 1, { upgrade(db) {
876
- db.createObjectStore(STORE);
877
- } });
920
+ if (this.opening === void 0) {
921
+ const opening = this.openAndSweep(() => {
922
+ if (this.opening === opening) this.opening = void 0;
923
+ });
924
+ this.opening = opening;
925
+ }
878
926
  return this.opening;
879
927
  }
880
- /** Run `work` on the database; a failure drops the handle (so a later call retries) and yields `fallback`. */
928
+ async openAndSweep(onDropped) {
929
+ const db = await openDB(this.name, VERSION, {
930
+ blocking() {
931
+ db.close();
932
+ onDropped();
933
+ },
934
+ terminated: onDropped,
935
+ upgrade(upgrading, oldVersion, _newVersion, tx) {
936
+ if (oldVersion < 1) upgrading.createObjectStore(STORE);
937
+ tx.objectStore(STORE).createIndex(BY_EXPIRY, BY_EXPIRY);
938
+ }
939
+ });
940
+ try {
941
+ await this.sweep(db);
942
+ } catch {}
943
+ return db;
944
+ }
945
+ /** Deletes every entry that has expired (`expiresAt <= now`). */
946
+ async sweep(db) {
947
+ const tx = guarded(db.transaction(STORE, "readwrite"));
948
+ let cursor = await tx.store.index(BY_EXPIRY).openCursor(IDBKeyRange.upperBound(this.now()));
949
+ while (cursor !== null) {
950
+ await cursor.delete();
951
+ cursor = await cursor.continue();
952
+ }
953
+ await tx.done;
954
+ }
955
+ /**
956
+ * Run `work` on the database; a failure yields `fallback`. The handle is
957
+ * shared by every in-flight call, so it is dropped (and closed) only when it
958
+ * is itself unusable: it never opened, or it was closed under us
959
+ * (`InvalidStateError`). Any other failure belongs to one operation (an
960
+ * uncloneable value, a quota error) and leaves the handle to the others.
961
+ */
881
962
  async run(fallback, work) {
882
963
  let used;
964
+ let db;
883
965
  try {
884
966
  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 {}
967
+ db = await used;
968
+ return await work(db);
969
+ } catch (error) {
970
+ if (db === void 0 || isInvalidState(error)) {
971
+ if (this.opening === used) this.opening = void 0;
972
+ db?.close();
973
+ }
891
974
  return fallback;
892
975
  }
893
976
  }
@@ -908,7 +991,18 @@ var IndexedDbStore = class {
908
991
  expiresAt: this.now() + (ttlMs ?? this.defaultTtlMs),
909
992
  entry
910
993
  };
911
- await db.put(STORE, stored, key);
994
+ const tx = guarded(db.transaction(STORE, "readwrite"));
995
+ await tx.store.put(stored, key);
996
+ if (this.maxEntries !== void 0) {
997
+ let excess = await tx.store.count() - this.maxEntries;
998
+ let cursor = excess > 0 ? await tx.store.index(BY_EXPIRY).openCursor() : null;
999
+ while (cursor !== null && excess > 0) {
1000
+ await cursor.delete();
1001
+ excess -= 1;
1002
+ cursor = await cursor.continue();
1003
+ }
1004
+ }
1005
+ await tx.done;
912
1006
  });
913
1007
  }
914
1008
  delete(key) {