@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,574 @@
|
|
|
1
|
+
import { computed, signal, untracked } from "@preact/signals-core";
|
|
2
|
+
import { matches } from "../criteria/evaluate.js";
|
|
3
|
+
import { isRelative, resolveRelative } from "../criteria/relative.js";
|
|
4
|
+
import { CriteriaError } from "../criteria/values.js";
|
|
5
|
+
import { keyText } from "../store/keys.js";
|
|
6
|
+
import { relatedAggregate } from "./related-aggregate.js";
|
|
7
|
+
import { parseTemplate } from "../template/template.js";
|
|
8
|
+
/** `condition` as plain criteria, bare paths spelled out. */
|
|
9
|
+
export function toCriteria(condition) {
|
|
10
|
+
if (typeof condition === "string")
|
|
11
|
+
return { op: "eq", field: condition, value: true };
|
|
12
|
+
switch (condition.op) {
|
|
13
|
+
case "and":
|
|
14
|
+
case "or":
|
|
15
|
+
return { op: condition.op, args: condition.args.map(toCriteria) };
|
|
16
|
+
case "not":
|
|
17
|
+
return { op: "not", arg: toCriteria(condition.arg) };
|
|
18
|
+
default:
|
|
19
|
+
return condition;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export const DEFAULT_STATE = Object.freeze({
|
|
23
|
+
visible: true,
|
|
24
|
+
enabled: true,
|
|
25
|
+
readOnly: false,
|
|
26
|
+
required: false,
|
|
27
|
+
reasons: Object.freeze({}),
|
|
28
|
+
});
|
|
29
|
+
/** Navigations a path may follow from a published record: `task.values.project.lead.name` follows two. */
|
|
30
|
+
export const MAX_HOPS = 3;
|
|
31
|
+
/**
|
|
32
|
+
* A page's published state, by key. Components publish under a key; rules and
|
|
33
|
+
* other components read by path without a reference to the publisher.
|
|
34
|
+
*
|
|
35
|
+
* Invariants: a key has one publisher per scope; reading an unpublished key
|
|
36
|
+
* gives `undefined` and follows it once it's published; a child scope's keys
|
|
37
|
+
* shadow its parent's.
|
|
38
|
+
*/
|
|
39
|
+
export class PageScope {
|
|
40
|
+
parent;
|
|
41
|
+
/** The clock relative dates in rules resolve against; a child scope has its parent's. */
|
|
42
|
+
now;
|
|
43
|
+
#entries = signal(new Map());
|
|
44
|
+
/** Where paths through a navigation find the related record; a child scope has its parent's. */
|
|
45
|
+
ctx;
|
|
46
|
+
#rules = [];
|
|
47
|
+
#derivations = new Map();
|
|
48
|
+
constructor(parent, options = {}) {
|
|
49
|
+
this.parent = parent;
|
|
50
|
+
this.now = options.now ?? parent?.now ?? Date.now;
|
|
51
|
+
this.ctx = options.ctx ?? parent?.ctx;
|
|
52
|
+
}
|
|
53
|
+
child() {
|
|
54
|
+
return new PageScope(this);
|
|
55
|
+
}
|
|
56
|
+
/** Publishes under `key`; returns the function that withdraws it. */
|
|
57
|
+
publish(key, publication) {
|
|
58
|
+
if (key.includes("."))
|
|
59
|
+
throw new Error(`a scope key can't contain '.': ${key}`);
|
|
60
|
+
const entries = this.#entries.peek();
|
|
61
|
+
if (entries.has(key))
|
|
62
|
+
throw new Error(`'${key}' is already published in this scope`);
|
|
63
|
+
this.#entries.value = new Map(entries).set(key, publication);
|
|
64
|
+
return () => {
|
|
65
|
+
const now = this.#entries.peek();
|
|
66
|
+
if (now.get(key) !== publication)
|
|
67
|
+
return;
|
|
68
|
+
const next = new Map(now);
|
|
69
|
+
next.delete(key);
|
|
70
|
+
this.#entries.value = next;
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/** The published keys, nearest scope first. */
|
|
74
|
+
keys() {
|
|
75
|
+
const own = [...this.#entries.peek().keys()];
|
|
76
|
+
return [...own, ...(this.parent?.keys().filter((k) => !own.includes(k)) ?? [])];
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* What can be read, typed: each published key's names, with the record
|
|
80
|
+
* type and its fields' CSDL types where the publication gives them. For
|
|
81
|
+
* rule builders and tools, to offer and check paths before a rule exists.
|
|
82
|
+
*/
|
|
83
|
+
schema() {
|
|
84
|
+
const out = {};
|
|
85
|
+
for (const key of this.keys()) {
|
|
86
|
+
const pub = this.#lookup(key);
|
|
87
|
+
out[key] = Object.fromEntries(Object.keys(pub.values).map((name) => {
|
|
88
|
+
const type = pub.types?.[name];
|
|
89
|
+
return [name, type ? { type: type.name, fields: Object.fromEntries(type.fields.map((f) => [f.name, f.type])) } : {}];
|
|
90
|
+
}));
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Why a path can't be read, or `undefined` if it can: an unpublished key,
|
|
96
|
+
* an unknown name, a property the record type lacks, a step past a field,
|
|
97
|
+
* or more than `MAX_HOPS` navigations.
|
|
98
|
+
*/
|
|
99
|
+
check(path) {
|
|
100
|
+
const [key, name, ...rest] = path.split(".");
|
|
101
|
+
const pub = this.#lookup(key ?? "");
|
|
102
|
+
if (!pub)
|
|
103
|
+
return `nothing is published as '${key}'`;
|
|
104
|
+
if (name === undefined || !(name in pub.values))
|
|
105
|
+
return `'${key}' has no '${name ?? ""}'`;
|
|
106
|
+
try {
|
|
107
|
+
this.#steps(path, pub.types?.[name], rest);
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
catch (e) {
|
|
111
|
+
if (e instanceof CriteriaError)
|
|
112
|
+
return e.message;
|
|
113
|
+
throw e;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* A path's value: the key's signal, then properties of the value it holds.
|
|
118
|
+
* From a typed record, a step that names a single-valued navigation reads
|
|
119
|
+
* the related record from the store through `ctx` (fetching it if it isn't
|
|
120
|
+
* loaded), so `task.values.project.name` follows the task's project; it's
|
|
121
|
+
* `undefined` until the record arrives and `null` with no related record.
|
|
122
|
+
*/
|
|
123
|
+
value(path) {
|
|
124
|
+
const [key, name, ...rest] = path.split(".");
|
|
125
|
+
const records = new Map();
|
|
126
|
+
return computed(() => {
|
|
127
|
+
const pub = this.#lookup(key);
|
|
128
|
+
const signal = pub?.values[name ?? ""];
|
|
129
|
+
let v = signal?.value;
|
|
130
|
+
const steps = signal ? this.#steps(path, pub?.types?.[name ?? ""], rest) : [];
|
|
131
|
+
for (const [i, p] of rest.entries()) {
|
|
132
|
+
// Null after a navigation is "no related record", and so is everything under it.
|
|
133
|
+
if (v === null)
|
|
134
|
+
return i > 0 && steps[i - 1]?.nav ? null : undefined;
|
|
135
|
+
if (v === undefined)
|
|
136
|
+
return undefined;
|
|
137
|
+
const step = steps[i];
|
|
138
|
+
if (step?.nav)
|
|
139
|
+
v = this.#related(step.nav, step.from, v, records);
|
|
140
|
+
else
|
|
141
|
+
v = v[p];
|
|
142
|
+
}
|
|
143
|
+
return v;
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* A derived value: the first case whose rule holds gives it, and a case
|
|
148
|
+
* without a rule is the default. `undefined` when no case applies, and
|
|
149
|
+
* while a path it has to read is `undefined` (an unpublished key, a
|
|
150
|
+
* related record still loading), so nothing is decided on missing data.
|
|
151
|
+
*/
|
|
152
|
+
select(cases, target) {
|
|
153
|
+
const owned = [];
|
|
154
|
+
const readPaths = new Set();
|
|
155
|
+
let built;
|
|
156
|
+
try {
|
|
157
|
+
built = cases.map((c) => {
|
|
158
|
+
const holds = c.when === undefined ? undefined : this.when(c.when);
|
|
159
|
+
const reads = c.when === undefined ? [] : pathsOf(toCriteria(c.when)).map((p) => this.value(p));
|
|
160
|
+
if (c.when)
|
|
161
|
+
pathsOf(toCriteria(c.when)).forEach((p) => readPaths.add(p));
|
|
162
|
+
if ("aggregate" in c) {
|
|
163
|
+
readPaths.add(c.path.slice(0, c.path.lastIndexOf(".")));
|
|
164
|
+
const aggregate = this.#aggregate(c);
|
|
165
|
+
owned.push(aggregate);
|
|
166
|
+
return { holds, reads, value: aggregate.value, error: aggregate.error };
|
|
167
|
+
}
|
|
168
|
+
if ("path" in c) {
|
|
169
|
+
readPaths.add(c.path);
|
|
170
|
+
return { holds, reads, value: this.value(c.path) };
|
|
171
|
+
}
|
|
172
|
+
if ("template" in c) {
|
|
173
|
+
const template = parseTemplate(c.template);
|
|
174
|
+
template.paths.forEach((p) => readPaths.add(p));
|
|
175
|
+
const paths = new Map(template.paths.map((p) => [p, this.value(p)]));
|
|
176
|
+
// Undefined until every path can be read, so no half-filled text.
|
|
177
|
+
return { holds, reads, value: computed(() => ([...paths.values()].some((r) => r.value === undefined) ? undefined : template.render((p) => paths.get(p).value))) };
|
|
178
|
+
}
|
|
179
|
+
const literal = c.value;
|
|
180
|
+
return { holds, reads, value: computed(() => literal) };
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
catch (e) {
|
|
184
|
+
for (const a of owned)
|
|
185
|
+
a.dispose();
|
|
186
|
+
throw e;
|
|
187
|
+
}
|
|
188
|
+
const matched = computed(() => {
|
|
189
|
+
for (const [i, c] of built.entries()) {
|
|
190
|
+
if (c.reads.some((r) => r.value === undefined))
|
|
191
|
+
return undefined;
|
|
192
|
+
if (c.holds === undefined || c.holds.value)
|
|
193
|
+
return i;
|
|
194
|
+
}
|
|
195
|
+
return undefined;
|
|
196
|
+
});
|
|
197
|
+
const result = computed(() => {
|
|
198
|
+
const i = matched.value;
|
|
199
|
+
return i === undefined ? undefined : built[i].value.value;
|
|
200
|
+
});
|
|
201
|
+
const explain = () => {
|
|
202
|
+
const i = matched.peek();
|
|
203
|
+
const error = i === undefined ? undefined : built[i]?.error?.peek();
|
|
204
|
+
return {
|
|
205
|
+
effect: "derived", rule: (i === undefined ? undefined : cases[i]?.when) ?? { op: "and", args: [] },
|
|
206
|
+
holds: i !== undefined, cases, ...(i === undefined ? {} : { matched: i }), value: result.peek(),
|
|
207
|
+
reads: Object.fromEntries([...readPaths].map((p) => [p, this.value(p).peek()])),
|
|
208
|
+
...(error === undefined ? {} : { error: String(error) }),
|
|
209
|
+
};
|
|
210
|
+
};
|
|
211
|
+
if (target)
|
|
212
|
+
this.#derivations.set(target, explain);
|
|
213
|
+
return Object.assign(result, { dispose: () => {
|
|
214
|
+
for (const a of owned)
|
|
215
|
+
a.dispose();
|
|
216
|
+
if (target && this.#derivations.get(target) === explain)
|
|
217
|
+
this.#derivations.delete(target);
|
|
218
|
+
} });
|
|
219
|
+
}
|
|
220
|
+
#aggregate(spec) {
|
|
221
|
+
const [key, name, ...rest] = spec.path.split(".");
|
|
222
|
+
const pub = this.#lookup(key);
|
|
223
|
+
const type = pub?.types?.[name ?? ""];
|
|
224
|
+
if (!type || !this.ctx || !rest.length)
|
|
225
|
+
throw new CriteriaError(`'${spec.path}' needs a typed record and a data context`);
|
|
226
|
+
const prefix = rest.slice(0, -1);
|
|
227
|
+
const steps = this.#steps(spec.path, type, prefix);
|
|
228
|
+
const last = steps.at(-1);
|
|
229
|
+
if (last && !last.nav)
|
|
230
|
+
throw new CriteriaError(`'${spec.path}' goes past a field`);
|
|
231
|
+
const from = last?.nav ? this.ctx.schema.set(last.nav.target).type : type;
|
|
232
|
+
const nav = from.navigation(rest.at(-1));
|
|
233
|
+
if (!nav?.collection || !nav.keys.length)
|
|
234
|
+
throw new CriteriaError(`'${spec.path}' must name a related list with foreign keys`);
|
|
235
|
+
if (steps.length + 1 > MAX_HOPS)
|
|
236
|
+
throw new CriteriaError(`'${spec.path}' follows more than ${MAX_HOPS} navigations`);
|
|
237
|
+
const field = this.ctx.schema.set(nav.target).type.field(spec.field);
|
|
238
|
+
if (!field)
|
|
239
|
+
throw new CriteriaError(`${nav.target} has no field '${spec.field}'`);
|
|
240
|
+
const numeric = new Set(["Edm.Byte", "Edm.SByte", "Edm.Int16", "Edm.Int32", "Edm.Int64", "Edm.Decimal", "Edm.Single", "Edm.Double"]).has(field.type);
|
|
241
|
+
const ordered = new Set(["Edm.String", "Edm.Date", "Edm.DateTimeOffset", "Edm.TimeOfDay"]).has(field.type);
|
|
242
|
+
if (!numeric && (spec.aggregate === "average" || !ordered)) {
|
|
243
|
+
throw new CriteriaError(`'${spec.path}.${spec.field}' must be numeric for ${spec.aggregate}`);
|
|
244
|
+
}
|
|
245
|
+
return relatedAggregate(this.ctx, this.value([key, name, ...prefix].join(".")), nav, spec.aggregate, spec.field);
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* The field a path reads, when its publication gives the record type and
|
|
249
|
+
* the path ends on a property of that record or of a record reached
|
|
250
|
+
* through its navigations. Other paths are untyped. A property the record
|
|
251
|
+
* type doesn't have is an error, not a silent null.
|
|
252
|
+
*/
|
|
253
|
+
field(path) {
|
|
254
|
+
const [key, name, ...rest] = path.split(".");
|
|
255
|
+
if (rest.length === 0)
|
|
256
|
+
return undefined;
|
|
257
|
+
const type = this.#lookup(key)?.types?.[name ?? ""];
|
|
258
|
+
if (!type)
|
|
259
|
+
return undefined;
|
|
260
|
+
let steps;
|
|
261
|
+
try {
|
|
262
|
+
steps = this.#steps(path, type, rest);
|
|
263
|
+
}
|
|
264
|
+
catch (e) {
|
|
265
|
+
throw e instanceof CriteriaError && !e.message.includes(path) ? new CriteriaError(`${e.message} (${path})`) : e;
|
|
266
|
+
}
|
|
267
|
+
const last = steps.at(-1);
|
|
268
|
+
return last?.field && steps.length === rest.length ? { ...last.field } : undefined;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Whether `rule` holds, as a signal. A field hidden on its record is
|
|
272
|
+
* unknown to the rule, not null: a test of it is unknown, and a rule holds
|
|
273
|
+
* only when it's true.
|
|
274
|
+
*/
|
|
275
|
+
when(rule) {
|
|
276
|
+
const criteria = toCriteria(rule);
|
|
277
|
+
const paths = pathsOf(criteria);
|
|
278
|
+
const values = paths.map((p) => this.value(p));
|
|
279
|
+
const lists = new Set(listsOf(criteria));
|
|
280
|
+
return computed(() => {
|
|
281
|
+
const entity = {};
|
|
282
|
+
paths.forEach((p, i) => (entity[p] = lists.has(p) ? toRows(values[i].value, p) : toValue(values[i].value)));
|
|
283
|
+
const unknown = new Set(paths.filter((p) => this.#hidden(p)));
|
|
284
|
+
const type = this.#ruleType(criteria, paths, entity, lists);
|
|
285
|
+
const rows = (path) => this.#rowType(path);
|
|
286
|
+
// Relative dates resolve when the rule is evaluated, which is when what it reads changes.
|
|
287
|
+
const resolved = resolveRelative(criteria, type, this.now(), rows);
|
|
288
|
+
return matches(resolved, entity, type, (from, path) => (from.name === "scope" ? rows(path) : undefined), unknown);
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
/** Conservative action availability: every referenced value must be available. */
|
|
292
|
+
available(rule) {
|
|
293
|
+
const paths = pathsOf(toCriteria(rule));
|
|
294
|
+
const values = paths.map(path => this.value(path));
|
|
295
|
+
const holds = this.when(rule);
|
|
296
|
+
return computed(() => values.every(value => value.value !== undefined) && holds.value);
|
|
297
|
+
}
|
|
298
|
+
/** Invalid publication/property paths in a rule, without evaluating it. */
|
|
299
|
+
checkRule(rule) {
|
|
300
|
+
return pathsOf(toCriteria(rule)).flatMap(path => {
|
|
301
|
+
const error = this.check(path);
|
|
302
|
+
return error ? [error] : [];
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
/** Whether a path is a field hidden on the record its publication holds. */
|
|
306
|
+
#hidden(path) {
|
|
307
|
+
const [key, name, prop, ...more] = path.split(".");
|
|
308
|
+
if (prop === undefined || more.length > 0)
|
|
309
|
+
return false;
|
|
310
|
+
return this.#lookup(key)?.hidden?.[name ?? ""]?.value.has(prop) ?? false;
|
|
311
|
+
}
|
|
312
|
+
/** The state of `target` from its rules; registered for `explain` and `readers`. */
|
|
313
|
+
state(target, rules, options = {}) {
|
|
314
|
+
const parts = Object.keys(rules).flatMap((effect) => {
|
|
315
|
+
const spec = rules[effect];
|
|
316
|
+
if (spec === undefined)
|
|
317
|
+
return [];
|
|
318
|
+
const { when, reason } = typeof spec === "object" && "when" in spec ? spec : { when: spec, reason: undefined };
|
|
319
|
+
const holds = options.availability && effect === "enabled" ? this.available(when) : this.when(when);
|
|
320
|
+
const registered = {
|
|
321
|
+
target,
|
|
322
|
+
effect,
|
|
323
|
+
rule: when,
|
|
324
|
+
...(reason !== undefined ? { reason } : {}),
|
|
325
|
+
paths: pathsOf(toCriteria(when)),
|
|
326
|
+
holds,
|
|
327
|
+
availability: options.availability ?? false,
|
|
328
|
+
};
|
|
329
|
+
this.#rules.push(registered);
|
|
330
|
+
return [registered];
|
|
331
|
+
});
|
|
332
|
+
return computed(() => {
|
|
333
|
+
const state = { ...DEFAULT_STATE, reasons: {} };
|
|
334
|
+
for (const r of parts) {
|
|
335
|
+
const value = r.holds.value;
|
|
336
|
+
state[r.effect] = value;
|
|
337
|
+
const atDefault = value === DEFAULT_STATE[r.effect];
|
|
338
|
+
if (!atDefault && r.reason !== undefined)
|
|
339
|
+
state.reasons[r.effect] = r.reason;
|
|
340
|
+
}
|
|
341
|
+
return Object.freeze({ ...state, reasons: Object.freeze(state.reasons) });
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
/** Every rule registered for `target`, with what it read and decided now. */
|
|
345
|
+
explain(target) {
|
|
346
|
+
const state = this.#rules
|
|
347
|
+
.filter((r) => r.target === target)
|
|
348
|
+
.map((r) => {
|
|
349
|
+
const unknown = untracked(() => r.paths.filter((p) => this.#hidden(p) || (r.availability && this.value(p).peek() === undefined)));
|
|
350
|
+
return {
|
|
351
|
+
effect: r.effect,
|
|
352
|
+
rule: r.rule,
|
|
353
|
+
...(r.reason !== undefined ? { reason: r.reason } : {}),
|
|
354
|
+
holds: r.holds.peek(),
|
|
355
|
+
reads: Object.fromEntries(r.paths.map((p) => [p, this.value(p).peek()])),
|
|
356
|
+
...(unknown.length ? { unknown } : {}),
|
|
357
|
+
};
|
|
358
|
+
});
|
|
359
|
+
const derived = this.#derivations.get(target);
|
|
360
|
+
return derived ? [...state, derived()] : state;
|
|
361
|
+
}
|
|
362
|
+
/** The targets whose rules read `path` or anything under it. */
|
|
363
|
+
readers(path) {
|
|
364
|
+
return this.#rules
|
|
365
|
+
.filter((r) => r.paths.some((p) => p === path || p.startsWith(`${path}.`) || path.startsWith(`${p}.`)))
|
|
366
|
+
.map((r) => ({ target: r.target, effect: r.effect }));
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* The typed steps of a path after its key and name: a field ends it, a
|
|
370
|
+
* single-valued navigation moves to the related type. Untyped steps (no
|
|
371
|
+
* record type, or past a field) aren't checked.
|
|
372
|
+
*/
|
|
373
|
+
#steps(path, type, rest) {
|
|
374
|
+
const out = [];
|
|
375
|
+
let at = type;
|
|
376
|
+
let hops = 0;
|
|
377
|
+
for (const p of rest) {
|
|
378
|
+
if (!at)
|
|
379
|
+
break;
|
|
380
|
+
const field = at.field(p);
|
|
381
|
+
if (field) {
|
|
382
|
+
out.push({ from: at, field });
|
|
383
|
+
if (out.length < rest.length)
|
|
384
|
+
throw new CriteriaError(`'${path}' goes past a field`);
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
const nav = at.navigation(p);
|
|
388
|
+
if (!nav)
|
|
389
|
+
throw new CriteriaError(`${at.name} has no field '${p}'`);
|
|
390
|
+
if (nav.collection)
|
|
391
|
+
throw new CriteriaError(`'${p}' on ${at.name} is a list; a path follows single records (${path})`);
|
|
392
|
+
if (++hops > MAX_HOPS)
|
|
393
|
+
throw new CriteriaError(`'${path}' follows more than ${MAX_HOPS} navigations`);
|
|
394
|
+
if (!this.ctx)
|
|
395
|
+
throw new CriteriaError(`'${path}' follows '${p}', which needs the scope's data context`);
|
|
396
|
+
out.push({ nav, from: at });
|
|
397
|
+
at = this.ctx.schema.set(nav.target).type;
|
|
398
|
+
}
|
|
399
|
+
return out;
|
|
400
|
+
}
|
|
401
|
+
/** The record `nav` leads to from `record`: null without a foreign key, `undefined` until it's loaded. */
|
|
402
|
+
#related(nav, from, record, cache) {
|
|
403
|
+
const ctx = this.ctx;
|
|
404
|
+
const target = ctx.schema.set(nav.target).type;
|
|
405
|
+
const local = new Map(nav.keys.map(([mine, theirs]) => [theirs, mine]));
|
|
406
|
+
const values = target.key.map((k) => record[local.get(k) ?? ""] ?? null);
|
|
407
|
+
if (values.some((v) => v === null)) {
|
|
408
|
+
if (!target.key.every((k) => local.has(k)))
|
|
409
|
+
throw new CriteriaError(`${from.name}.${nav.name} has no foreign key to follow`);
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
const key = keyText(values);
|
|
413
|
+
const id = `${nav.target} ${key}`;
|
|
414
|
+
let found = cache.get(id);
|
|
415
|
+
if (!found) {
|
|
416
|
+
found = ctx.lookup(nav.target, key);
|
|
417
|
+
cache.set(id, found);
|
|
418
|
+
}
|
|
419
|
+
return found.value;
|
|
420
|
+
}
|
|
421
|
+
/** The type of the rows a list path holds, from its publication's `types`. */
|
|
422
|
+
#rowType(path) {
|
|
423
|
+
const [key, name, ...rest] = path.split(".");
|
|
424
|
+
return rest.length === 0 ? this.#lookup(key)?.types?.[name ?? ""] : undefined;
|
|
425
|
+
}
|
|
426
|
+
#lookup(key) {
|
|
427
|
+
const own = this.#entries.value.get(key);
|
|
428
|
+
if (own || !this.parent)
|
|
429
|
+
return own;
|
|
430
|
+
return this.parent.#lookup(key);
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* A one-off entity type whose fields are the rule's paths. A path is typed
|
|
434
|
+
* by its publication where it says; otherwise by its value, or, while it's
|
|
435
|
+
* null, by what the rule compares it with. Untyped text compares only for
|
|
436
|
+
* equality: ordering it would guess (is it a date?), so that's an error.
|
|
437
|
+
*/
|
|
438
|
+
#ruleType(criteria, paths, entity, lists = new Set()) {
|
|
439
|
+
const types = new Map();
|
|
440
|
+
const fields = new Map();
|
|
441
|
+
for (const p of paths) {
|
|
442
|
+
if (lists.has(p))
|
|
443
|
+
continue;
|
|
444
|
+
const typed = this.field(p);
|
|
445
|
+
if (typed)
|
|
446
|
+
fields.set(p, { ...typed, name: p });
|
|
447
|
+
const known = typed?.type ?? typeOf(entity[p] ?? null);
|
|
448
|
+
if (known)
|
|
449
|
+
types.set(p, known);
|
|
450
|
+
}
|
|
451
|
+
for (const [p, other] of comparisons(criteria)) {
|
|
452
|
+
if (!types.has(p)) {
|
|
453
|
+
const t = typeof other === "string" ? types.get(other) : typeOf(other.value);
|
|
454
|
+
if (t)
|
|
455
|
+
types.set(p, t);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
for (const p of paths) {
|
|
459
|
+
if (!fields.has(p) && !lists.has(p)) {
|
|
460
|
+
fields.set(p, { name: p, type: types.get(p) ?? "Edm.String", nullable: true, computed: false, immutable: false, annotations: {} });
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
for (const p of ordered(criteria)) {
|
|
464
|
+
if (typeof entity[p] === "string" && !this.field(p)) {
|
|
465
|
+
throw new CriteriaError(`'${p}' has no type, so it can't be ordered; publish its record type`);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return {
|
|
469
|
+
name: "scope",
|
|
470
|
+
qualifiedName: "scope",
|
|
471
|
+
key: [],
|
|
472
|
+
fields: [...fields.values()],
|
|
473
|
+
navigations: [],
|
|
474
|
+
annotations: {},
|
|
475
|
+
field: (name) => fields.get(name),
|
|
476
|
+
navigation: (name) => (lists.has(name) ? { name, target: name, collection: true, keys: [], owned: false } : undefined),
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
function pathsOf(c) {
|
|
481
|
+
switch (c.op) {
|
|
482
|
+
case "and":
|
|
483
|
+
case "or":
|
|
484
|
+
return [...new Set(c.args.flatMap(pathsOf))];
|
|
485
|
+
case "not":
|
|
486
|
+
return pathsOf(c.arg);
|
|
487
|
+
case "any":
|
|
488
|
+
case "all":
|
|
489
|
+
case "via":
|
|
490
|
+
// The list's path; `where` names the rows' own properties.
|
|
491
|
+
return [c.field];
|
|
492
|
+
default:
|
|
493
|
+
return "other" in c ? [c.field, c.other] : [c.field];
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
function typeOf(value) {
|
|
497
|
+
if (typeof value === "boolean")
|
|
498
|
+
return "Edm.Boolean";
|
|
499
|
+
if (typeof value === "number")
|
|
500
|
+
return "Edm.Double";
|
|
501
|
+
if (typeof value === "string")
|
|
502
|
+
return "Edm.String";
|
|
503
|
+
return undefined;
|
|
504
|
+
}
|
|
505
|
+
/** Paths used with gt, ge, lt or le. */
|
|
506
|
+
function ordered(c) {
|
|
507
|
+
switch (c.op) {
|
|
508
|
+
case "and":
|
|
509
|
+
case "or":
|
|
510
|
+
return c.args.flatMap(ordered);
|
|
511
|
+
case "not":
|
|
512
|
+
return ordered(c.arg);
|
|
513
|
+
case "any":
|
|
514
|
+
case "all":
|
|
515
|
+
case "via":
|
|
516
|
+
return [];
|
|
517
|
+
case "gt":
|
|
518
|
+
case "ge":
|
|
519
|
+
case "lt":
|
|
520
|
+
case "le":
|
|
521
|
+
return "other" in c ? [c.field, c.other] : [c.field];
|
|
522
|
+
default:
|
|
523
|
+
return [];
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
/** Each compared path with what it's compared to: another path, or a literal. */
|
|
527
|
+
function comparisons(c) {
|
|
528
|
+
switch (c.op) {
|
|
529
|
+
case "and":
|
|
530
|
+
case "or":
|
|
531
|
+
return c.args.flatMap(comparisons);
|
|
532
|
+
case "not":
|
|
533
|
+
return comparisons(c.arg);
|
|
534
|
+
case "any":
|
|
535
|
+
case "all":
|
|
536
|
+
case "via":
|
|
537
|
+
return [];
|
|
538
|
+
case "in":
|
|
539
|
+
return c.values.map((value) => [c.field, { value }]);
|
|
540
|
+
default:
|
|
541
|
+
if ("other" in c)
|
|
542
|
+
return [[c.field, c.other], [c.other, c.field]];
|
|
543
|
+
return [[c.field, { value: isRelative(c.value) ? null : c.value }]];
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
/** Paths that `any` or `all` range over. */
|
|
547
|
+
function listsOf(c) {
|
|
548
|
+
switch (c.op) {
|
|
549
|
+
case "and":
|
|
550
|
+
case "or":
|
|
551
|
+
return c.args.flatMap(listsOf);
|
|
552
|
+
case "not":
|
|
553
|
+
return listsOf(c.arg);
|
|
554
|
+
case "any":
|
|
555
|
+
case "all":
|
|
556
|
+
return [c.field];
|
|
557
|
+
default:
|
|
558
|
+
return [];
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
function toRows(v, path) {
|
|
562
|
+
if (v === undefined || v === null)
|
|
563
|
+
return [];
|
|
564
|
+
if (!Array.isArray(v))
|
|
565
|
+
throw new CriteriaError(`'${path}' isn't a list, so any or all can't range over it`);
|
|
566
|
+
return v;
|
|
567
|
+
}
|
|
568
|
+
function toValue(v) {
|
|
569
|
+
if (v === undefined || v === null)
|
|
570
|
+
return null;
|
|
571
|
+
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean")
|
|
572
|
+
return v;
|
|
573
|
+
throw new CriteriaError(`a rule can only compare plain values, not ${Object.prototype.toString.call(v)}`);
|
|
574
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { type ReadonlySignal } from "@preact/signals-core";
|
|
2
|
+
import type { Collection } from "../collection/collection.ts";
|
|
3
|
+
import type { Entity } from "../schema/model.ts";
|
|
4
|
+
import type { RecordStore } from "../store/store.ts";
|
|
5
|
+
import type { KeyText } from "../store/keys.ts";
|
|
6
|
+
export interface SelectionOptions {
|
|
7
|
+
/** Several keys at once. */
|
|
8
|
+
readonly multiple?: boolean;
|
|
9
|
+
/** Pick order is meaningful; enables explicit reordering. Requires multiple. */
|
|
10
|
+
readonly ordered?: boolean;
|
|
11
|
+
/** Optional positive integer limit for ordered selection. */
|
|
12
|
+
readonly maxOrdered?: number;
|
|
13
|
+
/**
|
|
14
|
+
* When nothing chosen is in the collection, select its first loaded row.
|
|
15
|
+
* On by default for single selection, so a detail view always has a record.
|
|
16
|
+
*/
|
|
17
|
+
readonly fallback?: boolean;
|
|
18
|
+
}
|
|
19
|
+
/** Cross-field policy validation shared with declarative configuration contracts. */
|
|
20
|
+
export declare function selectionOptionsError(options: SelectionOptions): string | undefined;
|
|
21
|
+
/**
|
|
22
|
+
* The keys selected in a collection. `keys` is derived, never written back:
|
|
23
|
+
* chosen keys that have left the collection drop out, and the fallback
|
|
24
|
+
* applies, as soon as the collection changes. A key on a page the collection
|
|
25
|
+
* has unloaded stays selected.
|
|
26
|
+
*/
|
|
27
|
+
export declare class Selection {
|
|
28
|
+
#private;
|
|
29
|
+
readonly multiple: boolean;
|
|
30
|
+
readonly ordered: boolean;
|
|
31
|
+
readonly maxOrdered: number | undefined;
|
|
32
|
+
readonly keys: ReadonlySignal<readonly KeyText[]>;
|
|
33
|
+
/** The first selected key. */
|
|
34
|
+
readonly key: ReadonlySignal<KeyText | undefined>;
|
|
35
|
+
/** The selected records, live from the store. */
|
|
36
|
+
readonly records: ReadonlySignal<readonly Entity[]>;
|
|
37
|
+
/** The first selected record. */
|
|
38
|
+
readonly selected: ReadonlySignal<Entity | undefined>;
|
|
39
|
+
/** The fields hidden on the first selected record (`StoredRecord.hidden`). */
|
|
40
|
+
readonly hidden: ReadonlySignal<ReadonlySet<string>>;
|
|
41
|
+
readonly count: ReadonlySignal<number>;
|
|
42
|
+
readonly any: ReadonlySignal<boolean>;
|
|
43
|
+
readonly many: ReadonlySignal<boolean>;
|
|
44
|
+
constructor(store: RecordStore, collection: Collection | ReadonlySignal<Collection>, options?: SelectionOptions);
|
|
45
|
+
/** The collection selected in; a signal read, so views follow a selection whose collection changes. */
|
|
46
|
+
get collection(): Collection;
|
|
47
|
+
/** Selects only `key`, or nothing. */
|
|
48
|
+
select(key: KeyText | undefined): void;
|
|
49
|
+
/** Adds or removes `key` (multiple selection), or selects it alone. */
|
|
50
|
+
toggle(key: KeyText): boolean;
|
|
51
|
+
selectAll(keys: readonly KeyText[]): boolean;
|
|
52
|
+
/** Moves a selected key to a zero-based position; never changes membership. */
|
|
53
|
+
move(key: KeyText, index: number): boolean;
|
|
54
|
+
/** Selects nothing; with the fallback, the first row is selected again. */
|
|
55
|
+
clear(): void;
|
|
56
|
+
}
|