@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,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
|
+
}
|