@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 +50 -1
- package/dist/{auth-DwtNEKrw.mjs → auth-C5__H0Ln.mjs} +14 -2
- package/dist/auth-C5__H0Ln.mjs.map +1 -0
- package/dist/cache/index.d.mts +21 -2
- package/dist/cache/index.mjs +118 -24
- package/dist/cache/index.mjs.map +1 -1
- package/dist/{client-B--FzoDe.d.mts → client-Drvc7htU.d.mts} +10 -1
- package/dist/{client-DkSP1q63.mjs → client-nciUfgwW.mjs} +32 -10
- package/dist/client-nciUfgwW.mjs.map +1 -0
- package/dist/codegen/cli.mjs +14 -9
- package/dist/codegen/cli.mjs.map +1 -1
- package/dist/{events-Bp__8_4V.d.mts → events-DdT0AkNE.d.mts} +14 -2
- package/dist/{events-DD5dodtF.mjs → events-Dfz-rzC8.mjs} +57 -6
- package/dist/{events-DD5dodtF.mjs.map → events-Dfz-rzC8.mjs.map} +1 -1
- package/dist/index.d.mts +7 -1
- package/dist/index.mjs +3 -3
- package/dist/tanstack/index.d.mts +26 -6
- package/dist/tanstack/index.mjs +37 -23
- package/dist/tanstack/index.mjs.map +1 -1
- package/package.json +1 -1
- package/dist/auth-DwtNEKrw.mjs.map +0 -1
- package/dist/client-DkSP1q63.mjs.map +0 -1
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`.
|
|
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,
|
|
32
|
+
export { staticToken as i, cacheScope as n, isInsecureRemote as r, ANON as t };
|
|
21
33
|
|
|
22
|
-
//# sourceMappingURL=auth-
|
|
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"}
|
package/dist/cache/index.d.mts
CHANGED
|
@@ -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-
|
|
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
|
-
|
|
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>;
|
package/dist/cache/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { n as cacheScope, t as ANON } from "../auth-
|
|
2
|
-
import { a as tanstackCachePolicy, i as cachePolicyFromManifest, n as subscribeEvents, r as resetOnce, t as getEventHub } from "../events-
|
|
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,
|
|
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
|
|
455
|
+
const status = response?.status;
|
|
449
456
|
if (status === 412) return recordId === void 0 ? [] : clears([recordPrefix(resource, recordId)]);
|
|
450
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
876
|
-
|
|
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
|
-
|
|
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
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
(
|
|
890
|
-
|
|
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
|
-
|
|
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) {
|