@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 +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 +119 -24
- package/dist/cache/index.mjs.map +1 -1
- package/dist/{client-DkSP1q63.mjs → client-CUFoB5IC.mjs} +50 -16
- package/dist/client-CUFoB5IC.mjs.map +1 -0
- package/dist/{client-B--FzoDe.d.mts → client-Drvc7htU.d.mts} +10 -1
- 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,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
|
|
455
|
+
const status = response?.status;
|
|
449
456
|
if (status === 412) return recordId === void 0 ? [] : clears([recordPrefix(resource, recordId)]);
|
|
450
|
-
if (status
|
|
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
|
|
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)
|
|
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
|
-
|
|
876
|
-
|
|
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
|
-
|
|
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
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
(
|
|
890
|
-
|
|
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
|
-
|
|
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) {
|