@rindle/client 0.7.9 → 0.7.11
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/dist/ensure.d.ts +68 -0
- package/dist/ensure.d.ts.map +1 -0
- package/dist/ensure.js +184 -0
- package/dist/ensure.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/ensure.ts +248 -0
- package/src/index.ts +1 -0
package/dist/ensure.d.ts
ADDED
|
@@ -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":"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"}
|
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";
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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
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)
|