@getaster/data 0.0.0-stage → 0.1.0-preview.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/LICENSE +202 -0
- package/NOTICE +10 -0
- package/README.md +6 -2
- package/dist/authentication.d.ts +42 -0
- package/dist/authentication.js +121 -0
- package/dist/changes/changeset.d.ts +47 -0
- package/dist/changes/changeset.js +75 -0
- package/dist/collection/aggregate.d.ts +22 -0
- package/dist/collection/aggregate.js +94 -0
- package/dist/collection/collection.d.ts +96 -0
- package/dist/collection/collection.js +588 -0
- package/dist/collection/grouping.d.ts +75 -0
- package/dist/collection/grouping.js +190 -0
- package/dist/collection/groups.d.ts +31 -0
- package/dist/collection/groups.js +117 -0
- package/dist/collection/summary.d.ts +17 -0
- package/dist/collection/summary.js +103 -0
- package/dist/composition/activity.d.ts +36 -0
- package/dist/composition/activity.js +60 -0
- package/dist/composition/command.d.ts +22 -0
- package/dist/composition/command.js +57 -0
- package/dist/composition/lifetime.d.ts +11 -0
- package/dist/composition/lifetime.js +33 -0
- package/dist/composition/sessions.d.ts +64 -0
- package/dist/composition/sessions.js +209 -0
- package/dist/context.d.ts +76 -0
- package/dist/context.js +211 -0
- package/dist/criteria/ast.d.ts +79 -0
- package/dist/criteria/ast.js +1 -0
- package/dist/criteria/compile.d.ts +37 -0
- package/dist/criteria/compile.js +233 -0
- package/dist/criteria/evaluate.d.ts +20 -0
- package/dist/criteria/evaluate.js +171 -0
- package/dist/criteria/filter-draft.d.ts +79 -0
- package/dist/criteria/filter-draft.js +320 -0
- package/dist/criteria/relative.d.ts +13 -0
- package/dist/criteria/relative.js +82 -0
- package/dist/criteria/values.d.ts +20 -0
- package/dist/criteria/values.js +283 -0
- package/dist/edit/edit-model.d.ts +152 -0
- package/dist/edit/edit-model.js +620 -0
- package/dist/edit/list-edit.d.ts +116 -0
- package/dist/edit/list-edit.js +332 -0
- package/dist/edit/record-edit.d.ts +93 -0
- package/dist/edit/record-edit.js +317 -0
- package/dist/export/csv.d.ts +25 -0
- package/dist/export/csv.js +75 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.js +35 -0
- package/dist/odata/errors.d.ts +25 -0
- package/dist/odata/errors.js +56 -0
- package/dist/odata/source.d.ts +65 -0
- package/dist/odata/source.js +193 -0
- package/dist/progress/status.d.ts +29 -0
- package/dist/progress/status.js +105 -0
- package/dist/schema/csdl.d.ts +13 -0
- package/dist/schema/csdl.js +531 -0
- package/dist/schema/model.d.ts +132 -0
- package/dist/schema/model.js +1 -0
- package/dist/scope/filters.d.ts +130 -0
- package/dist/scope/filters.js +349 -0
- package/dist/scope/publications.d.ts +27 -0
- package/dist/scope/publications.js +74 -0
- package/dist/scope/related-aggregate.d.ts +9 -0
- package/dist/scope/related-aggregate.js +67 -0
- package/dist/scope/scope.d.ts +181 -0
- package/dist/scope/scope.js +574 -0
- package/dist/selection/selection.d.ts +56 -0
- package/dist/selection/selection.js +137 -0
- package/dist/store/keys.d.ts +10 -0
- package/dist/store/keys.js +21 -0
- package/dist/store/store.d.ts +72 -0
- package/dist/store/store.js +143 -0
- package/dist/template/template.d.ts +19 -0
- package/dist/template/template.js +186 -0
- package/dist/transport.d.ts +28 -0
- package/dist/transport.js +20 -0
- package/dist/tree/tree.d.ts +117 -0
- package/dist/tree/tree.js +435 -0
- package/dist/validate/criteria-rule.d.ts +28 -0
- package/dist/validate/criteria-rule.js +22 -0
- package/dist/validate/validate.d.ts +13 -0
- package/dist/validate/validate.js +123 -0
- 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
|
+
}
|