@getaster/data 0.0.0-stage → 0.1.0-preview.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +10 -0
  3. package/README.md +6 -2
  4. package/dist/authentication.d.ts +42 -0
  5. package/dist/authentication.js +121 -0
  6. package/dist/changes/changeset.d.ts +47 -0
  7. package/dist/changes/changeset.js +75 -0
  8. package/dist/collection/aggregate.d.ts +22 -0
  9. package/dist/collection/aggregate.js +94 -0
  10. package/dist/collection/collection.d.ts +96 -0
  11. package/dist/collection/collection.js +588 -0
  12. package/dist/collection/grouping.d.ts +75 -0
  13. package/dist/collection/grouping.js +190 -0
  14. package/dist/collection/groups.d.ts +31 -0
  15. package/dist/collection/groups.js +117 -0
  16. package/dist/collection/summary.d.ts +17 -0
  17. package/dist/collection/summary.js +103 -0
  18. package/dist/composition/activity.d.ts +36 -0
  19. package/dist/composition/activity.js +60 -0
  20. package/dist/composition/command.d.ts +22 -0
  21. package/dist/composition/command.js +57 -0
  22. package/dist/composition/lifetime.d.ts +11 -0
  23. package/dist/composition/lifetime.js +33 -0
  24. package/dist/composition/sessions.d.ts +64 -0
  25. package/dist/composition/sessions.js +209 -0
  26. package/dist/context.d.ts +76 -0
  27. package/dist/context.js +211 -0
  28. package/dist/criteria/ast.d.ts +79 -0
  29. package/dist/criteria/ast.js +1 -0
  30. package/dist/criteria/compile.d.ts +37 -0
  31. package/dist/criteria/compile.js +233 -0
  32. package/dist/criteria/evaluate.d.ts +20 -0
  33. package/dist/criteria/evaluate.js +171 -0
  34. package/dist/criteria/filter-draft.d.ts +79 -0
  35. package/dist/criteria/filter-draft.js +320 -0
  36. package/dist/criteria/relative.d.ts +13 -0
  37. package/dist/criteria/relative.js +82 -0
  38. package/dist/criteria/values.d.ts +20 -0
  39. package/dist/criteria/values.js +283 -0
  40. package/dist/edit/edit-model.d.ts +152 -0
  41. package/dist/edit/edit-model.js +620 -0
  42. package/dist/edit/list-edit.d.ts +116 -0
  43. package/dist/edit/list-edit.js +332 -0
  44. package/dist/edit/record-edit.d.ts +93 -0
  45. package/dist/edit/record-edit.js +317 -0
  46. package/dist/export/csv.d.ts +25 -0
  47. package/dist/export/csv.js +75 -0
  48. package/dist/index.d.ts +38 -0
  49. package/dist/index.js +35 -0
  50. package/dist/odata/errors.d.ts +25 -0
  51. package/dist/odata/errors.js +56 -0
  52. package/dist/odata/source.d.ts +65 -0
  53. package/dist/odata/source.js +193 -0
  54. package/dist/progress/status.d.ts +29 -0
  55. package/dist/progress/status.js +105 -0
  56. package/dist/schema/csdl.d.ts +13 -0
  57. package/dist/schema/csdl.js +531 -0
  58. package/dist/schema/model.d.ts +132 -0
  59. package/dist/schema/model.js +1 -0
  60. package/dist/scope/filters.d.ts +130 -0
  61. package/dist/scope/filters.js +349 -0
  62. package/dist/scope/publications.d.ts +27 -0
  63. package/dist/scope/publications.js +74 -0
  64. package/dist/scope/related-aggregate.d.ts +9 -0
  65. package/dist/scope/related-aggregate.js +67 -0
  66. package/dist/scope/scope.d.ts +181 -0
  67. package/dist/scope/scope.js +574 -0
  68. package/dist/selection/selection.d.ts +56 -0
  69. package/dist/selection/selection.js +137 -0
  70. package/dist/store/keys.d.ts +10 -0
  71. package/dist/store/keys.js +21 -0
  72. package/dist/store/store.d.ts +72 -0
  73. package/dist/store/store.js +143 -0
  74. package/dist/template/template.d.ts +19 -0
  75. package/dist/template/template.js +186 -0
  76. package/dist/transport.d.ts +28 -0
  77. package/dist/transport.js +20 -0
  78. package/dist/tree/tree.d.ts +117 -0
  79. package/dist/tree/tree.js +435 -0
  80. package/dist/validate/criteria-rule.d.ts +28 -0
  81. package/dist/validate/criteria-rule.js +22 -0
  82. package/dist/validate/validate.d.ts +13 -0
  83. package/dist/validate/validate.js +123 -0
  84. package/package.json +32 -4
@@ -0,0 +1,96 @@
1
+ import { type ReadonlySignal } from "@preact/signals-core";
2
+ import type { Criteria, Query } from "../criteria/ast.ts";
3
+ import type { Entity, EntityType, Schema } from "../schema/model.ts";
4
+ import type { ODataSource } from "../odata/source.ts";
5
+ import type { KeyText } from "../store/keys.ts";
6
+ import type { RecordStore, StoreChange, StoredRecord } from "../store/store.ts";
7
+ export type CollectionStatus = "idle" | "loading" | "error";
8
+ export interface CollectionOptions {
9
+ readonly pageSize?: number;
10
+ /** Loaded pages kept as `view` moves; those farthest from view are forgotten. 20 by default. */
11
+ readonly keepPages?: number;
12
+ /** The clock a filter's relative dates resolve against, at each fetch and each local placement. */
13
+ readonly now?: () => number;
14
+ /**
15
+ * Ask for an exact count (`Prefer: aster.count=exact`) where the server
16
+ * would estimate it or leave it out, for a total that matters, such as a
17
+ * figure on a dashboard. It costs the server a full count.
18
+ */
19
+ readonly exactCount?: boolean;
20
+ }
21
+ /**
22
+ * A query plus the positions of its results that are loaded. `keys[i]` is
23
+ * the record at server position `i`, or `undefined` where nothing is loaded.
24
+ *
25
+ * Invariant: loaded positions are in the query's sort order, and
26
+ * `keys.length` equals the server's count whenever the count is known.
27
+ *
28
+ * A server may leave the count out (a large set, loaded progressively).
29
+ * `count` is then the rows loaded so far plus a page more while the last
30
+ * page came back full, and `counted` is false until a short page shows
31
+ * where the results end.
32
+ *
33
+ * A server may also estimate it (`estimated`). The estimate sizes the
34
+ * layout as the query's first count; later pages' estimates are ignored, so
35
+ * one that drifts never starts the collection over. A short page replaces
36
+ * it with the real end; pages that run past it, or an empty page before
37
+ * it, turn it into a progressive count.
38
+ */
39
+ export declare class Collection {
40
+ #private;
41
+ readonly type: EntityType;
42
+ readonly pageSize: number;
43
+ readonly keepPages: number;
44
+ readonly keys: ReadonlySignal<readonly (KeyText | undefined)[]>;
45
+ /** The server's total for the query; `undefined` until the first page arrives. */
46
+ readonly count: ReadonlySignal<number | undefined>;
47
+ /** `count` is the server's total, or the end has been reached; otherwise more may follow. */
48
+ readonly counted: ReadonlySignal<boolean>;
49
+ /** `count` is the server's estimate: right for sizing a scrollbar, not for stating as a fact. */
50
+ readonly estimated: ReadonlySignal<boolean>;
51
+ readonly status: ReadonlySignal<CollectionStatus>;
52
+ readonly error: ReadonlySignal<unknown>;
53
+ /** A change arrived whose position couldn't be decided locally; a refresh is running or due. */
54
+ readonly stale: ReadonlySignal<boolean>;
55
+ readonly complete: ReadonlySignal<boolean>;
56
+ /** Entities by position, reading each record's signal so views follow edits. */
57
+ readonly items: ReadonlySignal<readonly (Entity | undefined)[]>;
58
+ constructor(store: RecordStore, source: ODataSource, query: Query, options?: CollectionOptions);
59
+ get query(): Query;
60
+ /** A loaded position, including its hidden fields. Reads only that record's signal. */
61
+ recordAt(index: number): StoredRecord | undefined;
62
+ /** Known membership, or undefined where unloaded/hidden/related fields prevent a local decision. */
63
+ has(key: KeyText): boolean | undefined;
64
+ /**
65
+ * Replaces the query (same entity set). The rows go at once, so nothing
66
+ * from the old query shows as an answer to the new one. Only the first
67
+ * page loads again: a view asks for the rows it shows, and reloading
68
+ * everything that was loaded deep down would cost a request per page.
69
+ */
70
+ setQuery(query: Query): void;
71
+ /** Loads every page touching `[start, end)`. */
72
+ ensure(start: number, end: number): Promise<void>;
73
+ /**
74
+ * Shows `[start, end)`: loads its pages, then the next page in the
75
+ * direction of travel without waiting for it, and forgets loaded pages
76
+ * farthest from view beyond `keepPages`. A refresh then reloads only the
77
+ * viewed pages.
78
+ */
79
+ view(start: number, end: number, retain?: readonly number[]): Promise<void>;
80
+ /** Loads every page; without a server count, page after page until one comes back short. */
81
+ loadAll(): Promise<void>;
82
+ /**
83
+ * Refetches what's shown, keeping current rows visible until the answers
84
+ * arrive: the viewed pages if `view` has been used, otherwise every loaded
85
+ * page. Pages it doesn't refetch are forgotten, as they may be stale.
86
+ */
87
+ refresh(): Promise<void>;
88
+ /**
89
+ * Applies a store change: the record leaves, moves or joins
90
+ * the collection when its new position is certain from loaded neighbours;
91
+ * otherwise the collection goes stale and refreshes.
92
+ */
93
+ apply(change: StoreChange): void;
94
+ }
95
+ /** The entity sets a filter reaches through `any`, `all` and `via`, at any depth. */
96
+ export declare function relatedSets(c: Criteria | undefined, type: EntityType, schema: Schema): ReadonlySet<string>;
@@ -0,0 +1,588 @@
1
+ import { batch, computed, signal } from "@preact/signals-core";
2
+ import { isPath } from "../criteria/compile.js";
3
+ import { matches, sortComparator } from "../criteria/evaluate.js";
4
+ import { hasRelative, relatedOf, resolveRelative } from "../criteria/relative.js";
5
+ /**
6
+ * A query plus the positions of its results that are loaded. `keys[i]` is
7
+ * the record at server position `i`, or `undefined` where nothing is loaded.
8
+ *
9
+ * Invariant: loaded positions are in the query's sort order, and
10
+ * `keys.length` equals the server's count whenever the count is known.
11
+ *
12
+ * A server may leave the count out (a large set, loaded progressively).
13
+ * `count` is then the rows loaded so far plus a page more while the last
14
+ * page came back full, and `counted` is false until a short page shows
15
+ * where the results end.
16
+ *
17
+ * A server may also estimate it (`estimated`). The estimate sizes the
18
+ * layout as the query's first count; later pages' estimates are ignored, so
19
+ * one that drifts never starts the collection over. A short page replaces
20
+ * it with the real end; pages that run past it, or an empty page before
21
+ * it, turn it into a progressive count.
22
+ */
23
+ export class Collection {
24
+ type;
25
+ pageSize;
26
+ keepPages;
27
+ #store;
28
+ #source;
29
+ #query;
30
+ #queryVersion = signal(0);
31
+ /** Undefined when the sort follows a path: the related values aren't here to compare. */
32
+ #compare;
33
+ /** The fields the filter and sort read; a record must have them current to be placed. */
34
+ #reads;
35
+ /** The filter reads related records (`any`, `all`, `via`). */
36
+ #lambda;
37
+ /** The sets those related records are in; a change there may change membership here. */
38
+ #related;
39
+ #now;
40
+ #exact;
41
+ #slots = [];
42
+ /** Bumped by every refresh, so responses to older requests are dropped. */
43
+ #generation = 0;
44
+ #inflight = new Map();
45
+ #pending = 0;
46
+ /** The range last shown through `view`; refreshes reload only its pages. */
47
+ #viewed;
48
+ #keys = signal([]);
49
+ #count = signal(undefined);
50
+ #counted = signal(false);
51
+ #estimated = signal(false);
52
+ #status = signal("idle");
53
+ #error = signal(undefined);
54
+ #stale = signal(false);
55
+ keys = this.#keys;
56
+ /** The server's total for the query; `undefined` until the first page arrives. */
57
+ count = this.#count;
58
+ /** `count` is the server's total, or the end has been reached; otherwise more may follow. */
59
+ counted = this.#counted;
60
+ /** `count` is the server's estimate: right for sizing a scrollbar, not for stating as a fact. */
61
+ estimated = this.#estimated;
62
+ status = this.#status;
63
+ error = this.#error;
64
+ /** A change arrived whose position couldn't be decided locally; a refresh is running or due. */
65
+ stale = this.#stale;
66
+ complete = computed(() => this.#counted.value && this.#keys.value.every((k) => k !== undefined));
67
+ /** Entities by position, reading each record's signal so views follow edits. */
68
+ items;
69
+ constructor(store, source, query, options = {}) {
70
+ this.pageSize = options.pageSize ?? 100;
71
+ this.keepPages = options.keepPages ?? 20;
72
+ this.#now = options.now ?? Date.now;
73
+ this.#exact = options.exactCount ?? false;
74
+ this.#store = store;
75
+ this.#source = source;
76
+ this.type = store.schema.set(query.set).type;
77
+ this.#query = query;
78
+ this.#compare = comparatorFor(query, this.type);
79
+ this.#reads = readsOf(query);
80
+ this.#lambda = hasLambda(query.filter);
81
+ this.#related = relatedSets(query.filter, this.type, store.schema);
82
+ const set = query.set;
83
+ this.items = computed(() => this.#keys.value.map((k) => (k === undefined ? undefined : store.record(set, k).value?.entity)));
84
+ }
85
+ get query() {
86
+ this.#queryVersion.value;
87
+ return this.#query;
88
+ }
89
+ /** A loaded position, including its hidden fields. Reads only that record's signal. */
90
+ recordAt(index) {
91
+ const key = this.#keys.value[index];
92
+ return key === undefined ? undefined : this.#store.record(this.query.set, key).value;
93
+ }
94
+ /** Known membership, or undefined where unloaded/hidden/related fields prevent a local decision. */
95
+ has(key) {
96
+ if (this.#keys.value.includes(key))
97
+ return true;
98
+ if (this.#count.value === undefined)
99
+ return undefined;
100
+ if (this.complete.value)
101
+ return false;
102
+ const stored = this.#store.record(this.query.set, key).value;
103
+ if (!stored)
104
+ return false;
105
+ const filter = this.#resolved().filter;
106
+ if (!filter)
107
+ return true;
108
+ if (this.#lambda || fieldsOf(filter).some((field) => isPath(field) || stored.hidden?.has(field) || (stored.known && !stored.known.has(field))))
109
+ return undefined;
110
+ return matches(filter, stored.entity, this.type);
111
+ }
112
+ /**
113
+ * Replaces the query (same entity set). The rows go at once, so nothing
114
+ * from the old query shows as an answer to the new one. Only the first
115
+ * page loads again: a view asks for the rows it shows, and reloading
116
+ * everything that was loaded deep down would cost a request per page.
117
+ */
118
+ setQuery(query) {
119
+ if (query.set !== this.#query.set)
120
+ throw new Error(`a collection stays on ${this.#query.set}; got ${query.set}`);
121
+ const wanted = this.#pending > 0 || this.#count.peek() !== undefined;
122
+ this.#query = query;
123
+ this.#compare = comparatorFor(query, this.type);
124
+ this.#reads = readsOf(query);
125
+ this.#lambda = hasLambda(query.filter);
126
+ this.#related = relatedSets(query.filter, this.type, this.#store.schema);
127
+ this.#generation++;
128
+ this.#inflight.clear();
129
+ this.#slots = [];
130
+ this.#viewed = undefined;
131
+ batch(() => {
132
+ this.#count.value = undefined;
133
+ this.#counted.value = false;
134
+ this.#estimated.value = false;
135
+ this.#stale.value = false;
136
+ this.#publish();
137
+ this.#queryVersion.value++;
138
+ });
139
+ if (wanted)
140
+ this.ensure(0, this.pageSize).catch(() => { });
141
+ }
142
+ /** Loads every page touching `[start, end)`. */
143
+ async ensure(start, end) {
144
+ const generation = this.#generation;
145
+ if (this.#count.peek() === undefined)
146
+ await this.#loadPage(0);
147
+ if (generation !== this.#generation)
148
+ return;
149
+ const count = this.#count.peek() ?? 0;
150
+ const pages = new Set();
151
+ for (let i = Math.max(0, start); i < Math.min(end, count); i++) {
152
+ if (this.#slots[i] === undefined)
153
+ pages.add(Math.floor(i / this.pageSize));
154
+ }
155
+ const running = [...pages].flatMap((p) => this.#inflight.get(p) ?? []);
156
+ const missing = [...pages].filter((p) => !this.#inflight.has(p));
157
+ await Promise.all([...running, ...runsOf(missing).map(([first, n]) => this.#loadPages(first, n))]);
158
+ }
159
+ /**
160
+ * Shows `[start, end)`: loads its pages, then the next page in the
161
+ * direction of travel without waiting for it, and forgets loaded pages
162
+ * farthest from view beyond `keepPages`. A refresh then reloads only the
163
+ * viewed pages.
164
+ */
165
+ async view(start, end, retain = []) {
166
+ const back = this.#viewed !== undefined && start < this.#viewed.start;
167
+ const viewed = { start, end, retain: [...new Set(retain.filter((i) => Number.isSafeInteger(i) && i >= 0))] };
168
+ this.#viewed = viewed;
169
+ await this.ensure(start, end);
170
+ if (this.#viewed !== viewed)
171
+ return;
172
+ this.#forget(start, end, viewed.retain);
173
+ const ahead = back ? [Math.max(0, start - this.pageSize), start] : [end, end + this.pageSize];
174
+ const count = this.#count.peek() ?? 0;
175
+ const pages = (from, to) => {
176
+ const first = Math.floor(Math.max(0, from) / this.pageSize);
177
+ const last = Math.floor((Math.min(count, to) - 1) / this.pageSize);
178
+ return Array.from({ length: Math.max(0, last - first + 1) }, (_, i) => first + i);
179
+ };
180
+ const protectedPages = new Set([
181
+ ...pages(start, end),
182
+ ...viewed.retain.filter((i) => i < count).map((i) => Math.floor(i / this.pageSize)),
183
+ ]);
184
+ // Prefetch only pages that can survive eviction; publishing an evicted page
185
+ // otherwise makes a reactive view request that same page indefinitely.
186
+ const extra = pages(ahead[0], ahead[1]).filter((p) => !protectedPages.has(p));
187
+ if (protectedPages.size + extra.length > this.keepPages)
188
+ return;
189
+ void this.ensure(ahead[0], ahead[1])
190
+ .then(() => this.#viewed === viewed && this.#forget(start, end, viewed.retain))
191
+ .catch(() => { });
192
+ }
193
+ /** Drops loaded pages beyond `keepPages`, farthest from `[start, end)` first. */
194
+ #forget(start, end, retain) {
195
+ const first = Math.floor(start / this.pageSize);
196
+ const last = Math.floor(Math.max(start, end - 1) / this.pageSize);
197
+ const pinned = new Set(retain.map((i) => Math.floor(i / this.pageSize)));
198
+ const loaded = new Set();
199
+ this.#slots.forEach((k, i) => k !== undefined && loaded.add(Math.floor(i / this.pageSize)));
200
+ if (loaded.size <= this.keepPages)
201
+ return;
202
+ const distance = (p) => pinned.has(p) ? 0 : (p < first ? first - p : p > last ? p - last : 0);
203
+ const drop = [...loaded].sort((a, b) => distance(b) - distance(a)).slice(0, loaded.size - this.keepPages);
204
+ let forgotten = false;
205
+ for (const p of drop) {
206
+ if (distance(p) === 0 || this.#inflight.has(p))
207
+ continue;
208
+ for (let i = p * this.pageSize; i < Math.min((p + 1) * this.pageSize, this.#slots.length); i++)
209
+ this.#slots[i] = undefined;
210
+ forgotten = true;
211
+ }
212
+ if (forgotten)
213
+ this.#publish();
214
+ }
215
+ /** Loads every page; without a server count, page after page until one comes back short. */
216
+ async loadAll() {
217
+ do
218
+ await this.ensure(0, Number.POSITIVE_INFINITY);
219
+ while (!this.#counted.peek());
220
+ }
221
+ /**
222
+ * Refetches what's shown, keeping current rows visible until the answers
223
+ * arrive: the viewed pages if `view` has been used, otherwise every loaded
224
+ * page. Pages it doesn't refetch are forgotten, as they may be stale.
225
+ */
226
+ refresh() {
227
+ return this.#refreshWindow([]);
228
+ }
229
+ async #refreshWindow(additional) {
230
+ const loadedPages = new Set(additional);
231
+ const viewed = this.#viewed;
232
+ if (viewed) {
233
+ for (const i of viewed.retain)
234
+ loadedPages.add(Math.floor(i / this.pageSize));
235
+ const last = Math.max(viewed.start, viewed.end - 1);
236
+ for (let p = Math.floor(viewed.start / this.pageSize); p <= Math.floor(last / this.pageSize); p++)
237
+ loadedPages.add(p);
238
+ }
239
+ else {
240
+ this.#slots.forEach((k, i) => k !== undefined && loadedPages.add(Math.floor(i / this.pageSize)));
241
+ }
242
+ if (loadedPages.size === 0)
243
+ loadedPages.add(0);
244
+ this.#generation++;
245
+ this.#inflight.clear();
246
+ const generation = this.#generation;
247
+ const runs = await Promise.all(runsOf([...loadedPages]).map(([first, n]) => this.#fetchPages(first, n, generation)));
248
+ if (generation !== this.#generation)
249
+ return;
250
+ const fresh = runs.flatMap((r) => (r ? this.#slices(r) : []));
251
+ if (fresh.length === 0)
252
+ return;
253
+ const answer = runs.find((r) => r?.count !== undefined);
254
+ // Without an exact count, the end found before stands until a short page moves it.
255
+ const { count, counted, estimated } = this.#settle(fresh, answer?.count, answer?.estimated ?? false);
256
+ this.#slots = [];
257
+ this.#resize(count);
258
+ for (const p of fresh)
259
+ this.#fill(p.page, p.keys);
260
+ batch(() => {
261
+ this.#count.value = count;
262
+ this.#counted.value = counted;
263
+ this.#estimated.value = estimated;
264
+ this.#stale.value = false;
265
+ this.#publish();
266
+ });
267
+ }
268
+ /**
269
+ * Applies a store change: the record leaves, moves or joins
270
+ * the collection when its new position is certain from loaded neighbours;
271
+ * otherwise the collection goes stale and refreshes.
272
+ */
273
+ apply(change) {
274
+ batch(() => this.#apply(change));
275
+ }
276
+ #apply(change) {
277
+ if (change.set !== this.query.set) {
278
+ if (this.#related.has(change.set) && change.origin.kind !== "fetch" && this.#count.peek() !== undefined)
279
+ this.#goStale();
280
+ return;
281
+ }
282
+ if (change.origin.kind === "fetch" && change.origin.collection === this)
283
+ return;
284
+ if (this.#count.peek() === undefined)
285
+ return;
286
+ // A record another query fetched, new to the store, already existed on
287
+ // the server: this collection's count includes it if it belongs, so
288
+ // unless it's a loaded row there is nothing to place.
289
+ if (change.origin.kind === "fetch" && change.before === undefined && !this.#slots.includes(change.key))
290
+ return;
291
+ const known = change.after?.known;
292
+ if (known && this.#reads.some((f) => !known.has(f))) {
293
+ this.#goStale();
294
+ return;
295
+ }
296
+ const after = change.after?.entity;
297
+ // A filter over related records can't be decided from this record alone.
298
+ if (this.#lambda) {
299
+ this.#goStale();
300
+ return;
301
+ }
302
+ const filter = this.#resolved().filter;
303
+ const belongs = after !== undefined && (!filter || matches(filter, after, this.type));
304
+ const at = this.#slots.indexOf(change.key);
305
+ if (at < 0 && !belongs)
306
+ return;
307
+ const count = this.#count.peek();
308
+ if (at >= 0) {
309
+ this.#slots.splice(at, 1);
310
+ if (count !== undefined)
311
+ this.#count.value = count - 1;
312
+ }
313
+ if (belongs) {
314
+ const place = this.#certainPosition(after);
315
+ if (place === undefined) {
316
+ // Keep the last confirmed position until the server settles a move.
317
+ if (at >= 0) {
318
+ this.#slots.splice(at, 0, change.key);
319
+ this.#count.value = count;
320
+ }
321
+ this.#publish();
322
+ this.#goStale();
323
+ return;
324
+ }
325
+ this.#slots.splice(place, 0, change.key);
326
+ const c = this.#count.peek();
327
+ if (c !== undefined)
328
+ this.#count.value = c + 1;
329
+ }
330
+ this.#publish();
331
+ }
332
+ /**
333
+ * The position `entity` takes, if loaded neighbours prove it: its loaded
334
+ * successor's predecessor slot is loaded too (or it's first), or it sorts
335
+ * after the loaded last row.
336
+ */
337
+ #certainPosition(entity) {
338
+ if (this.#compare === undefined)
339
+ return undefined;
340
+ const slots = this.#slots;
341
+ let next = -1;
342
+ for (let i = 0; i < slots.length; i++) {
343
+ const k = slots[i];
344
+ if (k === undefined)
345
+ continue;
346
+ const other = this.#store.get(this.query.set, k)?.entity;
347
+ if (other && this.#compare(entity, other) < 0) {
348
+ next = i;
349
+ break;
350
+ }
351
+ }
352
+ if (next === 0)
353
+ return 0;
354
+ if (next > 0)
355
+ return slots[next - 1] !== undefined ? next : undefined;
356
+ if (slots.length === 0)
357
+ return this.#count.peek() === undefined ? undefined : 0;
358
+ return slots[slots.length - 1] !== undefined ? slots.length : undefined;
359
+ }
360
+ /**
361
+ * Refreshes to settle a change whose position isn't certain. A failed
362
+ * refresh leaves the collection stale with `error` set.
363
+ */
364
+ #goStale() {
365
+ this.#stale.value = true;
366
+ if (this.#refreshing)
367
+ return;
368
+ // Changes that arrive together share one refresh.
369
+ this.#refreshing = true;
370
+ queueMicrotask(() => {
371
+ this.#refreshing = false;
372
+ this.refresh().catch(() => { });
373
+ });
374
+ }
375
+ #refreshing = false;
376
+ #loadPage(page) {
377
+ return this.#inflight.get(page) ?? this.#loadPages(page, 1);
378
+ }
379
+ /** Loads `n` adjacent pages from `first` in one request; each page counts as in flight until it's done. */
380
+ #loadPages(first, n) {
381
+ const generation = this.#generation;
382
+ const task = this.#fetchPages(first, n, generation)
383
+ .then(async (result) => {
384
+ if (!result || generation !== this.#generation)
385
+ return;
386
+ // A changed count invalidates offsets; replace the visible window together.
387
+ if (result.count !== undefined && !result.estimated && this.#counted.peek() && result.count !== this.#count.peek()) {
388
+ this.#stale.value = true;
389
+ await this.#refreshWindow(Array.from({ length: n }, (_, i) => first + i));
390
+ return;
391
+ }
392
+ const { count, counted, estimated } = this.#settle(this.#slices(result), result.count, result.estimated);
393
+ this.#resize(count);
394
+ this.#fill(first, result.keys);
395
+ batch(() => {
396
+ this.#count.value = count;
397
+ this.#counted.value = counted;
398
+ this.#estimated.value = estimated;
399
+ this.#publish();
400
+ });
401
+ })
402
+ .finally(() => {
403
+ for (let p = first; p < first + n; p++)
404
+ if (this.#inflight.get(p) === task)
405
+ this.#inflight.delete(p);
406
+ });
407
+ for (let p = first; p < first + n; p++)
408
+ this.#inflight.set(p, task);
409
+ return task;
410
+ }
411
+ /** A run's keys as one entry per page, for the count's extent. */
412
+ #slices(run) {
413
+ return Array.from({ length: run.pages }, (_, i) => ({
414
+ page: run.first + i,
415
+ keys: run.keys.slice(i * this.pageSize, (i + 1) * this.pageSize),
416
+ }));
417
+ }
418
+ async #fetchPages(first, pages, generation) {
419
+ this.#pending++;
420
+ this.#status.value = "loading";
421
+ try {
422
+ const result = await this.#source.query(this.#resolved(), {
423
+ skip: first * this.pageSize,
424
+ top: pages * this.pageSize,
425
+ count: true,
426
+ exact: this.#exact,
427
+ });
428
+ if (generation !== this.#generation)
429
+ return undefined;
430
+ const keys = this.#store.putAll(this.query.set, result.records, { kind: "fetch", collection: this }, this.query.select);
431
+ this.#error.value = undefined;
432
+ return { first, pages, keys, count: result.count, estimated: result.estimated ?? false };
433
+ }
434
+ catch (e) {
435
+ if (generation === this.#generation) {
436
+ this.#error.value = e;
437
+ }
438
+ throw e;
439
+ }
440
+ finally {
441
+ this.#pending--;
442
+ this.#status.value = this.#error.peek() ? "error" : this.#pending ? "loading" : "idle";
443
+ }
444
+ }
445
+ /** The query with relative dates resolved to now. */
446
+ #resolved() {
447
+ const q = this.query;
448
+ return q.filter && hasRelative(q.filter) ? { ...q, filter: resolveRelative(q.filter, this.type, this.#now(), relatedOf(this.#store.schema, this.type)) } : q;
449
+ }
450
+ #fill(page, keys) {
451
+ const start = page * this.pageSize;
452
+ keys.forEach((k, i) => {
453
+ const elsewhere = this.#slots.indexOf(k);
454
+ if (elsewhere >= 0 && elsewhere !== start + i)
455
+ this.#slots[elsewhere] = undefined;
456
+ this.#slots[start + i] = k;
457
+ });
458
+ }
459
+ /**
460
+ * The length to lay out after an answer of `pages`. An exact count is it;
461
+ * otherwise a short page that ends the results is. An estimate counts only
462
+ * as the query's first count. Otherwise the length stands while loaded
463
+ * pages stay inside it, and grows a page past the last one that ran past
464
+ * it (progressive loading). An empty page past what's loaded shows only
465
+ * that the end comes before it, so the length shrinks to what's loaded
466
+ * and a page more.
467
+ */
468
+ #settle(pages, total, estimate) {
469
+ if (total !== undefined && !estimate)
470
+ return { count: total, counted: true, estimated: false };
471
+ const size = this.pageSize;
472
+ const short = pages.find((p) => p.keys.length < size);
473
+ const at = short ? short.page * size : 0;
474
+ const follows = (page) => page === 0 || this.#slots[page * size - 1] !== undefined || pages.some((p) => p.page === page - 1);
475
+ if (short && (short.keys.length > 0 || follows(short.page)))
476
+ return { count: at + short.keys.length, counted: true, estimated: false };
477
+ const known = this.#count.peek();
478
+ const first = known === undefined;
479
+ const base = first ? (total ?? 0) : known;
480
+ if (short) {
481
+ // An empty page, but not right after a loaded one: the end is somewhere before it.
482
+ const before = pages.filter((p) => p.page < short.page).map((p) => (p.page + 1) * size);
483
+ const loaded = Math.max(this.#slots.findLastIndex((k) => k !== undefined) + 1, ...before);
484
+ return { count: Math.min(at, loaded + size), counted: false, estimated: false };
485
+ }
486
+ const reach = (Math.max(...pages.map((p) => p.page)) + 1) * size;
487
+ if (!first && reach <= base && this.#counted.peek())
488
+ return { count: base, counted: true, estimated: false };
489
+ const estimated = first ? total !== undefined : this.#estimated.peek();
490
+ if (estimated && reach < base)
491
+ return { count: base, counted: false, estimated: true };
492
+ return { count: Math.max(base, reach + size), counted: false, estimated: false };
493
+ }
494
+ #resize(count) {
495
+ while (this.#slots.length < count)
496
+ this.#slots.push(undefined);
497
+ this.#slots.length = count;
498
+ }
499
+ #publish() {
500
+ const previous = this.#keys.peek();
501
+ if (previous.length === this.#slots.length) {
502
+ let equal = true;
503
+ for (let i = 0; i < previous.length; i++)
504
+ if (previous[i] !== this.#slots[i]) {
505
+ equal = false;
506
+ break;
507
+ }
508
+ if (equal)
509
+ return;
510
+ }
511
+ this.#keys.value = Array.from(this.#slots);
512
+ }
513
+ }
514
+ /** Adjacent pages as `[first, count]` runs of at most `max`, so a view that straddles pages costs one request. */
515
+ function runsOf(pages, max = 5) {
516
+ const runs = [];
517
+ for (const p of [...pages].sort((a, b) => a - b)) {
518
+ const last = runs.at(-1);
519
+ if (last && last[0] + last[1] === p && last[1] < max)
520
+ last[1]++;
521
+ else
522
+ runs.push([p, 1]);
523
+ }
524
+ return runs;
525
+ }
526
+ function readsOf(query) {
527
+ const own = (query.orderBy ?? []).map((s) => s.field).filter((f) => !isPath(f));
528
+ return [...new Set([...(query.filter ? fieldsOf(query.filter) : []), ...own])];
529
+ }
530
+ function comparatorFor(query, type) {
531
+ const sort = query.orderBy ?? [];
532
+ return sort.some((k) => isPath(k.field)) ? undefined : sortComparator(sort, type);
533
+ }
534
+ function hasLambda(c) {
535
+ if (!c)
536
+ return false;
537
+ switch (c.op) {
538
+ case "and":
539
+ case "or":
540
+ return c.args.some(hasLambda);
541
+ case "not":
542
+ return hasLambda(c.arg);
543
+ case "any":
544
+ case "all":
545
+ case "via":
546
+ return true;
547
+ default:
548
+ return false;
549
+ }
550
+ }
551
+ /** The entity sets a filter reaches through `any`, `all` and `via`, at any depth. */
552
+ export function relatedSets(c, type, schema) {
553
+ const out = new Set();
554
+ const walk = (c, type) => {
555
+ switch (c.op) {
556
+ case "and":
557
+ case "or":
558
+ return c.args.forEach((a) => walk(a, type));
559
+ case "not":
560
+ return walk(c.arg, type);
561
+ case "any":
562
+ case "all":
563
+ case "via": {
564
+ const nav = type.navigation(c.field);
565
+ if (!nav)
566
+ return;
567
+ out.add(nav.target);
568
+ return walk(c.where, schema.set(nav.target).type);
569
+ }
570
+ default:
571
+ return;
572
+ }
573
+ };
574
+ if (c)
575
+ walk(c, type);
576
+ return out;
577
+ }
578
+ function fieldsOf(c) {
579
+ switch (c.op) {
580
+ case "and":
581
+ case "or":
582
+ return c.args.flatMap(fieldsOf);
583
+ case "not":
584
+ return fieldsOf(c.arg);
585
+ default:
586
+ return "other" in c ? [c.field, c.other] : [c.field];
587
+ }
588
+ }