@polyphron/viper-react 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +70 -0
- package/dist/auth.d.ts +66 -4
- package/dist/auth.js +102 -5
- package/dist/db-fetch.d.ts +121 -0
- package/dist/db-fetch.js +398 -0
- package/dist/db-page.js +38 -74
- package/dist/db-query.d.ts +0 -6
- package/dist/db-query.js +8 -8
- package/dist/db-shared.d.ts +50 -0
- package/dist/db-shared.js +82 -0
- package/dist/db.d.ts +1 -0
- package/dist/db.js +40 -71
- package/dist/identity.d.ts +21 -0
- package/dist/identity.js +54 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.js +5 -2
- package/dist/loaded.d.ts +30 -0
- package/dist/loaded.js +64 -0
- package/dist/page-plan.js +1 -4
- package/dist/records.d.ts +1 -14
- package/dist/records.js +4 -39
- package/dist/runs.d.ts +23 -0
- package/dist/runs.js +59 -0
- package/package.json +3 -2
package/dist/db-fetch.js
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
// EXPERIMENTAL: any fetch as a TanStack DB collection.
|
|
2
|
+
//
|
|
3
|
+
// For data that isn't records: settings, keys, logs, anything an endpoint
|
|
4
|
+
// hands back whole. It loads with `fetch`, loads again when told to (or on a
|
|
5
|
+
// timer, or when the window comes back and it's stale), and keeps the order
|
|
6
|
+
// the server gave. Records have realtime, so use `viperCollectionOptions` or
|
|
7
|
+
// `useLivePage` for them.
|
|
8
|
+
import { ViperError } from "@polyphron/viper-client";
|
|
9
|
+
import { createCollection } from "@tanstack/db";
|
|
10
|
+
import { useLiveQuery } from "@tanstack/react-db";
|
|
11
|
+
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
|
12
|
+
import { cacheOf, same, singleFlight } from "./db-shared.js";
|
|
13
|
+
// The key a one-object fetch keeps its row under.
|
|
14
|
+
const ONE = "one";
|
|
15
|
+
// Loads that failed on the network (not "no" or "not found") try again.
|
|
16
|
+
const RETRIES = 2;
|
|
17
|
+
// Every fetched collection that has a reader, for the focus check.
|
|
18
|
+
const watched = new Set();
|
|
19
|
+
if (typeof window !== "undefined") {
|
|
20
|
+
window.addEventListener("focus", () => watched.forEach((entry) => entry.onFocus()));
|
|
21
|
+
}
|
|
22
|
+
// Waits `ms`, or less if `signal` aborts.
|
|
23
|
+
function wait(ms, signal) {
|
|
24
|
+
return new Promise((done) => {
|
|
25
|
+
const timer = setTimeout(done, ms);
|
|
26
|
+
signal.addEventListener("abort", () => {
|
|
27
|
+
clearTimeout(timer);
|
|
28
|
+
done();
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Options for `createCollection()`: what `fetch` gives, as rows. A list is
|
|
34
|
+
* keyed by `getKey`; a single object (leave `getKey` out) is one row.
|
|
35
|
+
*/
|
|
36
|
+
export function fetchCollectionOptions(options) {
|
|
37
|
+
const { key, fetch, getKey, refetchInterval } = options;
|
|
38
|
+
const staleTime = options.staleTime ?? 30_000;
|
|
39
|
+
const kind = getKey ? "list" : "one";
|
|
40
|
+
const keyOf = getKey ?? (() => ONE);
|
|
41
|
+
const toRows = (data) => kind === "list" ? (data ?? []) : data ? [data] : [];
|
|
42
|
+
let state = {
|
|
43
|
+
order: [],
|
|
44
|
+
error: null,
|
|
45
|
+
loadedAt: 0,
|
|
46
|
+
fetching: false,
|
|
47
|
+
};
|
|
48
|
+
let last;
|
|
49
|
+
let readers = 0;
|
|
50
|
+
// Set by stale(), cleared by the next load.
|
|
51
|
+
let marked = false;
|
|
52
|
+
// Bumped by set(), so a load that started before it can't undo it.
|
|
53
|
+
let generation = 0;
|
|
54
|
+
const listeners = new Set();
|
|
55
|
+
const waiting = new Set();
|
|
56
|
+
const publish = (next) => {
|
|
57
|
+
state = { ...state, ...next };
|
|
58
|
+
listeners.forEach((listener) => listener());
|
|
59
|
+
};
|
|
60
|
+
// Set while the sync runs.
|
|
61
|
+
let live = null;
|
|
62
|
+
const sync = (params) => {
|
|
63
|
+
const { begin, write, commit, markReady } = params;
|
|
64
|
+
const rows = new Map();
|
|
65
|
+
let closed = false;
|
|
66
|
+
let timer;
|
|
67
|
+
const controller = new AbortController();
|
|
68
|
+
// Writes only what changed, so rows that stayed the same keep their
|
|
69
|
+
// identity and whatever renders them doesn't start over.
|
|
70
|
+
const apply = (data) => {
|
|
71
|
+
const next = toRows(data);
|
|
72
|
+
const keys = next.map(keyOf);
|
|
73
|
+
const kept = new Set(keys);
|
|
74
|
+
last = data;
|
|
75
|
+
marked = false;
|
|
76
|
+
begin();
|
|
77
|
+
for (const [k] of rows) {
|
|
78
|
+
if (!kept.has(k)) {
|
|
79
|
+
write({ type: "delete", key: k });
|
|
80
|
+
rows.delete(k);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
next.forEach((row, i) => {
|
|
84
|
+
const k = keys[i];
|
|
85
|
+
const old = rows.get(k);
|
|
86
|
+
if (old && same(old, row))
|
|
87
|
+
return;
|
|
88
|
+
write({ type: old ? "update" : "insert", value: row });
|
|
89
|
+
rows.set(k, row);
|
|
90
|
+
});
|
|
91
|
+
commit();
|
|
92
|
+
markReady();
|
|
93
|
+
publish({
|
|
94
|
+
order: same(keys, state.order) ? state.order : keys,
|
|
95
|
+
error: null,
|
|
96
|
+
loadedAt: Date.now(),
|
|
97
|
+
});
|
|
98
|
+
waiting.forEach((w) => w.done());
|
|
99
|
+
waiting.clear();
|
|
100
|
+
};
|
|
101
|
+
// The next timed load, while someone reads it.
|
|
102
|
+
const schedule = () => {
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
const every = typeof refetchInterval === "function"
|
|
105
|
+
? last === undefined
|
|
106
|
+
? false
|
|
107
|
+
: refetchInterval(last)
|
|
108
|
+
: refetchInterval;
|
|
109
|
+
if (every && readers > 0 && !closed) {
|
|
110
|
+
// A hidden tab waits rather than asking; nobody is looking.
|
|
111
|
+
timer = setTimeout(() => typeof document !== "undefined" && document.hidden
|
|
112
|
+
? schedule()
|
|
113
|
+
: void load(), every);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
const load = singleFlight({
|
|
117
|
+
start: () => publish({ fetching: true }),
|
|
118
|
+
pass: async () => {
|
|
119
|
+
let data;
|
|
120
|
+
const at = generation;
|
|
121
|
+
for (let tries = 0;; tries++) {
|
|
122
|
+
try {
|
|
123
|
+
data = await fetch(controller.signal);
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
// The server said no. No answer at all (status 0) and
|
|
128
|
+
// server trouble are worth another go.
|
|
129
|
+
const refused = error instanceof ViperError &&
|
|
130
|
+
error.status > 0 &&
|
|
131
|
+
error.status < 500;
|
|
132
|
+
if (closed || refused || tries >= RETRIES)
|
|
133
|
+
throw error;
|
|
134
|
+
await wait(1000 * 2 ** tries, controller.signal);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (closed)
|
|
138
|
+
return;
|
|
139
|
+
// set() put newer data in while this was on the way.
|
|
140
|
+
if (at === generation)
|
|
141
|
+
apply(data);
|
|
142
|
+
},
|
|
143
|
+
closed: () => closed,
|
|
144
|
+
fail: (error) => {
|
|
145
|
+
if (closed)
|
|
146
|
+
return;
|
|
147
|
+
// Ready with no rows, so preload() settles; the error is in state.
|
|
148
|
+
markReady();
|
|
149
|
+
publish({ error });
|
|
150
|
+
waiting.forEach((w) => w.fail(error));
|
|
151
|
+
waiting.clear();
|
|
152
|
+
},
|
|
153
|
+
settle: () => {
|
|
154
|
+
publish({ fetching: false });
|
|
155
|
+
schedule();
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
live = { load, apply, schedule };
|
|
159
|
+
void load();
|
|
160
|
+
return () => {
|
|
161
|
+
closed = true;
|
|
162
|
+
live = null;
|
|
163
|
+
clearTimeout(timer);
|
|
164
|
+
controller.abort();
|
|
165
|
+
};
|
|
166
|
+
};
|
|
167
|
+
// True when a reader should ask again: an old copy, or a failed first
|
|
168
|
+
// load (it may have been a blip).
|
|
169
|
+
const due = () => !state.fetching &&
|
|
170
|
+
(state.loadedAt
|
|
171
|
+
? marked || Date.now() - state.loadedAt > staleTime
|
|
172
|
+
: !!state.error);
|
|
173
|
+
const entry = {
|
|
174
|
+
onFocus: () => {
|
|
175
|
+
if (options.refetchOnFocus !== false && due())
|
|
176
|
+
void live?.load();
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
const utils = {
|
|
180
|
+
state: () => state,
|
|
181
|
+
subscribe: (listener) => {
|
|
182
|
+
listeners.add(listener);
|
|
183
|
+
return () => listeners.delete(listener);
|
|
184
|
+
},
|
|
185
|
+
refetch: async () => live?.load(),
|
|
186
|
+
ready: () => {
|
|
187
|
+
if (state.loadedAt)
|
|
188
|
+
return Promise.resolve();
|
|
189
|
+
// A failed first load is asked again, not handed back as is.
|
|
190
|
+
if (due())
|
|
191
|
+
void live?.load();
|
|
192
|
+
return new Promise((done, fail) => waiting.add({ done, fail }));
|
|
193
|
+
},
|
|
194
|
+
set: (data) => {
|
|
195
|
+
generation++;
|
|
196
|
+
live?.apply(data);
|
|
197
|
+
},
|
|
198
|
+
watch: () => {
|
|
199
|
+
readers++;
|
|
200
|
+
watched.add(entry);
|
|
201
|
+
// A reader that comes back to an old copy asks again.
|
|
202
|
+
if (due()) {
|
|
203
|
+
void live?.load();
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
live?.schedule();
|
|
207
|
+
}
|
|
208
|
+
return () => {
|
|
209
|
+
readers--;
|
|
210
|
+
if (readers === 0)
|
|
211
|
+
watched.delete(entry);
|
|
212
|
+
};
|
|
213
|
+
},
|
|
214
|
+
stale: async () => {
|
|
215
|
+
marked = true;
|
|
216
|
+
if (readers > 0)
|
|
217
|
+
await live?.load();
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
return {
|
|
221
|
+
id: `viper-fetch:${JSON.stringify(key)}`,
|
|
222
|
+
getKey: keyOf,
|
|
223
|
+
// An endpoint sends whole rows, so an update replaces rather than merges.
|
|
224
|
+
sync: { sync, rowUpdateMode: "full" },
|
|
225
|
+
utils,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
// Fetched collections by owner and key, so every reader of a key shares one
|
|
229
|
+
// collection (and one request). One leaves the cache when TanStack DB cleans
|
|
230
|
+
// it up, after nothing has read it for a while.
|
|
231
|
+
const made = cacheOf();
|
|
232
|
+
/**
|
|
233
|
+
* The fetched collection for `options.key` under `owner` (a client, say),
|
|
234
|
+
* made once and shared. For a list pass `getKey`; leave it out for one object.
|
|
235
|
+
*/
|
|
236
|
+
export function fetched(owner, options) {
|
|
237
|
+
return made(owner, JSON.stringify(options.key), () => {
|
|
238
|
+
const collection = Object.assign(createCollection(fetchCollectionOptions(options)), { kind: options.getKey ? "list" : "one", key: options.key });
|
|
239
|
+
// Asking for the data (or putting some in) starts the sync if nothing
|
|
240
|
+
// has yet, so a route loader can await ready() on its own.
|
|
241
|
+
const { ready, set } = collection.utils;
|
|
242
|
+
collection.utils.ready = () => {
|
|
243
|
+
collection.startSyncImmediate();
|
|
244
|
+
return ready();
|
|
245
|
+
};
|
|
246
|
+
collection.utils.set = (data) => {
|
|
247
|
+
collection.startSyncImmediate();
|
|
248
|
+
set(data);
|
|
249
|
+
};
|
|
250
|
+
return collection;
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
// True when `key` starts with every part of `prefix`.
|
|
254
|
+
function startsWith(key, prefix) {
|
|
255
|
+
return prefix.every((part, i) => same(part, key[i]));
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Loads again every fetched collection under `owner` whose key starts with
|
|
259
|
+
* `prefix`: the ones on screen now, the rest when next read. Call it after a
|
|
260
|
+
* change, the way you'd invalidate a query.
|
|
261
|
+
*/
|
|
262
|
+
export function refetchFetched(owner, prefix) {
|
|
263
|
+
const loads = [];
|
|
264
|
+
for (const collection of made.of(owner)?.values() ?? []) {
|
|
265
|
+
if (!startsWith(collection.key, prefix))
|
|
266
|
+
continue;
|
|
267
|
+
loads.push(collection.utils.stale());
|
|
268
|
+
}
|
|
269
|
+
return Promise.all(loads).then(() => undefined);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Drops every fetched collection under `owner` whose key starts with
|
|
273
|
+
* `prefix`, so nothing shows its data again (after a project is deleted).
|
|
274
|
+
*/
|
|
275
|
+
export function forgetFetched(owner, prefix) {
|
|
276
|
+
const byKey = made.of(owner);
|
|
277
|
+
for (const [hash, collection] of byKey ?? []) {
|
|
278
|
+
if (!startsWith(collection.key, prefix))
|
|
279
|
+
continue;
|
|
280
|
+
byKey?.delete(hash);
|
|
281
|
+
void collection.cleanup();
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const IDLE = {
|
|
285
|
+
order: [],
|
|
286
|
+
error: null,
|
|
287
|
+
loadedAt: 0,
|
|
288
|
+
fetching: false,
|
|
289
|
+
};
|
|
290
|
+
/**
|
|
291
|
+
* A fetched collection read as a list (or its one object), in the server's
|
|
292
|
+
* order. Pass null to read nothing yet. With `keepPrevious`, the last data
|
|
293
|
+
* stays on screen while a new collection (the next page, a new filter) loads.
|
|
294
|
+
*
|
|
295
|
+
* ```tsx
|
|
296
|
+
* const keys = useFetched(fetched(client, { key: ["keys"], fetch, getKey }))
|
|
297
|
+
* ```
|
|
298
|
+
*/
|
|
299
|
+
export function useFetched(collection, options = {}) {
|
|
300
|
+
const live = useLiveQuery({
|
|
301
|
+
query: (q) => collection
|
|
302
|
+
? q.from({ row: collection })
|
|
303
|
+
: undefined,
|
|
304
|
+
});
|
|
305
|
+
const state = useSyncExternalStore(collection?.utils.subscribe ?? noSubscribe, collection?.utils.state ?? idle, collection?.utils.state ?? idle);
|
|
306
|
+
useEffect(() => collection?.utils.watch(), [collection]);
|
|
307
|
+
// The collection keeps rows by key; the order is the server's.
|
|
308
|
+
const loaded = state.loadedAt > 0;
|
|
309
|
+
const data = useMemo(() => {
|
|
310
|
+
if (!collection || !loaded)
|
|
311
|
+
return undefined;
|
|
312
|
+
return read(collection);
|
|
313
|
+
// live.data changes whenever a row does.
|
|
314
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
315
|
+
}, [collection, live.data, state.order, loaded]);
|
|
316
|
+
// The last data that loaded, shown while the next collection does.
|
|
317
|
+
const [last, setLast] = useState(undefined);
|
|
318
|
+
if (options.keepPrevious && data !== undefined && data !== last)
|
|
319
|
+
setLast(data);
|
|
320
|
+
const error = (state.error ?? null);
|
|
321
|
+
const shown = data ?? (options.keepPrevious && !error ? last : undefined);
|
|
322
|
+
return {
|
|
323
|
+
data: shown,
|
|
324
|
+
error,
|
|
325
|
+
isError: !!error,
|
|
326
|
+
isPending: !!collection && shown === undefined && !error,
|
|
327
|
+
isSuccess: shown !== undefined,
|
|
328
|
+
isFetching: state.fetching,
|
|
329
|
+
updatedAt: state.loadedAt,
|
|
330
|
+
refetch: async () => collection?.utils.refetch(),
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Several fetched collections at once (one per project, say), in the same
|
|
335
|
+
* order: `{ data, error }` each, with `data` undefined until it loads.
|
|
336
|
+
*/
|
|
337
|
+
export function useFetchedAll(collections) {
|
|
338
|
+
const [tick, setTick] = useState(0);
|
|
339
|
+
// The list is usually made fresh each render, so compare by keys.
|
|
340
|
+
const id = collections.map((c) => JSON.stringify(c.key)).join(" ");
|
|
341
|
+
useEffect(() => {
|
|
342
|
+
const undo = collections.flatMap((c) => {
|
|
343
|
+
// Re-render only when data or an error lands, not on every fetch flag.
|
|
344
|
+
let seen = c.utils.state();
|
|
345
|
+
// Nothing else reads these, so start each and hold it, or TanStack DB
|
|
346
|
+
// would leave it idle, then clean it up.
|
|
347
|
+
c.startSyncImmediate();
|
|
348
|
+
const held = c.subscribeChanges(() => { });
|
|
349
|
+
return [
|
|
350
|
+
c.utils.watch(),
|
|
351
|
+
() => held.unsubscribe(),
|
|
352
|
+
c.utils.subscribe(() => {
|
|
353
|
+
const now = c.utils.state();
|
|
354
|
+
if (now.loadedAt !== seen.loadedAt || now.error !== seen.error) {
|
|
355
|
+
setTick((n) => n + 1);
|
|
356
|
+
}
|
|
357
|
+
seen = now;
|
|
358
|
+
}),
|
|
359
|
+
];
|
|
360
|
+
});
|
|
361
|
+
return () => undo.forEach((u) => u());
|
|
362
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
363
|
+
}, [id]);
|
|
364
|
+
return useMemo(() => collections.map((c) => {
|
|
365
|
+
const state = c.utils.state();
|
|
366
|
+
const data = state.loadedAt ? read(c) : undefined;
|
|
367
|
+
return { data, error: (state.error ?? null) };
|
|
368
|
+
}),
|
|
369
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
370
|
+
[id, tick]);
|
|
371
|
+
}
|
|
372
|
+
// A row without the `$synced`, `$key`... fields TanStack DB adds, so a
|
|
373
|
+
// form can send it back as is. Cached, so a row keeps its identity.
|
|
374
|
+
const plainRows = new WeakMap();
|
|
375
|
+
const VIRTUAL = ["$synced", "$origin", "$key", "$collectionId"];
|
|
376
|
+
function plain(row) {
|
|
377
|
+
let found = plainRows.get(row);
|
|
378
|
+
if (!found) {
|
|
379
|
+
const copy = {
|
|
380
|
+
...row,
|
|
381
|
+
};
|
|
382
|
+
VIRTUAL.forEach((name) => delete copy[name]);
|
|
383
|
+
found = copy;
|
|
384
|
+
plainRows.set(row, found);
|
|
385
|
+
}
|
|
386
|
+
return found;
|
|
387
|
+
}
|
|
388
|
+
// The rows of `collection` in the server's order, as plain objects.
|
|
389
|
+
function read(collection) {
|
|
390
|
+
const rows = collection.utils.state().order.flatMap((k) => {
|
|
391
|
+
const row = collection.get(k);
|
|
392
|
+
return row ? [plain(row)] : [];
|
|
393
|
+
});
|
|
394
|
+
return (collection.kind === "one" ? rows[0] : rows);
|
|
395
|
+
}
|
|
396
|
+
// For a null collection: nothing to hear, nothing loaded.
|
|
397
|
+
const noSubscribe = () => () => { };
|
|
398
|
+
const idle = () => IDLE;
|
package/dist/db-page.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createCollection } from "@tanstack/db";
|
|
2
2
|
import { useLiveQuery } from "@tanstack/react-db";
|
|
3
3
|
import { useMemo, useState, useSyncExternalStore } from "react";
|
|
4
|
+
import { cacheOf, sendOps, singleFlight } from "./db-shared.js";
|
|
4
5
|
import { pageComparator, planPage } from "./page-plan.js";
|
|
5
6
|
import { useViper } from "./provider.js";
|
|
6
7
|
// A burst of changes (an import, a batch) asks for the page once.
|
|
@@ -12,14 +13,6 @@ const EMPTY = {
|
|
|
12
13
|
totalItems: 0,
|
|
13
14
|
totalPages: 0,
|
|
14
15
|
};
|
|
15
|
-
// A delete of several rows, as one all-or-nothing batch.
|
|
16
|
-
async function deleteAll(viper, collection, ids) {
|
|
17
|
-
if (ids.length === 1) {
|
|
18
|
-
await viper.collection(collection).records.delete(ids[0]);
|
|
19
|
-
return;
|
|
20
|
-
}
|
|
21
|
-
await viper.batch(ids.map((id) => ({ method: "delete", collection, id })));
|
|
22
|
-
}
|
|
23
16
|
/**
|
|
24
17
|
* Options for `createCollection()`: one server page of `collection`, kept
|
|
25
18
|
* fresh from realtime. Its totals and row order are under `utils.info()`.
|
|
@@ -42,59 +35,42 @@ export function viperPageOptions(options) {
|
|
|
42
35
|
const sync = (params) => {
|
|
43
36
|
const { begin, write, commit, truncate, markReady, markError } = params;
|
|
44
37
|
let closed = false;
|
|
45
|
-
let loading = null;
|
|
46
|
-
let again = false;
|
|
47
38
|
let timer;
|
|
48
|
-
const load =
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
// Truncate and rewrite in one transaction, so a reload never
|
|
62
|
-
// shows an empty page in between.
|
|
63
|
-
begin();
|
|
64
|
-
truncate();
|
|
65
|
-
rows.clear();
|
|
66
|
-
for (const record of result.items) {
|
|
67
|
-
write({ type: "insert", value: record });
|
|
68
|
-
rows.set(record.id, record);
|
|
69
|
-
}
|
|
70
|
-
commit();
|
|
71
|
-
markReady();
|
|
72
|
-
publish({
|
|
73
|
-
order: result.items.map((r) => r.id),
|
|
74
|
-
page: result.page,
|
|
75
|
-
perPage: result.perPage,
|
|
76
|
-
totalItems: result.totalItems,
|
|
77
|
-
totalPages: result.totalPages,
|
|
78
|
-
});
|
|
79
|
-
} while (again && !closed);
|
|
80
|
-
}
|
|
81
|
-
catch (error) {
|
|
82
|
-
markError(error);
|
|
83
|
-
}
|
|
84
|
-
finally {
|
|
85
|
-
loading = null;
|
|
39
|
+
const load = singleFlight({
|
|
40
|
+
pass: async () => {
|
|
41
|
+
const result = await records.list(query);
|
|
42
|
+
if (closed)
|
|
43
|
+
return;
|
|
44
|
+
// Truncate and rewrite in one transaction, so a reload never
|
|
45
|
+
// shows an empty page in between.
|
|
46
|
+
begin();
|
|
47
|
+
truncate();
|
|
48
|
+
rows.clear();
|
|
49
|
+
for (const record of result.items) {
|
|
50
|
+
write({ type: "insert", value: record });
|
|
51
|
+
rows.set(record.id, record);
|
|
86
52
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
53
|
+
commit();
|
|
54
|
+
markReady();
|
|
55
|
+
publish({
|
|
56
|
+
order: result.items.map((r) => r.id),
|
|
57
|
+
page: result.page,
|
|
58
|
+
perPage: result.perPage,
|
|
59
|
+
totalItems: result.totalItems,
|
|
60
|
+
totalPages: result.totalPages,
|
|
61
|
+
});
|
|
62
|
+
},
|
|
63
|
+
closed: () => closed,
|
|
64
|
+
fail: markError,
|
|
65
|
+
});
|
|
90
66
|
const loadSoon = () => {
|
|
91
67
|
clearTimeout(timer);
|
|
92
68
|
timer = setTimeout(() => void load(), SETTLE);
|
|
93
69
|
};
|
|
94
70
|
const take = (event) => {
|
|
95
71
|
// The page on screen may be about to change; plan against the next one.
|
|
96
|
-
if (
|
|
97
|
-
|
|
72
|
+
if (load.busy()) {
|
|
73
|
+
void load();
|
|
98
74
|
return;
|
|
99
75
|
}
|
|
100
76
|
const plan = planPage(event, {
|
|
@@ -158,7 +134,11 @@ export function viperPageOptions(options) {
|
|
|
158
134
|
sync: { sync, rowUpdateMode: "full" },
|
|
159
135
|
utils,
|
|
160
136
|
onDelete: async ({ transaction }) => {
|
|
161
|
-
await
|
|
137
|
+
await sendOps(viper, transaction.mutations.map((m) => ({
|
|
138
|
+
method: "delete",
|
|
139
|
+
collection,
|
|
140
|
+
id: String(m.key),
|
|
141
|
+
})));
|
|
162
142
|
// The rows after them move up.
|
|
163
143
|
await live?.load();
|
|
164
144
|
},
|
|
@@ -166,32 +146,16 @@ export function viperPageOptions(options) {
|
|
|
166
146
|
}
|
|
167
147
|
// Page collections by client and page, so the route that preloads a page and
|
|
168
148
|
// the table that shows it share one collection (and one fetch). One leaves
|
|
169
|
-
//
|
|
170
|
-
|
|
149
|
+
// the cache when its sync stops, after nothing has read it for a while, and
|
|
150
|
+
// the next ask makes a fresh one.
|
|
151
|
+
const pages = cacheOf();
|
|
171
152
|
/**
|
|
172
153
|
* The page collection for `collection` and `query` on `viper`, made once and
|
|
173
154
|
* shared. Outside React (a route loader, say) call `.preload()` on it to
|
|
174
155
|
* start the fetch early.
|
|
175
156
|
*/
|
|
176
157
|
export function viperPage(viper, collection, query) {
|
|
177
|
-
|
|
178
|
-
if (!byKey) {
|
|
179
|
-
byKey = new Map();
|
|
180
|
-
pages.set(viper, byKey);
|
|
181
|
-
}
|
|
182
|
-
const key = `${collection}:${JSON.stringify(query)}`;
|
|
183
|
-
const found = byKey.get(key);
|
|
184
|
-
if (found)
|
|
185
|
-
return found;
|
|
186
|
-
const made = createCollection(viperPageOptions({ viper, collection, query }));
|
|
187
|
-
// Once it is cleaned up it can't be read again, so the next ask makes a
|
|
188
|
-
// new one.
|
|
189
|
-
made.on("status:change", ({ status }) => {
|
|
190
|
-
if (status === "cleaned-up")
|
|
191
|
-
byKey.delete(key);
|
|
192
|
-
});
|
|
193
|
-
byKey.set(key, made);
|
|
194
|
-
return made;
|
|
158
|
+
return pages(viper, `${collection}:${JSON.stringify(query)}`, () => createCollection(viperPageOptions({ viper, collection, query })));
|
|
195
159
|
}
|
|
196
160
|
/**
|
|
197
161
|
* One server page of `collection` as a live list: `records.list` once, then
|
package/dist/db-query.d.ts
CHANGED
|
@@ -6,12 +6,6 @@ export type Translated = {
|
|
|
6
6
|
/** True when the filter matches exactly what the query does. */
|
|
7
7
|
exact: boolean;
|
|
8
8
|
};
|
|
9
|
-
/**
|
|
10
|
-
* A string as a Viper string literal. Backslash escapes the next character
|
|
11
|
-
* in Viper's lexer, so escaping `\` and `"` is enough to keep a value from
|
|
12
|
-
* ever ending its own quotes.
|
|
13
|
-
*/
|
|
14
|
-
export declare function quote(value: string): string;
|
|
15
9
|
/**
|
|
16
10
|
* A subset's `where` as a Viper filter. Anything Viper cannot say is left out
|
|
17
11
|
* (so more rows load), and the query filters again in the browser.
|
package/dist/db-query.js
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
// EXPERIMENTAL: turns a TanStack DB subset request (`where`, `orderBy`) into
|
|
2
|
+
// the `filter` and `sort` strings of Viper's records API.
|
|
3
|
+
//
|
|
4
|
+
// Everything here may load MORE than the query asks for, never less: TanStack
|
|
5
|
+
// DB runs every live query's own `where` again over what is loaded, so a
|
|
6
|
+
// wider load only costs bandwidth. `exact` says whether the load is the
|
|
7
|
+
// query's exact answer; only then is it safe to ask for a limited window.
|
|
8
|
+
import { quote } from "@polyphron/viper-client";
|
|
1
9
|
/** A field name is a plain word, so it can go into a filter unquoted. */
|
|
2
10
|
const FIELD = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
3
11
|
/** The comparisons Viper writes the same way TanStack DB names them. */
|
|
@@ -17,14 +25,6 @@ const FLIP = {
|
|
|
17
25
|
lte: "gte",
|
|
18
26
|
};
|
|
19
27
|
const ANY = { text: null, exact: false };
|
|
20
|
-
/**
|
|
21
|
-
* A string as a Viper string literal. Backslash escapes the next character
|
|
22
|
-
* in Viper's lexer, so escaping `\` and `"` is enough to keep a value from
|
|
23
|
-
* ever ending its own quotes.
|
|
24
|
-
*/
|
|
25
|
-
export function quote(value) {
|
|
26
|
-
return `"${value.replace(/[\\"]/g, "\\$&")}"`;
|
|
27
|
-
}
|
|
28
28
|
// A value as a Viper literal, or null for one Viper has no way to write.
|
|
29
29
|
function literal(value) {
|
|
30
30
|
if (typeof value === "string")
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { BatchOp, RecordModel, Viper } from "@polyphron/viper-client";
|
|
2
|
+
/** What `singleFlight` needs from an adapter. */
|
|
3
|
+
export type FlightOptions = {
|
|
4
|
+
/** One load: fetch and put the answer into the collection. */
|
|
5
|
+
pass: () => Promise<void>;
|
|
6
|
+
/** True once the sync has stopped; a stopped sync drops its answers. */
|
|
7
|
+
closed: () => boolean;
|
|
8
|
+
/** Called with whatever a pass threw (the pass is not retried here). */
|
|
9
|
+
fail: (error: unknown) => void;
|
|
10
|
+
/** Runs before the first pass of a flight. */
|
|
11
|
+
start?: () => void;
|
|
12
|
+
/** Runs after a flight ends, whether it worked or failed. */
|
|
13
|
+
settle?: () => void;
|
|
14
|
+
};
|
|
15
|
+
/** A `load()` that runs one pass at a time; `busy()` says whether one runs. */
|
|
16
|
+
export type Flight = (() => Promise<void>) & {
|
|
17
|
+
busy: () => boolean;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Makes a `load()` that runs one pass at a time. A call during a pass does
|
|
21
|
+
* not start a second one; it asks for another pass right after the first, so
|
|
22
|
+
* a change that landed mid-load is never missed. Every caller gets the
|
|
23
|
+
* promise of the flight it joined.
|
|
24
|
+
*/
|
|
25
|
+
export declare function singleFlight(options: FlightOptions): Flight;
|
|
26
|
+
/** The bits of a TanStack DB collection that `cacheOf` listens to. */
|
|
27
|
+
type Evictable = {
|
|
28
|
+
on: (event: "status:change", callback: (change: {
|
|
29
|
+
status: string;
|
|
30
|
+
}) => void) => unknown;
|
|
31
|
+
};
|
|
32
|
+
/** A cache of collections; `of(owner)` lists what an owner holds now. */
|
|
33
|
+
export type Cache<V> = ((owner: object, key: string, make: () => V) => V) & {
|
|
34
|
+
of: (owner: object) => Map<string, V> | undefined;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* A cache of collections by owner (a client, say) and key, so every reader
|
|
38
|
+
* of a key shares one collection and one request. A collection leaves the
|
|
39
|
+
* cache once TanStack DB cleans it up, after nothing has read it for a while:
|
|
40
|
+
* the next ask builds a fresh one rather than waking the old one.
|
|
41
|
+
*/
|
|
42
|
+
export declare function cacheOf<V extends Evictable>(): Cache<V>;
|
|
43
|
+
/**
|
|
44
|
+
* Sends writes: one call for one, a single all-or-nothing batch for several.
|
|
45
|
+
* Resolves to the saved records, in order (null for a delete).
|
|
46
|
+
*/
|
|
47
|
+
export declare function sendOps(viper: Viper, ops: BatchOp[]): Promise<(RecordModel | null)[]>;
|
|
48
|
+
/** True when two values are the same, lists and objects included. */
|
|
49
|
+
export declare function same(a: unknown, b: unknown): boolean;
|
|
50
|
+
export {};
|