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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +10 -0
  3. package/README.md +6 -2
  4. package/dist/authentication.d.ts +42 -0
  5. package/dist/authentication.js +121 -0
  6. package/dist/changes/changeset.d.ts +47 -0
  7. package/dist/changes/changeset.js +75 -0
  8. package/dist/collection/aggregate.d.ts +22 -0
  9. package/dist/collection/aggregate.js +94 -0
  10. package/dist/collection/collection.d.ts +96 -0
  11. package/dist/collection/collection.js +588 -0
  12. package/dist/collection/grouping.d.ts +75 -0
  13. package/dist/collection/grouping.js +190 -0
  14. package/dist/collection/groups.d.ts +31 -0
  15. package/dist/collection/groups.js +117 -0
  16. package/dist/collection/summary.d.ts +17 -0
  17. package/dist/collection/summary.js +103 -0
  18. package/dist/composition/activity.d.ts +36 -0
  19. package/dist/composition/activity.js +60 -0
  20. package/dist/composition/command.d.ts +22 -0
  21. package/dist/composition/command.js +57 -0
  22. package/dist/composition/lifetime.d.ts +11 -0
  23. package/dist/composition/lifetime.js +33 -0
  24. package/dist/composition/sessions.d.ts +64 -0
  25. package/dist/composition/sessions.js +209 -0
  26. package/dist/context.d.ts +76 -0
  27. package/dist/context.js +211 -0
  28. package/dist/criteria/ast.d.ts +79 -0
  29. package/dist/criteria/ast.js +1 -0
  30. package/dist/criteria/compile.d.ts +37 -0
  31. package/dist/criteria/compile.js +233 -0
  32. package/dist/criteria/evaluate.d.ts +20 -0
  33. package/dist/criteria/evaluate.js +171 -0
  34. package/dist/criteria/filter-draft.d.ts +79 -0
  35. package/dist/criteria/filter-draft.js +320 -0
  36. package/dist/criteria/relative.d.ts +13 -0
  37. package/dist/criteria/relative.js +82 -0
  38. package/dist/criteria/values.d.ts +20 -0
  39. package/dist/criteria/values.js +283 -0
  40. package/dist/edit/edit-model.d.ts +152 -0
  41. package/dist/edit/edit-model.js +620 -0
  42. package/dist/edit/list-edit.d.ts +116 -0
  43. package/dist/edit/list-edit.js +332 -0
  44. package/dist/edit/record-edit.d.ts +93 -0
  45. package/dist/edit/record-edit.js +317 -0
  46. package/dist/export/csv.d.ts +25 -0
  47. package/dist/export/csv.js +75 -0
  48. package/dist/index.d.ts +38 -0
  49. package/dist/index.js +35 -0
  50. package/dist/odata/errors.d.ts +25 -0
  51. package/dist/odata/errors.js +56 -0
  52. package/dist/odata/source.d.ts +65 -0
  53. package/dist/odata/source.js +193 -0
  54. package/dist/progress/status.d.ts +29 -0
  55. package/dist/progress/status.js +105 -0
  56. package/dist/schema/csdl.d.ts +13 -0
  57. package/dist/schema/csdl.js +531 -0
  58. package/dist/schema/model.d.ts +132 -0
  59. package/dist/schema/model.js +1 -0
  60. package/dist/scope/filters.d.ts +130 -0
  61. package/dist/scope/filters.js +349 -0
  62. package/dist/scope/publications.d.ts +27 -0
  63. package/dist/scope/publications.js +74 -0
  64. package/dist/scope/related-aggregate.d.ts +9 -0
  65. package/dist/scope/related-aggregate.js +67 -0
  66. package/dist/scope/scope.d.ts +181 -0
  67. package/dist/scope/scope.js +574 -0
  68. package/dist/selection/selection.d.ts +56 -0
  69. package/dist/selection/selection.js +137 -0
  70. package/dist/store/keys.d.ts +10 -0
  71. package/dist/store/keys.js +21 -0
  72. package/dist/store/store.d.ts +72 -0
  73. package/dist/store/store.js +143 -0
  74. package/dist/template/template.d.ts +19 -0
  75. package/dist/template/template.js +186 -0
  76. package/dist/transport.d.ts +28 -0
  77. package/dist/transport.js +20 -0
  78. package/dist/tree/tree.d.ts +117 -0
  79. package/dist/tree/tree.js +435 -0
  80. package/dist/validate/criteria-rule.d.ts +28 -0
  81. package/dist/validate/criteria-rule.js +22 -0
  82. package/dist/validate/validate.d.ts +13 -0
  83. package/dist/validate/validate.js +123 -0
  84. package/package.json +32 -4
@@ -0,0 +1,130 @@
1
+ import { type ReadonlySignal } from "@preact/signals-core";
2
+ import type { Criteria, Query } from "../criteria/ast.ts";
3
+ import type { EdmType, Schema } from "../schema/model.ts";
4
+ import type { Selection } from "../selection/selection.ts";
5
+ import type { Publication } from "./scope.ts";
6
+ /**
7
+ * What a contribution is: a `filter` (a slice someone chose), a `selection`
8
+ * of records by identity ("these keys"), or a `value` picked from data ("status
9
+ * is approved", a chart's bar). Only identity selections are ignored by a
10
+ * list on their own set.
11
+ */
12
+ export type ContributionKind = "filter" | "selection" | "value";
13
+ /** One component's part of the shared filter. */
14
+ export interface Contribution {
15
+ /** The contributor. A new contribution under the same name replaces only that one. */
16
+ readonly by: string;
17
+ /** The contribution said in words, for chips and announcements: "Status is approved". */
18
+ readonly label: string;
19
+ readonly criteria: Criteria;
20
+ /**
21
+ * The entity set the criteria are written against. It applies there, and
22
+ * across a unique shortest path of up to three single-valued navigations, unless
23
+ * `related` is false. Without a set, the criteria apply by field name
24
+ * wherever a collection has the fields with compatible types.
25
+ */
26
+ readonly set?: string;
27
+ readonly related?: boolean;
28
+ /** The types of a contribution's fields when it has no `set`; otherwise inferred from its literals. */
29
+ readonly fields?: Readonly<Record<string, EdmType>>;
30
+ /** Applies only to collections on these sets. */
31
+ readonly only?: readonly string[];
32
+ /** `filter` by default. */
33
+ readonly kind?: ContributionKind;
34
+ }
35
+ /** A contribution as it applies to one collection. */
36
+ export interface Applied {
37
+ readonly by: string;
38
+ readonly label: string;
39
+ readonly criteria: Criteria;
40
+ /** The navigation it was followed through, for a contribution on a related set. */
41
+ readonly via?: string;
42
+ }
43
+ /** A contribution that doesn't apply to one collection, and why. */
44
+ export interface SkippedContribution {
45
+ readonly by: string;
46
+ readonly label: string;
47
+ readonly reason: string;
48
+ }
49
+ export interface Consumed {
50
+ /** Every applied contribution, ANDed; `undefined` when none applies. */
51
+ readonly criteria: Criteria | undefined;
52
+ readonly applied: readonly Applied[];
53
+ readonly skipped: readonly SkippedContribution[];
54
+ }
55
+ export interface ConsumeOptions {
56
+ /** The consumer's own contributor names. A component never consumes what it contributed. */
57
+ readonly self?: string | readonly string[];
58
+ /** Selections and values only from these contributors; filters always apply. `[]` follows none. */
59
+ readonly from?: readonly string[];
60
+ /**
61
+ * Take an identity selection on the consumer's own set. Off for lists,
62
+ * which would only copy the peer's selection; on for a KPI that totals
63
+ * the selected rows.
64
+ */
65
+ readonly sameSetSelections?: boolean;
66
+ }
67
+ /** A contribution as a contributor gives it to `share`, without its name. */
68
+ export type Shared = Omit<Contribution, "by">;
69
+ /** Saved manual slices; live selections and signal owners stay with their contributors. */
70
+ export interface FilterSnapshot {
71
+ readonly version: 1;
72
+ readonly contributions: readonly Contribution[];
73
+ }
74
+ /**
75
+ * The page's shared filter: named contributions that collections opt in to
76
+ * combining with their own query (design/page-rules.md, "Sharing filters").
77
+ * Nothing contributes or consumes by default.
78
+ *
79
+ * Invariants: one entry per contributor; a contribution applies to a
80
+ * collection whole or not at all; relative dates stay symbolic here and are
81
+ * resolved by each collection at its fetch.
82
+ */
83
+ export declare class SharedFilters {
84
+ #private;
85
+ readonly schema: Schema;
86
+ readonly contributions: ReadonlySignal<readonly Contribution[]>;
87
+ constructor(schema: Schema);
88
+ /** Adds or replaces `contribution.by`'s entry, keeping its place. Criteria on a set are checked against it now. */
89
+ set(contribution: Contribution): void;
90
+ /**
91
+ * Withdraws one contributor's entry. A contributor that follows a signal
92
+ * is asked to withdraw (its `clear`), so its own state agrees; one shared
93
+ * without a `clear` can't be removed from outside.
94
+ */
95
+ remove(by: string): void;
96
+ /** Whether `remove(by)` can withdraw that entry. */
97
+ removable(by: string): boolean;
98
+ /** Withdraws every entry that can be removed. */
99
+ clear(): void;
100
+ /**
101
+ * Contributes whatever `source` holds under `by`, following it; `undefined`
102
+ * withdraws. `clear` is how the contributor withdraws when someone else
103
+ * removes the entry (a chip's remove button): it should make `source`
104
+ * undefined, such as clearing a selection. Returns the function that stops
105
+ * following and withdraws. A name has one follower at a time.
106
+ */
107
+ share(by: string, source: ReadonlySignal<Shared | undefined>, clear?: () => void): () => void;
108
+ /** Serializable slices for saving with a screen; live signal contributions are excluded. */
109
+ snapshot(): FilterSnapshot;
110
+ /** Restores saved slices atomically, preserving live owners. Invalid snapshots leave the channel untouched. */
111
+ restore(snapshot: FilterSnapshot): void;
112
+ /** How the contributions apply to a collection on `set`. */
113
+ consume(set: string, options?: ConsumeOptions): ReadonlySignal<Consumed>;
114
+ /** `base` with the contributions that apply to its set ANDed on; for `ctx.collection` or `ctx.groups`. */
115
+ query(base: Query | ReadonlySignal<Query>, options?: ConsumeOptions): ReadonlySignal<Query>;
116
+ }
117
+ /** The page's shared filter as a publication: `contributions`, `labels`, `count`, `any`, so rules and labels can read it. */
118
+ export declare function filtersPublication(filters: SharedFilters): Publication;
119
+ /**
120
+ * A selection as a contribution: its keys as "key in these", on the
121
+ * selection's set, said by `label`. Nothing is contributed while nothing is
122
+ * selected, so deselecting, or the selected rows leaving the data, withdraws it.
123
+ */
124
+ export declare function selectionShare(selection: Selection, label: (count: number) => string): ReadonlySignal<Shared | undefined>;
125
+ /**
126
+ * Whether a value of type `from` can filter a field of type `to`: the same
127
+ * type, or an integer filtering any number. A relative date (`from` is
128
+ * `"relative"`) filters a date or a timestamp.
129
+ */
130
+ export declare function compatible(from: EdmType | "relative", to: EdmType): boolean;
@@ -0,0 +1,349 @@
1
+ import { computed, effect, signal } from "@preact/signals-core";
2
+ import { toFilter } from "../criteria/compile.js";
3
+ import { isRelative, relatedOf, resolveRelative } from "../criteria/relative.js";
4
+ import { assertLiteral, CriteriaError } from "../criteria/values.js";
5
+ import { keyFromText } from "../store/keys.js";
6
+ /**
7
+ * The page's shared filter: named contributions that collections opt in to
8
+ * combining with their own query (design/page-rules.md, "Sharing filters").
9
+ * Nothing contributes or consumes by default.
10
+ *
11
+ * Invariants: one entry per contributor; a contribution applies to a
12
+ * collection whole or not at all; relative dates stay symbolic here and are
13
+ * resolved by each collection at its fetch.
14
+ */
15
+ export class SharedFilters {
16
+ schema;
17
+ #entries = signal([]);
18
+ /** Contributors that follow a signal through `share`, with how each withdraws when asked. */
19
+ #following = new Map();
20
+ contributions = this.#entries;
21
+ constructor(schema) {
22
+ this.schema = schema;
23
+ }
24
+ /** Adds or replaces `contribution.by`'s entry, keeping its place. Criteria on a set are checked against it now. */
25
+ set(contribution) {
26
+ if (this.#following.has(contribution.by))
27
+ throw new Error(`'${contribution.by}' follows a signal; it can't also be set by hand`);
28
+ this.#put(contribution);
29
+ }
30
+ /**
31
+ * Withdraws one contributor's entry. A contributor that follows a signal
32
+ * is asked to withdraw (its `clear`), so its own state agrees; one shared
33
+ * without a `clear` can't be removed from outside.
34
+ */
35
+ remove(by) {
36
+ if (this.#following.has(by)) {
37
+ const clear = this.#following.get(by);
38
+ if (!clear)
39
+ throw new Error(`'${by}' follows a signal and gave no way to clear it`);
40
+ clear();
41
+ }
42
+ this.#drop(by);
43
+ }
44
+ /** Whether `remove(by)` can withdraw that entry. */
45
+ removable(by) {
46
+ return !this.#following.has(by) || this.#following.get(by) !== undefined;
47
+ }
48
+ /** Withdraws every entry that can be removed. */
49
+ clear() {
50
+ for (const c of this.#entries.peek())
51
+ if (this.removable(c.by))
52
+ this.remove(c.by);
53
+ }
54
+ /**
55
+ * Contributes whatever `source` holds under `by`, following it; `undefined`
56
+ * withdraws. `clear` is how the contributor withdraws when someone else
57
+ * removes the entry (a chip's remove button): it should make `source`
58
+ * undefined, such as clearing a selection. Returns the function that stops
59
+ * following and withdraws. A name has one follower at a time.
60
+ */
61
+ share(by, source, clear) {
62
+ if (this.#following.has(by))
63
+ throw new Error(`'${by}' is already shared`);
64
+ this.#following.set(by, clear);
65
+ const stop = effect(() => {
66
+ const c = source.value;
67
+ if (c)
68
+ this.#put({ ...c, by });
69
+ else
70
+ this.#drop(by);
71
+ });
72
+ return () => {
73
+ stop();
74
+ this.#following.delete(by);
75
+ this.#drop(by);
76
+ };
77
+ }
78
+ /** Serializable slices for saving with a screen; live signal contributions are excluded. */
79
+ snapshot() {
80
+ return structuredClone({ version: 1, contributions: this.#entries.peek().filter((c) => !this.#following.has(c.by) && (c.kind ?? "filter") !== "selection") });
81
+ }
82
+ /** Restores saved slices atomically, preserving live owners. Invalid snapshots leave the channel untouched. */
83
+ restore(snapshot) {
84
+ if (snapshot.version !== 1 || !Array.isArray(snapshot.contributions))
85
+ throw new Error("Unsupported shared-filter snapshot");
86
+ const entries = structuredClone(snapshot.contributions);
87
+ const names = new Set();
88
+ for (const c of entries) {
89
+ if (c?.kind !== undefined && c.kind !== "filter" && c.kind !== "value")
90
+ throw new Error("Saved slices cannot own identity selections");
91
+ if (!c || typeof c.by !== "string" || typeof c.label !== "string" || !c.criteria || names.has(c.by))
92
+ throw new Error("Invalid shared-filter snapshot");
93
+ if (this.#following.has(c.by))
94
+ throw new Error(`'${c.by}' follows a signal; restore its contributor instead`);
95
+ names.add(c.by);
96
+ if (c.set !== undefined)
97
+ check(c.criteria, this.schema.set(c.set), this.schema);
98
+ }
99
+ this.#entries.value = [...this.#entries.peek().filter((c) => this.#following.has(c.by) || (c.kind === "selection" && !names.has(c.by))), ...entries];
100
+ }
101
+ /** How the contributions apply to a collection on `set`. */
102
+ consume(set, options = {}) {
103
+ const target = this.schema.set(set);
104
+ const self = new Set(typeof options.self === "string" ? [options.self] : (options.self ?? []));
105
+ return computed(() => {
106
+ const applied = [];
107
+ const skipped = [];
108
+ for (const c of this.#entries.value) {
109
+ if (self.has(c.by))
110
+ continue;
111
+ const kind = c.kind ?? "filter";
112
+ if (kind !== "filter" && options.from && !options.from.includes(c.by))
113
+ continue;
114
+ const outcome = this.#match(c, target, kind, options.sameSetSelections ?? false);
115
+ if (typeof outcome === "string")
116
+ skipped.push({ by: c.by, label: c.label, reason: outcome });
117
+ else if (outcome)
118
+ applied.push({ by: c.by, label: c.label, ...outcome });
119
+ }
120
+ const parts = applied.map((a) => a.criteria);
121
+ const criteria = parts.length === 0 ? undefined : parts.length === 1 ? parts[0] : { op: "and", args: parts };
122
+ return { criteria, applied, skipped };
123
+ });
124
+ }
125
+ /** `base` with the contributions that apply to its set ANDed on; for `ctx.collection` or `ctx.groups`. */
126
+ query(base, options = {}) {
127
+ const b = "set" in base ? base : base.peek();
128
+ const consumed = this.consume(b.set, options);
129
+ return computed(() => {
130
+ const q = "set" in base ? base : base.value;
131
+ const shared = consumed.value.criteria;
132
+ if (!shared)
133
+ return q;
134
+ return { ...q, filter: q.filter ? { op: "and", args: [q.filter, shared] } : shared };
135
+ });
136
+ }
137
+ #drop(by) {
138
+ const entries = this.#entries.peek();
139
+ if (entries.some((c) => c.by === by))
140
+ this.#entries.value = entries.filter((c) => c.by !== by);
141
+ }
142
+ #put(c) {
143
+ if (c.set !== undefined) {
144
+ const set = this.schema.set(c.set);
145
+ check(c.criteria, set, this.schema);
146
+ }
147
+ const entries = this.#entries.peek();
148
+ const at = entries.findIndex((e) => e.by === c.by);
149
+ if (at >= 0 && JSON.stringify(entries[at]) === JSON.stringify(c))
150
+ return;
151
+ this.#entries.value = at >= 0 ? entries.map((e, i) => (i === at ? c : e)) : [...entries, c];
152
+ }
153
+ /**
154
+ * The criteria `c` puts on a collection on `target`, a reason it can't, or
155
+ * `undefined` when it isn't meant for it (scoped elsewhere, or a peer's
156
+ * selection of the same set).
157
+ */
158
+ #match(c, target, kind, sameSetSelections) {
159
+ if (c.only && !c.only.includes(target.name))
160
+ return undefined;
161
+ if (c.set === undefined) {
162
+ try {
163
+ const adapted = byName(c.criteria, c.fields ?? {}, target);
164
+ check(adapted, target, this.schema);
165
+ return { criteria: adapted };
166
+ }
167
+ catch (e) {
168
+ if (e instanceof CriteriaError)
169
+ return e.message;
170
+ throw e;
171
+ }
172
+ }
173
+ if (c.set === target.name) {
174
+ if (kind === "selection" && !sameSetSelections)
175
+ return undefined;
176
+ return { criteria: c.criteria };
177
+ }
178
+ if (c.related === false)
179
+ return `${c.label} applies to ${c.set} only`;
180
+ const paths = navigationPaths(this.schema, target, c.set);
181
+ if (paths.length === 0)
182
+ return `${target.name} has no foreign key to ${c.set}`;
183
+ if (paths.length > 1)
184
+ return `${target.name} reaches ${c.set} through ${paths.map((p) => p.map((n) => n.name).join(".")).join(" and ")}; it's not clear which to follow`;
185
+ const path = paths[0];
186
+ let criteria = c.criteria;
187
+ for (const nav of [...path].reverse())
188
+ criteria = throughKey(criteria, nav.name, nav.keys, this.schema.set(nav.target).type);
189
+ return { criteria, via: path.map((n) => n.name).join(".") };
190
+ }
191
+ }
192
+ /** The page's shared filter as a publication: `contributions`, `labels`, `count`, `any`, so rules and labels can read it. */
193
+ export function filtersPublication(filters) {
194
+ const contributions = filters.contributions;
195
+ return {
196
+ values: {
197
+ contributions,
198
+ labels: computed(() => contributions.value.map((c) => c.label)),
199
+ count: computed(() => contributions.value.length),
200
+ any: computed(() => contributions.value.length > 0),
201
+ },
202
+ };
203
+ }
204
+ /**
205
+ * A selection as a contribution: its keys as "key in these", on the
206
+ * selection's set, said by `label`. Nothing is contributed while nothing is
207
+ * selected, so deselecting, or the selected rows leaving the data, withdraws it.
208
+ */
209
+ export function selectionShare(selection, label) {
210
+ return computed(() => {
211
+ const keys = selection.keys.value;
212
+ if (keys.length === 0)
213
+ return undefined;
214
+ const collection = selection.collection;
215
+ const type = collection.type;
216
+ const values = keys.map((k) => keyFromText(k));
217
+ const criteria = type.key.length === 1
218
+ ? { op: "in", field: type.key[0], values: values.map((v) => v[0]) }
219
+ : { op: "or", args: values.map((v) => ({ op: "and", args: type.key.map((k, i) => ({ op: "eq", field: k, value: v[i] })) })) };
220
+ return { label: label(keys.length), criteria, set: collection.query.set, kind: "selection" };
221
+ });
222
+ }
223
+ /** Throws `CriteriaError` unless `criteria` compiles for `set`, relative dates resolved at an arbitrary time. */
224
+ function check(criteria, set, schema) {
225
+ toFilter(resolveRelative(criteria, set.type, 0, relatedOf(schema, set.type)), set.type, set, schema);
226
+ }
227
+ const INTEGERS = new Set(["Edm.Byte", "Edm.SByte", "Edm.Int16", "Edm.Int32", "Edm.Int64"]);
228
+ const NUMBERS = new Set([...INTEGERS, "Edm.Decimal", "Edm.Single", "Edm.Double"]);
229
+ const TEXT_NUMBERS = new Set(["Edm.Int64", "Edm.Decimal"]);
230
+ /**
231
+ * Whether a value of type `from` can filter a field of type `to`: the same
232
+ * type, or an integer filtering any number. A relative date (`from` is
233
+ * `"relative"`) filters a date or a timestamp.
234
+ */
235
+ export function compatible(from, to) {
236
+ if (from === "relative")
237
+ return to === "Edm.Date" || to === "Edm.DateTimeOffset";
238
+ return from === to || (INTEGERS.has(from) && NUMBERS.has(to));
239
+ }
240
+ function inferred(value) {
241
+ switch (typeof value) {
242
+ case "boolean":
243
+ return "Edm.Boolean";
244
+ case "number":
245
+ return Number.isInteger(value) ? "Edm.Int32" : "Edm.Double";
246
+ case "string":
247
+ return "Edm.String";
248
+ default:
249
+ return undefined;
250
+ }
251
+ }
252
+ /** A number as the JSON form of a numeric field: text for Int64 and Decimal. */
253
+ function converted(value, to) {
254
+ if (value === null || !NUMBERS.has(to))
255
+ return value;
256
+ if (TEXT_NUMBERS.has(to))
257
+ return typeof value === "number" ? String(value) : value;
258
+ return typeof value === "string" ? Number(value) : value;
259
+ }
260
+ /**
261
+ * A global contribution's criteria for `target`: each field must exist there
262
+ * with a compatible type, and literals take the field's JSON form. Any
263
+ * mismatch is a `CriteriaError`, which skips the whole contribution.
264
+ */
265
+ function byName(c, types, target) {
266
+ const field = (name) => {
267
+ const f = target.type.field(name);
268
+ if (!f)
269
+ throw new CriteriaError(`${target.name} has no field '${name}'`);
270
+ return f;
271
+ };
272
+ const fit = (name, value) => {
273
+ const f = field(name);
274
+ const from = isRelative(value) ? "relative" : (types[name] ?? inferred(value));
275
+ if (from !== undefined && !compatible(from, f.type))
276
+ throw new CriteriaError(`${target.name}.${name} is ${f.type}, not ${from}`);
277
+ if (isRelative(value))
278
+ return value;
279
+ const v = converted(value, f.type);
280
+ assertLiteral(v, f);
281
+ return v;
282
+ };
283
+ switch (c.op) {
284
+ case "and":
285
+ case "or":
286
+ return { op: c.op, args: c.args.map((a) => byName(a, types, target)) };
287
+ case "not":
288
+ return { op: "not", arg: byName(c.arg, types, target) };
289
+ case "any":
290
+ case "all":
291
+ case "via":
292
+ throw new CriteriaError(`a contribution without a set compares fields; ${c.op} over '${c.field}' needs one`);
293
+ case "in":
294
+ return { ...c, values: c.values.map((v) => fit(c.field, v)) };
295
+ case "contains":
296
+ case "startswith":
297
+ case "endswith":
298
+ fit(c.field, c.value);
299
+ return c;
300
+ default: {
301
+ if ("other" in c) {
302
+ const a = field(c.field);
303
+ const b = field(c.other);
304
+ const ta = types[c.field];
305
+ const tb = types[c.other];
306
+ if ((ta && !compatible(ta, a.type)) || (tb && !compatible(tb, b.type)) || a.type !== b.type) {
307
+ throw new CriteriaError(`${target.name}.${c.field} and ${c.other} can't be compared`);
308
+ }
309
+ return c;
310
+ }
311
+ return { ...c, value: fit(c.field, c.value) };
312
+ }
313
+ }
314
+ }
315
+ /**
316
+ * Criteria on a related set as criteria here, through navigation `nav`. A
317
+ * test of the related record's key alone becomes a test of the foreign key,
318
+ * which a collection can place locally; anything else goes through `via`.
319
+ */
320
+ function throughKey(c, nav, keys, related) {
321
+ const local = keys.find(([, theirs]) => related.key.length === 1 && theirs === related.key[0])?.[0];
322
+ if (local !== undefined && (c.op === "in" || c.op === "eq") && c.field === related.key[0] && !("other" in c)) {
323
+ return { ...c, field: local };
324
+ }
325
+ return { op: "via", field: nav, where: c };
326
+ }
327
+ /** Shortest paths of single-valued navigations, bounded and without revisiting a set. */
328
+ function navigationPaths(schema, from, to) {
329
+ let frontier = [{ set: from, path: [], seen: new Set([from.name]) }];
330
+ for (let depth = 0; depth < 3; depth++) {
331
+ const next = [];
332
+ const found = [];
333
+ for (const node of frontier) {
334
+ for (const nav of node.set.type.navigations) {
335
+ if (nav.collection || node.seen.has(nav.target))
336
+ continue;
337
+ const path = [...node.path, nav];
338
+ if (nav.target === to)
339
+ found.push(path);
340
+ else
341
+ next.push({ set: schema.set(nav.target), path, seen: new Set([...node.seen, nav.target]) });
342
+ }
343
+ }
344
+ if (found.length)
345
+ return found;
346
+ frontier = next;
347
+ }
348
+ return [];
349
+ }
@@ -0,0 +1,27 @@
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 { EditModel } from "../edit/edit-model.ts";
5
+ import type { ListEdit } from "../edit/list-edit.ts";
6
+ import type { KeyText } from "../store/keys.ts";
7
+ import type { Selection } from "../selection/selection.ts";
8
+ import type { Publication } from "./scope.ts";
9
+ /** `selected`, `records`, `keys`, `key`, `count`, `any`, `many`. */
10
+ export declare function selectionPublication(selection: Selection): Publication;
11
+ /** `values`, `record` (as saved), `dirty`, `valid`, `errors`, `mode`, `busy`, `deleted`. */
12
+ export declare function editPublication(edit: EditModel): Publication;
13
+ /** `total`, `loading`, `complete`, `stale`. */
14
+ export declare function collectionPublication(collection: Collection): Publication;
15
+ /**
16
+ * A repeating group: `rows` (the values of the rows not being removed) and
17
+ * `count`, typed, so rules range over them with `any` and `all`.
18
+ */
19
+ export declare function listPublication(list: ListEdit): Publication;
20
+ /**
21
+ * A page input: the record a page was opened with, as `record` and its
22
+ * `key`. The input is the key, plain text, so a page opened in another tab
23
+ * or window from the same address gets the same input.
24
+ */
25
+ export declare function inputPublication(ctx: DataContext, set: string, key: KeyText): Publication;
26
+ /** App state under names of its choosing. */
27
+ export declare function valuesPublication(values: Readonly<Record<string, ReadonlySignal<unknown>>>): Publication;
@@ -0,0 +1,74 @@
1
+ import { computed } from "@preact/signals-core";
2
+ // Every component of a kind publishes the same names, so rules read
3
+ // `<key>.selected.state` whichever list published it.
4
+ /** `selected`, `records`, `keys`, `key`, `count`, `any`, `many`. */
5
+ export function selectionPublication(selection) {
6
+ return {
7
+ values: {
8
+ selected: selection.selected,
9
+ records: selection.records,
10
+ keys: selection.keys,
11
+ key: selection.key,
12
+ count: selection.count,
13
+ any: selection.any,
14
+ many: selection.many,
15
+ },
16
+ types: { selected: selection.collection.type },
17
+ hidden: { selected: selection.hidden },
18
+ };
19
+ }
20
+ /** `values`, `record` (as saved), `dirty`, `valid`, `errors`, `mode`, `busy`, `deleted`. */
21
+ export function editPublication(edit) {
22
+ return {
23
+ values: {
24
+ values: edit.values,
25
+ record: edit.baseline,
26
+ dirty: edit.dirty,
27
+ valid: edit.valid,
28
+ errors: edit.errors,
29
+ mode: computed(() => (edit.isNew.value ? "create" : "update")),
30
+ busy: edit.busy,
31
+ deleted: edit.deleted,
32
+ },
33
+ types: { values: edit.type, record: edit.type },
34
+ hidden: { values: edit.hidden, record: edit.hidden },
35
+ };
36
+ }
37
+ /** `total`, `loading`, `complete`, `stale`. */
38
+ export function collectionPublication(collection) {
39
+ return {
40
+ values: {
41
+ total: collection.count,
42
+ loading: computed(() => collection.status.value === "loading"),
43
+ complete: collection.complete,
44
+ stale: collection.stale,
45
+ },
46
+ };
47
+ }
48
+ /**
49
+ * A repeating group: `rows` (the values of the rows not being removed) and
50
+ * `count`, typed, so rules range over them with `any` and `all`.
51
+ */
52
+ export function listPublication(list) {
53
+ const rows = computed(() => list.rows.value.filter((r) => !r.removing).map((r) => r.edit.values.value));
54
+ return { values: { rows, count: computed(() => rows.value.length) }, types: { rows: list.type } };
55
+ }
56
+ /**
57
+ * A page input: the record a page was opened with, as `record` and its
58
+ * `key`. The input is the key, plain text, so a page opened in another tab
59
+ * or window from the same address gets the same input.
60
+ */
61
+ export function inputPublication(ctx, set, key) {
62
+ const record = ctx.lookup(set, key);
63
+ const stored = ctx.store.record(set, key);
64
+ return {
65
+ values: { key: computed(() => key), record },
66
+ types: { record: ctx.schema.set(set).type },
67
+ hidden: { record: computed(() => stored.value?.hidden ?? NONE) },
68
+ };
69
+ }
70
+ const NONE = new Set();
71
+ /** App state under names of its choosing. */
72
+ export function valuesPublication(values) {
73
+ return { values };
74
+ }
@@ -0,0 +1,9 @@
1
+ import { type ReadonlySignal } from "@preact/signals-core";
2
+ import type { DataContext } from "../context.ts";
3
+ import type { Navigation, Value } from "../schema/model.ts";
4
+ /** An aggregate over the whole related set, including rows the client hasn't loaded. */
5
+ export declare function relatedAggregate(ctx: DataContext, parent: ReadonlySignal<unknown>, nav: Navigation, method: "min" | "max" | "average", field: string): {
6
+ value: ReadonlySignal<Value | undefined>;
7
+ error: ReadonlySignal<unknown>;
8
+ dispose: () => void;
9
+ };
@@ -0,0 +1,67 @@
1
+ import { computed, effect, signal, untracked } from "@preact/signals-core";
2
+ /** An aggregate over the whole related set, including rows the client hasn't loaded. */
3
+ export function relatedAggregate(ctx, parent, nav, method, field) {
4
+ const result = signal(undefined);
5
+ const error = signal(undefined);
6
+ let generation = 0;
7
+ let disposed = false;
8
+ let timer;
9
+ const filter = computed(() => {
10
+ const record = parent.value;
11
+ if (record === undefined)
12
+ return undefined;
13
+ if (record === null || nav.keys.some(([mine]) => record[mine] === null || record[mine] === undefined))
14
+ return null;
15
+ const parts = nav.keys.map(([mine, theirs]) => ({ op: "eq", field: theirs, value: record[mine] }));
16
+ return parts.length === 1 ? parts[0] : { op: "and", args: parts };
17
+ });
18
+ const refresh = async () => {
19
+ const ticket = ++generation;
20
+ const where = filter.peek();
21
+ result.value = undefined;
22
+ error.value = undefined;
23
+ if (where === undefined || where === null) {
24
+ result.value = where;
25
+ return;
26
+ }
27
+ try {
28
+ const rows = await ctx.source.aggregate(nav.target, { filter: where, aggregates: [{ field, method, as: "result" }] });
29
+ if (!disposed && ticket === generation)
30
+ result.value = rows[0]?.result ?? null;
31
+ }
32
+ catch (e) {
33
+ if (!disposed && ticket === generation)
34
+ error.value = e;
35
+ }
36
+ };
37
+ let last;
38
+ const stop = effect(() => {
39
+ // Key-equivalent parent edits don't change the query.
40
+ const key = JSON.stringify(filter.value);
41
+ untracked(() => {
42
+ if (key !== last) {
43
+ last = key;
44
+ void refresh();
45
+ }
46
+ });
47
+ });
48
+ const unsubscribe = ctx.store.subscribe((change) => {
49
+ if (change.set !== nav.target || change.origin.kind === "fetch")
50
+ return;
51
+ ++generation;
52
+ result.value = undefined;
53
+ clearTimeout(timer);
54
+ timer = setTimeout(() => void refresh(), 50);
55
+ });
56
+ return {
57
+ value: result,
58
+ error: error,
59
+ dispose: () => {
60
+ disposed = true;
61
+ ++generation;
62
+ clearTimeout(timer);
63
+ stop();
64
+ unsubscribe();
65
+ },
66
+ };
67
+ }