@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,75 @@
1
+ import { type ReadonlySignal } from "@preact/signals-core";
2
+ import type { Query } from "../criteria/ast.ts";
3
+ import { type Aggregate } from "../criteria/compile.ts";
4
+ import type { Schema, Value } from "../schema/model.ts";
5
+ import type { KeyText } from "../store/keys.ts";
6
+ import type { Collection } from "./collection.ts";
7
+ import type { Groups, GroupRow } from "./groups.ts";
8
+ export interface GroupingOptions {
9
+ /** Leaf collection page size, default 100. */
10
+ readonly pageSize?: number;
11
+ /** Pages retained per open leaf, default 20. */
12
+ readonly keepPages?: number;
13
+ }
14
+ /** A group header in the visible hierarchy. IDs are opaque and local to this grouping. */
15
+ export interface GroupingHeader {
16
+ readonly kind: "group";
17
+ readonly id: string;
18
+ readonly parent: string | undefined;
19
+ readonly path: readonly Value[];
20
+ readonly field: string;
21
+ readonly level: number;
22
+ readonly position: number;
23
+ readonly siblings: number;
24
+ readonly group: GroupRow;
25
+ readonly expanded: boolean;
26
+ readonly loading: boolean;
27
+ readonly error: unknown;
28
+ readonly totalsAvailable: boolean;
29
+ }
30
+ /** A record or unloaded record position beneath a final-level group. */
31
+ export interface GroupingRecord {
32
+ readonly kind: "row";
33
+ readonly key: KeyText | undefined;
34
+ readonly parent: string;
35
+ readonly level: number;
36
+ readonly position: number;
37
+ readonly siblings: number;
38
+ readonly collection: Collection;
39
+ readonly local: number;
40
+ }
41
+ export type GroupingRow = GroupingHeader | GroupingRecord;
42
+ interface GroupingContext {
43
+ readonly schema: Schema;
44
+ readonly now: () => number;
45
+ groups(query: ReadonlySignal<Query>, field: string, aggregates: readonly Aggregate[]): Groups;
46
+ collection(query: ReadonlySignal<Query>, options: GroupingOptions): Collection;
47
+ release(collection: Collection): void;
48
+ }
49
+ /** Lazy server grouping: one grouped query per open intermediate branch, paged records at leaves. */
50
+ export declare class Grouping {
51
+ #private;
52
+ readonly fields: readonly string[];
53
+ /** Open headers; collapsing a branch closes and releases its descendants. */
54
+ readonly open: ReadonlySignal<ReadonlySet<string>>;
55
+ /** Flattened visible headers and record positions, including unloaded placeholders. */
56
+ readonly rows: ReadonlySignal<readonly GroupingRow[]>;
57
+ /** Exact record count across root groups, undefined until their first answer. */
58
+ readonly total: ReadonlySignal<number | undefined>;
59
+ constructor(ctx: GroupingContext, query: Query | ReadonlySignal<Query>, fields: readonly string[], aggregates?: readonly Aggregate[], options?: GroupingOptions);
60
+ /** The root grouped query, including loading/error signals and refresh. */
61
+ get root(): Groups;
62
+ /** Opens a visible group. Only a final-level group requests record pages. */
63
+ expand(id: string): Promise<void>;
64
+ /** Releases this branch and all open descendants, including their query subscriptions. */
65
+ collapse(id: string): void;
66
+ /** Opens or collapses a visible group. */
67
+ toggle(id: string): Promise<void>;
68
+ /** Retries a branch's group list or visible leaf pages; omit the ID to retry root groups. */
69
+ retry(id?: string): Promise<void>;
70
+ /** Recounts changed aggregate specifications without closing open paths or leaf collections. */
71
+ setAggregates(aggregates: readonly Aggregate[]): void;
72
+ /** Stops all grouped queries and releases open leaf collections. */
73
+ dispose(): void;
74
+ }
75
+ export {};
@@ -0,0 +1,190 @@
1
+ import { batch, computed, effect, signal, untracked } from "@preact/signals-core";
2
+ import { toApply } from "../criteria/compile.js";
3
+ import { relatedOf, resolveRelative } from "../criteria/relative.js";
4
+ /** Lazy server grouping: one grouped query per open intermediate branch, paged records at leaves. */
5
+ export class Grouping {
6
+ fields;
7
+ #ctx;
8
+ #query;
9
+ #options;
10
+ #root = signal(undefined);
11
+ #open = signal(new Set());
12
+ #version = signal(0);
13
+ #branches = new Map();
14
+ #stop;
15
+ #aggregates;
16
+ #disposed = false;
17
+ /** Open headers; collapsing a branch closes and releases its descendants. */
18
+ open = this.#open;
19
+ /** Flattened visible headers and record positions, including unloaded placeholders. */
20
+ rows;
21
+ /** Exact record count across root groups, undefined until their first answer. */
22
+ total;
23
+ constructor(ctx, query, fields, aggregates = [], options = {}) {
24
+ if (!fields.length || new Set(fields).size !== fields.length || fields.some((f) => !f))
25
+ throw new Error("Grouping needs distinct nonempty fields");
26
+ this.#ctx = ctx;
27
+ this.fields = [...fields];
28
+ this.#query = "set" in query ? signal(query) : query;
29
+ this.#options = { ...options };
30
+ this.#aggregates = aggregates.map((a) => ({ ...a }));
31
+ this.#validate(this.#aggregates);
32
+ this.#root.value = this.#groups([]);
33
+ this.rows = computed(() => {
34
+ this.#version.value;
35
+ const rows = [];
36
+ this.#append(this.root, [], rows);
37
+ return rows;
38
+ });
39
+ this.total = computed(() => this.root.rows.value?.reduce((sum, row) => sum + row.count, 0));
40
+ this.#stop = effect(() => {
41
+ this.#version.value;
42
+ const gone = [];
43
+ for (const [id, branch] of this.#branches) {
44
+ const parent = branch.path.length === 1 ? this.root : this.#branches.get(idOf(branch.path.slice(0, -1)))?.groups;
45
+ const rows = parent?.rows.value;
46
+ if (!parent || (rows && !rows.some((r) => idOf([...branch.path.slice(0, -1), r.value]) === id)))
47
+ gone.push(id);
48
+ }
49
+ if (gone.length)
50
+ untracked(() => batch(() => { for (const id of gone)
51
+ this.collapse(id); }));
52
+ });
53
+ }
54
+ /** The root grouped query, including loading/error signals and refresh. */
55
+ get root() { return this.#root.value; }
56
+ /** Opens a visible group. Only a final-level group requests record pages. */
57
+ async expand(id) {
58
+ if (this.#disposed || this.#open.peek().has(id))
59
+ return;
60
+ const row = this.rows.peek().find((r) => r.kind === "group" && r.id === id);
61
+ if (!row)
62
+ return;
63
+ const branch = { path: row.path };
64
+ if (row.path.length < this.fields.length)
65
+ branch.groups = this.#groups(row.path);
66
+ else
67
+ branch.collection = this.#ctx.collection(computed(() => this.#scoped(row.path)), this.#options);
68
+ batch(() => {
69
+ this.#branches.set(id, branch);
70
+ this.#open.value = new Set([...this.#open.peek(), id]);
71
+ this.#version.value++;
72
+ });
73
+ if (branch.collection)
74
+ await branch.collection.ensure(0, 1);
75
+ }
76
+ /** Releases this branch and all open descendants, including their query subscriptions. */
77
+ collapse(id) {
78
+ const branch = this.#branches.get(id);
79
+ if (!branch)
80
+ return;
81
+ const next = new Set(this.#open.peek());
82
+ for (const [key, child] of this.#branches) {
83
+ if (!isWithin(child.path, branch.path))
84
+ continue;
85
+ child.groups?.dispose();
86
+ if (child.collection)
87
+ this.#ctx.release(child.collection);
88
+ this.#branches.delete(key);
89
+ next.delete(key);
90
+ }
91
+ batch(() => { this.#open.value = next; this.#version.value++; });
92
+ }
93
+ /** Opens or collapses a visible group. */
94
+ async toggle(id) {
95
+ if (this.#open.peek().has(id))
96
+ this.collapse(id);
97
+ else
98
+ await this.expand(id);
99
+ }
100
+ /** Retries a branch's group list or visible leaf pages; omit the ID to retry root groups. */
101
+ async retry(id) {
102
+ if (this.#disposed)
103
+ return;
104
+ if (id === undefined)
105
+ return this.root.refresh();
106
+ const branch = this.#branches.get(id);
107
+ if (branch?.groups)
108
+ await branch.groups.refresh();
109
+ else if (branch?.collection)
110
+ await branch.collection.refresh();
111
+ }
112
+ /** Recounts changed aggregate specifications without closing open paths or leaf collections. */
113
+ setAggregates(aggregates) {
114
+ if (this.#disposed || JSON.stringify(aggregates) === JSON.stringify(this.#aggregates))
115
+ return;
116
+ this.#validate(aggregates);
117
+ this.#aggregates = aggregates.map((a) => ({ ...a }));
118
+ batch(() => {
119
+ this.root.dispose();
120
+ this.#root.value = this.#groups([]);
121
+ for (const branch of this.#branches.values()) {
122
+ if (!branch.groups)
123
+ continue;
124
+ branch.groups.dispose();
125
+ branch.groups = this.#groups(branch.path);
126
+ }
127
+ this.#version.value++;
128
+ });
129
+ }
130
+ /** Stops all grouped queries and releases open leaf collections. */
131
+ dispose() {
132
+ this.#disposed = true;
133
+ this.#stop();
134
+ this.root.dispose();
135
+ for (const branch of this.#branches.values()) {
136
+ branch.groups?.dispose();
137
+ if (branch.collection)
138
+ this.#ctx.release(branch.collection);
139
+ }
140
+ this.#branches.clear();
141
+ }
142
+ #validate(aggregates) {
143
+ const q = this.#query.peek();
144
+ const set = this.#ctx.schema.set(q.set);
145
+ const filter = q.filter ? resolveRelative(q.filter, set.type, this.#ctx.now(), relatedOf(this.#ctx.schema, set.type)) : undefined;
146
+ toApply(set.type, filter, this.fields, aggregates, set, this.#ctx.schema);
147
+ }
148
+ #scoped(path) {
149
+ const q = this.#query.value;
150
+ const criteria = path.map((value, i) => ({ op: "eq", field: this.fields[i], value }));
151
+ if (q.filter)
152
+ criteria.unshift(q.filter);
153
+ return { ...q, ...(criteria.length ? { filter: { op: "and", args: criteria } } : {}) };
154
+ }
155
+ #groups(path) {
156
+ return this.#ctx.groups(computed(() => this.#scoped(path)), this.fields[path.length], this.#aggregates);
157
+ }
158
+ #append(groups, path, out) {
159
+ const rows = groups.rows.value;
160
+ if (!rows)
161
+ return;
162
+ const expanded = this.#open.value;
163
+ for (const [index, group] of rows.entries()) {
164
+ const childPath = [...path, group.value];
165
+ const id = idOf(childPath);
166
+ const branch = this.#branches.get(id);
167
+ const collection = branch?.collection;
168
+ const loading = branch?.groups?.status.value === "loading" || collection?.status.value === "loading";
169
+ out.push({ kind: "group", id, parent: path.length ? idOf(path) : undefined, path: childPath,
170
+ field: this.fields[path.length], level: childPath.length, position: index + 1, siblings: rows.length,
171
+ group, expanded: expanded.has(id), loading, error: branch?.groups?.error.value ?? collection?.error.value,
172
+ totalsAvailable: groups.status.value === "idle" });
173
+ if (!expanded.has(id))
174
+ continue;
175
+ if (branch?.groups)
176
+ this.#append(branch.groups, childPath, out);
177
+ else if (collection) {
178
+ const keys = collection.keys.value;
179
+ const count = collection.counted.value ? collection.count.value ?? group.count : group.count;
180
+ for (let i = 0; i < count; i++)
181
+ out.push({ kind: "row", key: keys[i], parent: id, level: childPath.length + 1,
182
+ position: i + 1, siblings: count, collection, local: i });
183
+ }
184
+ }
185
+ }
186
+ }
187
+ function idOf(path) { return `g:${JSON.stringify(path)}`; }
188
+ function isWithin(path, prefix) {
189
+ return path.length >= prefix.length && idOf(path.slice(0, prefix.length)) === idOf(prefix);
190
+ }
@@ -0,0 +1,31 @@
1
+ import { type ReadonlySignal } from "@preact/signals-core";
2
+ import type { Query } from "../criteria/ast.ts";
3
+ import type { Aggregate } from "../criteria/compile.ts";
4
+ import type { ODataSource } from "../odata/source.ts";
5
+ import type { Value } from "../schema/model.ts";
6
+ import type { RecordStore } from "../store/store.ts";
7
+ export interface GroupRow {
8
+ /** The group's value; null is a group of its own. */
9
+ readonly value: Value;
10
+ readonly count: number;
11
+ /** Each aggregate by its alias. */
12
+ readonly totals: Readonly<Record<string, Value>>;
13
+ }
14
+ /**
15
+ * The groups of a query by one field, with counts and aggregates, computed
16
+ * by the server (`$apply`) in one request. They follow the query, and any
17
+ * change to the set recounts them, shortly after, once per burst.
18
+ */
19
+ export declare class Groups {
20
+ #private;
21
+ readonly field: string;
22
+ /** In the group value's order; `undefined` until the first answer. */
23
+ readonly rows: ReadonlySignal<readonly GroupRow[] | undefined>;
24
+ readonly error: ReadonlySignal<unknown>;
25
+ /** The state of the current grouped request. */
26
+ readonly status: ReadonlySignal<"loading" | "idle" | "error">;
27
+ constructor(store: RecordStore, source: ODataSource, query: Query | ReadonlySignal<Query>, field: string, aggregates?: readonly Aggregate[], now?: () => number);
28
+ get query(): Query;
29
+ refresh(): Promise<void>;
30
+ dispose(): void;
31
+ }
@@ -0,0 +1,117 @@
1
+ import { batch, effect, signal, untracked } from "@preact/signals-core";
2
+ import { sameQuery } from "../criteria/compile.js";
3
+ import { relatedOf, resolveRelative } from "../criteria/relative.js";
4
+ import { relatedSets } from "./collection.js";
5
+ const RECOUNT_DELAY = 50;
6
+ /**
7
+ * The groups of a query by one field, with counts and aggregates, computed
8
+ * by the server (`$apply`) in one request. They follow the query, and any
9
+ * change to the set recounts them, shortly after, once per burst.
10
+ */
11
+ export class Groups {
12
+ field;
13
+ #source;
14
+ #aggregates;
15
+ #rows = signal(undefined);
16
+ #error = signal(undefined);
17
+ #status = signal("loading");
18
+ #stop = [];
19
+ #query;
20
+ #related;
21
+ #generation = 0;
22
+ #disposed = false;
23
+ #timer;
24
+ /** In the group value's order; `undefined` until the first answer. */
25
+ rows = this.#rows;
26
+ error = this.#error;
27
+ /** The state of the current grouped request. */
28
+ status = this.#status;
29
+ #now;
30
+ #resolved(filter) {
31
+ const type = this.#source.schema.set(this.#query.set).type;
32
+ return resolveRelative(filter, type, this.#now(), relatedOf(this.#source.schema, type));
33
+ }
34
+ constructor(store, source, query, field, aggregates = [], now = Date.now) {
35
+ this.#source = source;
36
+ this.#now = now;
37
+ this.field = field;
38
+ this.#aggregates = aggregates;
39
+ const follow = "set" in query ? undefined : query;
40
+ this.#query = follow ? follow.peek() : query;
41
+ this.#related = relatedSets(this.#query.filter, source.schema.set(this.#query.set).type, source.schema);
42
+ source.schema.set(this.#query.set).type.field(field) ?? fail(`${this.#query.set} has no field ${field}`);
43
+ this.#stop.push(store.subscribe((change) => {
44
+ if ((change.set === this.#query.set || this.#related.has(change.set)) && change.origin.kind !== "fetch")
45
+ this.#soon();
46
+ }));
47
+ if (follow) {
48
+ this.#stop.push(effect(() => {
49
+ const q = follow.value;
50
+ untracked(() => {
51
+ if (sameQuery(q, this.#query))
52
+ return;
53
+ this.#query = q;
54
+ this.#related = relatedSets(q.filter, source.schema.set(q.set).type, source.schema);
55
+ this.#rows.value = undefined;
56
+ void this.refresh();
57
+ });
58
+ }));
59
+ }
60
+ void this.refresh();
61
+ }
62
+ get query() {
63
+ return this.#query;
64
+ }
65
+ async refresh() {
66
+ if (this.#disposed)
67
+ return;
68
+ clearTimeout(this.#timer);
69
+ const generation = ++this.#generation;
70
+ batch(() => { this.#status.value = "loading"; this.#error.value = undefined; });
71
+ try {
72
+ const rows = await this.#source.aggregate(this.#query.set, {
73
+ ...(this.#query.filter ? { filter: this.#resolved(this.#query.filter) } : {}),
74
+ groupBy: [this.field],
75
+ aggregates: this.#aggregates,
76
+ // Groups follow the sort's direction on their field, ascending otherwise.
77
+ orderBy: [{ field: this.field, ...(this.#query.orderBy?.find((k) => k.field === this.field)?.descending ? { descending: true } : {}) }],
78
+ });
79
+ if (generation !== this.#generation)
80
+ return;
81
+ const previous = this.#rows.peek();
82
+ const byValue = new Map(previous?.map(row => [JSON.stringify(row.value), row]));
83
+ const result = rows.map((r) => {
84
+ const totals = {};
85
+ for (const a of this.#aggregates)
86
+ totals[a.as] = r[a.as] ?? null;
87
+ const value = r[this.field] ?? null;
88
+ const count = Number(r.count ?? 0);
89
+ const before = byValue.get(JSON.stringify(value));
90
+ return before && before.count === count && JSON.stringify(before.totals) === JSON.stringify(totals)
91
+ ? before : { value, count, totals };
92
+ });
93
+ const unchanged = previous?.length === result.length && result.every((row, i) => row === previous[i]);
94
+ batch(() => { this.#rows.value = unchanged ? previous : result; this.#status.value = "idle"; });
95
+ }
96
+ catch (e) {
97
+ if (generation === this.#generation)
98
+ batch(() => { this.#error.value = e; this.#status.value = "error"; });
99
+ }
100
+ }
101
+ dispose() {
102
+ this.#disposed = true;
103
+ this.#generation++;
104
+ clearTimeout(this.#timer);
105
+ for (const stop of this.#stop.splice(0))
106
+ stop();
107
+ }
108
+ #soon() {
109
+ this.#generation++;
110
+ this.#status.value = "loading";
111
+ clearTimeout(this.#timer);
112
+ this.#timer = setTimeout(() => void this.refresh(), RECOUNT_DELAY);
113
+ }
114
+ }
115
+ function fail(message) {
116
+ throw new Error(message);
117
+ }
@@ -0,0 +1,17 @@
1
+ import { type ReadonlySignal } from "@preact/signals-core";
2
+ import type { Query } from "../criteria/ast.ts";
3
+ import { type Aggregate } from "../criteria/compile.ts";
4
+ import type { ODataSource } from "../odata/source.ts";
5
+ import type { Value } from "../schema/model.ts";
6
+ import type { RecordStore } from "../store/store.ts";
7
+ /** Exact server totals for a query, independent of loaded collection pages. */
8
+ export declare class Summary {
9
+ #private;
10
+ readonly values: ReadonlySignal<Readonly<Record<string, Value>> | undefined>;
11
+ readonly status: ReadonlySignal<"loading" | "idle" | "error">;
12
+ readonly error: ReadonlySignal<unknown>;
13
+ constructor(store: RecordStore, source: ODataSource, query: Query | ReadonlySignal<Query>, aggregates?: readonly Aggregate[], now?: () => number);
14
+ /** Retry or recount. Errors are exposed through `error` and `status`. */
15
+ refresh(): Promise<void>;
16
+ dispose(): void;
17
+ }
@@ -0,0 +1,103 @@
1
+ import { batch, effect, signal, untracked } from "@preact/signals-core";
2
+ import { toApply } from "../criteria/compile.js";
3
+ import { relatedOf, resolveRelative } from "../criteria/relative.js";
4
+ import { relatedSets } from "./collection.js";
5
+ /** Exact server totals for a query, independent of loaded collection pages. */
6
+ export class Summary {
7
+ #values = signal(undefined);
8
+ #status = signal("loading");
9
+ #error = signal(undefined);
10
+ values = this.#values;
11
+ status = this.#status;
12
+ error = this.#error;
13
+ #source;
14
+ #aggregates;
15
+ #now;
16
+ #stop = [];
17
+ #query;
18
+ #signature;
19
+ #related;
20
+ #generation = 0;
21
+ #disposed = false;
22
+ #timer;
23
+ constructor(store, source, query, aggregates = [], now = Date.now) {
24
+ this.#source = source;
25
+ this.#aggregates = aggregates.map((a) => ({ ...a }));
26
+ this.#now = now;
27
+ const follow = "set" in query ? undefined : query;
28
+ this.#query = follow ? follow.peek() : query;
29
+ this.#signature = signature(this.#query);
30
+ this.#related = relatedSets(this.#query.filter, source.schema.set(this.#query.set).type, source.schema);
31
+ this.#validate();
32
+ this.#stop.push(store.subscribe((change) => {
33
+ if (change.origin.kind === "fetch" || (change.set !== this.#query.set && !this.#related.has(change.set)))
34
+ return;
35
+ this.#invalidate();
36
+ clearTimeout(this.#timer);
37
+ this.#timer = setTimeout(() => void this.refresh(), 50);
38
+ }));
39
+ if (follow)
40
+ this.#stop.push(effect(() => {
41
+ const q = follow.value;
42
+ untracked(() => {
43
+ if (signature(q) === this.#signature)
44
+ return;
45
+ this.#query = q;
46
+ this.#signature = signature(q);
47
+ this.#related = relatedSets(q.filter, source.schema.set(q.set).type, source.schema);
48
+ void this.refresh();
49
+ });
50
+ }));
51
+ void this.refresh();
52
+ }
53
+ /** Retry or recount. Errors are exposed through `error` and `status`. */
54
+ async refresh() {
55
+ if (this.#disposed)
56
+ return;
57
+ clearTimeout(this.#timer);
58
+ const generation = this.#invalidate();
59
+ try {
60
+ this.#validate();
61
+ const q = this.#query;
62
+ const type = this.#source.schema.set(q.set).type;
63
+ const filter = q.filter ? resolveRelative(q.filter, type, this.#now(), relatedOf(this.#source.schema, type)) : undefined;
64
+ const rows = await this.#source.aggregate(q.set, { ...(filter ? { filter } : {}), aggregates: this.#aggregates });
65
+ if (generation !== this.#generation || this.#disposed)
66
+ return;
67
+ const row = rows[0];
68
+ if (!row || !Number.isSafeInteger(Number(row.count)) || Number(row.count) < 0)
69
+ throw new Error("Summary response has no exact row count");
70
+ const values = { count: Number(row.count) };
71
+ for (const a of this.#aggregates)
72
+ values[a.as] = row[a.as] ?? null;
73
+ batch(() => { this.#values.value = values; this.#status.value = "idle"; });
74
+ }
75
+ catch (error) {
76
+ if (generation !== this.#generation || this.#disposed)
77
+ return;
78
+ batch(() => { this.#error.value = error; this.#status.value = "error"; });
79
+ }
80
+ }
81
+ dispose() {
82
+ this.#disposed = true;
83
+ this.#generation++;
84
+ clearTimeout(this.#timer);
85
+ for (const stop of this.#stop.splice(0))
86
+ stop();
87
+ }
88
+ #invalidate() {
89
+ this.#generation++;
90
+ batch(() => { this.#values.value = undefined; this.#error.value = undefined; this.#status.value = "loading"; });
91
+ return this.#generation;
92
+ }
93
+ #validate() {
94
+ const set = this.#source.schema.set(this.#query.set);
95
+ const criteria = this.#query.filter;
96
+ const filter = criteria ? resolveRelative(criteria, set.type, this.#now(), relatedOf(this.#source.schema, set.type)) : undefined;
97
+ toApply(set.type, filter, [], this.#aggregates, set, this.#source.schema);
98
+ }
99
+ }
100
+ /** Ordering and projection cannot change aggregate membership. */
101
+ function signature(query) {
102
+ return JSON.stringify([query.set, query.filter]);
103
+ }
@@ -0,0 +1,36 @@
1
+ import { type ReadonlySignal } from "@preact/signals-core";
2
+ import { Lifetime } from "./lifetime.ts";
3
+ export type InactivePolicy = "pause" | "continue";
4
+ export interface ActivityOptions {
5
+ readonly parent?: SessionActivity;
6
+ readonly inactive?: InactivePolicy;
7
+ readonly active?: boolean;
8
+ }
9
+ export interface ActivityRun {
10
+ readonly signal: AbortSignal;
11
+ readonly refresh: boolean;
12
+ /** Only the current, unaborted run can mark its refreshed data fresh. */
13
+ readonly markFresh: () => void;
14
+ }
15
+ /** Logical data activity; a paused ancestor always constrains descendants. */
16
+ export declare class SessionActivity {
17
+ #private;
18
+ readonly lifetime: Lifetime;
19
+ readonly active: ReadonlySignal<boolean>;
20
+ readonly running: ReadonlySignal<boolean>;
21
+ constructor(options?: ActivityOptions);
22
+ setActive(active: boolean): void;
23
+ /** Start an owned subscription or task on each resume. Release late acquisitions too.
24
+ * refresh is true after a pause or invalidate; successful refresh calls run.markFresh(). */
25
+ run(start: (run: ActivityRun) => void | (() => void) | Promise<void | (() => void)>): ActivityTask;
26
+ dispose(): void;
27
+ }
28
+ export declare class ActivityTask {
29
+ #private;
30
+ readonly lifetime: Lifetime;
31
+ readonly stale: ReadonlySignal<boolean>;
32
+ readonly error: ReadonlySignal<unknown>;
33
+ constructor(activity: SessionActivity, start: (run: ActivityRun) => void | (() => void) | Promise<void | (() => void)>);
34
+ invalidate(): void;
35
+ dispose(): void;
36
+ }
@@ -0,0 +1,60 @@
1
+ import { computed, effect, signal, untracked } from "@preact/signals-core";
2
+ import { Lifetime } from "./lifetime.js";
3
+ /** Logical data activity; a paused ancestor always constrains descendants. */
4
+ export class SessionActivity {
5
+ lifetime = new Lifetime();
6
+ #active = signal(true);
7
+ #disposed = signal(false);
8
+ active = this.#active;
9
+ running;
10
+ constructor(options = {}) {
11
+ this.lifetime.defer(() => { this.#disposed.value = true; });
12
+ this.#active.value = options.active ?? true;
13
+ this.running = computed(() => !this.#disposed.value && (options.parent?.running.value ?? true) && (this.#active.value || options.inactive === "continue"));
14
+ }
15
+ setActive(active) { if (!this.lifetime.disposed)
16
+ this.#active.value = active; }
17
+ /** Start an owned subscription or task on each resume. Release late acquisitions too.
18
+ * refresh is true after a pause or invalidate; successful refresh calls run.markFresh(). */
19
+ run(start) {
20
+ return this.lifetime.own(new ActivityTask(this, start));
21
+ }
22
+ dispose() { this.#disposed.value = true; this.lifetime.dispose(); }
23
+ }
24
+ export class ActivityTask {
25
+ #stale = signal(true);
26
+ #error = signal(undefined);
27
+ #revision = signal(0);
28
+ lifetime = new Lifetime();
29
+ stale = this.#stale;
30
+ error = this.#error;
31
+ constructor(activity, start) {
32
+ this.lifetime.defer(effect(() => {
33
+ const running = activity.running.value;
34
+ this.#revision.value;
35
+ if (!running) {
36
+ untracked(() => { this.#stale.value = true; });
37
+ return;
38
+ }
39
+ const attempt = new Lifetime();
40
+ untracked(() => {
41
+ this.#error.value = undefined;
42
+ Promise.resolve().then(() => {
43
+ attempt.signal.throwIfAborted();
44
+ return start({ signal: attempt.signal, refresh: this.#stale.peek(), markFresh: () => { if (!attempt.disposed)
45
+ this.#stale.value = false; } });
46
+ }).then(cleanup => { if (cleanup)
47
+ attempt.defer(cleanup); }).catch(error => {
48
+ if (!attempt.disposed)
49
+ this.#error.value = error;
50
+ });
51
+ });
52
+ return () => attempt.dispose();
53
+ }));
54
+ }
55
+ invalidate() { if (!this.lifetime.disposed) {
56
+ this.#stale.value = true;
57
+ this.#revision.value++;
58
+ } }
59
+ dispose() { this.lifetime.dispose(); }
60
+ }
@@ -0,0 +1,22 @@
1
+ import { type ReadonlySignal } from "@preact/signals-core";
2
+ export interface CommandOptions<T> {
3
+ readonly label: string;
4
+ readonly enabled?: () => boolean;
5
+ readonly reason?: () => string | undefined;
6
+ readonly run: (signal: AbortSignal) => T | Promise<T>;
7
+ }
8
+ /** One guarded action shared by buttons, menus, shortcuts and direct callers. */
9
+ export declare class Command<T = unknown> {
10
+ #private;
11
+ readonly label: string;
12
+ readonly enabled: ReadonlySignal<boolean>;
13
+ readonly reason: ReadonlySignal<string | undefined>;
14
+ readonly busy: ReadonlySignal<boolean>;
15
+ readonly error: ReadonlySignal<unknown>;
16
+ readonly result: ReadonlySignal<T | undefined>;
17
+ constructor(options: CommandOptions<T>);
18
+ /** Concurrent callers join the same invocation; unavailable actions reject. */
19
+ execute(): Promise<T>;
20
+ dispose(): void;
21
+ }
22
+ export declare function command<T>(options: CommandOptions<T>): Command<T>;