@plitzi/sdk-shared 0.35.10 → 0.36.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/CHANGELOG.md CHANGED
@@ -1,5 +1,55 @@
1
1
  # @plitzi/sdk-shared
2
2
 
3
+ ## 0.36.1
4
+
5
+ ### Patch Changes
6
+
7
+ - v0.36.1
8
+
9
+ ## 0.36.0
10
+
11
+ ### Minor Changes
12
+
13
+ - - **An `apiContainer` can keep its browser requests in a query cache, the way react-query does.** Moving between
14
+ sections or pages used to ask the API again every time a provider was shown: a provider in a hidden section is
15
+ mounted but disabled, so showing it fired a fresh request, and a page navigation remounted it and did the same.
16
+ With the new `cache: true` an answer is kept for `staleTime` seconds (30 by default) and shared by every cached
17
+ provider asking the same thing — same method, URL, credentials and headers, the token included, so two visitors
18
+ never share one. Past its time the answer is still drawn at once and a fresh one is fetched behind it; an answer
19
+ nobody shows is forgotten after `gcTime` seconds (300 by default). A refused request (`4xx`/`5xx`) is shown but
20
+ never kept. **The cache is off unless an element asks for it**: an uncached provider behaves as before, asking on
21
+ every mount and sharing nothing.
22
+
23
+ The cache is `@plitzi/sdk-shared/queries` (`queryCache`, `useQuery`, `invalidateQueries`,
24
+ `invalidateQueriesForWrite`), a nexus store written with the per-path `ttl` of `@plitzi/nexus` 1.2.0, which every
25
+ consumer now requires. It reacts to the store's own freshness events, so anything that expires a query's path
26
+ makes the providers on screen ask again.
27
+
28
+ An answer stops counting as current before its time when the element's `performQuery` runs (it always asks
29
+ again), when a flow runs the new global `invalidateQueries` step (source `queries`: `elements`, api container ids
30
+ — a container's requests are tagged with its own id — and/or a `url` prefix), and after a write. Both write steps
31
+ gained `invalidateQueries` / `invalidateElements`: a `webHook` sent with anything but `GET`/`HEAD` refreshes the
32
+ requests to its own site by default, a completed `runServerAction` refreshes all of them by default, and either
33
+ can name containers instead or refresh nothing. A `writeRecord` refreshes all. Providers on screen ask again at
34
+ once; the rest when they are next shown. A sign-in, sign-out or change of account drops everything held. Server-driven
35
+ providers and RSC are untouched.
36
+
37
+ - **A `webHook` that reads can be cached** (`cache`, `staleTime`), in the same cache and under the same key as an api
38
+ container asking the same thing. Its declaration now lives beside it (`utility/webHookSpec`) and the authoring
39
+ catalog gathers it instead of keeping a copy. A `HEAD` is no longer sent with a body, which `fetch` refused.
40
+
41
+ - **Step params can pick several elements.** A new param type, `elementIds` (with an `elementType` filter), is drawn
42
+ in the flow editor as a picker: the matching elements of the space to add, and the picked ones as chips to remove —
43
+ an id no element answers to any more is kept and flagged. The value is a list of ids, so nobody types a
44
+ comma-separated list; `invalidateQueries.elements` and the write steps' `invalidateElements` use it, and the MCP
45
+ catalog says which element type each such param takes.
46
+
47
+ - The dev-tools' Store tab lists the paths a store holds with a TTL — how long ago each was written, what is left
48
+ of it, and a button to expire one or all of them. The query cache appears there as "Queries".
49
+
50
+ - `useApi` no longer takes `params`: nothing passed them, and a GET cannot carry a body anyway. A mock now answers
51
+ synchronously, without a loading frame, and a provider whose URL has not resolved asks for nothing.
52
+
3
53
  ## 0.35.10
4
54
 
5
55
  ### Patch Changes
@@ -5,7 +5,8 @@ var e = {
5
5
  select: "select",
6
6
  boolean: "boolean",
7
7
  number: "text",
8
- scalar: "text"
8
+ scalar: "text",
9
+ elementIds: "elements"
9
10
  }, t = (e) => e.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[-_\s]+/).map((e) => `${e.charAt(0).toUpperCase()}${e.slice(1)}`).join(" "), n = (n, r) => ({
10
11
  type: r.builderType ?? e[r.type],
11
12
  label: r.label ?? t(n),
@@ -15,7 +16,8 @@ var e = {
15
16
  label: r.optionLabels?.[e] ?? t(e)
16
17
  })) } : {},
17
18
  ...r.when ? { when: r.when } : {},
18
- ...r.canBind === void 0 ? {} : { canBind: r.canBind }
19
+ ...r.canBind === void 0 ? {} : { canBind: r.canBind },
20
+ ...r.elementType === void 0 ? {} : { elementType: r.elementType }
19
21
  }), r = (e) => Object.fromEntries(Object.entries(e).map(([e, t]) => [e, n(e, t)])), i = (e, t, n, i = {}) => ({
20
22
  action: e,
21
23
  title: t.title,
@@ -1,9 +1,9 @@
1
1
  import { InteractionParamType } from '../types';
2
- export type BuiltinParamType = 'text' | 'textarea' | 'select' | 'boolean' | 'number' | 'scalar';
2
+ export type BuiltinParamType = 'text' | 'textarea' | 'select' | 'boolean' | 'number' | 'scalar' | 'elementIds';
3
3
  export interface BuiltinParam {
4
4
  type: BuiltinParamType;
5
5
  description: string;
6
- default?: string | number | boolean;
6
+ default?: string | number | boolean | string[];
7
7
  options?: string[];
8
8
  when?: (params: Record<string, unknown>) => boolean;
9
9
  required?: boolean;
@@ -18,6 +18,8 @@ export interface BuiltinParam {
18
18
  builderType?: InteractionParamType | ((params: Record<string, unknown>) => InteractionParamType);
19
19
  /** Whether the editor offers to bind this param to a data source. Bindable unless a param says otherwise. */
20
20
  canBind?: boolean;
21
+ /** For `elementIds`: the element type the editor offers (e.g. `apiContainer`); every element without one. */
22
+ elementType?: string;
21
23
  }
22
24
  export type ParamSpec = Record<string, BuiltinParam>;
23
25
  /** Declared params the agent supplied that end up HIDDEN because their own `when` guard is false against the
@@ -16,6 +16,7 @@ var t = (e, t, n) => {
16
16
  case "scalar": return typeof e == "string" || typeof e == "number" || typeof e == "boolean";
17
17
  case "text":
18
18
  case "textarea": return typeof e == "string";
19
+ case "elementIds": return Array.isArray(e) && e.every((e) => typeof e == "string");
19
20
  default: return !0;
20
21
  }
21
22
  }, o = (e, t, n) => {
@@ -0,0 +1,56 @@
1
+ import { default as queryCache, QueryMeta } from './queryCache';
2
+ export { QueryCache, GC_TIME, queryId, queryPath } from './queryCache';
3
+ export { default as useQuery } from './useQuery';
4
+ export type { QueriesState, QueryEntry, QueryMeta, QueryObserverOptions } from './queryCache';
5
+ export type { UseQueryOptions, UseQueryResult } from './useQuery';
6
+ /**
7
+ * Every query that could have read what `url` just wrote: the ones on the same origin.
8
+ *
9
+ * The origin, not the path: `POST /api/cart/items` changes what `GET /api/cart` answers, and no path rule tells
10
+ * those apart from two unrelated resources. Another origin is another backend, and nothing it writes shows up here.
11
+ */
12
+ export declare const invalidateQueriesForWrite: (url: string) => Promise<void>;
13
+ /** Ids written as a step writes them — `orders, members` — into a list, blanks dropped. */
14
+ export declare const parseIds: (value: unknown) => string[];
15
+ /** What an invalidation picks: requests whose URL starts with `url`, and/or the ones the named elements made. */
16
+ export type QuerySelector = {
17
+ url?: string;
18
+ elements?: readonly string[];
19
+ };
20
+ /**
21
+ * The queries a selector picks — every one of them for an empty selector. Both parts must hold when both are given.
22
+ *
23
+ * The URL is compared resolved, so `/api/orders` matches the `https://host/api/orders?page=2` a provider asked for.
24
+ * An element is named by its id, which is the tag its requests carry: the way to reach a request whose URL is a
25
+ * template nobody can write down.
26
+ */
27
+ export declare const matchesQuery: ({ url, elements }: QuerySelector) => (meta: QueryMeta) => boolean;
28
+ /** The `invalidateQueries` step, and anything else that knows which data changed. */
29
+ export declare const invalidateQueries: (selector?: QuerySelector) => Promise<void>;
30
+ /**
31
+ * One key for one request, whoever makes it — an api container and a webhook step asking the same thing share the
32
+ * answer. The headers carry the visitor's token, so two visitors never share one; sorted, so the order somebody
33
+ * typed them in does not split one; and without `Content-Type`, which says how a body is written, not what comes back.
34
+ */
35
+ export declare const requestKey: ({ method, url, credentials, headers }: {
36
+ method: string;
37
+ url: string;
38
+ credentials: string;
39
+ headers: Record<string, string>;
40
+ }) => string;
41
+ export { queryCache };
42
+ /** What a write step refreshes when it succeeds. `origin` needs the URL the write went to. */
43
+ export type WriteInvalidation = 'origin' | 'all' | 'elements' | 'none';
44
+ /**
45
+ * The refresh a write step asked for, once it succeeded.
46
+ *
47
+ * `elements` with no ids refreshes nothing: an author who picked "these containers" and named none did not mean all
48
+ * of them. A mode nobody recognises is treated as the caller's default rather than as `none`, so a typo still
49
+ * refreshes the page instead of leaving it showing data from before the write.
50
+ */
51
+ export declare const invalidateAfterWrite: ({ mode, fallback, elements, url }: {
52
+ mode: unknown;
53
+ fallback: WriteInvalidation;
54
+ elements?: unknown;
55
+ url?: string;
56
+ }) => Promise<void>;
@@ -0,0 +1,37 @@
1
+ import e, { GC_TIME as t, QueryCache as n, queryId as r, queryPath as i } from "./queryCache.mjs";
2
+ import a from "./useQuery.mjs";
3
+ //#region src/queries/index.ts
4
+ var o = () => typeof location > "u" ? void 0 : location.href, s = (e) => {
5
+ try {
6
+ return new URL(e, o()).origin;
7
+ } catch {
8
+ return;
9
+ }
10
+ }, c = (t) => {
11
+ let n = s(t);
12
+ return n ? e.invalidate((e) => s(e.url) === n) : Promise.resolve();
13
+ }, l = (e) => Array.isArray(e) ? e.filter((e) => typeof e == "string" && e.trim() !== "").map((e) => e.trim()) : typeof e == "string" ? e.split(",").map((e) => e.trim()).filter(Boolean) : [], u = (e) => {
14
+ try {
15
+ return new URL(e, o()).href;
16
+ } catch {
17
+ return e;
18
+ }
19
+ }, d = ({ url: e, elements: t }) => {
20
+ let n = e ? u(e) : void 0;
21
+ return (e) => (n === void 0 || u(e.url).startsWith(n)) && (!t?.length || t.some((t) => e.tags?.includes(t) ?? !1));
22
+ }, f = (t = {}) => t.url || t.elements?.length ? e.invalidate(d(t)) : e.invalidate(), p = ({ method: e, url: t, credentials: n, headers: r }) => JSON.stringify([
23
+ e.toLowerCase(),
24
+ t,
25
+ n,
26
+ Object.entries(r).filter(([e]) => e.toLowerCase() !== "content-type").sort(([e], [t]) => e < t ? -1 : +(e > t))
27
+ ]), m = ({ mode: e, fallback: t, elements: n, url: r }) => {
28
+ let i = e === "origin" || e === "all" || e === "elements" || e === "none" ? e : t;
29
+ if (i === "none") return Promise.resolve();
30
+ if (i === "elements") {
31
+ let e = l(n);
32
+ return e.length > 0 ? f({ elements: e }) : Promise.resolve();
33
+ }
34
+ return i === "origin" && r ? c(r) : f();
35
+ };
36
+ //#endregion
37
+ export { t as GC_TIME, n as QueryCache, m as invalidateAfterWrite, f as invalidateQueries, c as invalidateQueriesForWrite, d as matchesQuery, l as parseIds, e as queryCache, r as queryId, i as queryPath, p as requestKey, a as useQuery };
@@ -0,0 +1,116 @@
1
+ import { StoreApi } from '@plitzi/nexus';
2
+ /** How long, by default, an answer nobody is rendering is kept, so coming back within it still paints at once. */
3
+ export declare const GC_TIME: number;
4
+ /**
5
+ * What a query is about, for the invalidations that pick queries by what they read rather than by key: the URL it
6
+ * asks, and the names its author gave it — the way to invalidate a request whose URL is a template.
7
+ */
8
+ export type QueryMeta = {
9
+ url: string;
10
+ tags?: readonly string[];
11
+ };
12
+ /** One query as the store holds it: what a hook renders from and what the dev-tools show. */
13
+ export type QueryEntry = {
14
+ key: string;
15
+ url: string;
16
+ /** Every tag anybody asking this query gave it. */
17
+ tags: string[];
18
+ data?: unknown;
19
+ error?: unknown;
20
+ isFetching: boolean;
21
+ };
22
+ export type QueriesState = {
23
+ entries: Partial<Record<string, QueryEntry>>;
24
+ };
25
+ export type QueryObserverOptions<T> = {
26
+ meta: QueryMeta;
27
+ fetcher: () => Promise<T>;
28
+ /** How long an answer counts as current, in milliseconds. `0` asks again on every mount. */
29
+ staleTime: number;
30
+ /** An answer this rejects is shown but never trusted: the next observer to mount asks again. */
31
+ isCacheable?: (data: T) => boolean;
32
+ };
33
+ /**
34
+ * One path segment per key.
35
+ *
36
+ * A key carries a URL, and a URL is full of dots — the store's path separator — so the key cannot be the segment
37
+ * itself. cyrb53: 53 bits keep a collision out of reach for the queries one page makes, and the entry stores its
38
+ * full key besides, so a collision would read as a miss rather than as somebody else's answer.
39
+ */
40
+ export declare const queryId: (key: string) => string;
41
+ export declare const queryPath: (key: string) => `entries.${string}`;
42
+ /**
43
+ * Answers to read requests, shared by everything that asks the same question — modelled on react-query.
44
+ *
45
+ * An answer is served while it is fresh, served AND asked again in the background once it is stale, and asked again
46
+ * at once when something says the data behind it changed. The answers live in a nexus store written with the TTL of
47
+ * whoever asked, so "is this still good?" is the store's own `isStale`, and the dev-tools show exactly what is held.
48
+ *
49
+ * A query is "active" while an enabled observer renders it. A provider inside a hidden tab is mounted but not active,
50
+ * so an invalidation marks its answer stale and it asks when it is shown again, not for a branch nobody sees.
51
+ *
52
+ * Memory only, and module-scoped like the action-runs store: the page's own session is the only one it can hold, and
53
+ * {@link QueryCache.reset} is how a change of session makes sure nothing of the previous one is ever served. Nothing
54
+ * fills it during a server render — observers attach in effects.
55
+ */
56
+ export declare class QueryCache {
57
+ readonly store: StoreApi<QueriesState>;
58
+ private runtimes;
59
+ /** Bumped by {@link reset}: an answer carrying an older epoch belongs to a session that has ended. */
60
+ private epochValue;
61
+ private unwatch;
62
+ constructor(store?: StoreApi<QueriesState>);
63
+ get epoch(): number;
64
+ /** The entry for `key`; a different key that hashed to the same segment is not it. */
65
+ getEntry(key: string): QueryEntry | undefined;
66
+ /** Whether `key` has been answered in this session — every answer is written with a TTL, even a distrusted one. */
67
+ hasAnswer(key: string): boolean;
68
+ /**
69
+ * Keeps `key` from being collected for as long as the returned function is not called, and for `gcTime` after.
70
+ * The latest reader's `gcTime` is the one kept.
71
+ */
72
+ hold(key: string, gcTime?: number): () => void;
73
+ /** Makes `key` active for as long as the returned function is not called, asking for it if it is not current. */
74
+ observe<T>(key: string, options: QueryObserverOptions<T>): () => void;
75
+ /**
76
+ * The answer for `key`, asking only when what is held is not current — for a caller that is not a component: a
77
+ * flow step reading a URL. Nobody holds the entry afterwards, so it is collected `gcTime` after this.
78
+ */
79
+ fetchQuery<T>(key: string, options: QueryObserverOptions<T> & {
80
+ gcTime?: number;
81
+ }): Promise<T | undefined>;
82
+ /** Asks again for `key` now, whatever the age of what is held — the explicit "reload this". */
83
+ refetch(key: string): Promise<void>;
84
+ /**
85
+ * Marks every query `matches` accepts (all of them without one) as stale, and asks again for the active ones.
86
+ *
87
+ * The inactive ones keep what they hold and ask when they are next shown, which is the whole saving: a write does
88
+ * not turn into a request for every provider on every tab the visitor is not looking at.
89
+ */
90
+ invalidate(matches?: (meta: QueryMeta) => boolean): Promise<void>;
91
+ /**
92
+ * Forgets every answer, for a change of who is looking.
93
+ *
94
+ * Invalidating would not do: a stale query still SHOWS what it holds until the new answer lands, and what it holds
95
+ * belongs to the previous visitor. The epoch makes a request still in flight from before land nowhere.
96
+ */
97
+ reset(): Promise<void>;
98
+ /**
99
+ * Expiring the path is the invalidation: {@link onExpired} does the rest, for this call and for anybody else who
100
+ * expires a query's path — the dev-tools' "Expire" included. A query that has never answered has no record to
101
+ * expire, so it is marked outdated here, which is what makes a request already out be asked again.
102
+ */
103
+ private invalidateOne;
104
+ /** A query's path was expired: whatever is out now answers from before it, and whoever is looking asks again. */
105
+ private onExpired;
106
+ private fetchActive;
107
+ private fetch;
108
+ /** The strictest TTL among those rendering the query, so no observer is served an answer older than it accepts. */
109
+ private ttlOf;
110
+ private ensure;
111
+ private cancelGc;
112
+ private scheduleGc;
113
+ private collect;
114
+ }
115
+ declare const queryCache: QueryCache;
116
+ export default queryCache;
@@ -0,0 +1,166 @@
1
+ import { createStore as e } from "@plitzi/nexus";
2
+ //#region src/queries/queryCache.ts
3
+ var t = 3e5, n = (e) => {
4
+ let t = 3735928559, n = 1103547991;
5
+ for (let r = 0; r < e.length; r++) {
6
+ let i = e.charCodeAt(r);
7
+ t = Math.imul(t ^ i, 2654435761), n = Math.imul(n ^ i, 1597334677);
8
+ }
9
+ return t = Math.imul(t ^ t >>> 16, 2246822507) ^ Math.imul(n ^ n >>> 13, 3266489909), n = Math.imul(n ^ n >>> 16, 2246822507) ^ Math.imul(t ^ t >>> 13, 3266489909), (4294967296 * (2097151 & n) + (t >>> 0)).toString(36);
10
+ }, r = (e) => `entries.${n(e)}`, i = class {
11
+ store;
12
+ runtimes = /* @__PURE__ */ new Map();
13
+ epochValue = 0;
14
+ unwatch;
15
+ constructor(t = e({ entries: {} }, { id: "queries" })) {
16
+ this.store = t;
17
+ }
18
+ get epoch() {
19
+ return this.epochValue;
20
+ }
21
+ getEntry(e) {
22
+ let t = this.store.getPath(r(e));
23
+ return t?.key === e ? t : void 0;
24
+ }
25
+ hasAnswer(e) {
26
+ return this.getEntry(e) !== void 0 && this.store.getFreshness(r(e)) !== void 0;
27
+ }
28
+ hold(e, n = t) {
29
+ let r = this.ensure(e);
30
+ return r.gcTime = n, r.holds++, this.cancelGc(r), () => {
31
+ r.holds--, this.scheduleGc(r);
32
+ };
33
+ }
34
+ observe(e, t) {
35
+ let n = this.ensure(e), { isCacheable: i } = t, a = {
36
+ meta: t.meta,
37
+ staleTime: t.staleTime,
38
+ fetcher: t.fetcher,
39
+ isCacheable: i && ((e) => i(e))
40
+ };
41
+ return n.observers.add(a), this.cancelGc(n), (!this.hasAnswer(e) || this.store.isStale(r(e))) && this.fetch(n, a), () => {
42
+ n.observers.delete(a), this.scheduleGc(n);
43
+ };
44
+ }
45
+ async fetchQuery(e, t) {
46
+ let n = this.ensure(e);
47
+ if (t.gcTime !== void 0 && (n.gcTime = t.gcTime), this.hasAnswer(e) && !this.store.isStale(r(e))) return this.scheduleGc(n), this.getEntry(e)?.data;
48
+ let { isCacheable: i } = t;
49
+ await this.fetch(n, {
50
+ meta: t.meta,
51
+ staleTime: t.staleTime,
52
+ fetcher: t.fetcher,
53
+ isCacheable: i && ((e) => i(e))
54
+ });
55
+ let a = this.getEntry(e)?.data;
56
+ return this.scheduleGc(n), a;
57
+ }
58
+ refetch(e) {
59
+ let t = this.runtimes.get(n(e));
60
+ return t ? this.invalidateOne(t) : Promise.resolve();
61
+ }
62
+ invalidate(e) {
63
+ let t = [];
64
+ return this.runtimes.forEach((n) => {
65
+ let r = this.getEntry(n.key);
66
+ !r || e && !e({
67
+ url: r.url,
68
+ tags: r.tags
69
+ }) || t.push(this.invalidateOne(n));
70
+ }), Promise.all(t).then(() => void 0);
71
+ }
72
+ reset() {
73
+ this.epochValue++, this.runtimes.forEach((e) => {
74
+ e.version++, e.inFlight = void 0;
75
+ }), this.store.setState("entries", {});
76
+ let e = [];
77
+ return this.runtimes.forEach((t) => e.push(this.fetchActive(t))), Promise.all(e).then(() => void 0);
78
+ }
79
+ invalidateOne(e) {
80
+ return this.store.expire(r(e.key)).length === 0 && e.version++, this.fetchActive(e);
81
+ }
82
+ onExpired(e) {
83
+ let t = this.runtimes.get(e.slice(8).split(".")[0]);
84
+ t && (t.version++, this.fetchActive(t));
85
+ }
86
+ fetchActive(e) {
87
+ let t = e.observers.values().next();
88
+ return t.done ? Promise.resolve() : this.fetch(e, t.value);
89
+ }
90
+ fetch(e, t) {
91
+ if (e.inFlight) return e.inFlight;
92
+ let { key: i } = e, { url: a } = t.meta;
93
+ t.meta.tags?.forEach((t) => e.tags.add(t));
94
+ let o = [...e.tags], s = r(i);
95
+ this.getEntry(i) ? this.store.setState(`${s}.isFetching`, !0) : this.store.setState(s, {
96
+ key: i,
97
+ url: a,
98
+ tags: o,
99
+ isFetching: !0
100
+ });
101
+ let c = this.epochValue, l = e.version, u = (r) => {
102
+ if (c !== this.epochValue || this.runtimes.get(n(i)) !== e) return Promise.resolve();
103
+ e.inFlight = void 0;
104
+ let u = l !== e.version;
105
+ if ("error" in r) {
106
+ let e = this.getEntry(i);
107
+ this.store.setState(s, {
108
+ key: i,
109
+ url: a,
110
+ tags: o,
111
+ data: e?.data,
112
+ error: r.error,
113
+ isFetching: !1
114
+ }, { ttl: 0 });
115
+ } else {
116
+ let n = !u && (t.isCacheable?.(r.data) ?? !0);
117
+ this.store.setState(s, {
118
+ key: i,
119
+ url: a,
120
+ tags: o,
121
+ data: r.data,
122
+ isFetching: !1
123
+ }, { ttl: n ? this.ttlOf(e, t) : 0 });
124
+ }
125
+ return u ? this.fetchActive(e) : Promise.resolve();
126
+ };
127
+ return e.inFlight = t.fetcher().then((e) => u({ data: e }), (e) => u({ error: e })), e.inFlight;
128
+ }
129
+ ttlOf(e, t) {
130
+ let n = e.observers.size > 0 ? Infinity : t.staleTime;
131
+ return e.observers.forEach((e) => {
132
+ n = Math.min(n, e.staleTime);
133
+ }), n;
134
+ }
135
+ ensure(e) {
136
+ let r = n(e), i = this.runtimes.get(r);
137
+ return i || (i = {
138
+ key: e,
139
+ tags: /* @__PURE__ */ new Set(),
140
+ observers: /* @__PURE__ */ new Set(),
141
+ holds: 0,
142
+ version: 0,
143
+ gcTime: t
144
+ }, this.runtimes.set(r, i)), this.unwatch ??= this.store.watchFreshness("entries", (e) => {
145
+ e.type === "expired" && this.onExpired(e.path);
146
+ }), i;
147
+ }
148
+ cancelGc(e) {
149
+ clearTimeout(e.gcTimer), e.gcTimer = void 0;
150
+ }
151
+ scheduleGc(e) {
152
+ if (!(e.holds > 0 || e.observers.size > 0)) {
153
+ if (this.cancelGc(e), e.gcTime <= 0) {
154
+ this.collect(e);
155
+ return;
156
+ }
157
+ e.gcTimer = setTimeout(() => this.collect(e), e.gcTime);
158
+ }
159
+ }
160
+ collect(e) {
161
+ let t = n(e.key);
162
+ this.runtimes.get(t) === e && e.holds === 0 && e.observers.size === 0 && (this.runtimes.delete(t), this.store.setState(r(e.key), void 0, { unmount: !0 }));
163
+ }
164
+ }, a = new i();
165
+ //#endregion
166
+ export { t as GC_TIME, i as QueryCache, a as default, n as queryId, r as queryPath };
@@ -0,0 +1,23 @@
1
+ import { QueryMeta } from './queryCache';
2
+ export type UseQueryOptions<T> = {
3
+ /** What identifies the answer. Without one the hook is inert: it reads, holds and asks for nothing. */
4
+ key: string | undefined;
5
+ meta: QueryMeta;
6
+ fetcher: () => Promise<T>;
7
+ enabled?: boolean;
8
+ /** How long an answer counts as current, in milliseconds. */
9
+ staleTime: number;
10
+ /** How long an answer nobody renders is kept, in milliseconds. `0` forgets it on unmount. */
11
+ gcTime?: number;
12
+ isCacheable?: (data: T) => boolean;
13
+ };
14
+ export type UseQueryResult<T> = {
15
+ data?: T;
16
+ /** A request is in flight, or about to be for a query that has never been answered. */
17
+ isFetching: boolean;
18
+ /** Nothing to render with yet — as opposed to a refresh of something already on screen. */
19
+ isLoading: boolean;
20
+ refetch: () => void;
21
+ };
22
+ declare const useQuery: <T>({ key, meta, fetcher, enabled, staleTime, gcTime, isCacheable }: UseQueryOptions<T>) => UseQueryResult<T>;
23
+ export default useQuery;
@@ -0,0 +1,47 @@
1
+ import e, { GC_TIME as t, queryPath as n } from "./queryCache.mjs";
2
+ import { useCallback as r, useEffect as i, useRef as a } from "react";
3
+ import { createStoreHook as o } from "@plitzi/nexus/react";
4
+ //#region src/queries/useQuery.ts
5
+ var { useStore: s } = o(), c = ({ key: o, meta: c, fetcher: l, enabled: u = !0, staleTime: d, gcTime: f = t, isCacheable: p }) => {
6
+ let m = o !== void 0, [h] = s(n(o ?? ""), {
7
+ store: e.store,
8
+ enabled: m
9
+ }), g = m && h?.key === o ? h : void 0, _ = g !== void 0 && e.hasAnswer(g.key), v = a(l), y = a(p);
10
+ i(() => {
11
+ v.current = l, y.current = p;
12
+ }, [l, p]), i(() => o === void 0 ? void 0 : e.hold(o, f), [o, f]);
13
+ let { url: b } = c, x = c.tags?.join("\0") ?? "";
14
+ i(() => {
15
+ if (u && o !== void 0) return e.observe(o, {
16
+ meta: {
17
+ url: b,
18
+ tags: x ? x.split("\0") : []
19
+ },
20
+ staleTime: d,
21
+ fetcher: () => v.current(),
22
+ isCacheable: (e) => y.current?.(e) ?? !0
23
+ });
24
+ }, [
25
+ u,
26
+ o,
27
+ b,
28
+ x,
29
+ d
30
+ ]);
31
+ let S = _ ? g.data : void 0, C = a(void 0);
32
+ S === void 0 ? C.current && C.current.epoch !== e.epoch && (C.current = void 0) : C.current = {
33
+ epoch: e.epoch,
34
+ data: S
35
+ };
36
+ let w = S ?? C.current?.data, T = (g?.isFetching ?? !1) || u && m && !_, E = r(() => {
37
+ u && o !== void 0 && e.refetch(o);
38
+ }, [u, o]);
39
+ return {
40
+ data: w,
41
+ isFetching: T,
42
+ isLoading: T && w === void 0,
43
+ refetch: E
44
+ };
45
+ };
46
+ //#endregion
47
+ export { c as default };