@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,620 @@
1
+ import { batch, computed, effect, signal, untracked } from "@preact/signals-core";
2
+ import { diff, isEmpty } from "../changes/changeset.js";
3
+ import { ODataError } from "../odata/errors.js";
4
+ import { CriteriaError } from "../criteria/values.js";
5
+ import { DEFAULT_STATE } from "../scope/scope.js";
6
+ import { keyFromText } from "../store/keys.js";
7
+ import { validateEntity } from "../validate/validate.js";
8
+ const NONE = new Set();
9
+ /**
10
+ * Uncommitted values for one record, apart from the stored record.
11
+ *
12
+ * Invariant: `baseline` is the stored record the edit started from (or the
13
+ * defaults for a new one); a field is pending when its value differs from
14
+ * the baseline, or when pending is forced on.
15
+ *
16
+ * A field hidden on the stored record (`StoredRecord.hidden`) is read-only,
17
+ * never pending and never sent: its `null` isn't a value the person has, so
18
+ * neither a change, a check nor a save may act on it. Every diff below goes
19
+ * through `#diff`, which leaves hidden fields out.
20
+ */
21
+ export class EditModel {
22
+ entitySet;
23
+ type;
24
+ #ctx;
25
+ #rules;
26
+ #key = signal(undefined);
27
+ #baseline = signal({});
28
+ #hidden = signal(NONE);
29
+ #baseEtag;
30
+ #values = signal({});
31
+ #forced = signal(new Set());
32
+ #errors = signal({});
33
+ #formError = signal(undefined);
34
+ #busy = signal(false);
35
+ #conflict = signal(undefined);
36
+ #deleted = signal(false);
37
+ /** Fields with a derived value that the person has changed since. */
38
+ #typed = signal(new Set());
39
+ #derived = new Map();
40
+ #derivedNow = new Map();
41
+ /** Derived fields written in this task, to name them if they never settle. */
42
+ #writes = [];
43
+ #pending = new Map();
44
+ #ruled = signal(new Map());
45
+ #states = new Map();
46
+ #disposers = [];
47
+ /** A past change shown over its old values: read-only, never saved. */
48
+ #replay = false;
49
+ #explainer;
50
+ key = this.#key;
51
+ baseline = this.#baseline;
52
+ values = this.#values;
53
+ /** Fields the server hides from this caller on the record; their value is unknown, not empty. */
54
+ hidden = this.#hidden;
55
+ errors = this.#errors;
56
+ /** A failure that isn't about one field. */
57
+ formError = this.#formError;
58
+ busy = this.#busy;
59
+ conflict = this.#conflict;
60
+ /** The record was deleted, by someone else or elsewhere in this client. Saving is refused. */
61
+ deleted = this.#deleted;
62
+ isNew = computed(() => this.#key.value === undefined);
63
+ dirty = computed(() => !isEmpty(this.#diff(this.#baseline.value, this.#values.value)));
64
+ /** Whether the current values would pass `validate()`, without showing errors. */
65
+ valid = computed(() => Object.keys(this.#check(this.#values.value)).length === 0);
66
+ /** Edits an existing record when `key` is given, otherwise a new one starting from `defaults`. */
67
+ constructor(ctx, set, key, defaults = {}, options = {}) {
68
+ this.#ctx = ctx;
69
+ this.#rules = options.rules ?? [];
70
+ this.entitySet = set;
71
+ this.type = ctx.schema.set(set).type;
72
+ if (key === undefined) {
73
+ this.#baseline.value = defaults;
74
+ this.#values.value = defaults;
75
+ }
76
+ else {
77
+ const stored = ctx.store.get(set, key);
78
+ if (!stored)
79
+ throw new Error(`${set} ${key} isn't loaded`);
80
+ this.#key.value = key;
81
+ this.#baseline.value = stored.entity;
82
+ this.#values.value = stored.entity;
83
+ this.#hidden.value = stored.hidden ?? NONE;
84
+ this.#baseEtag = stored.etag;
85
+ }
86
+ const unsubscribe = ctx.store.subscribe((change) => {
87
+ if (change.set !== set || change.key !== this.#key.peek())
88
+ return;
89
+ if (change.origin.kind === "save" && change.origin.by === this)
90
+ return;
91
+ if (change.after)
92
+ this.#theirs(change.after);
93
+ else {
94
+ batch(() => {
95
+ this.#deleted.value = true;
96
+ this.#formError.value = "This record has been deleted, so these changes can't be saved.";
97
+ });
98
+ }
99
+ });
100
+ this.#disposers.push(unsubscribe);
101
+ }
102
+ /**
103
+ * A change shown as it was made: `before` is the baseline and `after` the
104
+ * values, so pending marks exactly what changed. Every field is read-only
105
+ * and nothing is saved.
106
+ */
107
+ static replay(ctx, set, before, after, forced = []) {
108
+ const m = new EditModel(ctx, set, undefined, before);
109
+ m.#replay = true;
110
+ m.#values.value = after;
111
+ m.#forced.value = new Set(forced);
112
+ return m;
113
+ }
114
+ /** A past change shown over its old values (`replay`), not an edit. */
115
+ get replaying() {
116
+ return this.#replay;
117
+ }
118
+ /** The ETag of the version the edit started from. */
119
+ get etag() {
120
+ return this.#baseEtag;
121
+ }
122
+ /**
123
+ * Takes what was saved elsewhere (a deep update of a parent) as the new
124
+ * starting point: the saved record if given, otherwise the current values.
125
+ */
126
+ accept(saved) {
127
+ batch(() => {
128
+ if (saved) {
129
+ this.#baseline.value = saved.entity;
130
+ this.#values.value = saved.entity;
131
+ this.#hidden.value = saved.hidden ?? NONE;
132
+ this.#baseEtag = saved.etag;
133
+ }
134
+ else {
135
+ this.#baseline.value = this.#values.peek();
136
+ }
137
+ this.#forced.value = new Set();
138
+ this.#typed.value = new Set();
139
+ this.#errors.value = {};
140
+ this.#formError.value = undefined;
141
+ });
142
+ }
143
+ /** Shows errors the server gave for these fields, such as a deep update's errors on a child. */
144
+ showErrors(errors) {
145
+ this.#errors.value = errors;
146
+ }
147
+ /** The working state, for keeping across a reload. */
148
+ draft() {
149
+ const key = this.#key.peek();
150
+ return {
151
+ ...(key === undefined ? {} : { key }),
152
+ baseline: this.#baseline.peek(),
153
+ values: this.#values.peek(),
154
+ ...(this.#baseEtag === undefined ? {} : { etag: this.#baseEtag }),
155
+ forced: [...this.#forced.peek()],
156
+ ...(this.#typed.peek().size ? { typed: [...this.#typed.peek()] } : {}),
157
+ };
158
+ }
159
+ /**
160
+ * Takes up a draft of this record. If the stored record has moved on since,
161
+ * that's handled as any external change: followed where it touches nothing
162
+ * edited, otherwise a conflict.
163
+ */
164
+ resume(draft) {
165
+ if (draft.key !== this.#key.peek())
166
+ throw new Error(`a draft of ${draft.key ?? "a new record"} doesn't fit ${this.#key.peek() ?? "a new record"}`);
167
+ batch(() => {
168
+ this.#baseline.value = draft.baseline;
169
+ this.#values.value = draft.values;
170
+ this.#baseEtag = draft.etag;
171
+ this.#forced.value = new Set(draft.forced);
172
+ this.#typed.value = new Set(draft.typed ?? []);
173
+ });
174
+ const key = this.#key.peek();
175
+ const stored = key === undefined ? undefined : this.#ctx.store.get(this.entitySet, key);
176
+ if (stored) {
177
+ this.#hidden.value = stored.hidden ?? NONE;
178
+ this.#theirs(stored);
179
+ }
180
+ }
181
+ get(name) {
182
+ return this.#values.value[name];
183
+ }
184
+ /**
185
+ * Sets a value. A field that's read-only keeps its value and shows the
186
+ * reason as its error. Once a field shows an error, the error follows the
187
+ * value: it changes with it and clears as soon as the value is valid.
188
+ * Empty text is null on a field marked `emptyAsNull`.
189
+ */
190
+ set(name, entered) {
191
+ const value = entered === "" && this.#field(name).emptyAsNull ? null : entered;
192
+ const state = this.state(name).peek();
193
+ if (state.readOnly) {
194
+ this.#errors.value = { ...this.#errors.peek(), [name]: [state.reasons.readOnly ?? `${this.#title(name)} is read-only.`] };
195
+ return;
196
+ }
197
+ batch(() => {
198
+ this.#values.value = { ...this.#values.peek(), [name]: value };
199
+ if (this.#derived.has(name) && !this.#typed.peek().has(name))
200
+ this.#typed.value = new Set(this.#typed.peek()).add(name);
201
+ if (this.#errors.peek()[name]) {
202
+ const { [name]: _, ...rest } = this.#errors.peek();
203
+ const now = this.#check(this.#values.peek())[name];
204
+ this.#errors.value = now ? { ...rest, [name]: now } : rest;
205
+ }
206
+ });
207
+ }
208
+ /**
209
+ * Page rules for some fields, named `<target>.<field>` in the scope's
210
+ * `explain`. Call once, before the fields are shown.
211
+ */
212
+ applyRules(scope, target, rules) {
213
+ this.#explainer = { scope, target };
214
+ const ruled = new Map(this.#ruled.peek());
215
+ for (const [name, { clearWhenHidden: _, derived, ...state }] of Object.entries(rules)) {
216
+ const field = this.#field(name);
217
+ ruled.set(name, scope.state(`${target}.${name}`, state));
218
+ if (derived === undefined)
219
+ continue;
220
+ if (field.computed)
221
+ throw new Error(`${this.type.name}.${name} is set by the server, so it can't have a derived value`);
222
+ if ("peek" in derived)
223
+ this.#derived.set(name, derived);
224
+ else {
225
+ const selected = scope.select(derived, `${target}.${name}`);
226
+ this.#derived.set(name, selected);
227
+ this.#disposers.push(() => selected.dispose());
228
+ }
229
+ }
230
+ this.#ruled.value = ruled;
231
+ for (const name of Object.keys(rules))
232
+ if (rules[name].derived !== undefined)
233
+ this.#derive(name, `${target}.${name}`);
234
+ for (const [name, { clearWhenHidden }] of Object.entries(rules)) {
235
+ if (!clearWhenHidden)
236
+ continue;
237
+ // Only a change from shown to hidden clears; a field that starts
238
+ // hidden keeps its value, whatever order the page was set up in.
239
+ let shown;
240
+ this.#disposers.push(effect(() => {
241
+ const visible = this.state(name).value.visible;
242
+ const hid = shown === true && !visible;
243
+ shown = visible;
244
+ if (!hid)
245
+ return;
246
+ untracked(() => {
247
+ if ((this.#values.peek()[name] ?? null) !== null) {
248
+ this.#values.value = { ...this.#values.peek(), [name]: null };
249
+ }
250
+ });
251
+ }));
252
+ }
253
+ }
254
+ /**
255
+ * Whether the field holds its derived value: suggested or replaced, not
256
+ * typed over. False for a field without one.
257
+ */
258
+ derived(name) {
259
+ let d = this.#derivedNow.get(name);
260
+ if (!d) {
261
+ d = computed(() => {
262
+ const source = this.#derived.get(name);
263
+ if (!source || this.#typed.value.has(name))
264
+ return false;
265
+ const v = source.value;
266
+ return v !== undefined && Object.is(v ?? null, this.#values.value[name] ?? null);
267
+ });
268
+ this.#derivedNow.set(name, d);
269
+ }
270
+ return d;
271
+ }
272
+ /**
273
+ * Follows a field's derived value. A read-only field always takes it. An
274
+ * editable one takes it when it changes, until the person types in the
275
+ * field; a new record takes it from the start, while an existing one keeps
276
+ * its stored value until the derived value first changes. An `undefined`
277
+ * derived value (no case applies, a related record still loading) leaves
278
+ * the value alone.
279
+ */
280
+ #derive(name, path) {
281
+ const source = this.#derived.get(name);
282
+ let seen;
283
+ this.#disposers.push(effect(() => {
284
+ const v = source.value;
285
+ const { readOnly } = this.state(name).value;
286
+ const typed = this.#typed.value.has(name);
287
+ // A read-only field follows its own value too, so a revert is replaced again.
288
+ const current = readOnly ? this.#values.value[name] : undefined;
289
+ if (v === undefined || this.#replay || this.#deleted.value)
290
+ return;
291
+ if (v !== null && typeof v === "object")
292
+ throw new CriteriaError(`the derived value of ${path} isn't a plain value`);
293
+ const changed = seen !== undefined && !Object.is(seen.v, v);
294
+ const first = seen === undefined;
295
+ seen = { v };
296
+ const apply = readOnly ? !typed : !typed && (changed || (first && untracked(() => this.isNew.value)));
297
+ if (!apply || Object.is((readOnly ? current : untracked(() => this.#values.value[name])) ?? null, v))
298
+ return;
299
+ untracked(() => this.#write(name, path, v));
300
+ }));
301
+ }
302
+ /** Writes a derived value; a loop between derived values surfaces here with the fields in it. */
303
+ #write(name, path, v) {
304
+ if (this.#writes.length === 0)
305
+ queueMicrotask(() => (this.#writes = []));
306
+ this.#writes.push(path);
307
+ try {
308
+ this.#values.value = { ...this.#values.peek(), [name]: v };
309
+ }
310
+ catch (e) {
311
+ if (e instanceof Error && e.message === "Cycle detected") {
312
+ const fields = [...new Set(this.#writes)].join(", ");
313
+ throw new CriteriaError(`derived values feed each other and never settle: ${fields}`);
314
+ }
315
+ throw e;
316
+ }
317
+ }
318
+ /** The page rules behind a field's state, with what they read now. */
319
+ explain(name) {
320
+ const e = this.#explainer;
321
+ const rules = e ? e.scope.explain(`${e.target}.${name}`) : [];
322
+ const source = this.#derived.get(name);
323
+ if (source && !rules.some((r) => r.effect === "derived"))
324
+ rules.push({ effect: "derived", rule: { op: "and", args: [] }, holds: source.peek() !== undefined, reads: {}, value: source.peek() });
325
+ return rules.map((r) => r.effect === "derived" ? { ...r, overridden: this.#typed.peek().has(name), current: this.#values.peek()[name] } : r);
326
+ }
327
+ /**
328
+ * A field's state: its page rules, then what the metadata and the record
329
+ * say. Computed fields are read-only, immutable ones once saved, and every
330
+ * field of a deleted record; a non-nullable field is required.
331
+ */
332
+ state(name) {
333
+ let state = this.#states.get(name);
334
+ if (!state) {
335
+ const field = this.#field(name);
336
+ state = computed(() => {
337
+ const ruled = this.#ruled.value.get(name)?.value ?? DEFAULT_STATE;
338
+ const reasons = { ...ruled.reasons };
339
+ let readOnly = ruled.readOnly;
340
+ if (this.#replay) {
341
+ readOnly = true;
342
+ reasons.readOnly = "This is a past change.";
343
+ }
344
+ else if (this.#deleted.value) {
345
+ readOnly = true;
346
+ reasons.readOnly = "This record has been deleted.";
347
+ }
348
+ else if (this.#hidden.value.has(name)) {
349
+ readOnly = true;
350
+ reasons.readOnly = `You can't see ${this.#title(name)} on this record.`;
351
+ }
352
+ else if (field.computed || (field.immutable && !this.isNew.value)) {
353
+ readOnly = true;
354
+ reasons.readOnly ??= field.computed
355
+ ? `${this.#title(name)} is set by the server.`
356
+ : `${this.#title(name)} can't be changed once it's set.`;
357
+ }
358
+ const required = ruled.required || (!field.nullable && !field.computed);
359
+ return Object.freeze({ ...ruled, readOnly, required, reasons: Object.freeze(reasons) });
360
+ });
361
+ this.#states.set(name, state);
362
+ }
363
+ return state;
364
+ }
365
+ pending(name) {
366
+ let p = this.#pending.get(name);
367
+ if (!p) {
368
+ p = computed(() => !this.#hidden.value.has(name) &&
369
+ (this.#forced.value.has(name) || !Object.is(this.#baseline.value[name] ?? null, this.#values.value[name] ?? null)));
370
+ this.#pending.set(name, p);
371
+ }
372
+ return p;
373
+ }
374
+ forcePending(name, on) {
375
+ const next = new Set(this.#forced.peek());
376
+ if (on)
377
+ next.add(name);
378
+ else
379
+ next.delete(name);
380
+ this.#forced.value = next;
381
+ }
382
+ /** Puts the starting value back; a derived field takes suggestions again from the next change. */
383
+ revert(name) {
384
+ batch(() => {
385
+ if (name === undefined) {
386
+ this.#values.value = this.#baseline.peek();
387
+ this.#errors.value = {};
388
+ this.#typed.value = new Set();
389
+ }
390
+ else {
391
+ if (this.#typed.peek().has(name)) {
392
+ const typed = new Set(this.#typed.peek());
393
+ typed.delete(name);
394
+ this.#typed.value = typed;
395
+ }
396
+ this.#values.value = { ...this.#values.peek(), [name]: this.#baseline.peek()[name] ?? null };
397
+ const { [name]: _, ...rest } = this.#errors.peek();
398
+ this.#errors.value = rest;
399
+ }
400
+ });
401
+ }
402
+ /** Validates every field and shows the errors; returns whether the values can be saved. */
403
+ validate() {
404
+ const errors = this.#check(this.#values.peek());
405
+ this.#errors.value = errors;
406
+ return Object.keys(errors).length === 0;
407
+ }
408
+ /**
409
+ * Errors for `values`: metadata checks (on update, only for changed
410
+ * fields), required-by-rule on every ruled field, then app rules, whose
411
+ * messages win. Hidden fields aren't checked; the server still is.
412
+ */
413
+ #check(values) {
414
+ const mode = untracked(() => this.isNew.value) ? "create" : "update";
415
+ const changed = mode === "create" ? values : this.#changed(values);
416
+ const errors = { ...validateEntity(this.type, changed, mode) };
417
+ for (const name of this.#ruled.value.keys()) {
418
+ const state = this.state(name).value;
419
+ if (state.required && (values[name] ?? null) === null && !errors[name] && !this.#hidden.value.has(name)) {
420
+ errors[name] = [`${this.#title(name)} is required.`];
421
+ }
422
+ }
423
+ const ruled = new Set();
424
+ for (const rule of this.#rules) {
425
+ for (const [name, message] of Object.entries(rule(values))) {
426
+ if (message === undefined || ruled.has(name))
427
+ continue;
428
+ errors[name] = [message];
429
+ ruled.add(name);
430
+ }
431
+ }
432
+ for (const name of Object.keys(errors)) {
433
+ if (this.type.field(name) && (!this.state(name).value.visible || this.#hidden.value.has(name)))
434
+ delete errors[name];
435
+ }
436
+ return errors;
437
+ }
438
+ /** The change a save would make, or `undefined` when nothing changed. */
439
+ changes() {
440
+ const changes = this.#diff(this.#baseline.peek(), this.#values.peek());
441
+ const key = this.#key.peek();
442
+ if (key === undefined)
443
+ return { kind: "create", set: this.entitySet, values: this.#values.peek() };
444
+ if (isEmpty(changes))
445
+ return undefined;
446
+ return { kind: "update", set: this.entitySet, key: keyFromText(key), changes };
447
+ }
448
+ /**
449
+ * Validates, then creates or updates. Field errors from the server land on
450
+ * their fields. On a 412 the newer record is loaded: when the other change
451
+ * touched none of the fields edited here, the edit is rebased onto it and
452
+ * sent once more; otherwise `conflict` is set. Returns `undefined` when
453
+ * nothing was saved, and then `errors`, `formError`, `conflict` or
454
+ * `deleted` says why.
455
+ */
456
+ async save() {
457
+ if (this.#replay || this.#busy.peek() || this.#conflict.peek() || this.#deleted.peek())
458
+ return undefined;
459
+ if (!this.validate())
460
+ return undefined;
461
+ if (this.#key.peek() !== undefined && !this.dirty.peek())
462
+ return undefined;
463
+ this.#busy.value = true;
464
+ this.#formError.value = undefined;
465
+ try {
466
+ let saved;
467
+ let sent = this.#values.peek();
468
+ for (let attempt = 0; saved === undefined; attempt++) {
469
+ const etag = this.#baseEtag;
470
+ sent = this.#values.peek();
471
+ try {
472
+ saved = await this.#send();
473
+ }
474
+ catch (e) {
475
+ const rebased = e instanceof ODataError && e.status === 412 && this.#baseEtag !== etag && !this.#conflict.peek();
476
+ if (!rebased || attempt > 0)
477
+ throw e;
478
+ if (!this.validate())
479
+ return undefined;
480
+ }
481
+ }
482
+ // Changes typed while the save was in flight stay, on top of the saved record.
483
+ const later = this.#diff(sent, this.#values.peek());
484
+ const values = { ...saved.record.entity };
485
+ batch(() => {
486
+ this.#hidden.value = saved.record.hidden ?? NONE;
487
+ for (const [name, c] of Object.entries(later))
488
+ if (!this.#hidden.peek().has(name))
489
+ values[name] = c.to;
490
+ this.#key.value = saved.key;
491
+ this.#baseline.value = saved.record.entity;
492
+ this.#values.value = values;
493
+ this.#baseEtag = saved.record.etag;
494
+ this.#forced.value = new Set();
495
+ this.#errors.value = {};
496
+ });
497
+ return saved;
498
+ }
499
+ catch (e) {
500
+ if (!(e instanceof ODataError))
501
+ throw e;
502
+ batch(() => {
503
+ const fieldErrors = e.fieldErrors();
504
+ this.#errors.value = fieldErrors;
505
+ const explained = this.#conflict.peek() || this.#deleted.peek() || Object.keys(fieldErrors).length > 0;
506
+ if (!explained)
507
+ this.#formError.value = e.message;
508
+ });
509
+ return undefined;
510
+ }
511
+ finally {
512
+ this.#busy.value = false;
513
+ }
514
+ }
515
+ #send() {
516
+ const key = this.#key.peek();
517
+ return key === undefined
518
+ ? this.#ctx.create(this.entitySet, this.#values.peek(), this)
519
+ : this.#ctx.update(this.entitySet, key, this.#changedValues(), this);
520
+ }
521
+ /** Takes the server's record as the new baseline and keeps local changes on top. */
522
+ rebase() {
523
+ const conflict = this.#conflict.peek();
524
+ const stored = this.#key.peek() === undefined ? undefined : this.#ctx.store.get(this.entitySet, this.#key.peek());
525
+ if (!conflict || !stored)
526
+ return;
527
+ const mine = this.#diff(this.#baseline.peek(), this.#values.peek());
528
+ const values = { ...stored.entity };
529
+ batch(() => {
530
+ this.#hidden.value = stored.hidden ?? NONE;
531
+ for (const [name, c] of Object.entries(mine))
532
+ if (!this.#hidden.peek().has(name))
533
+ values[name] = c.to;
534
+ this.#baseline.value = stored.entity;
535
+ this.#values.value = values;
536
+ this.#baseEtag = stored.etag;
537
+ this.#conflict.value = undefined;
538
+ });
539
+ }
540
+ /** Drops local changes in favour of the server's record. */
541
+ discard() {
542
+ const key = this.#key.peek();
543
+ const stored = key === undefined ? undefined : this.#ctx.store.get(this.entitySet, key);
544
+ batch(() => {
545
+ if (stored) {
546
+ this.#baseline.value = stored.entity;
547
+ this.#hidden.value = stored.hidden ?? NONE;
548
+ this.#baseEtag = stored.etag;
549
+ }
550
+ this.#values.value = this.#baseline.peek();
551
+ this.#errors.value = {};
552
+ this.#conflict.value = undefined;
553
+ });
554
+ }
555
+ dispose() {
556
+ for (const dispose of this.#disposers.splice(0))
557
+ dispose();
558
+ }
559
+ /**
560
+ * Another change to this record arrived: follow it when idle, otherwise
561
+ * flag a conflict. A field it hides that was edited here differs from the
562
+ * baseline (it's now `null`), so it's a conflict rather than a change
563
+ * quietly dropped.
564
+ */
565
+ #theirs(record) {
566
+ const { entity: theirs, etag } = record;
567
+ if (etag !== undefined && etag === this.#baseEtag)
568
+ return;
569
+ const mine = this.#diff(this.#baseline.peek(), this.#values.peek());
570
+ if (isEmpty(mine)) {
571
+ batch(() => {
572
+ this.#baseline.value = theirs;
573
+ this.#values.value = theirs;
574
+ this.#hidden.value = record.hidden ?? NONE;
575
+ this.#baseEtag = etag;
576
+ });
577
+ return;
578
+ }
579
+ const base = this.#baseline.peek();
580
+ const fields = Object.keys(mine).filter((name) => !Object.is(theirs[name] ?? null, base[name] ?? null) || record.hidden?.has(name));
581
+ if (fields.length === 0) {
582
+ const values = { ...theirs };
583
+ for (const [name, c] of Object.entries(mine))
584
+ values[name] = c.to;
585
+ batch(() => {
586
+ this.#baseline.value = theirs;
587
+ this.#values.value = values;
588
+ this.#hidden.value = record.hidden ?? NONE;
589
+ this.#baseEtag = etag;
590
+ });
591
+ return;
592
+ }
593
+ this.#conflict.value = { theirs, fields };
594
+ }
595
+ /** Field changes from `a` to `b`, leaving out the fields hidden on the record. */
596
+ #diff(a, b) {
597
+ const changes = diff(a, b);
598
+ for (const name of this.#hidden.value)
599
+ delete changes[name];
600
+ return changes;
601
+ }
602
+ #changedValues() {
603
+ return this.#changed(this.#values.peek());
604
+ }
605
+ #changed(values) {
606
+ const out = {};
607
+ for (const [name, c] of Object.entries(this.#diff(untracked(() => this.#baseline.value), values)))
608
+ out[name] = c.to;
609
+ return out;
610
+ }
611
+ #title(name) {
612
+ return this.type.field(name)?.title ?? name;
613
+ }
614
+ #field(name) {
615
+ const field = this.type.field(name);
616
+ if (!field)
617
+ throw new Error(`${this.type.name} has no field ${name}`);
618
+ return field;
619
+ }
620
+ }