@getaster/data 0.0.0-stage → 0.1.0-preview.0

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,193 @@
1
+ import { formatLiteral, toApply, toQueryString } from "../criteria/compile.js";
2
+ import { parseCsdl } from "../schema/csdl.js";
3
+ import { errorFromResponse } from "./errors.js";
4
+ /** Instance annotations aster-odata writes (`Aster.Data.V1`, alias `AsterData`). */
5
+ const COUNT_ESTIMATED = ["@AsterData.CountEstimated", "@Aster.Data.V1.CountEstimated"];
6
+ const HIDDEN = ["@AsterData.Hidden", "@Aster.Data.V1.Hidden"];
7
+ const ACCEPT = "application/json;odata.metadata=minimal;IEEE754Compatible=true";
8
+ /** OData 4.01 over a transport. Every failure is an `ODataError`. */
9
+ export class ODataSource {
10
+ schema;
11
+ #transport;
12
+ constructor(schema, transport) {
13
+ this.schema = schema;
14
+ this.#transport = transport;
15
+ }
16
+ static async connect(transport) {
17
+ const res = await transport({ method: "GET", url: "$metadata", headers: { accept: "application/json" } });
18
+ if (res.status !== 200)
19
+ throw errorFromResponse(res.status, res.body);
20
+ return new ODataSource(parseCsdl(JSON.parse(res.body)), transport);
21
+ }
22
+ /**
23
+ * One range of a query's results. With `count`, the total too; `exact`
24
+ * asks for an exact one (`Prefer: aster.count=exact`) where the server
25
+ * would otherwise estimate it or leave it out.
26
+ */
27
+ async query(query, range = {}) {
28
+ const options = [toQueryString(query, this.schema)];
29
+ if (range.skip)
30
+ options.push(`$skip=${range.skip}`);
31
+ if (range.top !== undefined)
32
+ options.push(`$top=${range.top}`);
33
+ if (range.count)
34
+ options.push("$count=true");
35
+ const qs = options.filter(Boolean).join("&");
36
+ const headers = range.count && range.exact ? { prefer: "aster.count=exact" } : undefined;
37
+ let body = await this.#json("GET", qs ? `${query.set}?${qs}` : query.set, undefined, headers);
38
+ const count = body["@odata.count"];
39
+ const estimated = COUNT_ESTIMATED.some((a) => body[a] === true);
40
+ const records = [];
41
+ for (;;) {
42
+ const value = Array.isArray(body.value) ? body.value : [];
43
+ records.push(...value.map(toRecord));
44
+ const next = body["@odata.nextLink"];
45
+ if (typeof next !== "string" || (range.top !== undefined && records.length >= range.top))
46
+ break;
47
+ body = await this.#json("GET", relativeToRoot(next, query.set));
48
+ }
49
+ return {
50
+ records: range.top === undefined ? records : records.slice(0, range.top),
51
+ ...(count === undefined ? {} : { count: Number(count), ...(estimated ? { estimated } : {}) }),
52
+ };
53
+ }
54
+ /**
55
+ * Counts and aggregates over a set, grouped by `groupBy` (one row per
56
+ * distinct combination) or over all of it. Rows hold the group values,
57
+ * `count`, and each aggregate's alias.
58
+ */
59
+ async aggregate(set, request) {
60
+ const entitySet = this.schema.set(set);
61
+ const apply = toApply(entitySet.type, request.filter, request.groupBy ?? [], request.aggregates ?? [], entitySet, this.schema);
62
+ const order = request.orderBy?.length ? `&$orderby=${encodeURIComponent(request.orderBy.map((k) => `${k.field}${k.descending ? " desc" : ""}`).join(","))}` : "";
63
+ let body = await this.#json("GET", `${set}?$apply=${encodeURIComponent(apply)}${order}`);
64
+ const values = [];
65
+ for (;;) {
66
+ if (Array.isArray(body.value))
67
+ values.push(...body.value);
68
+ const next = body["@odata.nextLink"];
69
+ if (typeof next !== "string")
70
+ break;
71
+ body = await this.#json("GET", relativeToRoot(next, set));
72
+ }
73
+ return values.map((row) => {
74
+ const out = {};
75
+ for (const [k, v] of Object.entries(row))
76
+ if (!k.includes("@"))
77
+ out[k] = v;
78
+ return out;
79
+ });
80
+ }
81
+ async get(set, key) {
82
+ return toRecord(await this.#json("GET", this.#entityUrl(set, key)));
83
+ }
84
+ async create(set, values) {
85
+ return toRecord(await this.#json("POST", set, JSON.stringify(values), { prefer: "return=representation" }));
86
+ }
87
+ /**
88
+ * PATCH with `If-Match`; returns the updated record. `deltas` carries
89
+ * owned collections' changes as `<navigation>@delta`, so the record and
90
+ * its children change in one request, all or nothing.
91
+ */
92
+ async update(set, key, changes, etag, deltas = {}) {
93
+ const type = this.schema.set(set).type;
94
+ const body = { ...changes };
95
+ for (const [nav, entries] of Object.entries(deltas)) {
96
+ const target = type.navigation(nav)?.target;
97
+ if (!target)
98
+ throw new Error(`${type.name} has no navigation ${nav}`);
99
+ body[`${nav}@delta`] = entries.map((e) => this.#deltaEntry(target, e));
100
+ }
101
+ return toRecord(await this.#json("PATCH", this.#entityUrl(set, key), JSON.stringify(body), {
102
+ prefer: "return=representation",
103
+ ...ifMatch(etag),
104
+ }));
105
+ }
106
+ /** An entry of `@delta`: a removal by `@id`, or values with any children inserted under it. */
107
+ #deltaEntry(set, entry) {
108
+ if ("removed" in entry)
109
+ return { "@removed": { reason: "deleted" }, "@id": this.#entityUrl(set, entry.removed) };
110
+ const type = this.schema.set(set).type;
111
+ const out = { ...entry.values };
112
+ for (const [nav, children] of Object.entries(entry.nested ?? {})) {
113
+ const target = type.navigation(nav)?.target;
114
+ if (!target)
115
+ throw new Error(`${type.name} has no navigation ${nav}`);
116
+ out[nav] = children.map((c) => this.#deltaEntry(target, c));
117
+ }
118
+ return out;
119
+ }
120
+ async delete(set, key, etag) {
121
+ await this.#send("DELETE", this.#entityUrl(set, key), undefined, ifMatch(etag));
122
+ }
123
+ #entityUrl(set, key) {
124
+ const type = this.schema.set(set).type;
125
+ const literal = (name, v) => {
126
+ const field = type.field(name);
127
+ if (!field)
128
+ throw new Error(`${type.name} has no key property ${name}`);
129
+ return encodeURIComponent(formatLiteral(v, field));
130
+ };
131
+ const parts = type.key.map((name, i) => {
132
+ const v = key[i];
133
+ if (v === undefined)
134
+ throw new Error(`missing key value for ${name}`);
135
+ return type.key.length === 1 ? literal(name, v) : `${name}=${literal(name, v)}`;
136
+ });
137
+ return `${set}(${parts.join(",")})`;
138
+ }
139
+ async #json(method, url, body, headers) {
140
+ const res = await this.#send(method, url, body, headers);
141
+ const parsed = JSON.parse(res.body);
142
+ if (res.headers.etag && parsed["@odata.etag"] === undefined)
143
+ parsed["@odata.etag"] = res.headers.etag;
144
+ return parsed;
145
+ }
146
+ async #send(method, url, body, headers) {
147
+ const request = {
148
+ method,
149
+ url,
150
+ headers: {
151
+ accept: ACCEPT,
152
+ ...(body === undefined ? {} : { "content-type": "application/json;IEEE754Compatible=true" }),
153
+ ...headers,
154
+ },
155
+ ...(body === undefined ? {} : { body }),
156
+ };
157
+ const res = await this.#transport(request);
158
+ if (res.status >= 400)
159
+ throw errorFromResponse(res.status, res.body, res.headers.etag);
160
+ return res;
161
+ }
162
+ }
163
+ /**
164
+ * `If-Match` whenever an ETag is held, whatever `$metadata` declares. With
165
+ * none on an ETag-bearing set, the server answers 428 rather than overwriting.
166
+ */
167
+ function ifMatch(etag) {
168
+ return etag !== undefined ? { "if-match": etag } : {};
169
+ }
170
+ /** Next links are absolute; the transport takes URLs relative to the service root. */
171
+ function relativeToRoot(link, set) {
172
+ const at = link.lastIndexOf(`/${set}`);
173
+ return at < 0 ? link : link.slice(at + 1);
174
+ }
175
+ /**
176
+ * Splits control information (`@odata.*`) from the entity's properties.
177
+ * `@AsterData.Hidden` lists the fields the caller may not see on this
178
+ * record; they arrive as `null`.
179
+ */
180
+ function toRecord(json) {
181
+ const entity = {};
182
+ let etag;
183
+ let hidden;
184
+ for (const [name, v] of Object.entries(json)) {
185
+ if (name === "@odata.etag")
186
+ etag = String(v);
187
+ else if (HIDDEN.includes(name) && Array.isArray(v) && v.length > 0)
188
+ hidden = new Set(v.map(String));
189
+ else if (!name.includes("@"))
190
+ entity[name] = v;
191
+ }
192
+ return { entity, ...(etag === undefined ? {} : { etag }), ...(hidden ? { hidden } : {}) };
193
+ }
@@ -0,0 +1,29 @@
1
+ import { type ReadonlySignal } from "@preact/signals-core";
2
+ export interface ProgressPolicy {
3
+ /** Milliseconds before a busy operation becomes visually visible. */
4
+ readonly delayMs?: number;
5
+ /** Minimum total visible time, including any completion message. */
6
+ readonly minVisibleMs?: number;
7
+ }
8
+ export declare const DEFAULT_PROGRESS_POLICY: Readonly<{
9
+ delayMs: 250;
10
+ minVisibleMs: 400;
11
+ }>;
12
+ export type ProgressPhase = "idle" | "waiting" | "working" | "complete" | "error";
13
+ export interface ProgressWork {
14
+ readonly busy: boolean;
15
+ readonly error?: unknown;
16
+ }
17
+ /** Validate and snapshot presentation timing without changing the source operation. */
18
+ export declare function progressPolicy(options?: ProgressPolicy): Readonly<Required<ProgressPolicy>>;
19
+ /** Delayed presentation of borrowed work; actual busy/error values remain immediate. */
20
+ export declare class ProgressState {
21
+ #private;
22
+ readonly busy: ReadonlySignal<boolean>;
23
+ readonly error: ReadonlySignal<unknown>;
24
+ readonly phase: ReadonlySignal<ProgressPhase>;
25
+ readonly visible: ReadonlySignal<boolean>;
26
+ constructor(source: () => ProgressWork, policy?: ProgressPolicy | (() => ProgressPolicy));
27
+ /** Stops timers/subscriptions; never cancels or completes the borrowed operation. */
28
+ dispose(): void;
29
+ }
@@ -0,0 +1,105 @@
1
+ import { batch, computed, effect, signal, untracked } from "@preact/signals-core";
2
+ export const DEFAULT_PROGRESS_POLICY = Object.freeze({ delayMs: 250, minVisibleMs: 400 });
3
+ /** Validate and snapshot presentation timing without changing the source operation. */
4
+ export function progressPolicy(options = {}) {
5
+ if (!options || typeof options !== "object" || ![Object.prototype, null].includes(Object.getPrototypeOf(options)))
6
+ throw new TypeError("Progress policy must be a plain object");
7
+ for (const [key, value] of Object.entries(options)) {
8
+ if (!["delayMs", "minVisibleMs"].includes(key))
9
+ throw new TypeError(`Unknown progress policy '${key}'`);
10
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0 || value > 2_147_483_647)
11
+ throw new RangeError(`${key} must be an integer between 0 and 2147483647`);
12
+ }
13
+ return Object.freeze({ ...DEFAULT_PROGRESS_POLICY, ...options });
14
+ }
15
+ /** Delayed presentation of borrowed work; actual busy/error values remain immediate. */
16
+ export class ProgressState {
17
+ #busy = signal(false);
18
+ #error = signal(undefined);
19
+ #phase = signal("idle");
20
+ busy = this.#busy;
21
+ error = this.#error;
22
+ phase = this.#phase;
23
+ visible = computed(() => ["working", "complete", "error"].includes(this.phase.value));
24
+ #stop;
25
+ #timer;
26
+ #revision = 0;
27
+ #disposed = false;
28
+ #started;
29
+ #shown;
30
+ #work = { busy: false };
31
+ #policy = DEFAULT_PROGRESS_POLICY;
32
+ constructor(source, policy = {}) {
33
+ const fixed = typeof policy === "function" ? DEFAULT_PROGRESS_POLICY : progressPolicy(policy);
34
+ const readPolicy = typeof policy === "function" ? policy : () => fixed;
35
+ this.#stop = effect(() => {
36
+ const work = source();
37
+ if (!work || typeof work.busy !== "boolean")
38
+ throw new TypeError("Progress work must expose a boolean busy value");
39
+ const settings = progressPolicy(readPolicy());
40
+ untracked(() => this.#update(work, settings));
41
+ });
42
+ }
43
+ #update(work, policy) {
44
+ if (this.#disposed)
45
+ return;
46
+ this.#cancel();
47
+ const now = performance.now();
48
+ if (work.busy && !this.#busy.peek())
49
+ this.#started = now;
50
+ this.#work = { busy: work.busy, error: work.error };
51
+ this.#policy = policy;
52
+ batch(() => {
53
+ this.#busy.value = work.busy;
54
+ this.#error.value = work.error;
55
+ if (work.error !== undefined && work.error !== null) {
56
+ this.#shown = undefined;
57
+ this.#phase.value = "error";
58
+ return;
59
+ }
60
+ if (work.busy) {
61
+ if (this.#shown !== undefined) {
62
+ this.#phase.value = "working";
63
+ return;
64
+ }
65
+ const remaining = policy.delayMs - (now - (this.#started ?? now));
66
+ if (remaining > 0) {
67
+ this.#phase.value = "waiting";
68
+ this.#schedule(remaining);
69
+ }
70
+ else {
71
+ this.#shown = now;
72
+ this.#phase.value = "working";
73
+ }
74
+ return;
75
+ }
76
+ this.#started = undefined;
77
+ const remaining = this.#shown === undefined ? 0 : policy.minVisibleMs - (now - this.#shown);
78
+ if (remaining > 0) {
79
+ this.#phase.value = "complete";
80
+ this.#schedule(remaining);
81
+ }
82
+ else {
83
+ this.#shown = undefined;
84
+ this.#phase.value = "idle";
85
+ }
86
+ });
87
+ }
88
+ #schedule(ms) {
89
+ const revision = this.#revision;
90
+ this.#timer = setTimeout(() => {
91
+ if (!this.#disposed && revision === this.#revision)
92
+ this.#update(this.#work, this.#policy);
93
+ }, ms);
94
+ }
95
+ #cancel() { this.#revision++; clearTimeout(this.#timer); this.#timer = undefined; }
96
+ /** Stops timers/subscriptions; never cancels or completes the borrowed operation. */
97
+ dispose() {
98
+ if (this.#disposed)
99
+ return;
100
+ this.#disposed = true;
101
+ this.#cancel();
102
+ this.#stop();
103
+ this.#phase.value = "idle";
104
+ }
105
+ }
@@ -0,0 +1,13 @@
1
+ import type { Schema } from "./model.ts";
2
+ /** A metadata problem, with the JSON path of the offending member. */
3
+ export declare class SchemaError extends Error {
4
+ readonly path: string;
5
+ constructor(message: string, path: string);
6
+ }
7
+ /**
8
+ * Parses a CSDL JSON 4.01 `$metadata` document. Throws `SchemaError` when
9
+ * the document is malformed; a property it can't model (a stream, an
10
+ * untyped or complex value, a collection) is left out and listed in
11
+ * `skipped`, and the rest of the schema stands.
12
+ */
13
+ export declare function parseCsdl(json: unknown): Schema;