@craftainer/crud-sdk 0.3.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 +66 -2
- 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 +27 -5
- package/dist/cache/index.mjs +136 -28
- package/dist/cache/index.mjs.map +1 -1
- package/dist/{client-BU9rPr8h.d.mts → client-Drvc7htU.d.mts} +19 -2
- package/dist/{client-DJkbw9_q.mjs → client-nciUfgwW.mjs} +74 -17
- package/dist/client-nciUfgwW.mjs.map +1 -0
- package/dist/codegen/cli.mjs +50 -12
- package/dist/codegen/cli.mjs.map +1 -1
- package/dist/{events-DpSWzSN9.d.mts → events-DdT0AkNE.d.mts} +30 -7
- package/dist/{events-Cqj9Rbon.mjs → events-Dfz-rzC8.mjs} +110 -14
- package/dist/events-Dfz-rzC8.mjs.map +1 -0
- package/dist/index.d.mts +7 -1
- package/dist/index.mjs +3 -3
- package/dist/tanstack/index.d.mts +30 -8
- package/dist/tanstack/index.mjs +45 -25
- package/dist/tanstack/index.mjs.map +1 -1
- package/package.json +1 -1
- package/dist/auth-DwtNEKrw.mjs.map +0 -1
- package/dist/client-DJkbw9_q.mjs.map +0 -1
- package/dist/events-Cqj9Rbon.mjs.map +0 -1
package/README.md
CHANGED
|
@@ -51,8 +51,32 @@ 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.
|
|
58
|
+
- **Same origin.** The client sends the token to every URL it builds, so a
|
|
59
|
+
manifest `base_path`, `paths.*` or `events.path` that resolves to another
|
|
60
|
+
origin than `baseUrl`'s (an absolute URL or a protocol-relative `//host`)
|
|
61
|
+
is refused with a `ManifestError`. For a split-origin deployment, list the
|
|
62
|
+
extra origins: `createClient({ baseUrl, allowedOrigins: ["https://files.example"] })`
|
|
63
|
+
(exact origins: scheme, host and port; an entry that is not an http(s)
|
|
64
|
+
origin throws at `createClient`). The same rule already applies to the
|
|
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.
|
|
56
80
|
- **Operations** (each gated on the manifest's capabilities and `paths`,
|
|
57
81
|
else `CapabilityError`): `list`, `iterAll`, `get`, `create`, `update`,
|
|
58
82
|
`updateMany`, `delete`, `deleteMany`, `restore({ id } | { filters })`,
|
|
@@ -102,7 +126,11 @@ crud-sdk-codegen --manifest ./manifest.json --out src/crud-types.ts
|
|
|
102
126
|
Writes `<Name><Version>Read|Create|Update` (and `Draft`) interfaces per
|
|
103
127
|
resource from the manifest's JSON Schemas. `--manifest` is a manifest URL, a
|
|
104
128
|
server origin (discovers through the api-catalog) or a file; the optional
|
|
105
|
-
peer `json-schema-to-typescript` must be installed.
|
|
129
|
+
peer `json-schema-to-typescript` must be installed. The header names the
|
|
130
|
+
source without credentials, query or fragment. Two resources whose names
|
|
131
|
+
map to the same type name (`hero-x` and `hero_x`, same version), or a name
|
|
132
|
+
that is not a valid identifier (a leading digit), fail with a `CrudError`
|
|
133
|
+
naming them.
|
|
106
134
|
|
|
107
135
|
## Caching / TanStack
|
|
108
136
|
|
|
@@ -117,10 +145,37 @@ objects, snake_case members).
|
|
|
117
145
|
- Every read revalidates (`If-None-Match`, `304` rebuilt from the store).
|
|
118
146
|
`max_age`/`stale_while_revalidate` freshness applies only while the
|
|
119
147
|
resource's SSE stream is connected, never with `cache.revalidate_only`.
|
|
148
|
+
Every connection clears the resource once per cache, including for a
|
|
149
|
+
subscription that joins an already connected stream, which clears before
|
|
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.
|
|
120
157
|
- Wire the stream with `invalidateOnEvents(cache, getEventHub({ fetch, url,
|
|
121
158
|
events }), "heroes")`; `fetch` must be authenticated. Events invalidate and
|
|
122
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).
|
|
123
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.
|
|
124
179
|
`clearMany(prefixes)` is optional on a custom `CacheStore`.
|
|
125
180
|
- `cacheStatus(response)` says `hit`, `stale`, `revalidated` or `miss`;
|
|
126
181
|
`cache.close()` stops background refreshes.
|
|
@@ -168,6 +223,15 @@ which returns `{ queryKey }` or `{ predicate }` to spread into
|
|
|
168
223
|
`invalidateQueries`/`removeQueries`. Mutations and `bridgeEvents` invalidate
|
|
169
224
|
every scope of the client's resource, since a write changes what every caller sees.
|
|
170
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
|
+
|
|
171
235
|
```ts
|
|
172
236
|
import { createClient } from "@craftainer/crud-sdk";
|
|
173
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
|
|
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>;
|
|
@@ -157,10 +176,13 @@ export declare function eventPrefixes(resource: string, action: string, recordId
|
|
|
157
176
|
/**
|
|
158
177
|
* Subscribe `cache` to `hub` for the manifest resource `resource`. Events
|
|
159
178
|
* clear the spec's prefixes, a reconnect clears the resource, and fresh hits
|
|
160
|
-
* are allowed only while the stream is connected.
|
|
161
|
-
*
|
|
179
|
+
* are allowed only while the stream is connected. The cache is cleared once
|
|
180
|
+
* per connection, whenever it subscribes: a subscription that joins an already
|
|
181
|
+
* connected hub (or during the handshake) clears the resource first, unless
|
|
182
|
+
* the cache was already cleared on that connection, and only then opens the
|
|
183
|
+
* gate. Returns the unsubscribe (it also closes the freshness gate).
|
|
162
184
|
*/
|
|
163
185
|
export declare function invalidateOnEvents(cache: Cache, hub: EventHub, resource: string): () => void;
|
|
164
186
|
//#endregion
|
|
165
|
-
export { type Cache, type CacheEntry, type CacheKind, type CacheOptions, type CachePolicy, type CacheStatus, type CacheStore, type CacheStoreMany, type CrudEvent, type EventHandlers, type EventHub, type EventHubOptions, type EventsConfig, type IndexedDbStoreOptions, type KeyParts, type ManifestCacheHints, type MemoryStoreOptions, type SubscribeEventsOptions, cachePolicyFromManifest, getEventHub, subscribeEvents, tanstackCachePolicy };
|
|
187
|
+
export { type Cache, type CacheEntry, type CacheKind, type CacheOptions, type CachePolicy, type CacheStatus, type CacheStore, type CacheStoreMany, type CrudEvent, type EventConnection, type EventHandlers, type EventHub, type EventHubOptions, type EventsConfig, type IndexedDbStoreOptions, type KeyParts, type ManifestCacheHints, type MemoryStoreOptions, type SubscribeEventsOptions, cachePolicyFromManifest, getEventHub, subscribeEvents, tanstackCachePolicy };
|
|
166
188
|
//# sourceMappingURL=index.d.mts.map
|
package/dist/cache/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { n as cacheScope, t as ANON } from "../auth-
|
|
2
|
-
import {
|
|
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) {
|
|
@@ -940,8 +1034,11 @@ const connected = /* @__PURE__ */ new WeakMap();
|
|
|
940
1034
|
/**
|
|
941
1035
|
* Subscribe `cache` to `hub` for the manifest resource `resource`. Events
|
|
942
1036
|
* clear the spec's prefixes, a reconnect clears the resource, and fresh hits
|
|
943
|
-
* are allowed only while the stream is connected.
|
|
944
|
-
*
|
|
1037
|
+
* are allowed only while the stream is connected. The cache is cleared once
|
|
1038
|
+
* per connection, whenever it subscribes: a subscription that joins an already
|
|
1039
|
+
* connected hub (or during the handshake) clears the resource first, unless
|
|
1040
|
+
* the cache was already cleared on that connection, and only then opens the
|
|
1041
|
+
* gate. Returns the unsubscribe (it also closes the freshness gate).
|
|
945
1042
|
*/
|
|
946
1043
|
function invalidateOnEvents(cache, hub, resource) {
|
|
947
1044
|
const byResource = connected.get(cache) ?? /* @__PURE__ */ new Map();
|
|
@@ -949,6 +1046,8 @@ function invalidateOnEvents(cache, hub, resource) {
|
|
|
949
1046
|
const live = byResource.get(resource) ?? /* @__PURE__ */ new Set();
|
|
950
1047
|
byResource.set(resource, live);
|
|
951
1048
|
const self = {};
|
|
1049
|
+
let latest = 0;
|
|
1050
|
+
const reset = (connection) => resetOnce(connection, cache, resource, () => cache.clearResource(resource));
|
|
952
1051
|
const mark = (up) => {
|
|
953
1052
|
if (up) live.add(self);
|
|
954
1053
|
else live.delete(self);
|
|
@@ -956,10 +1055,19 @@ function invalidateOnEvents(cache, hub, resource) {
|
|
|
956
1055
|
};
|
|
957
1056
|
const unsubscribe = hub.subscribe({
|
|
958
1057
|
onEvent: (event) => cache.invalidateEvent(resource, event.action, event.recordId),
|
|
959
|
-
onReset: () => cache.clearResource(resource),
|
|
960
|
-
onStatus:
|
|
1058
|
+
onReset: (connection) => connection === void 0 ? cache.clearResource(resource) : reset(connection),
|
|
1059
|
+
onStatus: async (up, connection) => {
|
|
1060
|
+
latest += 1;
|
|
1061
|
+
const mine = latest;
|
|
1062
|
+
if (up && connection !== void 0) {
|
|
1063
|
+
await reset(connection);
|
|
1064
|
+
if (mine !== latest) return;
|
|
1065
|
+
}
|
|
1066
|
+
mark(up);
|
|
1067
|
+
}
|
|
961
1068
|
});
|
|
962
1069
|
return () => {
|
|
1070
|
+
latest += 1;
|
|
963
1071
|
try {
|
|
964
1072
|
unsubscribe();
|
|
965
1073
|
} finally {
|