@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.
- 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,116 @@
|
|
|
1
|
+
import { type ReadonlySignal } from "@preact/signals-core";
|
|
2
|
+
import { type ChangeSet } from "../changes/changeset.ts";
|
|
3
|
+
import type { Collection } from "../collection/collection.ts";
|
|
4
|
+
import type { DataContext } from "../context.ts";
|
|
5
|
+
import type { Entity, EntityType } from "../schema/model.ts";
|
|
6
|
+
import { type KeyText } from "../store/keys.ts";
|
|
7
|
+
import { EditModel, type EditDraft, type EditOptions } from "./edit-model.ts";
|
|
8
|
+
export interface ListRow {
|
|
9
|
+
/** The record's key, or `new:<n>` until a new row is saved. */
|
|
10
|
+
readonly id: string;
|
|
11
|
+
readonly edit: EditModel;
|
|
12
|
+
readonly isNew: boolean;
|
|
13
|
+
/** Marked for deletion, to happen on save. */
|
|
14
|
+
readonly removing: boolean;
|
|
15
|
+
/** Why the last attempt to delete it failed. */
|
|
16
|
+
readonly removeError?: string;
|
|
17
|
+
/** A new row's fields that take another new row's key on save, by field: that row's id. */
|
|
18
|
+
readonly links?: Readonly<Record<string, string>>;
|
|
19
|
+
}
|
|
20
|
+
export interface ListEditOptions extends EditOptions {
|
|
21
|
+
/** Values for a new row, such as the parent's key. */
|
|
22
|
+
readonly defaults?: () => Entity;
|
|
23
|
+
/**
|
|
24
|
+
* A field holding each row's place: rows are shown in its order, `move`
|
|
25
|
+
* renumbers it from 1, and a new row goes last.
|
|
26
|
+
*/
|
|
27
|
+
readonly positionField?: string;
|
|
28
|
+
}
|
|
29
|
+
export interface ListSaveResult {
|
|
30
|
+
readonly saved: readonly ChangeSet[];
|
|
31
|
+
/** Rows whose write failed; they stay pending with the error on them. */
|
|
32
|
+
readonly failed: readonly string[];
|
|
33
|
+
}
|
|
34
|
+
/** A list edit's working state, enough to resume it after a reload. Plain data. */
|
|
35
|
+
export interface ListDraft {
|
|
36
|
+
readonly added: readonly {
|
|
37
|
+
readonly id: string;
|
|
38
|
+
readonly edit: EditDraft;
|
|
39
|
+
readonly links: Readonly<Record<string, string>>;
|
|
40
|
+
}[];
|
|
41
|
+
readonly removing: readonly string[];
|
|
42
|
+
readonly edits: Readonly<Record<string, EditDraft>>;
|
|
43
|
+
readonly counter: number;
|
|
44
|
+
}
|
|
45
|
+
interface Existing {
|
|
46
|
+
readonly key: KeyText;
|
|
47
|
+
readonly entity: Entity;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Edits the rows of a collection together: changes to existing rows, new
|
|
51
|
+
* rows and rows marked for deletion are all pending until `save()`.
|
|
52
|
+
*
|
|
53
|
+
* Invariants: an existing row keeps one edit model while it's in the
|
|
54
|
+
* collection; removing a new row discards it, removing an existing one only
|
|
55
|
+
* marks it; a new row shows as pending in every writable field. A new row
|
|
56
|
+
* can link a field to another new row (a parent added in the same edit):
|
|
57
|
+
* rows are created in the order added, and the link takes the other row's
|
|
58
|
+
* key once it has one.
|
|
59
|
+
*/
|
|
60
|
+
export declare class ListEdit {
|
|
61
|
+
#private;
|
|
62
|
+
readonly type: EntityType;
|
|
63
|
+
readonly set: string;
|
|
64
|
+
/** Undefined for a replay. */
|
|
65
|
+
readonly collection: Collection | undefined;
|
|
66
|
+
/** Existing rows in the collection's order, then new rows in the order added; by position when there is one. */
|
|
67
|
+
readonly rows: ReadonlySignal<readonly ListRow[]>;
|
|
68
|
+
readonly dirty: ReadonlySignal<boolean>;
|
|
69
|
+
readonly valid: ReadonlySignal<boolean>;
|
|
70
|
+
readonly busy: ReadonlySignal<boolean>;
|
|
71
|
+
/** Rows have a position field: `move` reorders them. */
|
|
72
|
+
get ordered(): boolean;
|
|
73
|
+
constructor(ctx: DataContext, collection: Collection, options?: ListEditOptions);
|
|
74
|
+
/** @internal */
|
|
75
|
+
constructor(ctx: DataContext, source: Collection | {
|
|
76
|
+
set: string;
|
|
77
|
+
rows: readonly Existing[];
|
|
78
|
+
}, options?: ListEditOptions);
|
|
79
|
+
/**
|
|
80
|
+
* A past change to a list, shown over its old rows: updated rows pending in
|
|
81
|
+
* their changed fields, removed rows struck through, added rows pending in
|
|
82
|
+
* full. Read-only; nothing is saved.
|
|
83
|
+
*/
|
|
84
|
+
static replay(ctx: DataContext, set: string, before: readonly Entity[], changes: readonly ChangeSet[], options?: ListEditOptions): ListEdit;
|
|
85
|
+
/** Adds a new row, pending in full. `links` names, by field, the new row whose key that field takes on save. */
|
|
86
|
+
add(values?: Entity, links?: Readonly<Record<string, string>>): ListRow;
|
|
87
|
+
/** Discards a new row; marks an existing row for deletion. */
|
|
88
|
+
remove(id: string): void;
|
|
89
|
+
/** Takes back a pending deletion. */
|
|
90
|
+
restore(id: string): void;
|
|
91
|
+
/** Puts a row at `index` among the rows not being removed, renumbering the position field from 1. */
|
|
92
|
+
move(id: string, index: number): void;
|
|
93
|
+
/** Every pending change, as change sets in the order `save()` applies them: deletions, updates, creations. */
|
|
94
|
+
changes(): ChangeSet[];
|
|
95
|
+
/** Throws away every pending change. */
|
|
96
|
+
revert(): void;
|
|
97
|
+
/**
|
|
98
|
+
* Applies the changes one write at a time: deletions, then updates, then
|
|
99
|
+
* creations. A failed write leaves its row pending with the error on its
|
|
100
|
+
* edit model, and the rest still go. Not atomic: that needs `$batch`.
|
|
101
|
+
*/
|
|
102
|
+
save(): Promise<ListSaveResult>;
|
|
103
|
+
/**
|
|
104
|
+
* Takes the current state as saved elsewhere (a deep update of the
|
|
105
|
+
* parent): changed rows keep their values as their new baseline, new
|
|
106
|
+
* rows and deletions are dropped, as the collection's refresh brings
|
|
107
|
+
* the saved rows.
|
|
108
|
+
*/
|
|
109
|
+
accept(): void;
|
|
110
|
+
/** The working state: new rows, pending deletions and edited rows. */
|
|
111
|
+
draft(): ListDraft;
|
|
112
|
+
/** Takes up a draft. Rows it names that are no longer in the list are dropped. */
|
|
113
|
+
resume(draft: ListDraft): void;
|
|
114
|
+
dispose(): void;
|
|
115
|
+
}
|
|
116
|
+
export {};
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { batch, computed, signal, untracked } from "@preact/signals-core";
|
|
2
|
+
import { apply } from "../changes/changeset.js";
|
|
3
|
+
import { ODataError } from "../odata/errors.js";
|
|
4
|
+
import { keyFromText, keyText, keyValues } from "../store/keys.js";
|
|
5
|
+
import { EditModel } from "./edit-model.js";
|
|
6
|
+
/**
|
|
7
|
+
* Edits the rows of a collection together: changes to existing rows, new
|
|
8
|
+
* rows and rows marked for deletion are all pending until `save()`.
|
|
9
|
+
*
|
|
10
|
+
* Invariants: an existing row keeps one edit model while it's in the
|
|
11
|
+
* collection; removing a new row discards it, removing an existing one only
|
|
12
|
+
* marks it; a new row shows as pending in every writable field. A new row
|
|
13
|
+
* can link a field to another new row (a parent added in the same edit):
|
|
14
|
+
* rows are created in the order added, and the link takes the other row's
|
|
15
|
+
* key once it has one.
|
|
16
|
+
*/
|
|
17
|
+
export class ListEdit {
|
|
18
|
+
type;
|
|
19
|
+
set;
|
|
20
|
+
/** Undefined for a replay. */
|
|
21
|
+
collection;
|
|
22
|
+
#ctx;
|
|
23
|
+
#options;
|
|
24
|
+
#existing;
|
|
25
|
+
#edits = new Map();
|
|
26
|
+
#new = signal([]);
|
|
27
|
+
#removing = signal(new Set());
|
|
28
|
+
#removeErrors = signal(new Map());
|
|
29
|
+
#busy = signal(false);
|
|
30
|
+
#replay = false;
|
|
31
|
+
#counter = 0;
|
|
32
|
+
/** Existing rows in the collection's order, then new rows in the order added; by position when there is one. */
|
|
33
|
+
rows;
|
|
34
|
+
dirty;
|
|
35
|
+
valid;
|
|
36
|
+
busy = this.#busy;
|
|
37
|
+
/** Rows have a position field: `move` reorders them. */
|
|
38
|
+
get ordered() {
|
|
39
|
+
return this.#options.positionField !== undefined && !this.#replay;
|
|
40
|
+
}
|
|
41
|
+
constructor(ctx, source, options = {}) {
|
|
42
|
+
this.#ctx = ctx;
|
|
43
|
+
this.#options = options;
|
|
44
|
+
if ("rows" in source) {
|
|
45
|
+
this.set = source.set;
|
|
46
|
+
this.collection = undefined;
|
|
47
|
+
this.#existing = signal(source.rows);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
const collection = source;
|
|
51
|
+
this.set = collection.query.set;
|
|
52
|
+
this.collection = collection;
|
|
53
|
+
this.#existing = computed(() => {
|
|
54
|
+
const items = collection.items.value;
|
|
55
|
+
return collection.keys.value.flatMap((key, i) => (key === undefined || items[i] === undefined ? [] : [{ key, entity: items[i] }]));
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
this.type = ctx.schema.set(this.set).type;
|
|
59
|
+
this.rows = computed(() => {
|
|
60
|
+
const removing = this.#removing.value;
|
|
61
|
+
const errors = this.#removeErrors.value;
|
|
62
|
+
const existing = this.#existing.value.map(({ key }) => ({
|
|
63
|
+
id: key,
|
|
64
|
+
edit: untracked(() => this.#editFor(key)),
|
|
65
|
+
isNew: false,
|
|
66
|
+
removing: removing.has(key),
|
|
67
|
+
...(errors.has(key) ? { removeError: errors.get(key) } : {}),
|
|
68
|
+
}));
|
|
69
|
+
const added = this.#new.value.map(({ id, edit, links }) => ({ id, edit, isNew: true, removing: false, links }));
|
|
70
|
+
const rows = [...existing, ...added];
|
|
71
|
+
const at = options.positionField;
|
|
72
|
+
if (at === undefined)
|
|
73
|
+
return rows;
|
|
74
|
+
const place = (r) => Number(r.edit.values.value[at] ?? Number.MAX_SAFE_INTEGER);
|
|
75
|
+
return rows.map((r, i) => [r, i]).sort(([a, i], [b, j]) => place(a) - place(b) || i - j).map(([r]) => r);
|
|
76
|
+
});
|
|
77
|
+
this.dirty = computed(() => this.#new.value.length > 0 || this.#removing.value.size > 0 || this.rows.value.some((r) => r.edit.dirty.value));
|
|
78
|
+
this.valid = computed(() => this.rows.value.every((r) => r.removing || r.edit.valid.value));
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* A past change to a list, shown over its old rows: updated rows pending in
|
|
82
|
+
* their changed fields, removed rows struck through, added rows pending in
|
|
83
|
+
* full. Read-only; nothing is saved.
|
|
84
|
+
*/
|
|
85
|
+
static replay(ctx, set, before, changes, options = {}) {
|
|
86
|
+
const type = ctx.schema.set(set).type;
|
|
87
|
+
const rows = before.map((entity) => ({ key: keyText(keyValues(type, entity)), entity }));
|
|
88
|
+
const list = new ListEdit(ctx, { set, rows }, options);
|
|
89
|
+
list.#replay = true;
|
|
90
|
+
const removing = new Set();
|
|
91
|
+
const added = [];
|
|
92
|
+
for (const c of changes) {
|
|
93
|
+
switch (c.kind) {
|
|
94
|
+
case "update": {
|
|
95
|
+
const key = keyText(c.key);
|
|
96
|
+
const old = rows.find((r) => r.key === key)?.entity ?? {};
|
|
97
|
+
list.#edits.set(key, EditModel.replay(ctx, set, old, apply(old, c.changes)));
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
case "delete":
|
|
101
|
+
removing.add(keyText(c.key));
|
|
102
|
+
break;
|
|
103
|
+
case "create": {
|
|
104
|
+
const forced = type.fields.filter((f) => !f.computed).map((f) => f.name);
|
|
105
|
+
added.push({ id: `new:${++list.#counter}`, edit: EditModel.replay(ctx, set, {}, c.values, forced), links: {} });
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
batch(() => {
|
|
111
|
+
list.#removing.value = removing;
|
|
112
|
+
list.#new.value = added;
|
|
113
|
+
});
|
|
114
|
+
return list;
|
|
115
|
+
}
|
|
116
|
+
/** Adds a new row, pending in full. `links` names, by field, the new row whose key that field takes on save. */
|
|
117
|
+
add(values = {}, links = {}) {
|
|
118
|
+
if (this.#replay)
|
|
119
|
+
throw new Error("a replay can't be edited");
|
|
120
|
+
const at = this.#options.positionField;
|
|
121
|
+
const position = at === undefined ? {} : { [at]: Math.max(0, ...this.rows.peek().map((r) => Number(r.edit.values.peek()[at] ?? 0))) + 1 };
|
|
122
|
+
const edit = this.#newEdit({ ...this.#options.defaults?.(), ...position, ...values });
|
|
123
|
+
const id = `new:${++this.#counter}`;
|
|
124
|
+
this.#new.value = [...this.#new.peek(), { id, edit, links }];
|
|
125
|
+
return { id, edit, isNew: true, removing: false, links };
|
|
126
|
+
}
|
|
127
|
+
/** Discards a new row; marks an existing row for deletion. */
|
|
128
|
+
remove(id) {
|
|
129
|
+
if (this.#replay)
|
|
130
|
+
return;
|
|
131
|
+
const added = this.#new.peek().find((r) => r.id === id);
|
|
132
|
+
if (added) {
|
|
133
|
+
added.edit.dispose();
|
|
134
|
+
this.#new.value = this.#new.peek().filter((r) => r.id !== id);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
this.#removing.value = new Set(this.#removing.peek()).add(id);
|
|
138
|
+
}
|
|
139
|
+
/** Takes back a pending deletion. */
|
|
140
|
+
restore(id) {
|
|
141
|
+
if (this.#replay)
|
|
142
|
+
return;
|
|
143
|
+
const next = new Set(this.#removing.peek());
|
|
144
|
+
next.delete(id);
|
|
145
|
+
this.#removing.value = next;
|
|
146
|
+
this.#setRemoveError(id, undefined);
|
|
147
|
+
}
|
|
148
|
+
/** Puts a row at `index` among the rows not being removed, renumbering the position field from 1. */
|
|
149
|
+
move(id, index) {
|
|
150
|
+
const at = this.#options.positionField;
|
|
151
|
+
if (this.#replay || at === undefined)
|
|
152
|
+
return;
|
|
153
|
+
const live = this.rows.peek().filter((r) => !r.removing);
|
|
154
|
+
const row = live.find((r) => r.id === id);
|
|
155
|
+
if (!row)
|
|
156
|
+
return;
|
|
157
|
+
const order = live.filter((r) => r !== row);
|
|
158
|
+
order.splice(Math.max(0, Math.min(order.length, index)), 0, row);
|
|
159
|
+
batch(() => order.forEach((r, i) => r.edit.values.peek()[at] !== i + 1 && r.edit.set(at, i + 1)));
|
|
160
|
+
}
|
|
161
|
+
/** Every pending change, as change sets in the order `save()` applies them: deletions, updates, creations. */
|
|
162
|
+
changes() {
|
|
163
|
+
const rows = this.rows.peek();
|
|
164
|
+
return [
|
|
165
|
+
...rows.filter((r) => r.removing).map((r) => ({
|
|
166
|
+
kind: "delete",
|
|
167
|
+
set: this.set,
|
|
168
|
+
key: keyFromText(r.id),
|
|
169
|
+
values: known(r.edit.baseline.peek(), r.edit.hidden.peek()),
|
|
170
|
+
})),
|
|
171
|
+
...rows.filter((r) => !r.removing && !r.isNew).flatMap((r) => r.edit.changes() ?? []),
|
|
172
|
+
...this.#new.peek().flatMap(({ edit }) => edit.changes() ?? []),
|
|
173
|
+
];
|
|
174
|
+
}
|
|
175
|
+
/** Throws away every pending change. */
|
|
176
|
+
revert() {
|
|
177
|
+
batch(() => {
|
|
178
|
+
for (const { edit } of this.#new.peek())
|
|
179
|
+
edit.dispose();
|
|
180
|
+
this.#new.value = [];
|
|
181
|
+
this.#removing.value = new Set();
|
|
182
|
+
for (const edit of this.#edits.values())
|
|
183
|
+
edit.revert();
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Applies the changes one write at a time: deletions, then updates, then
|
|
188
|
+
* creations. A failed write leaves its row pending with the error on its
|
|
189
|
+
* edit model, and the rest still go. Not atomic: that needs `$batch`.
|
|
190
|
+
*/
|
|
191
|
+
async save() {
|
|
192
|
+
if (this.#replay || this.#busy.peek())
|
|
193
|
+
return { saved: [], failed: [] };
|
|
194
|
+
this.#busy.value = true;
|
|
195
|
+
const saved = [];
|
|
196
|
+
const failed = [];
|
|
197
|
+
try {
|
|
198
|
+
for (const id of [...this.#removing.peek()]) {
|
|
199
|
+
try {
|
|
200
|
+
saved.push(await this.#ctx.remove(this.set, id));
|
|
201
|
+
this.restore(id);
|
|
202
|
+
}
|
|
203
|
+
catch (e) {
|
|
204
|
+
if (!(e instanceof ODataError))
|
|
205
|
+
throw e;
|
|
206
|
+
failed.push(id);
|
|
207
|
+
this.#setRemoveError(id, `Couldn’t delete it: ${e.message}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
for (const row of this.rows.peek()) {
|
|
211
|
+
if (row.removing || !row.edit.dirty.peek() || row.isNew)
|
|
212
|
+
continue;
|
|
213
|
+
const result = await row.edit.save();
|
|
214
|
+
if (result)
|
|
215
|
+
saved.push(result.change);
|
|
216
|
+
else
|
|
217
|
+
failed.push(row.id);
|
|
218
|
+
}
|
|
219
|
+
const created = new Map();
|
|
220
|
+
for (const { id, edit, links } of [...this.#new.peek()]) {
|
|
221
|
+
for (const [field, other] of Object.entries(links)) {
|
|
222
|
+
const key = created.get(other);
|
|
223
|
+
if (key !== undefined)
|
|
224
|
+
edit.set(field, key);
|
|
225
|
+
}
|
|
226
|
+
const result = await edit.save();
|
|
227
|
+
if (!result) {
|
|
228
|
+
failed.push(id);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
saved.push(result.change);
|
|
232
|
+
created.set(id, keyFromText(result.key)[0]);
|
|
233
|
+
this.#edits.set(result.key, edit);
|
|
234
|
+
this.#new.value = this.#new.peek().filter((r) => r.id !== id);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
this.#busy.value = false;
|
|
239
|
+
}
|
|
240
|
+
return { saved, failed };
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Takes the current state as saved elsewhere (a deep update of the
|
|
244
|
+
* parent): changed rows keep their values as their new baseline, new
|
|
245
|
+
* rows and deletions are dropped, as the collection's refresh brings
|
|
246
|
+
* the saved rows.
|
|
247
|
+
*/
|
|
248
|
+
accept() {
|
|
249
|
+
batch(() => {
|
|
250
|
+
for (const { edit } of this.#new.peek())
|
|
251
|
+
edit.dispose();
|
|
252
|
+
this.#new.value = [];
|
|
253
|
+
this.#removing.value = new Set();
|
|
254
|
+
this.#removeErrors.value = new Map();
|
|
255
|
+
for (const edit of this.#edits.values())
|
|
256
|
+
if (edit.dirty.peek())
|
|
257
|
+
edit.accept();
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
/** The working state: new rows, pending deletions and edited rows. */
|
|
261
|
+
draft() {
|
|
262
|
+
const edits = {};
|
|
263
|
+
for (const [key, edit] of this.#edits)
|
|
264
|
+
if (edit.dirty.peek())
|
|
265
|
+
edits[key] = edit.draft();
|
|
266
|
+
return {
|
|
267
|
+
added: this.#new.peek().map(({ id, edit, links }) => ({ id, edit: edit.draft(), links })),
|
|
268
|
+
removing: [...this.#removing.peek()],
|
|
269
|
+
edits,
|
|
270
|
+
counter: this.#counter,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
/** Takes up a draft. Rows it names that are no longer in the list are dropped. */
|
|
274
|
+
resume(draft) {
|
|
275
|
+
batch(() => {
|
|
276
|
+
this.revert();
|
|
277
|
+
this.#counter = Math.max(this.#counter, draft.counter);
|
|
278
|
+
this.#new.value = draft.added.map(({ id, edit, links }) => {
|
|
279
|
+
const m = this.#newEdit(edit.values);
|
|
280
|
+
m.resume(edit);
|
|
281
|
+
return { id, edit: m, links };
|
|
282
|
+
});
|
|
283
|
+
const present = new Set(this.#existing.peek().map((r) => r.key));
|
|
284
|
+
this.#removing.value = new Set(draft.removing.filter((k) => present.has(k)));
|
|
285
|
+
for (const [key, edit] of Object.entries(draft.edits))
|
|
286
|
+
if (present.has(key))
|
|
287
|
+
this.#editFor(key).resume(edit);
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
dispose() {
|
|
291
|
+
for (const edit of this.#edits.values())
|
|
292
|
+
edit.dispose();
|
|
293
|
+
for (const { edit } of this.#new.peek())
|
|
294
|
+
edit.dispose();
|
|
295
|
+
this.#edits.clear();
|
|
296
|
+
}
|
|
297
|
+
#newEdit(values) {
|
|
298
|
+
const edit = new EditModel(this.#ctx, this.set, undefined, values, this.#options);
|
|
299
|
+
for (const field of edit.type.fields)
|
|
300
|
+
if (!field.computed)
|
|
301
|
+
edit.forcePending(field.name, true);
|
|
302
|
+
return edit;
|
|
303
|
+
}
|
|
304
|
+
#setRemoveError(id, message) {
|
|
305
|
+
const next = new Map(this.#removeErrors.peek());
|
|
306
|
+
if (message === undefined)
|
|
307
|
+
next.delete(id);
|
|
308
|
+
else
|
|
309
|
+
next.set(id, message);
|
|
310
|
+
this.#removeErrors.value = next;
|
|
311
|
+
}
|
|
312
|
+
#editFor(key) {
|
|
313
|
+
let edit = this.#edits.get(key);
|
|
314
|
+
if (!edit) {
|
|
315
|
+
if (this.#replay) {
|
|
316
|
+
const entity = this.#existing.peek().find((r) => r.key === key)?.entity ?? {};
|
|
317
|
+
edit = EditModel.replay(this.#ctx, this.set, entity, entity);
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
edit = new EditModel(this.#ctx, this.set, key, {}, this.#options);
|
|
321
|
+
}
|
|
322
|
+
this.#edits.set(key, edit);
|
|
323
|
+
}
|
|
324
|
+
return edit;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
/** A deleted record's values for undo, without the hidden ones: their `null` isn't what was stored. */
|
|
328
|
+
function known(entity, hidden) {
|
|
329
|
+
if (hidden.size === 0)
|
|
330
|
+
return entity;
|
|
331
|
+
return Object.fromEntries(Object.entries(entity).filter(([name]) => !hidden.has(name)));
|
|
332
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { type ReadonlySignal } from "@preact/signals-core";
|
|
2
|
+
import { type ChangeSet } from "../changes/changeset.ts";
|
|
3
|
+
import type { DataContext } from "../context.ts";
|
|
4
|
+
import type { Entity } from "../schema/model.ts";
|
|
5
|
+
import { type KeyText } from "../store/keys.ts";
|
|
6
|
+
import { EditModel, type EditDraft, type EditOptions } from "./edit-model.ts";
|
|
7
|
+
import { ListEdit, type ListDraft, type ListEditOptions } from "./list-edit.ts";
|
|
8
|
+
/** A record and its owned rows as they were: what a replay shows a change over. Plain data. */
|
|
9
|
+
export interface RecordSnapshot {
|
|
10
|
+
readonly record: Entity;
|
|
11
|
+
readonly owned: Readonly<Record<string, readonly Entity[]>>;
|
|
12
|
+
}
|
|
13
|
+
/** A record edit's working state, enough to resume it after a reload. Plain data. */
|
|
14
|
+
export interface RecordDraft {
|
|
15
|
+
readonly edit: EditDraft;
|
|
16
|
+
readonly owned: Readonly<Record<string, ListDraft>>;
|
|
17
|
+
}
|
|
18
|
+
export interface RecordEditOptions extends EditOptions {
|
|
19
|
+
/** Options for each owned collection's list, by navigation name. */
|
|
20
|
+
readonly owned?: Readonly<Record<string, ListEditOptions>>;
|
|
21
|
+
}
|
|
22
|
+
/** What one save did: `change` is what was sent, with no server-assigned keys. A save is all or nothing. */
|
|
23
|
+
export interface RecordSave {
|
|
24
|
+
readonly ok: boolean;
|
|
25
|
+
readonly change: ChangeSet;
|
|
26
|
+
/** The record and its owned rows before the save. */
|
|
27
|
+
readonly before: RecordSnapshot;
|
|
28
|
+
/** `["record"]` when the save was refused, and nothing was saved; otherwise empty. */
|
|
29
|
+
readonly failed: readonly string[];
|
|
30
|
+
}
|
|
31
|
+
/** What fails validation: the record's fields, and each owned list's rows. */
|
|
32
|
+
export interface RecordProblems {
|
|
33
|
+
readonly record: readonly string[];
|
|
34
|
+
readonly owned: Readonly<Record<string, readonly {
|
|
35
|
+
readonly id: string;
|
|
36
|
+
readonly fields: readonly string[];
|
|
37
|
+
}[]>>;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Edits an existing record together with the collections it owns
|
|
41
|
+
* (`AsterData.Owned`): its fields in one edit model, each owned collection
|
|
42
|
+
* in a list edit whose new rows point at the record. Everything is one
|
|
43
|
+
* change set, one validation and one save; nothing is written per record
|
|
44
|
+
* type.
|
|
45
|
+
*
|
|
46
|
+
* Saving is one deep update (`<navigation>@delta`): the record and every
|
|
47
|
+
* owned change in one request, all or nothing.
|
|
48
|
+
*/
|
|
49
|
+
export declare class RecordEdit {
|
|
50
|
+
#private;
|
|
51
|
+
readonly set: string;
|
|
52
|
+
readonly key: KeyText;
|
|
53
|
+
readonly edit: EditModel;
|
|
54
|
+
readonly owned: Readonly<Record<string, ListEdit>>;
|
|
55
|
+
readonly dirty: ReadonlySignal<boolean>;
|
|
56
|
+
readonly valid: ReadonlySignal<boolean>;
|
|
57
|
+
/** Settles when every owned collection has loaded. */
|
|
58
|
+
readonly ready: Promise<void>;
|
|
59
|
+
constructor(ctx: DataContext, set: string, key: KeyText, options?: RecordEditOptions);
|
|
60
|
+
/** @internal */
|
|
61
|
+
constructor(ctx: DataContext, set: string, key: KeyText, options: RecordEditOptions, replay: {
|
|
62
|
+
edit: EditModel;
|
|
63
|
+
owned: Record<string, ListEdit>;
|
|
64
|
+
});
|
|
65
|
+
/**
|
|
66
|
+
* A past change shown over what the record and its owned rows were: the
|
|
67
|
+
* record's fields and each list as replays, read-only, with pending
|
|
68
|
+
* marking exactly what the change did.
|
|
69
|
+
*/
|
|
70
|
+
static replay(ctx: DataContext, before: RecordSnapshot, change: ChangeSet): RecordEdit;
|
|
71
|
+
/** The record and its owned rows as they were when editing began. */
|
|
72
|
+
snapshot(): RecordSnapshot;
|
|
73
|
+
/** The change a save would make, or `undefined` when nothing changed. */
|
|
74
|
+
changes(): ChangeSet | undefined;
|
|
75
|
+
/** Validates every field, shown or not, and says what fails. */
|
|
76
|
+
problems(): RecordProblems;
|
|
77
|
+
/**
|
|
78
|
+
* Validates, then writes the record and every owned change in one deep
|
|
79
|
+
* update (`<navigation>@delta`), all or nothing: new children of a new
|
|
80
|
+
* child go inside its entry. Returns `undefined` when nothing changed or
|
|
81
|
+
* something is invalid (see `problems()`). On a 412 the newer record is
|
|
82
|
+
* taken in: with no overlap the edit is sent once more, otherwise
|
|
83
|
+
* `edit.conflict` says so. Server errors land on the fields and rows they
|
|
84
|
+
* name.
|
|
85
|
+
*/
|
|
86
|
+
save(): Promise<RecordSave | undefined>;
|
|
87
|
+
readonly failure: ReadonlySignal<string | undefined>;
|
|
88
|
+
revert(): void;
|
|
89
|
+
draft(): RecordDraft;
|
|
90
|
+
/** Takes up a draft once the owned rows are loaded. */
|
|
91
|
+
resume(draft: RecordDraft): Promise<void>;
|
|
92
|
+
dispose(): void;
|
|
93
|
+
}
|