@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,57 @@
1
+ import { computed, signal } from "@preact/signals-core";
2
+ import { Lifetime } from "./lifetime.js";
3
+ /** One guarded action shared by buttons, menus, shortcuts and direct callers. */
4
+ export class Command {
5
+ #lifetime = new Lifetime();
6
+ #busy = signal(false);
7
+ #error = signal(undefined);
8
+ #result = signal(undefined);
9
+ #disposed = signal(false);
10
+ #options;
11
+ #pending;
12
+ label;
13
+ enabled;
14
+ reason;
15
+ busy = this.#busy;
16
+ error = this.#error;
17
+ result = this.#result;
18
+ constructor(options) {
19
+ this.#options = options;
20
+ this.label = options.label;
21
+ this.enabled = computed(() => !this.#disposed.value && !this.#busy.value && (options.enabled?.() ?? true));
22
+ this.reason = computed(() => this.#disposed.value ? "This action is closed." : this.#busy.value ? "Working…" : this.enabled.value ? undefined : options.reason?.() ?? "This action is unavailable.");
23
+ }
24
+ /** Concurrent callers join the same invocation; unavailable actions reject. */
25
+ execute() {
26
+ if (this.#disposed.peek())
27
+ return Promise.reject(new Error("This action is closed."));
28
+ if (this.#pending)
29
+ return this.#pending;
30
+ if (!this.enabled.value)
31
+ return Promise.reject(new Error(this.reason.value));
32
+ this.#busy.value = true;
33
+ this.#error.value = undefined;
34
+ const pending = Promise.resolve().then(() => {
35
+ this.#lifetime.signal.throwIfAborted();
36
+ return this.#options.run(this.#lifetime.signal);
37
+ }).then(value => {
38
+ if (!this.#lifetime.disposed)
39
+ this.#result.value = value;
40
+ return value;
41
+ }).catch((error) => {
42
+ if (!this.#lifetime.disposed)
43
+ this.#error.value = error;
44
+ throw error;
45
+ }).finally(() => {
46
+ this.#pending = undefined;
47
+ this.#busy.value = false;
48
+ });
49
+ this.#pending = pending;
50
+ return pending;
51
+ }
52
+ dispose() {
53
+ this.#disposed.value = true;
54
+ this.#lifetime.dispose();
55
+ }
56
+ }
57
+ export function command(options) { return new Command(options); }
@@ -0,0 +1,11 @@
1
+ /** An explicit owner; borrowed resources are never registered here. */
2
+ export declare class Lifetime {
3
+ #private;
4
+ get signal(): AbortSignal;
5
+ get disposed(): boolean;
6
+ defer(cleanup: () => void): void;
7
+ own<T extends {
8
+ dispose(): void;
9
+ }>(resource: T): T;
10
+ dispose(): void;
11
+ }
@@ -0,0 +1,33 @@
1
+ /** An explicit owner; borrowed resources are never registered here. */
2
+ export class Lifetime {
3
+ #abort = new AbortController();
4
+ #cleanups = [];
5
+ get signal() { return this.#abort.signal; }
6
+ get disposed() { return this.signal.aborted; }
7
+ defer(cleanup) {
8
+ if (this.disposed)
9
+ cleanup();
10
+ else
11
+ this.#cleanups.push(cleanup);
12
+ }
13
+ own(resource) {
14
+ this.defer(() => resource.dispose());
15
+ return resource;
16
+ }
17
+ dispose() {
18
+ if (this.disposed)
19
+ return;
20
+ this.#abort.abort();
21
+ const errors = [];
22
+ for (const cleanup of this.#cleanups.splice(0).reverse()) {
23
+ try {
24
+ cleanup();
25
+ }
26
+ catch (error) {
27
+ errors.push(error);
28
+ }
29
+ }
30
+ if (errors.length)
31
+ throw new AggregateError(errors, "Resource cleanup failed");
32
+ }
33
+ }
@@ -0,0 +1,64 @@
1
+ import { type ReadonlySignal } from "@preact/signals-core";
2
+ import { Lifetime } from "./lifetime.ts";
3
+ import { SessionActivity, type InactivePolicy } from "./activity.ts";
4
+ import { PageScope } from "../scope/scope.ts";
5
+ export interface SessionDescriptor {
6
+ readonly key: string;
7
+ readonly type: string;
8
+ readonly inputs?: Readonly<Record<string, unknown>>;
9
+ readonly lazy?: boolean;
10
+ readonly inactive?: InactivePolicy;
11
+ }
12
+ export interface SessionResource {
13
+ dispose?(): void;
14
+ canClose?(signal: AbortSignal): boolean | Promise<boolean>;
15
+ setActive?(active: boolean): void;
16
+ }
17
+ export interface SessionMount {
18
+ readonly lifetime: Lifetime;
19
+ readonly scope: PageScope;
20
+ readonly activity: SessionActivity;
21
+ }
22
+ export interface KeyedSessionsOptions<D extends SessionDescriptor, T extends SessionResource> {
23
+ readonly create: (descriptor: D, mount: SessionMount) => T | Promise<T>;
24
+ readonly validate?: (descriptor: D) => void;
25
+ /** Validate immutable identity before any reconciliation mutations. */
26
+ readonly checkUpdate?: (previous: D, next: D) => void;
27
+ readonly update?: (resource: T, previous: D, next: D) => void;
28
+ readonly parentScope?: PageScope;
29
+ readonly activity?: SessionActivity;
30
+ }
31
+ export type SessionStatus = "idle" | "loading" | "ready" | "error" | "disposed";
32
+ /** A bounded collection: only descriptors in the current reconciliation are retained. */
33
+ export declare class KeyedSessions<D extends SessionDescriptor, T extends SessionResource> {
34
+ #private;
35
+ readonly options: KeyedSessionsOptions<D, T>;
36
+ readonly lifetime: Lifetime;
37
+ readonly sessions: ReadonlySignal<readonly KeyedSession<D, T>[]>;
38
+ constructor(options: KeyedSessionsOptions<D, T>);
39
+ get(key: string): KeyedSession<D, T> | undefined;
40
+ reconcile(descriptors: readonly D[]): void;
41
+ ensure(key: string): Promise<T>;
42
+ activate(key: string | undefined): void;
43
+ requestClose(key: string): Promise<boolean>;
44
+ /** Descriptor removal and owner disposal are unconditional, unlike user close. */
45
+ remove(key: string): void;
46
+ dispose(): void;
47
+ }
48
+ export declare class KeyedSession<D extends SessionDescriptor, T extends SessionResource> {
49
+ #private;
50
+ readonly options: KeyedSessionsOptions<D, T>;
51
+ readonly lifetime: Lifetime;
52
+ readonly scope: PageScope;
53
+ readonly activity: SessionActivity;
54
+ readonly status: ReadonlySignal<SessionStatus>;
55
+ readonly value: ReadonlySignal<T | undefined>;
56
+ readonly error: ReadonlySignal<unknown>;
57
+ get descriptor(): D;
58
+ constructor(descriptor: D, options: KeyedSessionsOptions<D, T>);
59
+ update(descriptor: D): void;
60
+ activate(active: boolean): void;
61
+ ensure(): Promise<T>;
62
+ requestClose(): Promise<boolean>;
63
+ dispose(): void;
64
+ }
@@ -0,0 +1,209 @@
1
+ import { signal } from "@preact/signals-core";
2
+ import { Lifetime } from "./lifetime.js";
3
+ import { SessionActivity } from "./activity.js";
4
+ import { PageScope } from "../scope/scope.js";
5
+ /** A bounded collection: only descriptors in the current reconciliation are retained. */
6
+ export class KeyedSessions {
7
+ options;
8
+ lifetime = new Lifetime();
9
+ #entries = new Map();
10
+ #sessions = signal([]);
11
+ sessions = this.#sessions;
12
+ #active;
13
+ constructor(options) {
14
+ this.options = options;
15
+ this.lifetime.defer(() => {
16
+ const cleanup = new Lifetime();
17
+ for (const entry of this.#entries.values())
18
+ cleanup.defer(() => entry.dispose());
19
+ this.#entries.clear();
20
+ this.#sessions.value = [];
21
+ cleanup.dispose();
22
+ });
23
+ }
24
+ get(key) { return this.#entries.get(key); }
25
+ reconcile(descriptors) {
26
+ if (this.lifetime.disposed)
27
+ throw new Error("Sessions are disposed");
28
+ for (const descriptor of descriptors) {
29
+ if (!descriptor || typeof descriptor !== "object" || Array.isArray(descriptor))
30
+ throw new TypeError("Session descriptor must be a record");
31
+ if (typeof descriptor.key !== "string" || !descriptor.key.trim())
32
+ throw new TypeError("Session key must be a nonempty string");
33
+ if (typeof descriptor.type !== "string" || !descriptor.type.trim())
34
+ throw new TypeError("Session type must be a nonempty string");
35
+ if (descriptor.lazy !== undefined && typeof descriptor.lazy !== "boolean")
36
+ throw new TypeError("Session lazy must be a boolean");
37
+ if (descriptor.inactive !== undefined && descriptor.inactive !== "pause" && descriptor.inactive !== "continue")
38
+ throw new TypeError("Session inactive must be pause or continue");
39
+ if (descriptor.inputs !== undefined) {
40
+ const inputs = descriptor.inputs;
41
+ if (!inputs || typeof inputs !== "object" || (Object.getPrototypeOf(inputs) !== Object.prototype && Object.getPrototypeOf(inputs) !== null))
42
+ throw new TypeError("Session inputs must be an ordinary record");
43
+ }
44
+ }
45
+ descriptors = descriptors.map(descriptor => Object.freeze({ ...descriptor, ...(descriptor.inputs ? { inputs: Object.freeze({ ...descriptor.inputs }) } : {}) }));
46
+ const keys = new Set();
47
+ for (const descriptor of descriptors) {
48
+ if (!descriptor.key || keys.has(descriptor.key))
49
+ throw new Error(`Duplicate or empty session key '${descriptor.key}'`);
50
+ keys.add(descriptor.key);
51
+ this.options.validate?.(descriptor);
52
+ const previous = this.#entries.get(descriptor.key)?.descriptor;
53
+ if (previous) {
54
+ if (previous.type !== descriptor.type)
55
+ throw new Error(`Session '${descriptor.key}' cannot change type`);
56
+ if (previous.inactive !== descriptor.inactive)
57
+ throw new Error(`Session '${descriptor.key}' cannot change inactive policy`);
58
+ if (this.options.checkUpdate)
59
+ this.options.checkUpdate(previous, descriptor);
60
+ else
61
+ for (const key of new Set([...Object.keys(previous.inputs ?? {}), ...Object.keys(descriptor.inputs ?? {})])) {
62
+ if (!Object.is(previous.inputs?.[key], descriptor.inputs?.[key]))
63
+ throw new Error(`Session '${descriptor.key}' cannot change immutable input '${key}'`);
64
+ }
65
+ }
66
+ }
67
+ for (const key of this.#entries.keys())
68
+ if (!keys.has(key))
69
+ this.remove(key);
70
+ const ordered = descriptors.map(descriptor => {
71
+ let entry = this.#entries.get(descriptor.key);
72
+ if (entry)
73
+ entry.update(descriptor);
74
+ else {
75
+ entry = new KeyedSession(descriptor, this.options);
76
+ this.#entries.set(descriptor.key, entry);
77
+ }
78
+ return entry;
79
+ });
80
+ this.#sessions.value = ordered;
81
+ for (const entry of ordered) {
82
+ entry.activate(entry.descriptor.key === this.#active);
83
+ if (entry.descriptor.lazy === false)
84
+ void entry.ensure().catch(() => { });
85
+ }
86
+ }
87
+ ensure(key) {
88
+ const entry = this.#entries.get(key);
89
+ return entry ? entry.ensure() : Promise.reject(new Error(`Unknown session '${key}'`));
90
+ }
91
+ activate(key) {
92
+ if (key !== undefined && !this.#entries.has(key))
93
+ throw new Error(`Unknown session '${key}'`);
94
+ this.#active = key;
95
+ for (const entry of this.#entries.values())
96
+ entry.activate(entry.descriptor.key === key);
97
+ }
98
+ async requestClose(key) {
99
+ const entry = this.#entries.get(key);
100
+ if (!entry)
101
+ return false;
102
+ if (!await entry.requestClose())
103
+ return false;
104
+ if (this.#entries.get(key) !== entry)
105
+ return false;
106
+ this.remove(key);
107
+ return true;
108
+ }
109
+ /** Descriptor removal and owner disposal are unconditional, unlike user close. */
110
+ remove(key) {
111
+ const entry = this.#entries.get(key);
112
+ if (!entry)
113
+ return;
114
+ this.#entries.delete(key);
115
+ if (this.#active === key)
116
+ this.#active = undefined;
117
+ this.#sessions.value = this.#sessions.peek().filter(item => item !== entry);
118
+ entry.dispose();
119
+ }
120
+ dispose() { this.lifetime.dispose(); }
121
+ }
122
+ export class KeyedSession {
123
+ options;
124
+ lifetime = new Lifetime();
125
+ scope;
126
+ activity;
127
+ #status = signal("idle");
128
+ #value = signal(undefined);
129
+ #error = signal(undefined);
130
+ status = this.#status;
131
+ value = this.#value;
132
+ error = this.#error;
133
+ #descriptor;
134
+ #pending;
135
+ #closing;
136
+ #attempt;
137
+ get descriptor() { return this.#descriptor; }
138
+ constructor(descriptor, options) {
139
+ this.options = options;
140
+ this.#descriptor = descriptor;
141
+ this.lifetime.defer(() => {
142
+ this.#status.value = "disposed";
143
+ this.#value.value = undefined;
144
+ this.#attempt?.dispose();
145
+ });
146
+ this.scope = new PageScope(options.parentScope);
147
+ this.activity = this.lifetime.own(new SessionActivity({ ...(options.activity ? { parent: options.activity } : {}), inactive: descriptor.inactive ?? "pause", active: false }));
148
+ }
149
+ update(descriptor) {
150
+ const previous = this.#descriptor;
151
+ if (this.#value.peek())
152
+ this.options.update?.(this.#value.peek(), previous, descriptor);
153
+ this.#descriptor = descriptor;
154
+ }
155
+ activate(active) {
156
+ this.activity.setActive(active);
157
+ this.#value.peek()?.setActive?.(active);
158
+ if (active)
159
+ void this.ensure().catch(() => { });
160
+ }
161
+ ensure() {
162
+ if (this.lifetime.disposed)
163
+ return Promise.reject(new Error("Session is disposed"));
164
+ if (this.#pending)
165
+ return this.#pending;
166
+ if (this.#value.peek())
167
+ return Promise.resolve(this.#value.peek());
168
+ const attempt = this.#attempt = new Lifetime();
169
+ this.#status.value = "loading";
170
+ this.#error.value = undefined;
171
+ const initial = this.#descriptor;
172
+ const pending = Promise.resolve().then(() => {
173
+ attempt.signal.throwIfAborted();
174
+ return this.options.create(initial, { lifetime: attempt, scope: this.scope, activity: this.activity });
175
+ }).then(resource => {
176
+ attempt.defer(() => resource.dispose?.());
177
+ attempt.signal.throwIfAborted();
178
+ this.options.update?.(resource, initial, this.#descriptor);
179
+ resource.setActive?.(this.activity.active.peek());
180
+ this.#value.value = resource;
181
+ this.#status.value = "ready";
182
+ return resource;
183
+ }).catch(error => {
184
+ attempt.dispose();
185
+ if (!this.lifetime.disposed) {
186
+ this.#error.value = error;
187
+ this.#status.value = "error";
188
+ }
189
+ throw error;
190
+ }).finally(() => { this.#pending = undefined; });
191
+ this.#pending = pending;
192
+ return pending;
193
+ }
194
+ requestClose() {
195
+ if (this.#closing)
196
+ return this.#closing;
197
+ const pending = Promise.resolve().then(async () => {
198
+ if (this.lifetime.disposed)
199
+ return false;
200
+ // A started factory must finish before its resource's guard can be consulted.
201
+ const resource = this.#pending ? await this.#pending : this.#value.peek();
202
+ const accepted = await resource?.canClose?.(this.lifetime.signal) ?? true;
203
+ return accepted && !this.lifetime.disposed;
204
+ }).finally(() => { this.#closing = undefined; });
205
+ this.#closing = pending;
206
+ return pending;
207
+ }
208
+ dispose() { this.lifetime.dispose(); }
209
+ }
@@ -0,0 +1,76 @@
1
+ import { type ReadonlySignal } from "@preact/signals-core";
2
+ import { type ChangeSet } from "./changes/changeset.ts";
3
+ import { Collection, type CollectionOptions } from "./collection/collection.ts";
4
+ import { Summary } from "./collection/summary.ts";
5
+ import { Groups } from "./collection/groups.ts";
6
+ import { Grouping, type GroupingOptions } from "./collection/grouping.ts";
7
+ import { type Aggregate } from "./criteria/compile.ts";
8
+ import type { Query } from "./criteria/ast.ts";
9
+ import { ODataSource, type Deltas } from "./odata/source.ts";
10
+ import type { Entity, Schema } from "./schema/model.ts";
11
+ import { type KeyText } from "./store/keys.ts";
12
+ import { RecordStore, type StoredRecord } from "./store/store.ts";
13
+ import type { Transport } from "./transport.ts";
14
+ export interface Saved {
15
+ readonly key: KeyText;
16
+ readonly record: StoredRecord;
17
+ /** What the save did, for undo and change logs. */
18
+ readonly change: ChangeSet;
19
+ }
20
+ /**
21
+ * One client's view of a service: the store, every live collection, and the
22
+ * writes that keep them in step. All writes go through here so sync sees them.
23
+ */
24
+ export declare class DataContext {
25
+ #private;
26
+ readonly source: ODataSource;
27
+ readonly store: RecordStore;
28
+ /** The clock relative dates in queries are resolved against, at each fetch. */
29
+ readonly now: () => number;
30
+ constructor(source: ODataSource, options?: {
31
+ now?: () => number;
32
+ });
33
+ static connect(transport: Transport, options?: {
34
+ now?: () => number;
35
+ }): Promise<DataContext>;
36
+ get schema(): Schema;
37
+ /**
38
+ * A live collection. Given a signal, the collection follows it: each new
39
+ * query replaces the rows and loads again.
40
+ */
41
+ collection(query: Query | ReadonlySignal<Query>, options?: CollectionOptions): Collection;
42
+ /** Live group counts and aggregates of a query by one field; `dispose()` them when done. */
43
+ groups(query: Query | ReadonlySignal<Query>, field: string, aggregates?: readonly Aggregate[]): Groups;
44
+ /** A lazy hierarchy of grouped queries and paged leaf records. Dispose when done. */
45
+ grouping(query: Query | ReadonlySignal<Query>, fields: readonly string[], aggregates?: readonly Aggregate[], options?: GroupingOptions): Grouping;
46
+ /** Exact query totals; dispose when the consuming view closes. */
47
+ summary(query: Query | ReadonlySignal<Query>, aggregates?: readonly Aggregate[]): Summary;
48
+ /** Stops syncing a collection, and following its query. */
49
+ release(collection: Collection): void;
50
+ /**
51
+ * One record by key, for showing a foreign key's display value. A record
52
+ * that isn't in the store is fetched; lookups made in the same task share
53
+ * one request per set.
54
+ */
55
+ lookup(set: string, key: KeyText): ReadonlySignal<Entity | undefined>;
56
+ create(set: string, values: Entity, by?: object): Promise<Saved>;
57
+ /**
58
+ * Sends only `changes`, with `ifMatch` or else the stored record's ETag.
59
+ * `deltas` changes owned collections in the same request (deep update).
60
+ */
61
+ update(set: string, key: KeyText, changes: Entity, by?: object, ifMatch?: string, deltas?: Deltas): Promise<Saved>;
62
+ remove(set: string, key: KeyText, by?: object, ifMatch?: string): Promise<ChangeSet>;
63
+ /** Reads one record again; a record that's gone is removed. */
64
+ reload(set: string, key: KeyText, origin?: "fetch" | "external"): Promise<StoredRecord | undefined>;
65
+ /**
66
+ * Runs a change set, such as the inverse of an earlier save. `ifMatch`
67
+ * pins an update or delete to that version instead of the stored one.
68
+ */
69
+ run(change: ChangeSet, by?: object, ifMatch?: string): Promise<Saved | ChangeSet>;
70
+ /**
71
+ * Undoes a save. Given the `Saved`, the inverse is pinned to the version
72
+ * that save produced, so a change someone made since is a 412, not
73
+ * overwritten.
74
+ */
75
+ undo(saved: Saved | ChangeSet, by?: object): Promise<Saved | ChangeSet>;
76
+ }
@@ -0,0 +1,211 @@
1
+ import { computed, effect, untracked } from "@preact/signals-core";
2
+ import { creatable, invert, writable } from "./changes/changeset.js";
3
+ import { Collection } from "./collection/collection.js";
4
+ import { Summary } from "./collection/summary.js";
5
+ import { Groups } from "./collection/groups.js";
6
+ import { Grouping } from "./collection/grouping.js";
7
+ import { sameQuery } from "./criteria/compile.js";
8
+ import { ODataError } from "./odata/errors.js";
9
+ import { ODataSource } from "./odata/source.js";
10
+ import { keyFromText, keyText, keyValues } from "./store/keys.js";
11
+ import { RecordStore } from "./store/store.js";
12
+ /**
13
+ * One client's view of a service: the store, every live collection, and the
14
+ * writes that keep them in step. All writes go through here so sync sees them.
15
+ */
16
+ export class DataContext {
17
+ source;
18
+ store;
19
+ #collections = new Set();
20
+ /** Stops a collection following its query signal. */
21
+ #following = new Map();
22
+ /** Keys asked for through `lookup` and not yet answered, by set. */
23
+ #wanted = new Map();
24
+ #requested = new Set();
25
+ /** The clock relative dates in queries are resolved against, at each fetch. */
26
+ now;
27
+ constructor(source, options = {}) {
28
+ this.source = source;
29
+ this.now = options.now ?? Date.now;
30
+ this.store = new RecordStore(source.schema);
31
+ this.store.subscribe((change) => {
32
+ for (const c of this.#collections)
33
+ c.apply(change);
34
+ });
35
+ }
36
+ static async connect(transport, options = {}) {
37
+ return new DataContext(await ODataSource.connect(transport), options);
38
+ }
39
+ get schema() {
40
+ return this.source.schema;
41
+ }
42
+ /**
43
+ * A live collection. Given a signal, the collection follows it: each new
44
+ * query replaces the rows and loads again.
45
+ */
46
+ collection(query, options) {
47
+ const following = "set" in query ? undefined : query;
48
+ const c = new Collection(this.store, this.source, following ? following.peek() : query, { now: this.now, ...options });
49
+ this.#collections.add(c);
50
+ if (following) {
51
+ this.#following.set(c, effect(() => {
52
+ const q = following.value;
53
+ untracked(() => !sameQuery(q, c.query) && c.setQuery(q));
54
+ }));
55
+ }
56
+ return c;
57
+ }
58
+ /** Live group counts and aggregates of a query by one field; `dispose()` them when done. */
59
+ groups(query, field, aggregates = []) {
60
+ return new Groups(this.store, this.source, query, field, aggregates, this.now);
61
+ }
62
+ /** A lazy hierarchy of grouped queries and paged leaf records. Dispose when done. */
63
+ grouping(query, fields, aggregates = [], options = {}) {
64
+ return new Grouping(this, query, fields, aggregates, options);
65
+ }
66
+ /** Exact query totals; dispose when the consuming view closes. */
67
+ summary(query, aggregates = []) {
68
+ return new Summary(this.store, this.source, query, aggregates, this.now);
69
+ }
70
+ /** Stops syncing a collection, and following its query. */
71
+ release(collection) {
72
+ this.#collections.delete(collection);
73
+ this.#following.get(collection)?.();
74
+ this.#following.delete(collection);
75
+ }
76
+ /**
77
+ * One record by key, for showing a foreign key's display value. A record
78
+ * that isn't in the store is fetched; lookups made in the same task share
79
+ * one request per set.
80
+ */
81
+ lookup(set, key) {
82
+ const id = `${set} ${key}`;
83
+ if (!this.store.get(set, key) && !this.#requested.has(id)) {
84
+ this.#requested.add(id);
85
+ let keys = this.#wanted.get(set);
86
+ if (!keys) {
87
+ keys = new Set();
88
+ this.#wanted.set(set, keys);
89
+ queueMicrotask(() => void this.#fetchWanted(set));
90
+ }
91
+ keys.add(key);
92
+ }
93
+ const record = this.store.record(set, key);
94
+ return computed(() => record.value?.entity);
95
+ }
96
+ async create(set, values, by) {
97
+ const type = this.schema.set(set).type;
98
+ const sent = creatable(type, values);
99
+ const record = await this.source.create(set, sent);
100
+ const key = this.store.put(set, record, save(by));
101
+ return { key, record, change: { kind: "create", set, values: sent } };
102
+ }
103
+ /**
104
+ * Sends only `changes`, with `ifMatch` or else the stored record's ETag.
105
+ * `deltas` changes owned collections in the same request (deep update).
106
+ */
107
+ async update(set, key, changes, by, ifMatch, deltas) {
108
+ const before = this.#require(set, key);
109
+ const record = await this.#guard(set, key, () => this.source.update(set, keyFromText(key), changes, ifMatch ?? before.etag, deltas));
110
+ this.store.put(set, record, save(by));
111
+ const fieldChanges = {};
112
+ for (const [name, to] of Object.entries(changes))
113
+ fieldChanges[name] = { from: before.entity[name] ?? null, to };
114
+ return { key, record, change: { kind: "update", set, key: keyFromText(key), changes: fieldChanges } };
115
+ }
116
+ async remove(set, key, by, ifMatch) {
117
+ const before = this.#require(set, key);
118
+ await this.#guard(set, key, () => this.source.delete(set, keyFromText(key), ifMatch ?? before.etag));
119
+ this.store.remove(set, key, save(by));
120
+ // A hidden field's `null` isn't what was stored, so an undo mustn't write it back.
121
+ const values = before.hidden ? Object.fromEntries(Object.entries(before.entity).filter(([f]) => !before.hidden.has(f))) : before.entity;
122
+ return { kind: "delete", set, key: keyFromText(key), values };
123
+ }
124
+ /** Reads one record again; a record that's gone is removed. */
125
+ async reload(set, key, origin = "fetch") {
126
+ try {
127
+ const record = await this.source.get(set, keyFromText(key));
128
+ this.store.put(set, record, { kind: origin });
129
+ return record;
130
+ }
131
+ catch (e) {
132
+ if (e instanceof ODataError && e.status === 404) {
133
+ this.store.remove(set, key, { kind: origin });
134
+ return undefined;
135
+ }
136
+ throw e;
137
+ }
138
+ }
139
+ /**
140
+ * Runs a change set, such as the inverse of an earlier save. `ifMatch`
141
+ * pins an update or delete to that version instead of the stored one.
142
+ */
143
+ async run(change, by, ifMatch) {
144
+ switch (change.kind) {
145
+ case "create":
146
+ return this.create(change.set, change.values, by);
147
+ case "update": {
148
+ const to = {};
149
+ for (const [name, c] of Object.entries(change.changes))
150
+ to[name] = c.to;
151
+ const type = this.schema.set(change.set).type;
152
+ const key = keyText(change.key);
153
+ return this.update(change.set, key, writable(type, to), by, ifMatch);
154
+ }
155
+ case "delete":
156
+ return this.remove(change.set, keyText(change.key), by, ifMatch);
157
+ }
158
+ }
159
+ /**
160
+ * Undoes a save. Given the `Saved`, the inverse is pinned to the version
161
+ * that save produced, so a change someone made since is a 412, not
162
+ * overwritten.
163
+ */
164
+ undo(saved, by) {
165
+ const change = "change" in saved ? saved.change : saved;
166
+ const type = this.schema.set(change.set).type;
167
+ const created = "record" in saved ? keyValues(type, saved.record.entity) : undefined;
168
+ return this.run(invert(change, type, created), by, "record" in saved ? saved.record.etag : undefined);
169
+ }
170
+ async #fetchWanted(set) {
171
+ const keys = [...(this.#wanted.get(set) ?? [])];
172
+ this.#wanted.delete(set);
173
+ const type = this.schema.set(set).type;
174
+ const [keyField] = type.key;
175
+ try {
176
+ if (type.key.length === 1 && keyField !== undefined) {
177
+ const values = keys.map((k) => keyFromText(k)[0]);
178
+ const page = await this.source.query({ set, filter: { op: "in", field: keyField, values } }, { top: values.length });
179
+ this.store.putAll(set, page.records, { kind: "fetch" });
180
+ }
181
+ else {
182
+ await Promise.all(keys.map((k) => this.reload(set, k)));
183
+ }
184
+ }
185
+ catch {
186
+ // The records stay missing; a later lookup asks again.
187
+ for (const k of keys)
188
+ this.#requested.delete(`${set} ${k}`);
189
+ }
190
+ }
191
+ #require(set, key) {
192
+ const record = this.store.get(set, key);
193
+ if (!record)
194
+ throw new Error(`${set} ${key} isn't loaded`);
195
+ return record;
196
+ }
197
+ /** On 412 the stored record is refreshed, so the caller can show what changed. */
198
+ async #guard(set, key, write) {
199
+ try {
200
+ return await write();
201
+ }
202
+ catch (e) {
203
+ if (e instanceof ODataError && (e.status === 412 || e.status === 404))
204
+ await this.reload(set, key, "external");
205
+ throw e;
206
+ }
207
+ }
208
+ }
209
+ function save(by) {
210
+ return by ? { kind: "save", by } : { kind: "save" };
211
+ }