@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.
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,117 @@
1
+ import { type ReadonlySignal } from "@preact/signals-core";
2
+ import type { Collection } from "../collection/collection.ts";
3
+ import type { DataContext } from "../context.ts";
4
+ import type { Criteria, SortKey } from "../criteria/ast.ts";
5
+ import type { Entity, EntityType, Value } from "../schema/model.ts";
6
+ import { type KeyText } from "../store/keys.ts";
7
+ export type FilterMode = "keepAncestors" | "strict";
8
+ export interface TreeOptions {
9
+ readonly set: string;
10
+ /** The field holding a node's parent key. */
11
+ readonly parentField: string;
12
+ /** The parent value of top-level nodes: by default the parent field's `rootValue`, else null. */
13
+ readonly rootValue?: Value;
14
+ /** Limits the whole tree, such as one project's tasks. */
15
+ readonly base?: ReadonlySignal<Criteria | undefined>;
16
+ /** The view's filter, applied as `filterMode` says. */
17
+ readonly filter?: ReadonlySignal<Criteria | undefined>;
18
+ /** Siblings' order within each parent. */
19
+ readonly orderBy?: ReadonlySignal<readonly SortKey[]>;
20
+ /**
21
+ * `keepAncestors` (the default) shows each match with the path to it;
22
+ * `strict` hides a parent that doesn't match, and everything under it.
23
+ */
24
+ readonly filterMode?: FilterMode;
25
+ readonly pageSize?: number;
26
+ /** Matches beyond this make keep-ancestors give up with `tooBroad`. */
27
+ readonly matchLimit?: number;
28
+ /** Folders opened at most to show where matches are. */
29
+ readonly openLimit?: number;
30
+ }
31
+ /** A line of the tree as shown: a node, or a placeholder for one not loaded yet. */
32
+ export interface TreeRow {
33
+ /** Undefined for a placeholder. */
34
+ readonly key: KeyText | undefined;
35
+ /** The parent's key; `undefined` at the top level. */
36
+ readonly parent: KeyText | undefined;
37
+ /** 1 for the top level. */
38
+ readonly level: number;
39
+ /** Position among its siblings, from 1, and how many siblings there are. */
40
+ readonly position: number;
41
+ readonly siblings: number;
42
+ /** `true` with children, `false` known empty, `undefined` not yet known. */
43
+ readonly folder: boolean | undefined;
44
+ readonly open: boolean;
45
+ /** The level's collection and the row's index in it, for loading by page. */
46
+ readonly collection: Collection;
47
+ readonly index: number;
48
+ }
49
+ /**
50
+ * A tree over a self-referencing entity set, as one collection per open
51
+ * level: each is the parent criterion and the view's filter, so levels load
52
+ * by page and follow the store's sync rules like any collection (a node
53
+ * whose parent changes leaves one level and joins another).
54
+ *
55
+ * Open state is a set of keys, separate from the records: it survives
56
+ * reloads and filter changes, and two trees over the same data keep their
57
+ * own. Child counts come from one grouped request per level.
58
+ *
59
+ * With a filter in keep-ancestors mode, the tree finds the matches and walks
60
+ * up to the root, a level of parents per request, then shows each level's
61
+ * matches and the ancestors under it, and opens the paths (up to
62
+ * `openLimit`).
63
+ */
64
+ export declare class Tree {
65
+ #private;
66
+ readonly type: EntityType;
67
+ readonly key: string;
68
+ readonly options: TreeOptions;
69
+ /** The parent value of top-level nodes. */
70
+ readonly rootValue: Value;
71
+ /** Keys of the open folders. */
72
+ readonly open: ReadonlySignal<ReadonlySet<KeyText>>;
73
+ /** Keep-ancestors is finding the matches' paths. */
74
+ readonly searching: ReadonlySignal<boolean>;
75
+ /** More matches than `matchLimit`: narrow the filter. */
76
+ readonly tooBroad: ReadonlySignal<boolean>;
77
+ /** Finding the matches' paths failed; the rows shown stay as they were. The next filter clears it. */
78
+ readonly error: ReadonlySignal<unknown>;
79
+ /** The lines shown, top to bottom. */
80
+ readonly rows: ReadonlySignal<readonly TreeRow[]>;
81
+ constructor(ctx: DataContext, options: TreeOptions);
82
+ /** The level of `parent`'s children (the top level for `undefined`), created on first use. */
83
+ level(parent: KeyText | undefined): Collection;
84
+ /** A node's record as stored, read so a view follows its changes. */
85
+ entity(key: KeyText): Entity | undefined;
86
+ isOpen(key: KeyText): boolean;
87
+ /** Opens a folder and loads its first page. */
88
+ expand(key: KeyText): Promise<void>;
89
+ collapse(key: KeyText): void;
90
+ toggle(key: KeyText): Promise<void>;
91
+ /** Replaces the open state; folders that no longer exist are simply never shown. */
92
+ setOpen(keys: Iterable<KeyText>): void;
93
+ /** Whether a node has children: `undefined` until its level's counts arrive. */
94
+ hasChildren(key: KeyText, parent: KeyText | undefined): boolean | undefined;
95
+ /** The keys from the top level down to `key`'s parent, loading records as needed. */
96
+ ancestors(key: KeyText): Promise<KeyText[]>;
97
+ /**
98
+ * Opens every folder down to `key` and loads the pages of each level until
99
+ * the node is in it. Resolves to the node's row index, or -1 if it isn't
100
+ * in this tree (filtered out, or elsewhere).
101
+ */
102
+ reveal(key: KeyText): Promise<number>;
103
+ /**
104
+ * Every node under `key`, nearest first: a level of children per request
105
+ * (chunked), so a subtree can be listed before it's removed or moved.
106
+ */
107
+ descendants(key: KeyText): Promise<Entity[]>;
108
+ /**
109
+ * Moves a node under a new parent (`undefined`: the top level). Refuses a
110
+ * move into the node itself or its own subtree. The store's sync takes it
111
+ * out of the old level and into the new one; the node keeps its open state.
112
+ */
113
+ move(key: KeyText, parent: KeyText | undefined): Promise<void>;
114
+ /** Reloads every level's viewed pages, keeping open state. */
115
+ refresh(): Promise<void>;
116
+ dispose(): void;
117
+ }
@@ -0,0 +1,435 @@
1
+ import { batch, computed, signal, untracked } from "@preact/signals-core";
2
+ import { keyFromText, keyText } from "../store/keys.js";
3
+ const ROOT = "";
4
+ const IN_CHUNK = 200;
5
+ /**
6
+ * A tree over a self-referencing entity set, as one collection per open
7
+ * level: each is the parent criterion and the view's filter, so levels load
8
+ * by page and follow the store's sync rules like any collection (a node
9
+ * whose parent changes leaves one level and joins another).
10
+ *
11
+ * Open state is a set of keys, separate from the records: it survives
12
+ * reloads and filter changes, and two trees over the same data keep their
13
+ * own. Child counts come from one grouped request per level.
14
+ *
15
+ * With a filter in keep-ancestors mode, the tree finds the matches and walks
16
+ * up to the root, a level of parents per request, then shows each level's
17
+ * matches and the ancestors under it, and opens the paths (up to
18
+ * `openLimit`).
19
+ */
20
+ export class Tree {
21
+ type;
22
+ key;
23
+ options;
24
+ /** The parent value of top-level nodes. */
25
+ rootValue;
26
+ #ctx;
27
+ #levels = new Map();
28
+ #counts = new Map();
29
+ #open = signal(new Set());
30
+ #version = signal(0);
31
+ /** Keep-ancestors: the ancestors to show under each parent, by the parent's level key. */
32
+ #ancestors = signal(undefined);
33
+ #searching = signal(false);
34
+ #tooBroad = signal(false);
35
+ #error = signal(undefined);
36
+ #walk = 0;
37
+ #stop = [];
38
+ /** Keys of the open folders. */
39
+ open = this.#open;
40
+ /** Keep-ancestors is finding the matches' paths. */
41
+ searching = this.#searching;
42
+ /** More matches than `matchLimit`: narrow the filter. */
43
+ tooBroad = this.#tooBroad;
44
+ /** Finding the matches' paths failed; the rows shown stay as they were. The next filter clears it. */
45
+ error = this.#error;
46
+ /** The lines shown, top to bottom. */
47
+ rows;
48
+ constructor(ctx, options) {
49
+ this.#ctx = ctx;
50
+ this.options = options;
51
+ this.type = ctx.schema.set(options.set).type;
52
+ const key = this.type.key;
53
+ if (key.length !== 1)
54
+ throw new Error(`a tree needs a single-field key; ${options.set} has ${key.length}`);
55
+ this.key = key[0];
56
+ const parentField = this.type.field(options.parentField);
57
+ if (!parentField)
58
+ throw new Error(`${options.set} has no field ${options.parentField}`);
59
+ this.rootValue = options.rootValue ?? parentField.rootValue ?? null;
60
+ this.rows = computed(() => {
61
+ this.#version.value;
62
+ const out = [];
63
+ this.#rowsUnder(ROOT, undefined, 1, out);
64
+ return out;
65
+ });
66
+ const filter = options.filter;
67
+ if (filter && this.#mode() === "keepAncestors") {
68
+ let first = true;
69
+ this.#stop.push(computed(() => [filter.value, options.base?.value]).subscribe(([f]) => {
70
+ if (first && f === undefined)
71
+ return void (first = false);
72
+ first = false;
73
+ void this.#findAncestors(f);
74
+ }));
75
+ }
76
+ }
77
+ /** The level of `parent`'s children (the top level for `undefined`), created on first use. */
78
+ level(parent) {
79
+ const id = parent ?? ROOT;
80
+ let level = this.#levels.get(id);
81
+ if (!level) {
82
+ const parentValue = parent === undefined ? this.rootValue : keyFromText(parent)[0];
83
+ level = untracked(() => this.#ctx.collection(computed(() => this.#levelQuery(id, parentValue)), { pageSize: this.options.pageSize ?? 100 }));
84
+ this.#levels.set(id, level);
85
+ this.#countsFor(id, level);
86
+ this.#version.value++;
87
+ }
88
+ return level;
89
+ }
90
+ /** A node's record as stored, read so a view follows its changes. */
91
+ entity(key) {
92
+ return this.#ctx.store.record(this.options.set, key).value?.entity;
93
+ }
94
+ isOpen(key) {
95
+ return this.#open.peek().has(key);
96
+ }
97
+ /** Opens a folder and loads its first page. */
98
+ async expand(key) {
99
+ if (this.#open.peek().has(key))
100
+ return;
101
+ this.#open.value = new Set([...this.#open.peek(), key]);
102
+ const level = this.level(key);
103
+ await level.view(0, level.pageSize);
104
+ }
105
+ collapse(key) {
106
+ if (!this.#open.peek().has(key))
107
+ return;
108
+ const next = new Set(this.#open.peek());
109
+ next.delete(key);
110
+ this.#open.value = next;
111
+ }
112
+ toggle(key) {
113
+ if (this.#open.peek().has(key)) {
114
+ this.collapse(key);
115
+ return Promise.resolve();
116
+ }
117
+ return this.expand(key);
118
+ }
119
+ /** Replaces the open state; folders that no longer exist are simply never shown. */
120
+ setOpen(keys) {
121
+ this.#open.value = new Set(keys);
122
+ }
123
+ /** Whether a node has children: `undefined` until its level's counts arrive. */
124
+ hasChildren(key, parent) {
125
+ const rows = this.#counts.get(parent ?? ROOT)?.rows.value;
126
+ if (!rows)
127
+ return undefined;
128
+ const value = keyFromText(key)[0];
129
+ return rows.some((r) => r.value === value && r.count > 0);
130
+ }
131
+ /** The keys from the top level down to `key`'s parent, loading records as needed. */
132
+ async ancestors(key) {
133
+ const path = [];
134
+ const seen = new Set([key]);
135
+ let current = await this.#record(key);
136
+ while (current) {
137
+ const parent = current[this.options.parentField];
138
+ if (parent === null || parent === undefined || parent === this.rootValue)
139
+ break;
140
+ const parentKey = keyText([parent]);
141
+ if (seen.has(parentKey))
142
+ throw new Error(`${this.options.set} has a cycle at ${parentKey}`);
143
+ seen.add(parentKey);
144
+ path.unshift(parentKey);
145
+ current = await this.#record(parentKey);
146
+ }
147
+ return path;
148
+ }
149
+ /**
150
+ * Opens every folder down to `key` and loads the pages of each level until
151
+ * the node is in it. Resolves to the node's row index, or -1 if it isn't
152
+ * in this tree (filtered out, or elsewhere).
153
+ */
154
+ async reveal(key) {
155
+ const path = await this.ancestors(key);
156
+ batch(() => {
157
+ this.#open.value = new Set([...this.#open.peek(), ...path]);
158
+ for (const p of path)
159
+ this.level(p);
160
+ });
161
+ const chain = [undefined, ...path];
162
+ await Promise.all(chain.map(async (parent, i) => {
163
+ const level = this.level(parent);
164
+ const target = i + 1 < chain.length ? chain[i + 1] : key;
165
+ const at = await this.#position(level, target);
166
+ if (at !== undefined)
167
+ return level.view(Math.max(0, at - 1), at + 2);
168
+ // Where the position can't be counted, load page by page until the node is in.
169
+ await level.ensure(0, level.pageSize);
170
+ while (!level.keys.peek().includes(target) && !level.complete.peek()) {
171
+ const loaded = level.keys.peek().filter((k) => k !== undefined).length;
172
+ await level.ensure(loaded, loaded + level.pageSize);
173
+ if (level.keys.peek().filter((k) => k !== undefined).length === loaded)
174
+ break;
175
+ }
176
+ }));
177
+ return this.rows.peek().findIndex((r) => r.key === key);
178
+ }
179
+ /**
180
+ * Every node under `key`, nearest first: a level of children per request
181
+ * (chunked), so a subtree can be listed before it's removed or moved.
182
+ */
183
+ async descendants(key) {
184
+ const out = [];
185
+ let parents = [keyFromText(key)[0]];
186
+ const seen = new Set(parents);
187
+ while (parents.length) {
188
+ const next = [];
189
+ for (let i = 0; i < parents.length; i += IN_CHUNK) {
190
+ const c = this.#ctx.collection({ set: this.options.set, filter: { op: "in", field: this.options.parentField, values: parents.slice(i, i + IN_CHUNK) } }, { pageSize: 1000 });
191
+ try {
192
+ await c.loadAll();
193
+ for (const e of c.items.peek()) {
194
+ if (!e || seen.has(e[this.key]))
195
+ continue;
196
+ seen.add(e[this.key]);
197
+ out.push(e);
198
+ next.push(e[this.key]);
199
+ }
200
+ }
201
+ finally {
202
+ this.#ctx.release(c);
203
+ }
204
+ }
205
+ parents = next;
206
+ }
207
+ return out;
208
+ }
209
+ /**
210
+ * A node's index in its level, counted by the server: the siblings that
211
+ * sort before it. Undefined where the sort can't be expressed as a filter
212
+ * (a path into a related record, or a null in the node's sort values).
213
+ */
214
+ async #position(level, key) {
215
+ const node = await this.#record(key);
216
+ if (!node)
217
+ return undefined;
218
+ const sort = [...(level.query.orderBy ?? [])];
219
+ if (!sort.some((k) => k.field === this.key))
220
+ sort.push({ field: this.key });
221
+ if (sort.some((k) => k.field.includes("/") || node[k.field] === null || node[k.field] === undefined))
222
+ return undefined;
223
+ // (k1 before v1) or (k1 = v1 and k2 before v2) or …
224
+ const before = {
225
+ op: "or",
226
+ args: sort.map((k, i) => {
227
+ const equal = sort.slice(0, i).map((e) => ({ op: "eq", field: e.field, value: node[e.field] }));
228
+ const after = { op: k.descending ? "gt" : "lt", field: k.field, value: node[k.field] };
229
+ return equal.length ? { op: "and", args: [...equal, after] } : after;
230
+ }),
231
+ };
232
+ const counter = this.#ctx.collection({ set: this.options.set, filter: level.query.filter ? { op: "and", args: [level.query.filter, before] } : before, select: [this.key] }, { pageSize: 1, exactCount: true });
233
+ try {
234
+ await counter.ensure(0, 1);
235
+ return counter.counted.peek() ? counter.count.peek() : undefined;
236
+ }
237
+ finally {
238
+ this.#ctx.release(counter);
239
+ }
240
+ }
241
+ /**
242
+ * Moves a node under a new parent (`undefined`: the top level). Refuses a
243
+ * move into the node itself or its own subtree. The store's sync takes it
244
+ * out of the old level and into the new one; the node keeps its open state.
245
+ */
246
+ async move(key, parent) {
247
+ if (parent !== undefined) {
248
+ if (parent === key)
249
+ throw new Error("A node can't be moved into itself.");
250
+ if ((await this.ancestors(parent)).includes(key))
251
+ throw new Error("A node can't be moved into its own subtree.");
252
+ }
253
+ const value = parent === undefined ? this.rootValue : keyFromText(parent)[0];
254
+ await this.#ctx.update(this.options.set, key, { [this.options.parentField]: value });
255
+ }
256
+ /** Reloads every level's viewed pages, keeping open state. */
257
+ async refresh() {
258
+ await Promise.all([...this.#levels.values()].map((l) => l.refresh()));
259
+ }
260
+ dispose() {
261
+ for (const stop of this.#stop.splice(0))
262
+ stop();
263
+ for (const level of this.#levels.values())
264
+ this.#ctx.release(level);
265
+ for (const groups of this.#counts.values())
266
+ groups.dispose();
267
+ this.#levels.clear();
268
+ this.#counts.clear();
269
+ }
270
+ #mode() {
271
+ return this.options.filterMode ?? "keepAncestors";
272
+ }
273
+ #levelQuery(id, parentValue) {
274
+ const parent = { op: "eq", field: this.options.parentField, value: parentValue };
275
+ const filter = this.options.filter?.value;
276
+ let shown = filter;
277
+ if (filter && this.#mode() === "keepAncestors") {
278
+ const ancestors = this.#ancestors.value?.get(id) ?? [];
279
+ shown = ancestors.length ? { op: "or", args: [filter, { op: "in", field: this.key, values: ancestors }] } : filter;
280
+ }
281
+ const parts = [this.options.base?.value, parent, shown].filter((c) => c !== undefined);
282
+ return {
283
+ set: this.options.set,
284
+ filter: parts.length === 1 ? parts[0] : { op: "and", args: parts },
285
+ ...(this.options.orderBy?.value.length ? { orderBy: this.options.orderBy.value } : {}),
286
+ };
287
+ }
288
+ /** Child counts for a level's loaded nodes, under the same filter as their own levels would have. */
289
+ #countsFor(id, level) {
290
+ const query = computed(() => {
291
+ const loaded = level.keys.value.flatMap((k) => (k === undefined ? [] : [keyFromText(k)[0]]));
292
+ const filter = this.options.filter?.value;
293
+ const ancestors = this.#ancestors.value;
294
+ const all = ancestors ? [...ancestors.values()].flat() : [];
295
+ const shown = filter && this.#mode() === "keepAncestors" && all.length ? { op: "or", args: [filter, { op: "in", field: this.key, values: all }] } : filter;
296
+ const parts = [this.options.base?.value, { op: "in", field: this.options.parentField, values: loaded }, shown].filter((c) => c !== undefined);
297
+ return { set: this.options.set, filter: { op: "and", args: parts } };
298
+ });
299
+ this.#counts.set(id, untracked(() => this.#ctx.groups(query, this.options.parentField)));
300
+ }
301
+ #rowsUnder(id, parent, depth, out) {
302
+ const level = id === ROOT ? untracked(() => this.level(undefined)) : this.#levels.get(id);
303
+ if (!level)
304
+ return;
305
+ // An open level that hasn't answered yet has no lines for a view to ask about: load its first page.
306
+ if (level.count.value === undefined && level.status.peek() !== "loading") {
307
+ queueMicrotask(() => void level.ensure(0, level.pageSize).catch(() => { }));
308
+ }
309
+ const keys = level.keys.value;
310
+ const siblings = level.count.value ?? keys.length;
311
+ const open = this.#open.value;
312
+ const counts = this.#counts.get(id)?.rows.value;
313
+ keys.forEach((key, index) => {
314
+ const value = key === undefined ? undefined : keyFromText(key)[0];
315
+ const folder = key === undefined || !counts ? undefined : counts.some((r) => r.value === value && r.count > 0);
316
+ const isOpen = key !== undefined && open.has(key) && folder !== false;
317
+ out.push({ key, parent, level: depth, position: index + 1, siblings, folder, open: isOpen, collection: level, index });
318
+ if (isOpen) {
319
+ if (!this.#levels.has(key))
320
+ untracked(() => this.level(key));
321
+ this.#rowsUnder(key, key, depth + 1, out);
322
+ }
323
+ });
324
+ }
325
+ async #record(key) {
326
+ const stored = this.#ctx.store.get(this.options.set, key);
327
+ if (stored && stored.known?.has(this.options.parentField) !== false)
328
+ return stored.entity;
329
+ return (await this.#ctx.reload(this.options.set, key))?.entity;
330
+ }
331
+ /**
332
+ * Keep-ancestors: the matches' keys and parents, then their parents' and so
333
+ * on up to the root, each round one request per chunk of keys.
334
+ */
335
+ async #findAncestors(filter) {
336
+ const walk = ++this.#walk;
337
+ if (!filter) {
338
+ batch(() => {
339
+ this.#ancestors.value = undefined;
340
+ this.#tooBroad.value = false;
341
+ this.#searching.value = false;
342
+ this.#error.value = undefined;
343
+ });
344
+ return;
345
+ }
346
+ batch(() => {
347
+ this.#searching.value = true;
348
+ this.#error.value = undefined;
349
+ });
350
+ try {
351
+ const limit = this.options.matchLimit ?? 8000;
352
+ const base = this.options.base?.peek();
353
+ const parentField = this.options.parentField;
354
+ const root = this.rootValue;
355
+ const select = [this.key, parentField];
356
+ const fetch = async (criteria) => {
357
+ const c = this.#ctx.collection({ set: this.options.set, filter: base ? { op: "and", args: [base, criteria] } : criteria, select }, { pageSize: 1000 });
358
+ try {
359
+ await c.ensure(0, 1);
360
+ if ((c.count.peek() ?? 0) > limit)
361
+ return Promise.reject(new TooBroad());
362
+ await c.loadAll();
363
+ return c.items.peek().filter((e) => e !== undefined);
364
+ }
365
+ finally {
366
+ this.#ctx.release(c);
367
+ }
368
+ };
369
+ const matches = await fetch(filter);
370
+ if (walk !== this.#walk)
371
+ return;
372
+ const parentOf = new Map();
373
+ const matched = new Set();
374
+ for (const m of matches) {
375
+ matched.add(m[this.key]);
376
+ parentOf.set(m[this.key], m[parentField] ?? null);
377
+ }
378
+ let wanted = [...new Set([...parentOf.values()])].filter((p) => p !== root && p !== null && !parentOf.has(p));
379
+ while (wanted.length) {
380
+ if (parentOf.size > limit)
381
+ throw new TooBroad();
382
+ const found = [];
383
+ for (let i = 0; i < wanted.length; i += IN_CHUNK) {
384
+ found.push(...(await fetch({ op: "in", field: this.key, values: wanted.slice(i, i + IN_CHUNK) })));
385
+ }
386
+ if (walk !== this.#walk)
387
+ return;
388
+ for (const f of found)
389
+ parentOf.set(f[this.key], f[parentField] ?? null);
390
+ wanted = [...new Set(found.map((f) => f[parentField] ?? null))].filter((p) => p !== root && p !== null && !parentOf.has(p));
391
+ }
392
+ // Ancestors that don't match themselves, by their parent's level.
393
+ const byLevel = new Map();
394
+ const toOpen = new Set();
395
+ for (const [node, parent] of parentOf) {
396
+ if (matched.has(node))
397
+ continue;
398
+ const id = parent === root || parent === null ? ROOT : keyText([parent]);
399
+ byLevel.set(id, [...(byLevel.get(id) ?? []), node]);
400
+ }
401
+ // Open the path to every match: each parent on it, nearest the top first, up to the limit.
402
+ const depth = (v) => {
403
+ let n = 0;
404
+ for (let p = parentOf.get(v); p !== undefined && p !== null && p !== root; p = parentOf.get(p))
405
+ n++;
406
+ return n;
407
+ };
408
+ const parents = [...new Set(parentOf.values())].filter((p) => p !== null && p !== root).sort((a, b) => depth(a) - depth(b));
409
+ for (const v of parents.slice(0, this.options.openLimit ?? 200))
410
+ toOpen.add(keyText([v]));
411
+ batch(() => {
412
+ this.#tooBroad.value = false;
413
+ this.#ancestors.value = byLevel;
414
+ this.#open.value = new Set([...this.#open.peek(), ...toOpen]);
415
+ });
416
+ }
417
+ catch (e) {
418
+ if (walk !== this.#walk)
419
+ return;
420
+ // Nothing awaits the walk, so a failure is kept, not thrown.
421
+ if (!(e instanceof TooBroad))
422
+ return void (this.#error.value = e);
423
+ batch(() => {
424
+ this.#tooBroad.value = true;
425
+ this.#ancestors.value = new Map();
426
+ });
427
+ }
428
+ finally {
429
+ if (walk === this.#walk)
430
+ this.#searching.value = false;
431
+ }
432
+ }
433
+ }
434
+ class TooBroad extends Error {
435
+ }
@@ -0,0 +1,28 @@
1
+ import type { Criteria } from "../criteria/ast.ts";
2
+ import type { Rule } from "../edit/edit-model.ts";
3
+ import type { EntityType } from "../schema/model.ts";
4
+ /** A test of a field's own value: a comparison without its field (`{ op: "gt", value: 0 }`). */
5
+ export type OwnTest = Criteria extends infer C ? (C extends {
6
+ readonly field: string;
7
+ } ? Omit<C, "field"> : never) : never;
8
+ /** Criteria whose leaves may leave out the field, meaning the validated one. */
9
+ export type OwnCriteria = Criteria | OwnTest | {
10
+ readonly op: "and" | "or";
11
+ readonly args: readonly OwnCriteria[];
12
+ } | {
13
+ readonly op: "not";
14
+ readonly arg: OwnCriteria;
15
+ };
16
+ /**
17
+ * A validator written as criteria: the value passes while `passWhen` holds,
18
+ * or fails while `failWhen` does. A test without a field is about the
19
+ * field itself; one that names fields reads the record. A null value isn't
20
+ * checked; required is its own rule.
21
+ */
22
+ export interface CriteriaValidator {
23
+ readonly passWhen?: OwnCriteria;
24
+ readonly failWhen?: OwnCriteria;
25
+ readonly message: string;
26
+ }
27
+ /** `validator` on `field` as an edit model rule. */
28
+ export declare function criteriaRule(type: EntityType, field: string, validator: CriteriaValidator, now?: () => number): Rule;
@@ -0,0 +1,22 @@
1
+ import { matches } from "../criteria/evaluate.js";
2
+ import { resolveRelative } from "../criteria/relative.js";
3
+ /** `validator` on `field` as an edit model rule. */
4
+ export function criteriaRule(type, field, validator, now = Date.now) {
5
+ const test = validator.passWhen ?? validator.failWhen;
6
+ if (!test || (validator.passWhen && validator.failWhen))
7
+ throw new Error("a criteria validator has one of passWhen and failWhen");
8
+ const criteria = own(test, field);
9
+ return (values) => {
10
+ if ((values[field] ?? null) === null)
11
+ return {};
12
+ const holds = matches(resolveRelative(criteria, type, now()), values, type);
13
+ return { [field]: holds === (validator.passWhen !== undefined) ? undefined : validator.message };
14
+ };
15
+ }
16
+ function own(c, field) {
17
+ if ("args" in c)
18
+ return { op: c.op, args: c.args.map((a) => own(a, field)) };
19
+ if ("arg" in c)
20
+ return { op: "not", arg: own(c.arg, field) };
21
+ return ("field" in c ? c : { ...c, field });
22
+ }
@@ -0,0 +1,13 @@
1
+ import type { Entity, EntityType, Field, Value } from "../schema/model.ts";
2
+ export type FieldErrors = Readonly<Record<string, readonly string[]>>;
3
+ /**
4
+ * Client-side validation from metadata, with the same rules the server
5
+ * applies. Messages use the field's title and say how to fix the value;
6
+ * `AsterData.RequiredMessage` and `AsterData.ErrorMessage` override them.
7
+ */
8
+ export declare function validateField(field: Field, value: Value | undefined): string[];
9
+ /**
10
+ * Errors by field. On update only the fields present in `values` are
11
+ * checked, and an immutable field among them is an error.
12
+ */
13
+ export declare function validateEntity(type: EntityType, values: Entity, mode: "create" | "update"): FieldErrors;