@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,317 @@
|
|
|
1
|
+
import { batch, computed, signal } from "@preact/signals-core";
|
|
2
|
+
import { apply, creatable } 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
|
+
import { ListEdit } from "./list-edit.js";
|
|
7
|
+
/**
|
|
8
|
+
* Edits an existing record together with the collections it owns
|
|
9
|
+
* (`AsterData.Owned`): its fields in one edit model, each owned collection
|
|
10
|
+
* in a list edit whose new rows point at the record. Everything is one
|
|
11
|
+
* change set, one validation and one save; nothing is written per record
|
|
12
|
+
* type.
|
|
13
|
+
*
|
|
14
|
+
* Saving is one deep update (`<navigation>@delta`): the record and every
|
|
15
|
+
* owned change in one request, all or nothing.
|
|
16
|
+
*/
|
|
17
|
+
export class RecordEdit {
|
|
18
|
+
set;
|
|
19
|
+
key;
|
|
20
|
+
edit;
|
|
21
|
+
owned;
|
|
22
|
+
dirty;
|
|
23
|
+
valid;
|
|
24
|
+
/** Settles when every owned collection has loaded. */
|
|
25
|
+
ready;
|
|
26
|
+
#ctx;
|
|
27
|
+
#collections = [];
|
|
28
|
+
#replay;
|
|
29
|
+
constructor(ctx, set, key, options = {}, replay) {
|
|
30
|
+
this.#ctx = ctx;
|
|
31
|
+
this.set = set;
|
|
32
|
+
this.key = key;
|
|
33
|
+
this.#replay = replay !== undefined;
|
|
34
|
+
if (replay) {
|
|
35
|
+
this.edit = replay.edit;
|
|
36
|
+
this.owned = Object.freeze(replay.owned);
|
|
37
|
+
this.ready = Promise.resolve();
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
this.edit = new EditModel(ctx, set, key, {}, options);
|
|
41
|
+
const record = this.edit.baseline.peek();
|
|
42
|
+
const owned = {};
|
|
43
|
+
for (const nav of ownedNavigations(ctx, set)) {
|
|
44
|
+
const listOptions = options.owned?.[nav.name] ?? {};
|
|
45
|
+
const collection = ctx.collection({ set: nav.target, filter: ownedFilter(nav, record), orderBy: [{ field: listOptions.positionField ?? keyField(ctx, nav.target) }] }, { pageSize: 1000 });
|
|
46
|
+
this.#collections.push(collection);
|
|
47
|
+
const defaults = Object.fromEntries(nav.keys.map(([principal, dependent]) => [dependent, record[principal] ?? null]));
|
|
48
|
+
owned[nav.name] = new ListEdit(ctx, collection, { ...listOptions, defaults: () => ({ ...listOptions.defaults?.(), ...defaults }) });
|
|
49
|
+
}
|
|
50
|
+
this.owned = Object.freeze(owned);
|
|
51
|
+
this.ready = Promise.all(this.#collections.map((c) => c.loadAll())).then(() => { });
|
|
52
|
+
}
|
|
53
|
+
const lists = Object.values(this.owned);
|
|
54
|
+
this.dirty = computed(() => this.edit.dirty.value || lists.some((l) => l.dirty.value));
|
|
55
|
+
this.valid = computed(() => this.edit.valid.value && lists.every((l) => l.valid.value));
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* A past change shown over what the record and its owned rows were: the
|
|
59
|
+
* record's fields and each list as replays, read-only, with pending
|
|
60
|
+
* marking exactly what the change did.
|
|
61
|
+
*/
|
|
62
|
+
static replay(ctx, before, change) {
|
|
63
|
+
const set = change.set;
|
|
64
|
+
const changes = change.kind === "update" ? change.changes : {};
|
|
65
|
+
const after = change.kind === "create" ? change.values : apply(before.record, changes);
|
|
66
|
+
const edit = EditModel.replay(ctx, set, change.kind === "create" ? {} : before.record, after);
|
|
67
|
+
const owned = {};
|
|
68
|
+
for (const nav of ownedNavigations(ctx, set)) {
|
|
69
|
+
const rows = before.owned[nav.name] ?? [];
|
|
70
|
+
const ownedChanges = change.kind === "update" ? (change.owned?.[nav.name] ?? [])
|
|
71
|
+
: change.kind === "delete" ? rows.map((values) => ({ kind: "delete", set: nav.target, key: keyOfRow(ctx, nav.target, values), values }))
|
|
72
|
+
: [];
|
|
73
|
+
owned[nav.name] = ListEdit.replay(ctx, nav.target, rows, ownedChanges);
|
|
74
|
+
}
|
|
75
|
+
const key = change.kind === "create" ? "" : keyText(change.key);
|
|
76
|
+
return new RecordEdit(ctx, set, key, {}, { edit, owned });
|
|
77
|
+
}
|
|
78
|
+
/** The record and its owned rows as they were when editing began. */
|
|
79
|
+
snapshot() {
|
|
80
|
+
const owned = {};
|
|
81
|
+
for (const [name, list] of Object.entries(this.owned)) {
|
|
82
|
+
owned[name] = list.rows.peek().filter((r) => !r.isNew).map((r) => r.edit.baseline.peek());
|
|
83
|
+
}
|
|
84
|
+
return { record: this.edit.baseline.peek(), owned };
|
|
85
|
+
}
|
|
86
|
+
/** The change a save would make, or `undefined` when nothing changed. */
|
|
87
|
+
changes() {
|
|
88
|
+
if (!this.dirty.peek())
|
|
89
|
+
return undefined;
|
|
90
|
+
const own = this.edit.changes();
|
|
91
|
+
const owned = {};
|
|
92
|
+
for (const [name, list] of Object.entries(this.owned)) {
|
|
93
|
+
const c = list.changes();
|
|
94
|
+
if (c.length)
|
|
95
|
+
owned[name] = c;
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
kind: "update",
|
|
99
|
+
set: this.set,
|
|
100
|
+
key: keyFromText(this.key),
|
|
101
|
+
changes: own?.kind === "update" ? own.changes : {},
|
|
102
|
+
...(Object.keys(owned).length ? { owned } : {}),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/** Validates every field, shown or not, and says what fails. */
|
|
106
|
+
problems() {
|
|
107
|
+
this.edit.validate();
|
|
108
|
+
const owned = {};
|
|
109
|
+
for (const [name, list] of Object.entries(this.owned)) {
|
|
110
|
+
const failing = list.rows.peek().filter((r) => !r.removing && !r.edit.validate());
|
|
111
|
+
if (failing.length)
|
|
112
|
+
owned[name] = failing.map((r) => ({ id: r.id, fields: Object.keys(r.edit.errors.peek()) }));
|
|
113
|
+
}
|
|
114
|
+
return { record: Object.keys(this.edit.errors.peek()), owned };
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Validates, then writes the record and every owned change in one deep
|
|
118
|
+
* update (`<navigation>@delta`), all or nothing: new children of a new
|
|
119
|
+
* child go inside its entry. Returns `undefined` when nothing changed or
|
|
120
|
+
* something is invalid (see `problems()`). On a 412 the newer record is
|
|
121
|
+
* taken in: with no overlap the edit is sent once more, otherwise
|
|
122
|
+
* `edit.conflict` says so. Server errors land on the fields and rows they
|
|
123
|
+
* name.
|
|
124
|
+
*/
|
|
125
|
+
async save() {
|
|
126
|
+
if (this.#replay || this.#busy || !this.dirty.peek())
|
|
127
|
+
return undefined;
|
|
128
|
+
if (this.edit.conflict.peek() || this.edit.deleted.peek())
|
|
129
|
+
return undefined;
|
|
130
|
+
const problems = this.problems();
|
|
131
|
+
if (problems.record.length || Object.keys(problems.owned).length)
|
|
132
|
+
return undefined;
|
|
133
|
+
this.#busy = true;
|
|
134
|
+
this.#failure.value = undefined;
|
|
135
|
+
try {
|
|
136
|
+
const before = this.snapshot();
|
|
137
|
+
const change = this.changes();
|
|
138
|
+
const { deltas, paths } = this.#deltas();
|
|
139
|
+
const own = change.kind === "update" ? Object.fromEntries(Object.entries(change.changes).map(([k, c]) => [k, c.to])) : {};
|
|
140
|
+
for (let attempt = 0;; attempt++) {
|
|
141
|
+
try {
|
|
142
|
+
const saved = await this.#ctx.update(this.set, this.key, own, this.edit, this.edit.etag, deltas);
|
|
143
|
+
batch(() => {
|
|
144
|
+
this.edit.accept(saved.record);
|
|
145
|
+
for (const list of Object.values(this.owned))
|
|
146
|
+
list.accept();
|
|
147
|
+
});
|
|
148
|
+
await Promise.all(this.#collections.map((c) => c.refresh()));
|
|
149
|
+
return { ok: true, change, before, failed: [] };
|
|
150
|
+
}
|
|
151
|
+
catch (e) {
|
|
152
|
+
if (!(e instanceof ODataError))
|
|
153
|
+
throw e;
|
|
154
|
+
// A 412 takes in the newer record; with nothing overlapping, the edit goes once more.
|
|
155
|
+
if (e.isConflict && attempt === 0 && !this.edit.conflict.peek())
|
|
156
|
+
continue;
|
|
157
|
+
this.#showErrors(e, paths);
|
|
158
|
+
return { ok: false, change, before, failed: ["record"] };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
this.#busy = false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
#busy = false;
|
|
167
|
+
/**
|
|
168
|
+
* Each owned list's changes as `@delta` entries, and the row behind each
|
|
169
|
+
* entry by its path in the request (`clauses[2]`, `clauses[0]/children[1]`),
|
|
170
|
+
* for placing errors. A new row linked to another new row goes inside that
|
|
171
|
+
* row's entry, under the navigation whose key is the link's field.
|
|
172
|
+
*/
|
|
173
|
+
#deltas() {
|
|
174
|
+
const deltas = {};
|
|
175
|
+
const behind = new Map();
|
|
176
|
+
for (const [name, list] of Object.entries(this.owned)) {
|
|
177
|
+
const type = list.type;
|
|
178
|
+
const entries = [];
|
|
179
|
+
const created = new Map();
|
|
180
|
+
for (const row of list.rows.peek()) {
|
|
181
|
+
if (row.removing) {
|
|
182
|
+
const e = { removed: keyFromText(row.id) };
|
|
183
|
+
entries.push(e);
|
|
184
|
+
behind.set(e, row);
|
|
185
|
+
}
|
|
186
|
+
else if (row.isNew) {
|
|
187
|
+
const e = { values: creatable(type, row.edit.values.peek()), nested: {} };
|
|
188
|
+
created.set(row.id, e);
|
|
189
|
+
behind.set(e, row);
|
|
190
|
+
}
|
|
191
|
+
else if (row.edit.dirty.peek()) {
|
|
192
|
+
const c = row.edit.changes();
|
|
193
|
+
if (c?.kind !== "update")
|
|
194
|
+
continue;
|
|
195
|
+
const changed = Object.fromEntries(Object.entries(c.changes).map(([k, v]) => [k, v.to]));
|
|
196
|
+
const e = { values: { ...keyValuesOf(type, row.edit.baseline.peek()), ...changed } };
|
|
197
|
+
entries.push(e);
|
|
198
|
+
behind.set(e, row);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
for (const row of list.rows.peek()) {
|
|
202
|
+
const entry = created.get(row.id);
|
|
203
|
+
if (!entry)
|
|
204
|
+
continue;
|
|
205
|
+
const [field, parent] = Object.entries(row.links ?? {})[0] ?? [];
|
|
206
|
+
const host = parent === undefined ? undefined : created.get(parent);
|
|
207
|
+
const nav = field === undefined ? undefined : type.navigations.find((n) => n.collection && n.keys.some(([, d]) => d === field));
|
|
208
|
+
if (host && nav && field) {
|
|
209
|
+
const { [field]: _, ...values } = entry.values;
|
|
210
|
+
entry.values = values;
|
|
211
|
+
(host.nested[nav.name] ??= []).push(entry);
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
entries.push(entry);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (entries.length)
|
|
218
|
+
deltas[name] = entries;
|
|
219
|
+
}
|
|
220
|
+
const paths = new Map();
|
|
221
|
+
const walk = (prefix, entries) => entries.forEach((e, i) => {
|
|
222
|
+
const path = `${prefix}[${i}]`;
|
|
223
|
+
const row = behind.get(e);
|
|
224
|
+
if (row)
|
|
225
|
+
paths.set(path, row);
|
|
226
|
+
if ("nested" in e)
|
|
227
|
+
for (const [nav, kids] of Object.entries(e.nested ?? {}))
|
|
228
|
+
walk(`${path}/${nav}`, kids);
|
|
229
|
+
});
|
|
230
|
+
for (const [name, entries] of Object.entries(deltas))
|
|
231
|
+
walk(name, entries);
|
|
232
|
+
return { deltas, paths };
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Puts a refused save's errors where they belong: a target naming a row's
|
|
236
|
+
* path and a field (`clauses[0]/children[1]/value`) on that row's field, a
|
|
237
|
+
* bare name on the record's; anything else is the save's `failure`.
|
|
238
|
+
*/
|
|
239
|
+
#showErrors(e, paths) {
|
|
240
|
+
const byRow = new Map();
|
|
241
|
+
const own = {};
|
|
242
|
+
for (const d of e.details) {
|
|
243
|
+
const target = d.target ?? "";
|
|
244
|
+
const at = target.lastIndexOf("/");
|
|
245
|
+
const row = at < 0 ? undefined : paths.get(target.slice(0, at));
|
|
246
|
+
const field = target.slice(at + 1);
|
|
247
|
+
if (row && /^\w+$/.test(field)) {
|
|
248
|
+
const errs = byRow.get(row.edit) ?? {};
|
|
249
|
+
(errs[field] ??= []).push(d.message);
|
|
250
|
+
byRow.set(row.edit, errs);
|
|
251
|
+
}
|
|
252
|
+
else if (/^\w+$/.test(target)) {
|
|
253
|
+
(own[target] ??= []).push(d.message);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
batch(() => {
|
|
257
|
+
if (Object.keys(own).length)
|
|
258
|
+
this.edit.showErrors(own);
|
|
259
|
+
for (const [edit, errs] of byRow)
|
|
260
|
+
edit.showErrors(errs);
|
|
261
|
+
});
|
|
262
|
+
this.#failure.value = byRow.size || Object.keys(own).length ? undefined : e.message;
|
|
263
|
+
}
|
|
264
|
+
/** Why the last save failed, when it isn't about a field. */
|
|
265
|
+
#failure = signal(undefined);
|
|
266
|
+
failure = this.#failure;
|
|
267
|
+
revert() {
|
|
268
|
+
batch(() => {
|
|
269
|
+
this.edit.revert();
|
|
270
|
+
for (const list of Object.values(this.owned))
|
|
271
|
+
list.revert();
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
draft() {
|
|
275
|
+
const owned = {};
|
|
276
|
+
for (const [name, list] of Object.entries(this.owned))
|
|
277
|
+
owned[name] = list.draft();
|
|
278
|
+
return { edit: this.edit.draft(), owned };
|
|
279
|
+
}
|
|
280
|
+
/** Takes up a draft once the owned rows are loaded. */
|
|
281
|
+
async resume(draft) {
|
|
282
|
+
await this.ready;
|
|
283
|
+
batch(() => {
|
|
284
|
+
this.edit.resume(draft.edit);
|
|
285
|
+
for (const [name, list] of Object.entries(this.owned)) {
|
|
286
|
+
const d = draft.owned[name];
|
|
287
|
+
if (d)
|
|
288
|
+
list.resume(d);
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
dispose() {
|
|
293
|
+
this.edit.dispose();
|
|
294
|
+
for (const list of Object.values(this.owned))
|
|
295
|
+
list.dispose();
|
|
296
|
+
for (const c of this.#collections)
|
|
297
|
+
this.#ctx.release(c);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
function ownedNavigations(ctx, set) {
|
|
301
|
+
return ctx.schema.set(set).type.navigations.filter((n) => n.owned && n.collection);
|
|
302
|
+
}
|
|
303
|
+
function ownedFilter(nav, record) {
|
|
304
|
+
const parts = nav.keys.map(([principal, dependent]) => ({ op: "eq", field: dependent, value: record[principal] ?? null }));
|
|
305
|
+
return parts.length === 1 ? parts[0] : { op: "and", args: parts };
|
|
306
|
+
}
|
|
307
|
+
function keyField(ctx, set) {
|
|
308
|
+
return ctx.schema.set(set).type.key[0];
|
|
309
|
+
}
|
|
310
|
+
/** A row's key properties by name, for a `@delta` entry. */
|
|
311
|
+
function keyValuesOf(type, row) {
|
|
312
|
+
const values = keyValues(type, row);
|
|
313
|
+
return Object.fromEntries(type.key.map((k, i) => [k, values[i] ?? null]));
|
|
314
|
+
}
|
|
315
|
+
function keyOfRow(ctx, set, row) {
|
|
316
|
+
return ctx.schema.set(set).type.key.map((k) => row[k] ?? null);
|
|
317
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { DataContext } from "../context.ts";
|
|
2
|
+
import type { Query } from "../criteria/ast.ts";
|
|
3
|
+
import { type KeyText } from "../store/keys.ts";
|
|
4
|
+
/** A plain metadata field and its CSV header, in output order. */
|
|
5
|
+
export interface ExportColumn {
|
|
6
|
+
/** Direct metadata property to export. */
|
|
7
|
+
readonly field: string;
|
|
8
|
+
/** Header text; defaults to the metadata title or field name. */
|
|
9
|
+
readonly title?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface CsvOptions {
|
|
12
|
+
/** Omit for all query results; provided keys export in this exact order. */
|
|
13
|
+
readonly keys?: readonly KeyText[];
|
|
14
|
+
readonly pageSize?: number;
|
|
15
|
+
/** Exceeding the limit rejects the entire export. Default 100,000. */
|
|
16
|
+
readonly maxRows?: number;
|
|
17
|
+
readonly signal?: AbortSignal;
|
|
18
|
+
readonly onProgress?: (rows: number) => void;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* CSV from fresh server records, without altering the store or paging window.
|
|
22
|
+
* The query, keys and columns are captured before the first request. The server
|
|
23
|
+
* must provide snapshot consistency if records can move during a paged export.
|
|
24
|
+
*/
|
|
25
|
+
export declare function exportCsv(ctx: DataContext, query: Query, columns: readonly ExportColumn[], options?: CsvOptions): Promise<string>;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { relatedOf, resolveRelative } from "../criteria/relative.js";
|
|
2
|
+
import { keyFromText } from "../store/keys.js";
|
|
3
|
+
/**
|
|
4
|
+
* CSV from fresh server records, without altering the store or paging window.
|
|
5
|
+
* The query, keys and columns are captured before the first request. The server
|
|
6
|
+
* must provide snapshot consistency if records can move during a paged export.
|
|
7
|
+
*/
|
|
8
|
+
export async function exportCsv(ctx, query, columns, options = {}) {
|
|
9
|
+
const snapshot = structuredClone(query);
|
|
10
|
+
const q = snapshot.filter ? { ...snapshot, filter: resolveRelative(snapshot.filter, ctx.schema.set(snapshot.set).type, ctx.now(), relatedOf(ctx.schema, ctx.schema.set(snapshot.set).type)) } : snapshot;
|
|
11
|
+
const type = ctx.schema.set(q.set).type;
|
|
12
|
+
const fields = columns.map((c) => {
|
|
13
|
+
const field = type.field(c.field);
|
|
14
|
+
if (!field)
|
|
15
|
+
throw new Error(`${q.set} has no export field ${c.field}`);
|
|
16
|
+
return { field: c.field, title: c.title ?? field.title ?? c.field, type: field.type };
|
|
17
|
+
});
|
|
18
|
+
if (!fields.length)
|
|
19
|
+
throw new Error("Choose at least one export column");
|
|
20
|
+
const pageSize = options.pageSize ?? 500;
|
|
21
|
+
const maxRows = options.maxRows ?? 100_000;
|
|
22
|
+
if (!Number.isSafeInteger(pageSize) || pageSize < 1)
|
|
23
|
+
throw new Error("pageSize must be a positive safe integer");
|
|
24
|
+
if (!Number.isSafeInteger(maxRows) || maxRows < 0 || maxRows >= Number.MAX_SAFE_INTEGER)
|
|
25
|
+
throw new Error("maxRows must be a nonnegative safe integer below MAX_SAFE_INTEGER");
|
|
26
|
+
const keys = options.keys ? [...new Set(options.keys)] : undefined;
|
|
27
|
+
const check = () => options.signal?.throwIfAborted();
|
|
28
|
+
check();
|
|
29
|
+
if (keys && keys.length > maxRows)
|
|
30
|
+
throw new Error(`Export exceeds ${maxRows} rows`);
|
|
31
|
+
const lines = [fields.map((f) => csvCell(f.title, true)).join(",")];
|
|
32
|
+
let count = 0;
|
|
33
|
+
const append = (record) => {
|
|
34
|
+
if (++count > maxRows)
|
|
35
|
+
throw new Error(`Export exceeds ${maxRows} rows`);
|
|
36
|
+
lines.push(fields.map((f) => {
|
|
37
|
+
if (record.hidden?.has(f.field) || (q.select && !q.select.includes(f.field)) || (record.known && !record.known.has(f.field)))
|
|
38
|
+
return '""';
|
|
39
|
+
return csvCell(record.entity[f.field] ?? null, f.type !== "Edm.Decimal" && f.type !== "Edm.Int64");
|
|
40
|
+
}).join(","));
|
|
41
|
+
};
|
|
42
|
+
if (keys) {
|
|
43
|
+
for (const key of keys) {
|
|
44
|
+
check();
|
|
45
|
+
const record = await ctx.source.get(q.set, keyFromText(key));
|
|
46
|
+
check();
|
|
47
|
+
append(record);
|
|
48
|
+
options.onProgress?.(count);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
// An extra row detects overflow even when the count is absent or estimated.
|
|
53
|
+
for (let skip = 0;;) {
|
|
54
|
+
check();
|
|
55
|
+
const top = Math.min(pageSize, maxRows - count + 1);
|
|
56
|
+
const page = await ctx.source.query(q, { skip, top });
|
|
57
|
+
check();
|
|
58
|
+
for (const record of page.records)
|
|
59
|
+
append(record);
|
|
60
|
+
options.onProgress?.(count);
|
|
61
|
+
if (page.records.length < top)
|
|
62
|
+
break;
|
|
63
|
+
skip += page.records.length;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
check();
|
|
67
|
+
return "\uFEFF" + lines.join("\r\n") + "\r\n";
|
|
68
|
+
}
|
|
69
|
+
/** Quote every cell; text that spreadsheets treat as a formula stays text. */
|
|
70
|
+
function csvCell(value, text) {
|
|
71
|
+
let content = value === null ? "" : String(value);
|
|
72
|
+
if (text && typeof value === "string" && /^[\s\u0000-\u001f]*[=+@-]/u.test(content))
|
|
73
|
+
content = "\u0027" + content;
|
|
74
|
+
return '"' + content.replaceAll('"', '""') + '"';
|
|
75
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export { ProgressState, progressPolicy, DEFAULT_PROGRESS_POLICY, type ProgressPhase, type ProgressPolicy, type ProgressWork } from "./progress/status.ts";
|
|
2
|
+
export type * from "./schema/model.ts";
|
|
3
|
+
export { Lifetime } from "./composition/lifetime.ts";
|
|
4
|
+
export { Command, command, type CommandOptions } from "./composition/command.ts";
|
|
5
|
+
export { parseCsdl, SchemaError } from "./schema/csdl.ts";
|
|
6
|
+
export type { Criteria, Query, RelativeDate, SortKey } from "./criteria/ast.ts";
|
|
7
|
+
export { FilterDraft, filterOperators, describeCriteria, type FilterNode, type FilterGroup, type FilterCondition, type FilterRelated, type FilterProblem, type FilterResult, type FilterChange } from "./criteria/filter-draft.ts";
|
|
8
|
+
export { hasRelative, isRelative, resolveRelative } from "./criteria/relative.ts";
|
|
9
|
+
export { criteriaRule, type CriteriaValidator, type OwnCriteria, type OwnTest } from "./validate/criteria-rule.ts";
|
|
10
|
+
export { parseTemplate, TemplateError, type Template } from "./template/template.ts";
|
|
11
|
+
export { toApply, toFilter, toOrderBy, toQueryString, formatLiteral, CriteriaError, type Aggregate } from "./criteria/compile.ts";
|
|
12
|
+
export { matches, compareValues, sortComparator, type Related } from "./criteria/evaluate.ts";
|
|
13
|
+
export type { Method, ODataRequest, ODataResponse, Transport } from "./transport.ts";
|
|
14
|
+
export { fetchTransport } from "./transport.ts";
|
|
15
|
+
export { Authentication, AuthenticationError, type AuthenticationOptions, type AuthenticationChallenge, type AuthenticationRecovery, type AuthenticationFailure } from "./authentication.ts";
|
|
16
|
+
export { ODataError, type ErrorDetail } from "./odata/errors.ts";
|
|
17
|
+
export { ODataSource, type Page } from "./odata/source.ts";
|
|
18
|
+
export { keyOf, keyText, keyValues, keyFromText, type KeyText, type KeyValues } from "./store/keys.ts";
|
|
19
|
+
export { RecordStore, type StoredRecord, type StoreChange, type Origin } from "./store/store.ts";
|
|
20
|
+
export { Collection, type CollectionOptions, type CollectionStatus } from "./collection/collection.ts";
|
|
21
|
+
export { aggregate, countBy, countWhere, subtractExact, sum, sumValues } from "./collection/aggregate.ts";
|
|
22
|
+
export { apply, creatable, diff, invert, merge, writable, type ChangeSet, type FieldChange, type OwnedChanges } from "./changes/changeset.ts";
|
|
23
|
+
export { validateEntity, validateField, type FieldErrors } from "./validate/validate.ts";
|
|
24
|
+
export { EditModel, type Conflict, type EditDraft, type EditOptions, type FieldRules, type Rule } from "./edit/edit-model.ts";
|
|
25
|
+
export { DataContext, type Saved } from "./context.ts";
|
|
26
|
+
export { Selection, selectionOptionsError, type SelectionOptions } from "./selection/selection.ts";
|
|
27
|
+
export { PageScope, toCriteria, DEFAULT_STATE, MAX_HOPS, type Case, type Condition, type Effect, type Explanation, type FieldState, type Publication, type Ruled, type ScopeName, type ScopeSchema, type SelectedValue, type StateRules, } from "./scope/scope.ts";
|
|
28
|
+
export { collectionPublication, editPublication, inputPublication, listPublication, selectionPublication, valuesPublication } from "./scope/publications.ts";
|
|
29
|
+
export { SharedFilters, compatible, filtersPublication, selectionShare, type Applied, type Consumed, type ConsumeOptions, type Contribution, type ContributionKind, type FilterSnapshot, type Shared, type SkippedContribution, } from "./scope/filters.ts";
|
|
30
|
+
export { ListEdit, type ListDraft, type ListEditOptions, type ListRow, type ListSaveResult } from "./edit/list-edit.ts";
|
|
31
|
+
export { RecordEdit, type RecordDraft, type RecordEditOptions, type RecordProblems, type RecordSave, type RecordSnapshot } from "./edit/record-edit.ts";
|
|
32
|
+
export { Groups, type GroupRow } from "./collection/groups.ts";
|
|
33
|
+
export { Grouping, type GroupingRow, type GroupingHeader, type GroupingRecord, type GroupingOptions } from "./collection/grouping.ts";
|
|
34
|
+
export { Tree, type TreeOptions, type TreeRow, type FilterMode } from "./tree/tree.ts";
|
|
35
|
+
export { Summary } from "./collection/summary.ts";
|
|
36
|
+
export { exportCsv, type CsvOptions, type ExportColumn } from "./export/csv.ts";
|
|
37
|
+
export { SessionActivity, ActivityTask, type ActivityOptions, type ActivityRun, type InactivePolicy } from "./composition/activity.ts";
|
|
38
|
+
export { KeyedSessions, KeyedSession, type SessionDescriptor, type SessionResource, type SessionMount, type KeyedSessionsOptions, type SessionStatus } from "./composition/sessions.ts";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export { ProgressState, progressPolicy, DEFAULT_PROGRESS_POLICY } from "./progress/status.js";
|
|
2
|
+
export { Lifetime } from "./composition/lifetime.js";
|
|
3
|
+
export { Command, command } from "./composition/command.js";
|
|
4
|
+
export { parseCsdl, SchemaError } from "./schema/csdl.js";
|
|
5
|
+
export { FilterDraft, filterOperators, describeCriteria } from "./criteria/filter-draft.js";
|
|
6
|
+
export { hasRelative, isRelative, resolveRelative } from "./criteria/relative.js";
|
|
7
|
+
export { criteriaRule } from "./validate/criteria-rule.js";
|
|
8
|
+
export { parseTemplate, TemplateError } from "./template/template.js";
|
|
9
|
+
export { toApply, toFilter, toOrderBy, toQueryString, formatLiteral, CriteriaError } from "./criteria/compile.js";
|
|
10
|
+
export { matches, compareValues, sortComparator } from "./criteria/evaluate.js";
|
|
11
|
+
export { fetchTransport } from "./transport.js";
|
|
12
|
+
export { Authentication, AuthenticationError } from "./authentication.js";
|
|
13
|
+
export { ODataError } from "./odata/errors.js";
|
|
14
|
+
export { ODataSource } from "./odata/source.js";
|
|
15
|
+
export { keyOf, keyText, keyValues, keyFromText } from "./store/keys.js";
|
|
16
|
+
export { RecordStore } from "./store/store.js";
|
|
17
|
+
export { Collection } from "./collection/collection.js";
|
|
18
|
+
export { aggregate, countBy, countWhere, subtractExact, sum, sumValues } from "./collection/aggregate.js";
|
|
19
|
+
export { apply, creatable, diff, invert, merge, writable } from "./changes/changeset.js";
|
|
20
|
+
export { validateEntity, validateField } from "./validate/validate.js";
|
|
21
|
+
export { EditModel } from "./edit/edit-model.js";
|
|
22
|
+
export { DataContext } from "./context.js";
|
|
23
|
+
export { Selection, selectionOptionsError } from "./selection/selection.js";
|
|
24
|
+
export { PageScope, toCriteria, DEFAULT_STATE, MAX_HOPS, } from "./scope/scope.js";
|
|
25
|
+
export { collectionPublication, editPublication, inputPublication, listPublication, selectionPublication, valuesPublication } from "./scope/publications.js";
|
|
26
|
+
export { SharedFilters, compatible, filtersPublication, selectionShare, } from "./scope/filters.js";
|
|
27
|
+
export { ListEdit } from "./edit/list-edit.js";
|
|
28
|
+
export { RecordEdit } from "./edit/record-edit.js";
|
|
29
|
+
export { Groups } from "./collection/groups.js";
|
|
30
|
+
export { Grouping } from "./collection/grouping.js";
|
|
31
|
+
export { Tree } from "./tree/tree.js";
|
|
32
|
+
export { Summary } from "./collection/summary.js";
|
|
33
|
+
export { exportCsv } from "./export/csv.js";
|
|
34
|
+
export { SessionActivity, ActivityTask } from "./composition/activity.js";
|
|
35
|
+
export { KeyedSessions, KeyedSession } from "./composition/sessions.js";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** One problem in a failed request. `message` is the server's and is what's shown; `code` and `params` are stable, for localising it. */
|
|
2
|
+
export interface ErrorDetail {
|
|
3
|
+
readonly code: string;
|
|
4
|
+
readonly message: string;
|
|
5
|
+
/** A property path, when the detail is about one field. */
|
|
6
|
+
readonly target?: string;
|
|
7
|
+
readonly params?: Readonly<Record<string, unknown>>;
|
|
8
|
+
}
|
|
9
|
+
/** A failed OData request, with the service's error object when it sent one. */
|
|
10
|
+
export declare class ODataError extends Error {
|
|
11
|
+
readonly status: number;
|
|
12
|
+
readonly code: string;
|
|
13
|
+
readonly target: string | undefined;
|
|
14
|
+
readonly details: readonly ErrorDetail[];
|
|
15
|
+
/** The current ETag the server sent with a 412. */
|
|
16
|
+
readonly etag: string | undefined;
|
|
17
|
+
constructor(status: number, code: string, message: string, details?: readonly ErrorDetail[], target?: string, etag?: string);
|
|
18
|
+
/** Someone changed the record since it was read (412). */
|
|
19
|
+
get isConflict(): boolean;
|
|
20
|
+
/** The key is already taken (409). */
|
|
21
|
+
get isDuplicate(): boolean;
|
|
22
|
+
/** Detail messages by property path. Details without a target are left out. */
|
|
23
|
+
fieldErrors(): Record<string, string[]>;
|
|
24
|
+
}
|
|
25
|
+
export declare function errorFromResponse(status: number, body: string, etag?: string): ODataError;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** A failed OData request, with the service's error object when it sent one. */
|
|
2
|
+
export class ODataError extends Error {
|
|
3
|
+
status;
|
|
4
|
+
code;
|
|
5
|
+
target;
|
|
6
|
+
details;
|
|
7
|
+
/** The current ETag the server sent with a 412. */
|
|
8
|
+
etag;
|
|
9
|
+
constructor(status, code, message, details = [], target, etag) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = "ODataError";
|
|
12
|
+
this.status = status;
|
|
13
|
+
this.code = code;
|
|
14
|
+
this.details = details;
|
|
15
|
+
this.target = target;
|
|
16
|
+
this.etag = etag;
|
|
17
|
+
}
|
|
18
|
+
/** Someone changed the record since it was read (412). */
|
|
19
|
+
get isConflict() {
|
|
20
|
+
return this.status === 412;
|
|
21
|
+
}
|
|
22
|
+
/** The key is already taken (409). */
|
|
23
|
+
get isDuplicate() {
|
|
24
|
+
return this.status === 409;
|
|
25
|
+
}
|
|
26
|
+
/** Detail messages by property path. Details without a target are left out. */
|
|
27
|
+
fieldErrors() {
|
|
28
|
+
const out = {};
|
|
29
|
+
for (const d of this.details) {
|
|
30
|
+
if (d.target)
|
|
31
|
+
(out[d.target] ??= []).push(d.message);
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function errorFromResponse(status, body, etag) {
|
|
37
|
+
try {
|
|
38
|
+
const parsed = JSON.parse(body);
|
|
39
|
+
const e = parsed.error;
|
|
40
|
+
if (e && typeof e === "object") {
|
|
41
|
+
const details = Array.isArray(e.details)
|
|
42
|
+
? e.details.map((d) => ({
|
|
43
|
+
code: String(d.code ?? ""),
|
|
44
|
+
message: String(d.message ?? ""),
|
|
45
|
+
...(typeof d.target === "string" ? { target: d.target } : {}),
|
|
46
|
+
...(typeof d.params === "object" && d.params !== null && !Array.isArray(d.params) ? { params: d.params } : {}),
|
|
47
|
+
}))
|
|
48
|
+
: [];
|
|
49
|
+
return new ODataError(status, String(e.code ?? status), String(e.message ?? `Request failed with status ${status}`), details, typeof e.target === "string" ? e.target : undefined, etag);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// Not JSON: fall through to a generic error.
|
|
54
|
+
}
|
|
55
|
+
return new ODataError(status, String(status), `Request failed with status ${status}`, [], undefined, etag);
|
|
56
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { Query } from "../criteria/ast.ts";
|
|
2
|
+
import { type Aggregate } from "../criteria/compile.ts";
|
|
3
|
+
import type { Criteria, SortKey } from "../criteria/ast.ts";
|
|
4
|
+
import type { Entity, Schema, Value } from "../schema/model.ts";
|
|
5
|
+
import type { KeyValues } from "../store/keys.ts";
|
|
6
|
+
import type { StoredRecord } from "../store/store.ts";
|
|
7
|
+
import type { Transport } from "../transport.ts";
|
|
8
|
+
export interface Page {
|
|
9
|
+
readonly records: readonly StoredRecord[];
|
|
10
|
+
/** Present when the request asked for `$count` and the server gave one. */
|
|
11
|
+
readonly count?: number;
|
|
12
|
+
/** `count` is the server's estimate (`@AsterData.CountEstimated`), not exact. */
|
|
13
|
+
readonly estimated?: boolean;
|
|
14
|
+
}
|
|
15
|
+
/** OData 4.01 over a transport. Every failure is an `ODataError`. */
|
|
16
|
+
export declare class ODataSource {
|
|
17
|
+
#private;
|
|
18
|
+
readonly schema: Schema;
|
|
19
|
+
constructor(schema: Schema, transport: Transport);
|
|
20
|
+
static connect(transport: Transport): Promise<ODataSource>;
|
|
21
|
+
/**
|
|
22
|
+
* One range of a query's results. With `count`, the total too; `exact`
|
|
23
|
+
* asks for an exact one (`Prefer: aster.count=exact`) where the server
|
|
24
|
+
* would otherwise estimate it or leave it out.
|
|
25
|
+
*/
|
|
26
|
+
query(query: Query, range?: {
|
|
27
|
+
skip?: number;
|
|
28
|
+
top?: number;
|
|
29
|
+
count?: boolean;
|
|
30
|
+
exact?: boolean;
|
|
31
|
+
}): Promise<Page>;
|
|
32
|
+
/**
|
|
33
|
+
* Counts and aggregates over a set, grouped by `groupBy` (one row per
|
|
34
|
+
* distinct combination) or over all of it. Rows hold the group values,
|
|
35
|
+
* `count`, and each aggregate's alias.
|
|
36
|
+
*/
|
|
37
|
+
aggregate(set: string, request: {
|
|
38
|
+
readonly filter?: Criteria;
|
|
39
|
+
readonly groupBy?: readonly string[];
|
|
40
|
+
readonly aggregates?: readonly Aggregate[];
|
|
41
|
+
readonly orderBy?: readonly SortKey[];
|
|
42
|
+
}): Promise<Record<string, Value>[]>;
|
|
43
|
+
get(set: string, key: KeyValues): Promise<StoredRecord>;
|
|
44
|
+
create(set: string, values: Entity): Promise<StoredRecord>;
|
|
45
|
+
/**
|
|
46
|
+
* PATCH with `If-Match`; returns the updated record. `deltas` carries
|
|
47
|
+
* owned collections' changes as `<navigation>@delta`, so the record and
|
|
48
|
+
* its children change in one request, all or nothing.
|
|
49
|
+
*/
|
|
50
|
+
update(set: string, key: KeyValues, changes: Entity, etag: string | undefined, deltas?: Deltas): Promise<StoredRecord>;
|
|
51
|
+
delete(set: string, key: KeyValues, etag: string | undefined): Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* One change to an owned child in a deep update: its key and changed values,
|
|
55
|
+
* a new child's values (with its own new children by navigation), or a
|
|
56
|
+
* removal by key.
|
|
57
|
+
*/
|
|
58
|
+
export type DeltaEntry = {
|
|
59
|
+
readonly values: Entity;
|
|
60
|
+
readonly nested?: Readonly<Record<string, readonly DeltaEntry[]>>;
|
|
61
|
+
} | {
|
|
62
|
+
readonly removed: KeyValues;
|
|
63
|
+
};
|
|
64
|
+
/** Deep update changes by owned navigation. */
|
|
65
|
+
export type Deltas = Readonly<Record<string, readonly DeltaEntry[]>>;
|