@rindle/client 0.7.8 → 0.7.10

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.
@@ -0,0 +1,68 @@
1
+ import type { AnyQuery } from "./query.ts";
2
+ import type { ColsMap } from "./schema.ts";
3
+ import type { Store } from "./store.ts";
4
+ /** When an {@link QueryEnsureCache.ensure} call may resolve.
5
+ *
6
+ * - `complete` waits for the server-authoritative result (the default).
7
+ * - `present` resolves as soon as the local view contains a result, while the remote retain keeps
8
+ * revalidating in the background. An authoritative empty result also resolves it, so a real
9
+ * not-found query never waits forever.
10
+ *
11
+ * `present` deliberately does not add a `partial` {@link ResultType}: a locally useful answer and
12
+ * server authority are independent facts. While it resolves early, the view's result type remains
13
+ * `unknown` until the server says otherwise. */
14
+ export type EnsureQueryUntil = "complete" | "present";
15
+ export interface EnsureQueryOptions {
16
+ /** Readiness policy. Defaults to `complete`. */
17
+ until?: EnsureQueryUntil;
18
+ /** Cancel this caller's wait. The shared query may stay retained for another waiter/prefetch. */
19
+ signal?: AbortSignal;
20
+ }
21
+ export interface QueryEnsureCacheOptions {
22
+ /** Keep a completed preload alive for this long so the destination can adopt its rows. */
23
+ releaseDelayMs?: number;
24
+ /** Bound retained preloads. In-flight waits are never evicted. */
25
+ maxEntries?: number;
26
+ }
27
+ /** The structural surface consumed by framework adapters such as `@rindle/tanstack`. */
28
+ export interface QueryEnsurer {
29
+ ensure(query: AnyQuery, options?: EnsureQueryOptions): Promise<void>;
30
+ }
31
+ /**
32
+ * Deduplicates route/intent preloads and holds their live view through the navigation handoff.
33
+ *
34
+ * The cache materializes the real named query rather than retaining sync coverage alone: that is
35
+ * what makes `until: "present"` observable for overlapping queries already satisfied by local
36
+ * normalized rows. Once the server marks the query complete, the entry is kept briefly for a
37
+ * destination component to take its own retain, then released automatically.
38
+ */
39
+ export declare class QueryEnsureCache<S extends ColsMap> implements QueryEnsurer {
40
+ private readonly store;
41
+ private readonly releaseDelayMs;
42
+ private readonly maxEntries;
43
+ private readonly entries;
44
+ private closed;
45
+ constructor(store: Store<S>, options?: QueryEnsureCacheOptions);
46
+ /**
47
+ * Ensure a named query is retained, resolving according to `options.until`.
48
+ *
49
+ * Concurrent calls for the same `(name, args, AST)` share one materialized view and one remote
50
+ * subscription. A `present` call can resolve from local rows while a concurrent `complete` call
51
+ * continues waiting for server authority.
52
+ */
53
+ ensure<Q extends AnyQuery>(query: Q, options?: EnsureQueryOptions): Promise<void>;
54
+ /** Release every retained preload and reject outstanding waits. Idempotent. */
55
+ close(): void;
56
+ /** Number of retained query entries (primarily useful for tests/devtools). */
57
+ size(): number;
58
+ private createEntry;
59
+ private inspect;
60
+ private isReady;
61
+ private touch;
62
+ private scheduleRelease;
63
+ private trim;
64
+ private dispose;
65
+ private queryError;
66
+ private detachAbort;
67
+ }
68
+ //# sourceMappingURL=ensure.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ensure.d.ts","sourceRoot":"","sources":["../src/ensure.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAC3C,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AAGxC;;;;;;;;;gDASgD;AAChD,MAAM,MAAM,gBAAgB,GAAG,UAAU,GAAG,SAAS,CAAC;AAEtD,MAAM,WAAW,kBAAkB;IACjC,gDAAgD;IAChD,KAAK,CAAC,EAAE,gBAAgB,CAAC;IACzB,iGAAiG;IACjG,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED,MAAM,WAAW,uBAAuB;IACtC,0FAA0F;IAC1F,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,kEAAkE;IAClE,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,wFAAwF;AACxF,MAAM,WAAW,YAAY;IAC3B,MAAM,CAAC,KAAK,EAAE,QAAQ,EAAE,OAAO,CAAC,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACtE;AA6BD;;;;;;;GAOG;AACH,qBAAa,gBAAgB,CAAC,CAAC,SAAS,OAAO,CAAE,YAAW,YAAY;IACtE,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAW;IACjC,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAS;IACxC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAkC;IAC1D,OAAO,CAAC,MAAM,CAAS;gBAEX,KAAK,EAAE,KAAK,CAAC,CAAC,CAAC,EAAE,OAAO,GAAE,uBAA4B;IAMlE;;;;;;OAMG;IACG,MAAM,CAAC,CAAC,SAAS,QAAQ,EAAE,KAAK,EAAE,CAAC,EAAE,OAAO,GAAE,kBAAuB,GAAG,OAAO,CAAC,IAAI,CAAC;IAuC3F,+EAA+E;IAC/E,KAAK,IAAI,IAAI;IAOb,8EAA8E;IAC9E,IAAI,IAAI,MAAM;IAId,OAAO,CAAC,WAAW;IAcnB,OAAO,CAAC,OAAO;IAsBf,OAAO,CAAC,OAAO;IAKf,OAAO,CAAC,KAAK;IAQb,OAAO,CAAC,eAAe;IAUvB,OAAO,CAAC,IAAI;IAQZ,OAAO,CAAC,OAAO;IAef,OAAO,CAAC,UAAU;IAIlB,OAAO,CAAC,WAAW;CAGpB"}
package/dist/ensure.js ADDED
@@ -0,0 +1,184 @@
1
+ // Query readiness for route loaders and intent prefetching. `ResultType` remains the
2
+ // server-authority axis; this module only decides when a caller has enough data to continue.
3
+ import { stableKey } from "./key.js";
4
+ const DEFAULT_RELEASE_DELAY_MS = 30_000;
5
+ const DEFAULT_MAX_ENTRIES = 32;
6
+ /**
7
+ * Deduplicates route/intent preloads and holds their live view through the navigation handoff.
8
+ *
9
+ * The cache materializes the real named query rather than retaining sync coverage alone: that is
10
+ * what makes `until: "present"` observable for overlapping queries already satisfied by local
11
+ * normalized rows. Once the server marks the query complete, the entry is kept briefly for a
12
+ * destination component to take its own retain, then released automatically.
13
+ */
14
+ export class QueryEnsureCache {
15
+ store;
16
+ releaseDelayMs;
17
+ maxEntries;
18
+ entries = new Map();
19
+ closed = false;
20
+ constructor(store, options = {}) {
21
+ this.store = store;
22
+ this.releaseDelayMs = Math.max(0, options.releaseDelayMs ?? DEFAULT_RELEASE_DELAY_MS);
23
+ this.maxEntries = Math.max(1, options.maxEntries ?? DEFAULT_MAX_ENTRIES);
24
+ }
25
+ /**
26
+ * Ensure a named query is retained, resolving according to `options.until`.
27
+ *
28
+ * Concurrent calls for the same `(name, args, AST)` share one materialized view and one remote
29
+ * subscription. A `present` call can resolve from local rows while a concurrent `complete` call
30
+ * continues waiting for server authority.
31
+ */
32
+ async ensure(query, options = {}) {
33
+ if (this.closed)
34
+ throw new Error("QueryEnsureCache.ensure: cache is closed.");
35
+ if (options.signal?.aborted)
36
+ throw abortError();
37
+ if (typeof query.name !== "string") {
38
+ throw new Error("QueryEnsureCache.ensure: preloading requires a named query.");
39
+ }
40
+ const key = stableKey({ ast: query.ast(), remote: { name: query.name, args: query.args } });
41
+ let entry = this.entries.get(key);
42
+ if (!entry)
43
+ entry = this.createEntry(key, query);
44
+ this.touch(entry);
45
+ const until = options.until ?? "complete";
46
+ if (entry.view.resultType === "error") {
47
+ const error = this.queryError();
48
+ this.dispose(entry, error);
49
+ throw error;
50
+ }
51
+ if (this.isReady(entry, until)) {
52
+ if (entry.view.resultType === "complete")
53
+ this.scheduleRelease(entry);
54
+ this.trim();
55
+ return;
56
+ }
57
+ const ready = new Promise((resolve, reject) => {
58
+ const waiter = { until, resolve, reject, signal: options.signal };
59
+ if (options.signal) {
60
+ waiter.onAbort = () => {
61
+ if (!entry.waiters.delete(waiter))
62
+ return;
63
+ reject(abortError());
64
+ };
65
+ options.signal.addEventListener("abort", waiter.onAbort, { once: true });
66
+ }
67
+ entry.waiters.add(waiter);
68
+ });
69
+ this.trim();
70
+ return ready;
71
+ }
72
+ /** Release every retained preload and reject outstanding waits. Idempotent. */
73
+ close() {
74
+ if (this.closed)
75
+ return;
76
+ this.closed = true;
77
+ const error = new Error("QueryEnsureCache: closed before the query became ready.");
78
+ for (const entry of [...this.entries.values()])
79
+ this.dispose(entry, error);
80
+ }
81
+ /** Number of retained query entries (primarily useful for tests/devtools). */
82
+ size() {
83
+ return this.entries.size;
84
+ }
85
+ createEntry(key, query) {
86
+ const view = this.store.materialize(query);
87
+ const entry = {
88
+ key,
89
+ view,
90
+ waiters: new Set(),
91
+ unsubscribe: () => { },
92
+ lastUsedAt: Date.now(),
93
+ };
94
+ this.entries.set(key, entry);
95
+ entry.unsubscribe = view.subscribe(() => this.inspect(entry));
96
+ return entry;
97
+ }
98
+ inspect(entry) {
99
+ if (this.entries.get(entry.key) !== entry)
100
+ return;
101
+ if (entry.view.resultType === "error") {
102
+ this.dispose(entry, this.queryError());
103
+ return;
104
+ }
105
+ for (const waiter of [...entry.waiters]) {
106
+ if (!this.isReady(entry, waiter.until))
107
+ continue;
108
+ entry.waiters.delete(waiter);
109
+ this.detachAbort(waiter);
110
+ waiter.resolve();
111
+ }
112
+ // A present waiter may have continued while authority was still unknown. Keep the retain alive
113
+ // through that revalidation; only a completed query starts the handoff/expiry clock.
114
+ if (entry.view.resultType === "complete" && entry.waiters.size === 0) {
115
+ this.scheduleRelease(entry);
116
+ this.trim();
117
+ }
118
+ }
119
+ isReady(entry, until) {
120
+ if (entry.view.resultType === "complete")
121
+ return true;
122
+ return until === "present" && hasLocalResult(entry.view.data);
123
+ }
124
+ touch(entry) {
125
+ entry.lastUsedAt = Date.now();
126
+ if (entry.releaseTimer !== undefined) {
127
+ clearTimeout(entry.releaseTimer);
128
+ entry.releaseTimer = undefined;
129
+ }
130
+ }
131
+ scheduleRelease(entry) {
132
+ if (this.entries.get(entry.key) !== entry || entry.waiters.size > 0)
133
+ return;
134
+ if (entry.releaseTimer !== undefined)
135
+ clearTimeout(entry.releaseTimer);
136
+ // Even a configured zero delay crosses a task boundary. Some local-first backends publish
137
+ // `complete` immediately before folding the authoritative catch-up batch; synchronous teardown
138
+ // here would unregister the view in the middle of that delivery.
139
+ entry.releaseTimer = setTimeout(() => this.dispose(entry), this.releaseDelayMs);
140
+ entry.releaseTimer.unref?.();
141
+ }
142
+ trim() {
143
+ if (this.entries.size <= this.maxEntries)
144
+ return;
145
+ const idle = [...this.entries.values()]
146
+ .filter((entry) => entry.waiters.size === 0)
147
+ .sort((left, right) => left.lastUsedAt - right.lastUsedAt);
148
+ while (this.entries.size > this.maxEntries && idle.length > 0)
149
+ this.dispose(idle.shift());
150
+ }
151
+ dispose(entry, error) {
152
+ if (this.entries.get(entry.key) !== entry)
153
+ return;
154
+ this.entries.delete(entry.key);
155
+ if (entry.releaseTimer !== undefined)
156
+ clearTimeout(entry.releaseTimer);
157
+ entry.unsubscribe();
158
+ entry.view.destroy();
159
+ if (error) {
160
+ for (const waiter of entry.waiters) {
161
+ this.detachAbort(waiter);
162
+ waiter.reject(error);
163
+ }
164
+ }
165
+ entry.waiters.clear();
166
+ }
167
+ queryError() {
168
+ return new Error("QueryEnsureCache.ensure: query entered the error result state.");
169
+ }
170
+ detachAbort(waiter) {
171
+ if (waiter.signal && waiter.onAbort)
172
+ waiter.signal.removeEventListener("abort", waiter.onAbort);
173
+ }
174
+ }
175
+ /** A plural query is locally present when it has at least one row; a `.one()` query when non-null. */
176
+ function hasLocalResult(data) {
177
+ return Array.isArray(data) ? data.length > 0 : data !== null && data !== undefined;
178
+ }
179
+ function abortError() {
180
+ const error = new Error("QueryEnsureCache.ensure: wait was aborted.");
181
+ error.name = "AbortError";
182
+ return error;
183
+ }
184
+ //# sourceMappingURL=ensure.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ensure.js","sourceRoot":"","sources":["../src/ensure.ts"],"names":[],"mappings":"AAAA,qFAAqF;AACrF,6FAA6F;AAE7F,OAAO,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AA6DrC,MAAM,wBAAwB,GAAG,MAAM,CAAC;AACxC,MAAM,mBAAmB,GAAG,EAAE,CAAC;AAE/B;;;;;;;GAOG;AACH,MAAM,OAAO,gBAAgB;IACV,KAAK,CAAW;IAChB,cAAc,CAAS;IACvB,UAAU,CAAS;IACnB,OAAO,GAAG,IAAI,GAAG,EAAuB,CAAC;IAClD,MAAM,GAAG,KAAK,CAAC;IAEvB,YAAY,KAAe,EAAE,UAAmC,EAAE;QAChE,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;QACnB,IAAI,CAAC,cAAc,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,OAAO,CAAC,cAAc,IAAI,wBAAwB,CAAC,CAAC;QACtF,IAAI,CAAC,UAAU,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,OAAO,CAAC,UAAU,IAAI,mBAAmB,CAAC,CAAC;IAC3E,CAAC;IAED;;;;;;OAMG;IACH,KAAK,CAAC,MAAM,CAAqB,KAAQ,EAAE,UAA8B,EAAE;QACzE,IAAI,IAAI,CAAC,MAAM;YAAE,MAAM,IAAI,KAAK,CAAC,2CAA2C,CAAC,CAAC;QAC9E,IAAI,OAAO,CAAC,MAAM,EAAE,OAAO;YAAE,MAAM,UAAU,EAAE,CAAC;QAChD,IAAI,OAAO,KAAK,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;YACnC,MAAM,IAAI,KAAK,CAAC,6DAA6D,CAAC,CAAC;QACjF,CAAC;QAED,MAAM,GAAG,GAAG,SAAS,CAAC,EAAE,GAAG,EAAE,KAAK,CAAC,GAAG,EAAE,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;QAC5F,IAAI,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QAClC,IAAI,CAAC,KAAK;YAAE,KAAK,GAAG,IAAI,CAAC,WAAW,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;QACjD,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;QAElB,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,IAAI,UAAU,CAAC;QAC1C,IAAI,KAAK,CAAC,IAAI,CAAC,UAAU,KAAK,OAAO,EAAE,CAAC;YACtC,MAAM,KAAK,GAAG,IAAI,CAAC,UAAU,EAAE,CAAC;YAChC,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;YAC3B,MAAM,KAAK,CAAC;QACd,CAAC;QACD,IAAI,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,KAAK,CAAC,EAAE,CAAC;YAC/B,IAAI,KAAK,CAAC,IAAI,CAAC,UAAU,KAAK,UAAU;gBAAE,IAAI,CAAC,eAAe,CAAC,KAAK,CAAC,CAAC;YACtE,IAAI,CAAC,IAAI,EAAE,CAAC;YACZ,OAAO;QACT,CAAC;QAED,MAAM,KAAK,GAAG,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;YAClD,MAAM,MAAM,GAAiB,EAAE,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE,CAAC;YAChF,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC;gBACnB,MAAM,CAAC,OAAO,GAAG,GAAG,EAAE;oBACpB,IAAI,CAAC,KAAM,CAAC,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC;wBAAE,OAAO;oBAC3C,MAAM,CAAC,UAAU,EAAE,CAAC,CAAC;gBACvB,CAAC,CAAC;gBACF,OAAO,CAAC,MAAM,CAAC,gBAAgB,CAAC,OAAO,EAAE,MAAM,CAAC,OAAO,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC;YAC3E,CAAC;YACD,KAAM,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;QAC7B,CAAC,CAAC,CAAC;QACH,IAAI,CAAC,IAAI,EAAE,CAAC;QACZ,OAAO,KAAK,CAAC;IACf,CAAC;IAED,+EAA+E;IAC/E,KAAK;QACH,IAAI,IAAI,CAAC,MAAM;YAAE,OAAO;QACxB,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC;QACnB,MAAM,KAAK,GAAG,IAAI,KAAK,CAAC,yDAAyD,CAAC,CAAC;QACnF,KAAK,MAAM,KAAK,IAAI,CAAC,GAAG,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC;YAAE,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;IAC7E,CAAC;IAED,8EAA8E;IAC9E,IAAI;QACF,OAAO,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC;IAC3B,CAAC;IAEO,WAAW,CAAC,GAAW,EAAE,KAAe;QAC9C,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,WAAW,CAAC,KAAK,CAA0B,CAAC;QACpE,MAAM,KAAK,GAAgB;YACzB,GAAG;YACH,IAAI;YACJ,OAAO,EAAE,IAAI,GAAG,EAAE;YAClB,WAAW,EAAE,GAAG,EAAE,GAAE,CAAC;YACrB,UAAU,EAAE,IAAI,CAAC,GAAG,EAAE;SACvB,CAAC;QACF,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;QAC7B,KAAK,CAAC,WAAW,GAAG,IAAI,CAAC,SAAS,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC;QAC9D,OAAO,KAAK,CAAC;IACf,CAAC;IAEO,OAAO,CAAC,KAAkB;QAChC,IAAI,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,KAAK;YAAE,OAAO;QAClD,IAAI,KAAK,CAAC,IAAI,CAAC,UAAU,KAAK,OAAO,EAAE,CAAC;YACtC,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,IAAI,CAAC,UAAU,EAAE,CAAC,CAAC;YACvC,OAAO;QACT,CAAC;QAED,KAAK,MAAM,MAAM,IAAI,CAAC,GAAG,KAAK,CAAC,OAAO,CAAC,EAAE,CAAC;YACxC,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,MAAM,CAAC,KAAK,CAAC;gBAAE,SAAS;YACjD,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;YAC7B,IAAI,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC;YACzB,MAAM,CAAC,OAAO,EAAE,CAAC;QACnB,CAAC;QAED,+FAA+F;QAC/F,qFAAqF;QACrF,IAAI,KAAK,CAAC,IAAI,CAAC,UAAU,KAAK,UAAU,IAAI,KAAK,CAAC,OAAO,CAAC,IAAI,KAAK,CAAC,EAAE,CAAC;YACrE,IAAI,CAAC,eAAe,CAAC,KAAK,CAAC,CAAC;YAC5B,IAAI,CAAC,IAAI,EAAE,CAAC;QACd,CAAC;IACH,CAAC;IAEO,OAAO,CAAC,KAAkB,EAAE,KAAuB;QACzD,IAAI,KAAK,CAAC,IAAI,CAAC,UAAU,KAAK,UAAU;YAAE,OAAO,IAAI,CAAC;QACtD,OAAO,KAAK,KAAK,SAAS,IAAI,cAAc,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAChE,CAAC;IAEO,KAAK,CAAC,KAAkB;QAC9B,KAAK,CAAC,UAAU,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;QAC9B,IAAI,KAAK,CAAC,YAAY,KAAK,SAAS,EAAE,CAAC;YACrC,YAAY,CAAC,KAAK,CAAC,YAAY,CAAC,CAAC;YACjC,KAAK,CAAC,YAAY,GAAG,SAAS,CAAC;QACjC,CAAC;IACH,CAAC;IAEO,eAAe,CAAC,KAAkB;QACxC,IAAI,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,KAAK,IAAI,KAAK,CAAC,OAAO,CAAC,IAAI,GAAG,CAAC;YAAE,OAAO;QAC5E,IAAI,KAAK,CAAC,YAAY,KAAK,SAAS;YAAE,YAAY,CAAC,KAAK,CAAC,YAAY,CAAC,CAAC;QACvE,0FAA0F;QAC1F,+FAA+F;QAC/F,iEAAiE;QACjE,KAAK,CAAC,YAAY,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,IAAI,CAAC,cAAc,CAAC,CAAC;QAC/E,KAAK,CAAC,YAAuC,CAAC,KAAK,EAAE,EAAE,CAAC;IAC3D,CAAC;IAEO,IAAI;QACV,IAAI,IAAI,CAAC,OAAO,CAAC,IAAI,IAAI,IAAI,CAAC,UAAU;YAAE,OAAO;QACjD,MAAM,IAAI,GAAG,CAAC,GAAG,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC;aACpC,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,KAAK,CAAC,CAAC;aAC3C,IAAI,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC,IAAI,CAAC,UAAU,GAAG,KAAK,CAAC,UAAU,CAAC,CAAC;QAC7D,OAAO,IAAI,CAAC,OAAO,CAAC,IAAI,GAAG,IAAI,CAAC,UAAU,IAAI,IAAI,CAAC,MAAM,GAAG,CAAC;YAAE,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,EAAG,CAAC,CAAC;IAC7F,CAAC;IAEO,OAAO,CAAC,KAAkB,EAAE,KAAa;QAC/C,IAAI,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,KAAK;YAAE,OAAO;QAClD,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QAC/B,IAAI,KAAK,CAAC,YAAY,KAAK,SAAS;YAAE,YAAY,CAAC,KAAK,CAAC,YAAY,CAAC,CAAC;QACvE,KAAK,CAAC,WAAW,EAAE,CAAC;QACpB,KAAK,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC;QACrB,IAAI,KAAK,EAAE,CAAC;YACV,KAAK,MAAM,MAAM,IAAI,KAAK,CAAC,OAAO,EAAE,CAAC;gBACnC,IAAI,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC;gBACzB,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;YACvB,CAAC;QACH,CAAC;QACD,KAAK,CAAC,OAAO,CAAC,KAAK,EAAE,CAAC;IACxB,CAAC;IAEO,UAAU;QAChB,OAAO,IAAI,KAAK,CAAC,gEAAgE,CAAC,CAAC;IACrF,CAAC;IAEO,WAAW,CAAC,MAAoB;QACtC,IAAI,MAAM,CAAC,MAAM,IAAI,MAAM,CAAC,OAAO;YAAE,MAAM,CAAC,MAAM,CAAC,mBAAmB,CAAC,OAAO,EAAE,MAAM,CAAC,OAAO,CAAC,CAAC;IAClG,CAAC;CACF;AAED,sGAAsG;AACtG,SAAS,cAAc,CAAC,IAAa;IACnC,OAAO,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,KAAK,IAAI,IAAI,IAAI,KAAK,SAAS,CAAC;AACrF,CAAC;AAED,SAAS,UAAU;IACjB,MAAM,KAAK,GAAG,IAAI,KAAK,CAAC,4CAA4C,CAAC,CAAC;IACtE,KAAK,CAAC,IAAI,GAAG,YAAY,CAAC;IAC1B,OAAO,KAAK,CAAC;AACf,CAAC"}
package/dist/index.d.ts CHANGED
@@ -3,6 +3,7 @@ export * from "./operators.ts";
3
3
  export * from "./query.ts";
4
4
  export * from "./view.ts";
5
5
  export * from "./store.ts";
6
+ export * from "./ensure.ts";
6
7
  export * from "./ssr.ts";
7
8
  export { resolveChange, subRow } from "./resolve.ts";
8
9
  export type { NamedRow, ResolvedChange } from "./resolve.ts";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAIA,cAAc,aAAa,CAAC;AAC5B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,YAAY,CAAC;AAC3B,cAAc,WAAW,CAAC;AAC1B,cAAc,YAAY,CAAC;AAC3B,cAAc,UAAU,CAAC;AAEzB,OAAO,EAAE,aAAa,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AACrD,YAAY,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAE7D,YAAY,EAAE,QAAQ,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAE7E,OAAO,EACL,cAAc,EACd,kBAAkB,EAClB,iBAAiB,EACjB,kBAAkB,EAClB,KAAK,EACL,MAAM,GACP,MAAM,mBAAmB,CAAC;AAC3B,YAAY,EACV,SAAS,EACT,eAAe,EACf,WAAW,EACX,KAAK,EACL,OAAO,EACP,WAAW,EACX,UAAU,EACV,QAAQ,EACR,WAAW,EACX,cAAc,EACd,UAAU,EACV,aAAa,EACb,qBAAqB,EACrB,cAAc,EACd,WAAW,GACZ,MAAM,mBAAmB,CAAC;AAE3B,OAAO,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AAErC,OAAO,EAAE,kBAAkB,EAAE,aAAa,EAAE,WAAW,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAE3G,YAAY,EACV,OAAO,EACP,kBAAkB,EAClB,kBAAkB,EAClB,WAAW,EACX,OAAO,EACP,UAAU,EACV,MAAM,EACN,QAAQ,EACR,gBAAgB,EAChB,oBAAoB,EACpB,eAAe,EACf,YAAY,EACZ,gBAAgB,EAChB,qBAAqB,EACrB,gBAAgB,EAChB,OAAO,EACP,aAAa,EACb,OAAO,EACP,WAAW,EACX,UAAU,EACV,QAAQ,EACR,cAAc,EACd,OAAO,EACP,UAAU,EACV,SAAS,GACV,MAAM,YAAY,CAAC;AAEpB,OAAO,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAG9F,YAAY,EACV,SAAS,EACT,GAAG,EACH,KAAK,EACL,SAAS,EACT,kBAAkB,EAClB,WAAW,EACX,GAAG,EACH,QAAQ,EACR,QAAQ,EACR,SAAS,EACT,QAAQ,EACR,aAAa,GACd,MAAM,UAAU,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAIA,cAAc,aAAa,CAAC;AAC5B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,YAAY,CAAC;AAC3B,cAAc,WAAW,CAAC;AAC1B,cAAc,YAAY,CAAC;AAC3B,cAAc,aAAa,CAAC;AAC5B,cAAc,UAAU,CAAC;AAEzB,OAAO,EAAE,aAAa,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AACrD,YAAY,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAE7D,YAAY,EAAE,QAAQ,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAE7E,OAAO,EACL,cAAc,EACd,kBAAkB,EAClB,iBAAiB,EACjB,kBAAkB,EAClB,KAAK,EACL,MAAM,GACP,MAAM,mBAAmB,CAAC;AAC3B,YAAY,EACV,SAAS,EACT,eAAe,EACf,WAAW,EACX,KAAK,EACL,OAAO,EACP,WAAW,EACX,UAAU,EACV,QAAQ,EACR,WAAW,EACX,cAAc,EACd,UAAU,EACV,aAAa,EACb,qBAAqB,EACrB,cAAc,EACd,WAAW,GACZ,MAAM,mBAAmB,CAAC;AAE3B,OAAO,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AAErC,OAAO,EAAE,kBAAkB,EAAE,aAAa,EAAE,WAAW,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAE3G,YAAY,EACV,OAAO,EACP,kBAAkB,EAClB,kBAAkB,EAClB,WAAW,EACX,OAAO,EACP,UAAU,EACV,MAAM,EACN,QAAQ,EACR,gBAAgB,EAChB,oBAAoB,EACpB,eAAe,EACf,YAAY,EACZ,gBAAgB,EAChB,qBAAqB,EACrB,gBAAgB,EAChB,OAAO,EACP,aAAa,EACb,OAAO,EACP,WAAW,EACX,UAAU,EACV,QAAQ,EACR,cAAc,EACd,OAAO,EACP,UAAU,EACV,SAAS,GACV,MAAM,YAAY,CAAC;AAEpB,OAAO,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAG9F,YAAY,EACV,SAAS,EACT,GAAG,EACH,KAAK,EACL,SAAS,EACT,kBAAkB,EAClB,WAAW,EACX,GAAG,EACH,QAAQ,EACR,QAAQ,EACR,SAAS,EACT,QAAQ,EACR,aAAa,GACd,MAAM,UAAU,CAAC"}
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ export * from "./operators.js"; // eq/ne/gt/ge/lt/le/like/notLike/ilike/notIlike
6
6
  export * from "./query.js"; // Query, QueryRoot, queries/newQueryBuilder, defineQuery/NamedQuery, exists, notExists, defineFragment/Fragment/FragmentRef/isFragment
7
7
  export * from "./view.js"; // ArrayView, SingularArrayView, FlatArrayView, SingularView, ViewTypes
8
8
  export * from "./store.js"; // Store, WriteTx, AssembledNode, DehydratedQuery, DehydratedState
9
+ export * from "./ensure.js"; // QueryEnsureCache, EnsureQueryOptions, QueryEnsurer
9
10
  export * from "./ssr.js"; // createServerStore, ServerStore, OneShotBackend, OneShotQueryFn, OneShotResult, ServerStoreOptions
10
11
  export { resolveChange, subRow } from "./resolve.js"; // positional FlatChange → named rows (inverse of the wire encoder)
11
12
  // shared (generator) mutator seam — one body, sync on the client + async on the server
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,8FAA8F;AAC9F,0FAA0F;AAC1F,8CAA8C;AAE9C,cAAc,aAAa,CAAC,CAAC,oMAAoM;AACjO,cAAc,gBAAgB,CAAC,CAAC,mGAAmG;AACnI,cAAc,YAAY,CAAC,CAAC,uIAAuI;AACnK,cAAc,WAAW,CAAC,CAAC,uEAAuE;AAClG,cAAc,YAAY,CAAC,CAAC,kEAAkE;AAC9F,cAAc,UAAU,CAAC,CAAC,oGAAoG;AAE9H,OAAO,EAAE,aAAa,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC,CAAC,mEAAmE;AAIzH,uFAAuF;AACvF,OAAO,EACL,cAAc,EACd,kBAAkB,EAClB,iBAAiB,EACjB,kBAAkB,EAClB,KAAK,EACL,MAAM,GACP,MAAM,mBAAmB,CAAC;AAmB3B,OAAO,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC,CAAC,2DAA2D;AAEjG,OAAO,EAAE,kBAAkB,EAAE,aAAa,EAAE,WAAW,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AA8B3G,OAAO,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,8FAA8F;AAC9F,0FAA0F;AAC1F,8CAA8C;AAE9C,cAAc,aAAa,CAAC,CAAC,oMAAoM;AACjO,cAAc,gBAAgB,CAAC,CAAC,mGAAmG;AACnI,cAAc,YAAY,CAAC,CAAC,uIAAuI;AACnK,cAAc,WAAW,CAAC,CAAC,uEAAuE;AAClG,cAAc,YAAY,CAAC,CAAC,kEAAkE;AAC9F,cAAc,aAAa,CAAC,CAAC,qDAAqD;AAClF,cAAc,UAAU,CAAC,CAAC,oGAAoG;AAE9H,OAAO,EAAE,aAAa,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC,CAAC,mEAAmE;AAIzH,uFAAuF;AACvF,OAAO,EACL,cAAc,EACd,kBAAkB,EAClB,iBAAiB,EACjB,kBAAkB,EAClB,KAAK,EACL,MAAM,GACP,MAAM,mBAAmB,CAAC;AAmB3B,OAAO,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC,CAAC,2DAA2D;AAEjG,OAAO,EAAE,kBAAkB,EAAE,aAAa,EAAE,WAAW,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AA8B3G,OAAO,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rindle/client",
3
- "version": "0.7.8",
3
+ "version": "0.7.10",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
package/src/ensure.ts ADDED
@@ -0,0 +1,248 @@
1
+ // Query readiness for route loaders and intent prefetching. `ResultType` remains the
2
+ // server-authority axis; this module only decides when a caller has enough data to continue.
3
+
4
+ import { stableKey } from "./key.ts";
5
+ import type { AnyQuery } from "./query.ts";
6
+ import type { ColsMap } from "./schema.ts";
7
+ import type { Store } from "./store.ts";
8
+ import type { ResultType } from "./types.ts";
9
+
10
+ /** When an {@link QueryEnsureCache.ensure} call may resolve.
11
+ *
12
+ * - `complete` waits for the server-authoritative result (the default).
13
+ * - `present` resolves as soon as the local view contains a result, while the remote retain keeps
14
+ * revalidating in the background. An authoritative empty result also resolves it, so a real
15
+ * not-found query never waits forever.
16
+ *
17
+ * `present` deliberately does not add a `partial` {@link ResultType}: a locally useful answer and
18
+ * server authority are independent facts. While it resolves early, the view's result type remains
19
+ * `unknown` until the server says otherwise. */
20
+ export type EnsureQueryUntil = "complete" | "present";
21
+
22
+ export interface EnsureQueryOptions {
23
+ /** Readiness policy. Defaults to `complete`. */
24
+ until?: EnsureQueryUntil;
25
+ /** Cancel this caller's wait. The shared query may stay retained for another waiter/prefetch. */
26
+ signal?: AbortSignal;
27
+ }
28
+
29
+ export interface QueryEnsureCacheOptions {
30
+ /** Keep a completed preload alive for this long so the destination can adopt its rows. */
31
+ releaseDelayMs?: number;
32
+ /** Bound retained preloads. In-flight waits are never evicted. */
33
+ maxEntries?: number;
34
+ }
35
+
36
+ /** The structural surface consumed by framework adapters such as `@rindle/tanstack`. */
37
+ export interface QueryEnsurer {
38
+ ensure(query: AnyQuery, options?: EnsureQueryOptions): Promise<void>;
39
+ }
40
+
41
+ interface EnsureView {
42
+ readonly data: unknown;
43
+ readonly resultType: ResultType;
44
+ subscribe(listener: () => void): () => void;
45
+ destroy(): void;
46
+ }
47
+
48
+ interface EnsureWaiter {
49
+ until: EnsureQueryUntil;
50
+ resolve: () => void;
51
+ reject: (reason: Error) => void;
52
+ signal?: AbortSignal;
53
+ onAbort?: () => void;
54
+ }
55
+
56
+ interface EnsureEntry {
57
+ key: string;
58
+ view: EnsureView;
59
+ waiters: Set<EnsureWaiter>;
60
+ unsubscribe: () => void;
61
+ releaseTimer?: ReturnType<typeof setTimeout>;
62
+ lastUsedAt: number;
63
+ }
64
+
65
+ const DEFAULT_RELEASE_DELAY_MS = 30_000;
66
+ const DEFAULT_MAX_ENTRIES = 32;
67
+
68
+ /**
69
+ * Deduplicates route/intent preloads and holds their live view through the navigation handoff.
70
+ *
71
+ * The cache materializes the real named query rather than retaining sync coverage alone: that is
72
+ * what makes `until: "present"` observable for overlapping queries already satisfied by local
73
+ * normalized rows. Once the server marks the query complete, the entry is kept briefly for a
74
+ * destination component to take its own retain, then released automatically.
75
+ */
76
+ export class QueryEnsureCache<S extends ColsMap> implements QueryEnsurer {
77
+ private readonly store: Store<S>;
78
+ private readonly releaseDelayMs: number;
79
+ private readonly maxEntries: number;
80
+ private readonly entries = new Map<string, EnsureEntry>();
81
+ private closed = false;
82
+
83
+ constructor(store: Store<S>, options: QueryEnsureCacheOptions = {}) {
84
+ this.store = store;
85
+ this.releaseDelayMs = Math.max(0, options.releaseDelayMs ?? DEFAULT_RELEASE_DELAY_MS);
86
+ this.maxEntries = Math.max(1, options.maxEntries ?? DEFAULT_MAX_ENTRIES);
87
+ }
88
+
89
+ /**
90
+ * Ensure a named query is retained, resolving according to `options.until`.
91
+ *
92
+ * Concurrent calls for the same `(name, args, AST)` share one materialized view and one remote
93
+ * subscription. A `present` call can resolve from local rows while a concurrent `complete` call
94
+ * continues waiting for server authority.
95
+ */
96
+ async ensure<Q extends AnyQuery>(query: Q, options: EnsureQueryOptions = {}): Promise<void> {
97
+ if (this.closed) throw new Error("QueryEnsureCache.ensure: cache is closed.");
98
+ if (options.signal?.aborted) throw abortError();
99
+ if (typeof query.name !== "string") {
100
+ throw new Error("QueryEnsureCache.ensure: preloading requires a named query.");
101
+ }
102
+
103
+ const key = stableKey({ ast: query.ast(), remote: { name: query.name, args: query.args } });
104
+ let entry = this.entries.get(key);
105
+ if (!entry) entry = this.createEntry(key, query);
106
+ this.touch(entry);
107
+
108
+ const until = options.until ?? "complete";
109
+ if (entry.view.resultType === "error") {
110
+ const error = this.queryError();
111
+ this.dispose(entry, error);
112
+ throw error;
113
+ }
114
+ if (this.isReady(entry, until)) {
115
+ if (entry.view.resultType === "complete") this.scheduleRelease(entry);
116
+ this.trim();
117
+ return;
118
+ }
119
+
120
+ const ready = new Promise<void>((resolve, reject) => {
121
+ const waiter: EnsureWaiter = { until, resolve, reject, signal: options.signal };
122
+ if (options.signal) {
123
+ waiter.onAbort = () => {
124
+ if (!entry!.waiters.delete(waiter)) return;
125
+ reject(abortError());
126
+ };
127
+ options.signal.addEventListener("abort", waiter.onAbort, { once: true });
128
+ }
129
+ entry!.waiters.add(waiter);
130
+ });
131
+ this.trim();
132
+ return ready;
133
+ }
134
+
135
+ /** Release every retained preload and reject outstanding waits. Idempotent. */
136
+ close(): void {
137
+ if (this.closed) return;
138
+ this.closed = true;
139
+ const error = new Error("QueryEnsureCache: closed before the query became ready.");
140
+ for (const entry of [...this.entries.values()]) this.dispose(entry, error);
141
+ }
142
+
143
+ /** Number of retained query entries (primarily useful for tests/devtools). */
144
+ size(): number {
145
+ return this.entries.size;
146
+ }
147
+
148
+ private createEntry(key: string, query: AnyQuery): EnsureEntry {
149
+ const view = this.store.materialize(query) as unknown as EnsureView;
150
+ const entry: EnsureEntry = {
151
+ key,
152
+ view,
153
+ waiters: new Set(),
154
+ unsubscribe: () => {},
155
+ lastUsedAt: Date.now(),
156
+ };
157
+ this.entries.set(key, entry);
158
+ entry.unsubscribe = view.subscribe(() => this.inspect(entry));
159
+ return entry;
160
+ }
161
+
162
+ private inspect(entry: EnsureEntry): void {
163
+ if (this.entries.get(entry.key) !== entry) return;
164
+ if (entry.view.resultType === "error") {
165
+ this.dispose(entry, this.queryError());
166
+ return;
167
+ }
168
+
169
+ for (const waiter of [...entry.waiters]) {
170
+ if (!this.isReady(entry, waiter.until)) continue;
171
+ entry.waiters.delete(waiter);
172
+ this.detachAbort(waiter);
173
+ waiter.resolve();
174
+ }
175
+
176
+ // A present waiter may have continued while authority was still unknown. Keep the retain alive
177
+ // through that revalidation; only a completed query starts the handoff/expiry clock.
178
+ if (entry.view.resultType === "complete" && entry.waiters.size === 0) {
179
+ this.scheduleRelease(entry);
180
+ this.trim();
181
+ }
182
+ }
183
+
184
+ private isReady(entry: EnsureEntry, until: EnsureQueryUntil): boolean {
185
+ if (entry.view.resultType === "complete") return true;
186
+ return until === "present" && hasLocalResult(entry.view.data);
187
+ }
188
+
189
+ private touch(entry: EnsureEntry): void {
190
+ entry.lastUsedAt = Date.now();
191
+ if (entry.releaseTimer !== undefined) {
192
+ clearTimeout(entry.releaseTimer);
193
+ entry.releaseTimer = undefined;
194
+ }
195
+ }
196
+
197
+ private scheduleRelease(entry: EnsureEntry): void {
198
+ if (this.entries.get(entry.key) !== entry || entry.waiters.size > 0) return;
199
+ if (entry.releaseTimer !== undefined) clearTimeout(entry.releaseTimer);
200
+ // Even a configured zero delay crosses a task boundary. Some local-first backends publish
201
+ // `complete` immediately before folding the authoritative catch-up batch; synchronous teardown
202
+ // here would unregister the view in the middle of that delivery.
203
+ entry.releaseTimer = setTimeout(() => this.dispose(entry), this.releaseDelayMs);
204
+ (entry.releaseTimer as { unref?: () => void }).unref?.();
205
+ }
206
+
207
+ private trim(): void {
208
+ if (this.entries.size <= this.maxEntries) return;
209
+ const idle = [...this.entries.values()]
210
+ .filter((entry) => entry.waiters.size === 0)
211
+ .sort((left, right) => left.lastUsedAt - right.lastUsedAt);
212
+ while (this.entries.size > this.maxEntries && idle.length > 0) this.dispose(idle.shift()!);
213
+ }
214
+
215
+ private dispose(entry: EnsureEntry, error?: Error): void {
216
+ if (this.entries.get(entry.key) !== entry) return;
217
+ this.entries.delete(entry.key);
218
+ if (entry.releaseTimer !== undefined) clearTimeout(entry.releaseTimer);
219
+ entry.unsubscribe();
220
+ entry.view.destroy();
221
+ if (error) {
222
+ for (const waiter of entry.waiters) {
223
+ this.detachAbort(waiter);
224
+ waiter.reject(error);
225
+ }
226
+ }
227
+ entry.waiters.clear();
228
+ }
229
+
230
+ private queryError(): Error {
231
+ return new Error("QueryEnsureCache.ensure: query entered the error result state.");
232
+ }
233
+
234
+ private detachAbort(waiter: EnsureWaiter): void {
235
+ if (waiter.signal && waiter.onAbort) waiter.signal.removeEventListener("abort", waiter.onAbort);
236
+ }
237
+ }
238
+
239
+ /** A plural query is locally present when it has at least one row; a `.one()` query when non-null. */
240
+ function hasLocalResult(data: unknown): boolean {
241
+ return Array.isArray(data) ? data.length > 0 : data !== null && data !== undefined;
242
+ }
243
+
244
+ function abortError(): Error {
245
+ const error = new Error("QueryEnsureCache.ensure: wait was aborted.");
246
+ error.name = "AbortError";
247
+ return error;
248
+ }
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ export * from "./operators.ts"; // eq/ne/gt/ge/lt/le/like/notLike/ilike/notIlike
7
7
  export * from "./query.ts"; // Query, QueryRoot, queries/newQueryBuilder, defineQuery/NamedQuery, exists, notExists, defineFragment/Fragment/FragmentRef/isFragment
8
8
  export * from "./view.ts"; // ArrayView, SingularArrayView, FlatArrayView, SingularView, ViewTypes
9
9
  export * from "./store.ts"; // Store, WriteTx, AssembledNode, DehydratedQuery, DehydratedState
10
+ export * from "./ensure.ts"; // QueryEnsureCache, EnsureQueryOptions, QueryEnsurer
10
11
  export * from "./ssr.ts"; // createServerStore, ServerStore, OneShotBackend, OneShotQueryFn, OneShotResult, ServerStoreOptions
11
12
 
12
13
  export { resolveChange, subRow } from "./resolve.ts"; // positional FlatChange → named rows (inverse of the wire encoder)