@sincpro/client-framework 0.5.0 → 0.6.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.
package/README.md CHANGED
@@ -297,15 +297,18 @@ case runs the same — nothing traces, nothing reports. In a test, `memoryTracer
297
297
  The backend writes every message with one set of rules, and every client reads and writes them
298
298
  the same — so they live here, not in each platform:
299
299
 
300
- | Value | Travels as | Here |
301
- | ----------- | ------------------------------------------ | ------------------------------------------------ |
302
- | a decimal | text, every digit: `"10.50"` | `Decimal` — exact, on `BigInt`; rounds half up |
303
- | a money | `{ "amount": "10.50", "currency": "BOB" }` | `Money` — never adds two currencies |
304
- | a day | `"2026-10-02"`, never converted | `isDay`, `dayOf(instant, zone)` for «today» |
305
- | an instant | ISO text: `"2026-10-02T14:30:00Z"` | `wallTimeOf` / `instantOf` on the company's zone |
306
- | an id | a UUID v7 as 32 hex | `newEntityId`, `isEntityId`, `momentOf` |
307
- | bytes | `{ "$bytes": "<base64>" }` | `valuesOf` / `readValues` |
308
- | the context | only what travels: text, numbers, lists | `plainCodec`, `travelling` |
300
+ | Value | Travels as | Here |
301
+ | ------------ | ------------------------------------------ | -------------------------------------------------- |
302
+ | a decimal | text, every digit: `"10.50"` | `Decimal` — exact, on `BigInt`; rounds half up |
303
+ | a money | `{ "amount": "10.50", "currency": "BOB" }` | `Money` — never adds two currencies |
304
+ | a day | `"2026-10-02"`, never converted | `isDay`, `dayOf(instant, zone)` for «today» |
305
+ | an instant | ISO text: `"2026-10-02T14:30:00Z"` | `wallTimeOf` / `instantOf` on the company's zone |
306
+ | an id | a UUID v7 as 32 hex | `newEntityId`, `isEntityId`, `momentOf` |
307
+ | bytes | `{ "$bytes": "<base64>" }` | `valuesOf` / `readValues` |
308
+ | the context | only what travels: text, numbers, lists | `plainCodec`, `travelling` |
309
+ | a to-many | `{ "items": […], "count": {…}, "cursor" }` | `EntityCollection<T>`, `collectionOf`, `isPartial` |
310
+ | an export | Parquet, Arrow or JSON Lines, streamed | `downloadRoute`, `exportRoute`, `Download` |
311
+ | a large page | Parquet, when the search says so | `restTransport({ decoders: [parquetDecoder] })` |
309
312
 
310
313
  ```ts
311
314
  import { Money, pack, unpack, valuesOf } from "@sincpro/client-framework";
@@ -322,6 +325,25 @@ Not restrictive, like the backend: reading keeps every key it finds, a typed rea
322
325
  once, naming where. Showing a value to a person — `Intl.NumberFormat`, a date picker — is each
323
326
  platform's, never this package's.
324
327
 
328
+ A relation that holds several records — the invoices of a customer — comes as a page of them,
329
+ never an array: forty records, «at least 1000», and the cursor to go on. Read it with
330
+ `collectionOf` (an array from a hand-written domain reads the same) — a `HeldCollection`, edited
331
+ the way a string is: `append`, `extend`, `remove`, `replace` answer a new one. Say «1000+» when
332
+ `count.exact` is false, and ask for the rest as a list of the related entity
333
+ (`relatedCriteria(meta.fields.invoices, customer)`). A part of the record — the lines of an
334
+ invoice — is written back only whole: the backend refuses a page of it.
335
+
336
+ A search asked for millions of records answers Parquet instead of JSON to a client that reads it.
337
+ Give the transport the decoder — an optional entry over `hyparquet` — and nothing above it
338
+ changes: the same page, each value as the JSON wire writes it.
339
+
340
+ ```ts
341
+ import { restTransport } from "@sincpro/client-framework";
342
+ import { parquetDecoder } from "@sincpro/client-framework/parquet";
343
+
344
+ const transport = restTransport({ base: "/v1", decoders: [parquetDecoder] });
345
+ ```
346
+
325
347
  ## Where it sits
326
348
 
327
349
  ```mermaid
@@ -0,0 +1,50 @@
1
+ import type { Decoder } from "../client/transport.js";
2
+ import type { AnyRecord, Count, Dropped, Envelope } from "@sincpro/criteria";
3
+ /**
4
+ * A page sincpro_framework answered as Parquet, read into what JSON would have sent — the
5
+ * `[optional]` entry `@sincpro/client-framework/parquet`, over `hyparquet` (an optional peer).
6
+ *
7
+ * restTransport({ base: "/v1", decoders: [parquetDecoder] })
8
+ * // a search of two million records: Accept says Parquet is read, the server answers it,
9
+ * // and the screen gets the same page — records, cursor, count — it gets from JSON
10
+ *
11
+ * Context: a JSON page of millions is the slowest, largest way to send them; a search answers
12
+ * Parquet past its `parquet_above`, only to a caller that says it reads it. This is that
13
+ * reading. Every value comes back as the JSON wire writes it, so nothing above the transport
14
+ * changes: a decimal as its exact text, a day as `"2026-11-01"`, an instant as ISO text, an
15
+ * integer as a number, what the server wrote as JSON text (`json` in the footer) parsed back.
16
+ * The cursor, the count and the model's version travel in the file's footer.
17
+ *
18
+ * One limit, said: hyparquet reads a decimal as a JavaScript number, written back here as text
19
+ * at the column's scale — exact while the unscaled value stays below 2^53 (with two decimals,
20
+ * amounts up to ninety thousand billion).
21
+ */
22
+ export declare const PARQUET = "application/vnd.apache.parquet";
23
+ /** What the footer of a sincpro_framework Parquet answer says beside the records. */
24
+ export type ParquetFooter = {
25
+ /** The response's field holding the records: `entries`. */
26
+ records: string;
27
+ cursor: string | null;
28
+ count: Count | null;
29
+ model_version: string;
30
+ dropped: Dropped[];
31
+ /** The columns written as JSON text: a value object, a translated text. */
32
+ json?: string[];
33
+ };
34
+ /**
35
+ * The records of a sincpro_framework Parquet file — an answer's body, an export downloaded —
36
+ * each as the JSON wire writes it, and what its footer says.
37
+ *
38
+ * const { records, footer } = await parquetRecords(await download.blob().then((b) => b.arrayBuffer()));
39
+ */
40
+ export declare function parquetRecords(bytes: ArrayBuffer): Promise<{
41
+ records: AnyRecord[];
42
+ footer: ParquetFooter | null;
43
+ }>;
44
+ /**
45
+ * A Parquet answer read into the envelope a page is built from — what JSON would have sent:
46
+ * the records under the response's own field, the cursor, the count, the model's version.
47
+ */
48
+ export declare function readParquetAnswer(response: Response): Promise<Envelope & AnyRecord>;
49
+ /** What `restTransport({ decoders })` takes: the media type it reads, and how. */
50
+ export declare const parquetDecoder: Decoder;
@@ -0,0 +1,151 @@
1
+ import { parquetMetadata, parquetReadObjects, } from "hyparquet";
2
+ /**
3
+ * A page sincpro_framework answered as Parquet, read into what JSON would have sent — the
4
+ * `[optional]` entry `@sincpro/client-framework/parquet`, over `hyparquet` (an optional peer).
5
+ *
6
+ * restTransport({ base: "/v1", decoders: [parquetDecoder] })
7
+ * // a search of two million records: Accept says Parquet is read, the server answers it,
8
+ * // and the screen gets the same page — records, cursor, count — it gets from JSON
9
+ *
10
+ * Context: a JSON page of millions is the slowest, largest way to send them; a search answers
11
+ * Parquet past its `parquet_above`, only to a caller that says it reads it. This is that
12
+ * reading. Every value comes back as the JSON wire writes it, so nothing above the transport
13
+ * changes: a decimal as its exact text, a day as `"2026-11-01"`, an instant as ISO text, an
14
+ * integer as a number, what the server wrote as JSON text (`json` in the footer) parsed back.
15
+ * The cursor, the count and the model's version travel in the file's footer.
16
+ *
17
+ * One limit, said: hyparquet reads a decimal as a JavaScript number, written back here as text
18
+ * at the column's scale — exact while the unscaled value stays below 2^53 (with two decimals,
19
+ * amounts up to ninety thousand billion).
20
+ */
21
+ export const PARQUET = "application/vnd.apache.parquet";
22
+ const ENVELOPE = "sincpro";
23
+ const pad = (value, size = 2) => String(value).padStart(size, "0");
24
+ /** A day as the wire writes it: days since 1970-01-01 → `"2026-11-01"`. */
25
+ function dayOf(days) {
26
+ const at = new Date(days * 86_400_000);
27
+ return `${at.getUTCFullYear()}-${pad(at.getUTCMonth() + 1)}-${pad(at.getUTCDate())}`;
28
+ }
29
+ /** An instant as the wire writes it, to the microsecond: `"2026-11-06T14:30:00.123456Z"`. */
30
+ function instantOf(micros) {
31
+ // Floored to the second, so an instant before 1970 keeps its fraction on the right side.
32
+ const fraction = ((micros % 1000000n) + 1000000n) % 1000000n;
33
+ const seconds = (micros - fraction) / 1000000n;
34
+ const at = new Date(Number(seconds) * 1000);
35
+ return `${at.toISOString().slice(0, 19)}.${pad(Number(fraction), 6)}Z`;
36
+ }
37
+ /** The decimal columns at the top of the file's schema. */
38
+ function decimalScales(metadata) {
39
+ const scales = new Map();
40
+ const [root, ...elements] = metadata.schema;
41
+ let index = 0;
42
+ const top = root?.num_children ?? 0;
43
+ for (let column = 0; column < top && index < elements.length; column += 1) {
44
+ const element = elements[index];
45
+ const converted = element.converted_type === "DECIMAL";
46
+ if (converted || element.logical_type?.type === "DECIMAL") {
47
+ const logical = element.logical_type;
48
+ scales.set(element.name, {
49
+ scale: element.scale ?? logical?.scale ?? 0,
50
+ scaled: converted,
51
+ });
52
+ }
53
+ index += 1 + descendants(elements, index);
54
+ }
55
+ return scales;
56
+ }
57
+ /** How many schema elements a nested column holds below the one at `at`. */
58
+ function descendants(elements, at) {
59
+ let count = 0;
60
+ let pending = elements[at]?.num_children ?? 0;
61
+ let next = at + 1;
62
+ while (pending > 0 && next < elements.length) {
63
+ count += 1;
64
+ pending -= 1;
65
+ pending += elements[next]?.num_children ?? 0;
66
+ next += 1;
67
+ }
68
+ return count;
69
+ }
70
+ /** One value as the JSON wire writes it. */
71
+ /** An unscaled decimal (an integer, a bigint) as its exact text at `scale`. */
72
+ function scaledText(unscaled, scale) {
73
+ const digits = BigInt(unscaled);
74
+ const negative = digits < 0n;
75
+ const text = (negative ? -digits : digits).toString().padStart(scale + 1, "0");
76
+ const whole = scale === 0 ? text : `${text.slice(0, -scale)}.${text.slice(-scale)}`;
77
+ return negative ? `-${whole}` : whole;
78
+ }
79
+ /** What the server wrote as JSON text, read back — left as it is when it is no JSON. */
80
+ function parsedJson(value) {
81
+ try {
82
+ return JSON.parse(value);
83
+ }
84
+ catch {
85
+ return value;
86
+ }
87
+ }
88
+ function wireValue(value, decimal, json) {
89
+ if (value === null || value === undefined)
90
+ return null;
91
+ if (decimal !== undefined) {
92
+ if (!decimal.scaled && (typeof value === "number" || typeof value === "bigint"))
93
+ return scaledText(value, decimal.scale);
94
+ if (typeof value === "number")
95
+ return value.toFixed(decimal.scale);
96
+ }
97
+ if (typeof value === "bigint")
98
+ return value <= BigInt(Number.MAX_SAFE_INTEGER) &&
99
+ value >= BigInt(Number.MIN_SAFE_INTEGER)
100
+ ? Number(value)
101
+ : value.toString();
102
+ if (json && typeof value === "string")
103
+ return parsedJson(value);
104
+ return value;
105
+ }
106
+ /**
107
+ * The records of a sincpro_framework Parquet file — an answer's body, an export downloaded —
108
+ * each as the JSON wire writes it, and what its footer says.
109
+ *
110
+ * const { records, footer } = await parquetRecords(await download.blob().then((b) => b.arrayBuffer()));
111
+ */
112
+ export async function parquetRecords(bytes) {
113
+ const metadata = parquetMetadata(bytes);
114
+ const said = metadata.key_value_metadata?.find((one) => one.key === ENVELOPE)?.value;
115
+ const footer = said ? JSON.parse(said) : null;
116
+ const scales = decimalScales(metadata);
117
+ const json = new Set(footer?.json ?? []);
118
+ const rows = await parquetReadObjects({
119
+ file: bytes,
120
+ metadata,
121
+ parsers: {
122
+ dateFromDays: dayOf,
123
+ timestampFromMicroseconds: instantOf,
124
+ timestampFromMilliseconds: (millis) => instantOf(millis * 1000n),
125
+ timestampFromNanoseconds: (nanos) => instantOf(nanos / 1000n),
126
+ },
127
+ });
128
+ const records = rows.map((row) => {
129
+ const record = {};
130
+ for (const [name, value] of Object.entries(row))
131
+ record[name] = wireValue(value, scales.get(name), json.has(name));
132
+ return record;
133
+ });
134
+ return { records, footer };
135
+ }
136
+ /**
137
+ * A Parquet answer read into the envelope a page is built from — what JSON would have sent:
138
+ * the records under the response's own field, the cursor, the count, the model's version.
139
+ */
140
+ export async function readParquetAnswer(response) {
141
+ const { records, footer } = await parquetRecords(await response.arrayBuffer());
142
+ return {
143
+ [footer?.records ?? "records"]: records,
144
+ cursor: footer?.cursor ?? null,
145
+ count: footer?.count ?? null,
146
+ model_version: footer?.model_version ?? "",
147
+ dropped: footer?.dropped ?? [],
148
+ };
149
+ }
150
+ /** What `restTransport({ decoders })` takes: the media type it reads, and how. */
151
+ export const parquetDecoder = { mediaType: PARQUET, decode: readParquetAnswer };
@@ -1,5 +1,5 @@
1
1
  import type { Method } from "./transport.js";
2
- import { type AnyRecord, type Expression } from "@sincpro/criteria";
2
+ import { type AnyRecord, type Criteria, type Expression } from "@sincpro/criteria";
3
3
  /**
4
4
  * The parts any record may have beside its name: what a link, a card or a record's header says
5
5
  * about it, whatever the entity.
@@ -97,6 +97,8 @@ export type Entity<T extends AnyRecord = AnyRecord, C extends CommandBodies = Co
97
97
  route: string;
98
98
  type: string;
99
99
  };
100
+ /** Every record of a criteria as a file: the route `exportRoute` fills, `downloadRoute` asks. */
101
+ export?: string;
100
102
  /** Which fields are its description, state, picture… when they are not the conventional
101
103
  * ones (`summaryOf`). Empty: the convention decides. */
102
104
  summary: SummaryFields;
@@ -193,6 +195,9 @@ export type EntityRoutes<C extends CommandBodies = CommandBodies> = CommandRoute
193
195
  route: string;
194
196
  type: string;
195
197
  };
198
+ /** `"/invoices/export"`: every record of a criteria as a file (`Export[Invoice, Streamed]`),
199
+ * asked through `exportRoute` and `downloadRoute`. */
200
+ export?: string;
196
201
  /** `{ image: "logo", state: "estado_pago", person: true }` — not a route, but declared once
197
202
  * with the rest of what a screen should not have to know. */
198
203
  summary?: SummaryFields;
@@ -238,5 +243,15 @@ export declare function bodyFor(route: WriteRoute, id: string | undefined, body:
238
243
  * key the path needs and the parameters lack is the screen's bug, said before anything is sent.
239
244
  */
240
245
  export declare function listRoute(list: string, params?: Readonly<Record<string, string>>): string;
246
+ /** What an export is written as: Parquet, an Arrow IPC stream, or JSON Lines. */
247
+ export type ExportFormat = "parquet" | "arrow" | "jsonl";
248
+ /**
249
+ * The route of an export: its path, its keys filled from `params`, the criteria and the format as
250
+ * its query — what `downloadRoute` asks.
251
+ *
252
+ * exportRoute("/invoices/export", { selection: { field: "paid", operator: "=", value: false } }, "jsonl")
253
+ * -> "/invoices/export?criteria=%7B…%7D&format=jsonl"
254
+ */
255
+ export declare function exportRoute<T>(path: string, criteria?: Criteria<T>, format?: ExportFormat, params?: Readonly<Record<string, string>>): string;
241
256
  export declare function entity<T extends AnyRecord = AnyRecord, C extends CommandBodies = CommandBodies, A extends CommandAnswers = CommandAnswers>(key: string, routes: EntityRoutes<C>): Entity<T, C, A>;
242
257
  export {};
@@ -1,4 +1,4 @@
1
- import { readExpression } from "@sincpro/criteria";
1
+ import { readExpression, } from "@sincpro/criteria";
2
2
  /**
3
3
  * The params that say what a reference without an id is for: `new` is the form of a record
4
4
  * that does not exist yet, `pick` is the list opened to choose one. Params, because they are
@@ -136,6 +136,20 @@ export function listRoute(list, params) {
136
136
  return path;
137
137
  return `${path}${path.includes("?") ? "&" : "?"}${query}`;
138
138
  }
139
+ /**
140
+ * The route of an export: its path, its keys filled from `params`, the criteria and the format as
141
+ * its query — what `downloadRoute` asks.
142
+ *
143
+ * exportRoute("/invoices/export", { selection: { field: "paid", operator: "=", value: false } }, "jsonl")
144
+ * -> "/invoices/export?criteria=%7B…%7D&format=jsonl"
145
+ */
146
+ export function exportRoute(path, criteria, format, params) {
147
+ return listRoute(path, {
148
+ ...params,
149
+ ...(criteria === undefined ? {} : { criteria: JSON.stringify(criteria) }),
150
+ ...(format === undefined ? {} : { format }),
151
+ });
152
+ }
139
153
  export function entity(key, routes) {
140
154
  if (!/^[^:\s]+:[^:\s]+$/.test(key))
141
155
  throw new Error(`entity key "${key}" is not domain:kind`);
@@ -181,6 +195,7 @@ export function entity(key, routes) {
181
195
  ...(routes.draft === undefined ? {} : { draft: writeRoute(routes.draft) }),
182
196
  ...(routes.edit === undefined ? {} : { edit: writeRoute(routes.edit) }),
183
197
  ...(routes.history === undefined ? {} : { history: routes.history }),
198
+ ...(routes.export === undefined ? {} : { export: routes.export }),
184
199
  summary: routes.summary ?? {},
185
200
  ...(routes.aggregate === undefined ? {} : { aggregate: routes.aggregate }),
186
201
  };
@@ -60,9 +60,11 @@ export function memoryTransport(tables, options = {}) {
60
60
  },
61
61
  async one(route, signal) {
62
62
  await check(route, signal);
63
- const slash = route.lastIndexOf("/");
64
- const table = tableAt(route.slice(0, slash));
65
- const id = decodeURIComponent(route.slice(slash + 1));
63
+ // A record's route may carry a question (`?draft=true`): the id is the path's last part.
64
+ const path = route.split("?")[0] ?? route;
65
+ const slash = path.lastIndexOf("/");
66
+ const table = tableAt(path.slice(0, slash));
67
+ const id = decodeURIComponent(path.slice(slash + 1));
66
68
  const key = table.meta.get_id || table.meta.identity;
67
69
  const record = table.rows.find((row) => String(row[key]) === id);
68
70
  if (record === undefined)
@@ -1,4 +1,4 @@
1
- import { type Transport } from "./transport.js";
1
+ import { type Decoder, type Transport } from "./transport.js";
2
2
  import { type Criteria } from "@sincpro/criteria";
3
3
  /**
4
4
  * The REST wire of sincpro_framework (`FastApiGateway`).
@@ -26,6 +26,12 @@ export type RestOptions = {
26
26
  headers?: Record<string, string> | (() => Record<string, string>);
27
27
  /** Defaults to the global `fetch`. Inject one for tests. */
28
28
  fetch?: typeof fetch;
29
+ /**
30
+ * The formats besides JSON this transport reads a page in — `[parquetDecoder]` from
31
+ * `@sincpro/client-framework/parquet`. Each is said in `Accept`, so a search that answers its
32
+ * large pages as Parquet does so only to a client that reads it.
33
+ */
34
+ decoders?: readonly Decoder[];
29
35
  /** Where the bounded context's discovery document is: `"/openapi.json"` by default, at the
30
36
  * origin of `base`. `null`: none is read, and the definitions come only from `metas`. */
31
37
  document?: string | null;
@@ -4,6 +4,25 @@ import { readProblem } from "@sincpro/criteria";
4
4
  import { RestError, restSource } from "@sincpro/criteria/rest";
5
5
  /** The keys of an answer that are not the record: what is left is the record's own field. */
6
6
  const ENVELOPE = new Set(["cursor", "count", "model_version", "dropped", "draft", "missing"]);
7
+ /**
8
+ * The name a `Content-Disposition` says to save a file as; `""` when it says none. The encoded
9
+ * form (`filename*=UTF-8'es'informe%20final.parquet`, RFC 5987) wins, its charset and language
10
+ * dropped and its bytes decoded; the plain one (`filename="report 100%.csv"`) is taken as it is.
11
+ */
12
+ function filenameOf(disposition) {
13
+ const said = disposition ?? "";
14
+ const encoded = /filename\*\s*=\s*([^']*)'[^']*'([^;]+)/i.exec(said);
15
+ if (encoded?.[2] !== undefined) {
16
+ try {
17
+ return decodeURIComponent(encoded[2].trim().replace(/^"|"$/g, ""));
18
+ }
19
+ catch {
20
+ return encoded[2].trim();
21
+ }
22
+ }
23
+ const plain = /filename\s*=\s*("([^"]*)"|[^;]+)/i.exec(said);
24
+ return (plain?.[2] ?? plain?.[1] ?? "").trim();
25
+ }
7
26
  export function urlOf(base, route, criteria) {
8
27
  const url = `${base}${route}`;
9
28
  if (criteria === undefined)
@@ -25,7 +44,17 @@ function documentUrl(base, document) {
25
44
  export function restTransport(options = {}) {
26
45
  const base = options.base ?? "";
27
46
  const doFetch = options.fetch ?? ((input, init) => fetch(input, init));
47
+ const decoders = options.decoders ?? [];
48
+ const accept = decoders.length === 0
49
+ ? {}
50
+ : { Accept: ["application/json", ...decoders.map((one) => one.mediaType)].join(", ") };
28
51
  const own = () => typeof options.headers === "function" ? options.headers() : (options.headers ?? {});
52
+ /** A body in a format a decoder reads, read by it; anything else left to JSON. */
53
+ const decode = async (response) => {
54
+ const type = (response.headers.get("content-type") ?? "").split(";")[0]?.trim();
55
+ const decoder = decoders.find((one) => one.mediaType === type);
56
+ return decoder === undefined ? undefined : decoder.decode(response);
57
+ };
29
58
  // The discovery document, read once; asked again — with its ETag — when an answer says a
30
59
  // version of its model the document did not.
31
60
  const located = options.document === null ? null : (options.document ?? "/openapi.json");
@@ -104,8 +133,10 @@ export function restTransport(options = {}) {
104
133
  return {
105
134
  async list(route, criteria, signal, call) {
106
135
  const source = restSource(`${base}${route}`, {
107
- headers: () => ({ ...own(), ...call?.headers }),
136
+ // Only a page may come in another format: the decoders are said here, and nowhere else.
137
+ headers: () => ({ ...accept, ...own(), ...call?.headers }),
108
138
  fetch: doFetch,
139
+ ...(decoders.length === 0 ? {} : { decode }),
109
140
  });
110
141
  try {
111
142
  const page = await source.read(criteria, signal);
@@ -123,6 +154,20 @@ export function restTransport(options = {}) {
123
154
  async read(route, signal, call) {
124
155
  return (await get(route, urlOf(base, route), signal, undefined, call));
125
156
  },
157
+ async download(route, signal, call) {
158
+ const response = await doFetch(urlOf(base, route), {
159
+ headers: { ...own(), ...call?.headers },
160
+ ...(signal ? { signal } : {}),
161
+ });
162
+ if (!response.ok)
163
+ throw new TransportError(route, response.status, readProblem(await response.text()), undefined);
164
+ return {
165
+ mediaType: (response.headers.get("content-type") ?? "").split(";")[0]?.trim() ?? "",
166
+ filename: filenameOf(response.headers.get("content-disposition")),
167
+ body: response.body,
168
+ blob: () => response.blob(),
169
+ };
170
+ },
126
171
  async one(route, signal, call) {
127
172
  const answer = (await get(route, urlOf(base, route), signal, undefined, call));
128
173
  const own = Object.keys(answer).filter((key) => !ENVELOPE.has(key));
@@ -1,4 +1,4 @@
1
- import type { Criteria, Dropped, Meta, Page, Problem } from "@sincpro/criteria";
1
+ import type { AnyRecord, Criteria, Dropped, Envelope, Meta, Page, Problem } from "@sincpro/criteria";
2
2
  /**
3
3
  * The one seam between this package and a server.
4
4
  *
@@ -24,7 +24,43 @@ export interface Transport {
24
24
  * so a transport written before it keeps compiling; call it through `readRoute`.
25
25
  */
26
26
  read?<T>(route: string, signal?: AbortSignal, options?: CallOptions): Promise<T>;
27
+ /**
28
+ * A file the server streams — an export (`Export[Entity, Streamed]`): the route with its
29
+ * parameters already in it, the bytes as they arrive. Optional, as `read`; call it through
30
+ * `downloadRoute`.
31
+ */
32
+ download?(route: string, signal?: AbortSignal, options?: CallOptions): Promise<Download>;
27
33
  }
34
+ /**
35
+ * What a server streams instead of answering JSON — every record of a criteria as Parquet, an
36
+ * Arrow stream or JSON Lines: what it is, the name to save it under, and its bytes.
37
+ *
38
+ * Context: an export is millions of rows, written by the server a page at a time; parsing it as
39
+ * JSON, or holding it whole before the first byte is used, is what it exists to avoid. `body`
40
+ * hands the bytes on as they come — to Arrow JS, to a file — where the platform's fetch streams
41
+ * (a browser); `blob()` reads them all, which is what a phone's fetch can do.
42
+ */
43
+ export type Download = {
44
+ /** `"application/vnd.apache.parquet"`, `"application/vnd.apache.arrow.stream"`, `"application/jsonl"`. */
45
+ mediaType: string;
46
+ /** What the server says to save it as (`Content-Disposition`); `""` when it says nothing. */
47
+ filename: string;
48
+ /** The bytes as they arrive; `null` where the platform's fetch has no streams. */
49
+ body: ReadableStream<Uint8Array> | null;
50
+ /** Every byte, at once. */
51
+ blob(): Promise<Blob>;
52
+ };
53
+ /**
54
+ * How a transport reads a page that came in another format than JSON — Parquet, from
55
+ * `@sincpro/client-framework/parquet` —: the media type, said to the server in `Accept`, and how
56
+ * a body of it becomes the envelope JSON would have sent.
57
+ */
58
+ export type Decoder = {
59
+ mediaType: string;
60
+ decode(response: Response): Promise<Envelope & AnyRecord>;
61
+ };
62
+ /** A download through `transport.download`, or the reason it cannot be, by name. */
63
+ export declare function downloadRoute(transport: Transport, route: string, signal?: AbortSignal, options?: CallOptions): Promise<Download>;
28
64
  /**
29
65
  * A read through `transport.read`, or the reason it cannot be: a transport that has no `read`
30
66
  * is said so by name, never a `TypeError` from somewhere inside a screen.
@@ -1,3 +1,9 @@
1
+ /** A download through `transport.download`, or the reason it cannot be, by name. */
2
+ export function downloadRoute(transport, route, signal, options) {
3
+ if (transport.download === undefined)
4
+ return Promise.reject(new Error(`this transport has no download(): it cannot ask GET ${route} — give it one`));
5
+ return transport.download(route, signal, options);
6
+ }
1
7
  /**
2
8
  * A read through `transport.read`, or the reason it cannot be: a transport that has no `read`
3
9
  * is said so by name, never a `TypeError` from somewhere inside a screen.
@@ -7,17 +7,20 @@ import { ownerOf, restOf, WRITES, } from "./rest.js";
7
7
  *
8
8
  * Context: a method only writes down the route the contract published and hands the call to the
9
9
  * transport, which keeps the rules — the `Idempotency-Key`, the `Problem`, the model's version.
10
- * A GET is a page (`list`), one record (`one`), or — a Query of the context's own, `GET /me` —
11
- * a read of its answer as it came (`readRoute`, over a transport that has `read`). A GET's
10
+ * A GET is a page (`list`), one record (`one`), a file (`export`: `downloadRoute`, its bytes as
11
+ * they arrive), or — a Query of the context's own, `GET /me` — a read of its answer as it came
12
+ * (`readRoute`, over a transport that has `read`). A GET's
12
13
  * keys in its path are required parameters; every other field is an optional one.
13
14
  */
14
15
  /** A search's fields a page asks with, not the screen: the criteria, and its cursor inside it. */
15
16
  const PAGED = new Set(["criteria", "cursor"]);
17
+ /** An export's fields its method takes apart: the criteria and the format. */
18
+ const EXPORTED = new Set(["criteria", "cursor", "format"]);
16
19
  /** A GET's parameters: the path's keys, and the rest of its input but what a page asks with. */
17
- function askedOf(operation, rest, paged) {
20
+ function askedOf(operation, rest, paged, apart = PAGED) {
18
21
  const path = [...rest.fields].sort();
19
22
  const query = Object.keys(operation.input.properties ?? {})
20
- .filter((one) => !path.includes(one) && !(paged && PAGED.has(one)))
23
+ .filter((one) => !path.includes(one) && !(paged && apart.has(one)))
21
24
  .sort();
22
25
  return { path, query };
23
26
  }
@@ -29,6 +32,9 @@ function callOf(metamodel, operation, rest) {
29
32
  const owner = ownerOf(metamodel, operation);
30
33
  if (operation.role === "search" && owner !== undefined && owner in metamodel.models)
31
34
  return { kind: "list", entity: owner, ...askedOf(operation, rest, true) };
35
+ // An export answers bytes, never JSON: it is downloaded.
36
+ if (operation.role === "export" && owner !== undefined && owner in metamodel.models)
37
+ return { kind: "download", entity: owner, ...askedOf(operation, rest, true, EXPORTED) };
32
38
  const field = owner === undefined ? undefined : operation.targets[owner];
33
39
  const record = rest.fields.length === 1 && rest.fields[0] === field;
34
40
  if (operation.role === "get" && owner !== undefined && field !== undefined && record)
@@ -78,6 +84,15 @@ function methodSourceOf(local, answer, method, call, rest, doc) {
78
84
  const signature = params.signature === "" ? "" : `, ${params.signature}`;
79
85
  return `${doc} ${method}: (criteria: Criteria<${entity}>${signature}) =>\n transport.list<${entity}>(${params.route(rest.path)}, criteria),`;
80
86
  }
87
+ if (call.kind === "download") {
88
+ const entity = `dto.${identifierOf(call.entity)}`;
89
+ const params = paramsOf(call);
90
+ // The path's keys are required, so they come first: a required parameter never follows an
91
+ // optional one.
92
+ const keys = params.signature === "" ? "" : `${params.signature.replace("params?:", "params:")}, `;
93
+ const given = params.signature === "" ? "" : ", params";
94
+ return `${doc} ${method}: (${keys}criteria?: Criteria<${entity}>, format?: ExportFormat) =>\n downloadRoute(transport, exportRoute(${JSON.stringify(rest.path)}, criteria, format${given})),`;
95
+ }
81
96
  if (call.kind === "read") {
82
97
  const params = paramsOf(call);
83
98
  return `${doc} ${method}: (${params.signature}) =>\n readRoute<dto.${answer}>(transport, ${params.route(rest.path)}),`;
@@ -122,6 +137,7 @@ export function clientFileOf(metamodel, options = {}) {
122
137
  let paged = false;
123
138
  let reads = false;
124
139
  let sends = false;
140
+ let downloads = false;
125
141
  for (const [identity, operation] of operations) {
126
142
  const local = localOf(metamodel, identity);
127
143
  const rest = restOf(metamodel, identity, options);
@@ -135,6 +151,10 @@ export function clientFileOf(metamodel, options = {}) {
135
151
  claim(method, `operation ${identity}`);
136
152
  const doc = docOf([operation.description, deprecationOf(metamodel, operation)], " ");
137
153
  methods.push(methodSourceOf(local, answerOf(metamodel, identity), method, call, rest, doc));
154
+ if (call.kind === "download") {
155
+ downloads = true;
156
+ paged = true;
157
+ }
138
158
  if (call.kind === "list" || call.kind === "read") {
139
159
  paged ||= call.kind === "list";
140
160
  reads ||= call.kind === "read";
@@ -147,6 +167,7 @@ export function clientFileOf(metamodel, options = {}) {
147
167
  }
148
168
  const core = [
149
169
  "bindable",
170
+ ...(downloads ? ["downloadRoute", "type ExportFormat", "exportRoute"] : []),
150
171
  ...(lists ? ["listRoute"] : []),
151
172
  ...(sends ? ["pathFor"] : []),
152
173
  ...(reads ? ["readRoute"] : []),
@@ -20,7 +20,11 @@ const DEFINITIONS = "#/$defs/";
20
20
  /** A shared definition a package of the stack already types: imported, never written again. */
21
21
  const IMPORTED = {
22
22
  Criteria: { name: "Criteria", from: "@sincpro/criteria" },
23
+ Download: { name: "Download", from: "@sincpro/client-framework" },
24
+ EntityCollection: { name: "EntityCollection", from: "@sincpro/client-framework" },
23
25
  };
26
+ /** A relation that holds several records: a reading answers it as a page of them. */
27
+ const TO_MANY = new Set(["one2many", "many2many"]);
24
28
  /** The field types whose value is the record itself, not a key that names it. */
25
29
  const RELATIONAL = new Set(["many2one", "one2many", "many2many", "embedded"]);
26
30
  /** A related record: in an answer only when the read expands it. */
@@ -82,6 +86,29 @@ function optional(name, property, schema, record) {
82
86
  return EXPANDED.has(property["x-sincpro"]?.type ?? "");
83
87
  return !(schema.required ?? []).includes(name);
84
88
  }
89
+ /**
90
+ * The records a to-many holds, when `property` is one — however the contract writes it: the
91
+ * collection a reading answers (`{items, count, cursor}`, sincpro_framework 3.33 on) or, before
92
+ * that, a bare array, which said less than the wire sent.
93
+ */
94
+ function heldRecords(property) {
95
+ if (!TO_MANY.has(property["x-sincpro"]?.type ?? ""))
96
+ return undefined;
97
+ const held = property.properties?.["items"] ?? property;
98
+ return held.type === "array" ? held.items : undefined;
99
+ }
100
+ /**
101
+ * A property's TypeScript. A to-many inside a record is an `EntityCollection` of its records —
102
+ * a page with its count and cursor, as the read answers it — never an array, which would say the
103
+ * page is all there is.
104
+ */
105
+ function propertyTypeOf(property, scope, depth, record) {
106
+ const records = record ? heldRecords(property) : undefined;
107
+ if (records === undefined)
108
+ return typeOf(property, scope, depth, record);
109
+ scope.imports.add("EntityCollection");
110
+ return `EntityCollection<${typeOf(records, scope, depth, record)}>`;
111
+ }
85
112
  function objectOf(schema, scope, depth, record) {
86
113
  const properties = sorted(schema.properties ?? {});
87
114
  if (properties.length === 0) {
@@ -95,7 +122,7 @@ function objectOf(schema, scope, depth, record) {
95
122
  const lines = properties.map(([name, property]) => {
96
123
  const doc = docOf([property.description, keyNote(property)], pad);
97
124
  const mark = optional(name, property, schema, record) ? "?" : "";
98
- return `${doc}${pad}${keyOf(name)}${mark}: ${typeOf(property, scope, depth + 1, record)};`;
125
+ return `${doc}${pad}${keyOf(name)}${mark}: ${propertyTypeOf(property, scope, depth + 1, record)};`;
99
126
  });
100
127
  return `{\n${lines.join("\n")}\n${" ".repeat(depth)}}`;
101
128
  }
@@ -133,6 +160,13 @@ function typeOf(schema, scope, depth, record) {
133
160
  : objectOf(schema, scope, depth, record);
134
161
  }
135
162
  }
163
+ /** What a streamed answer is on the client — an export's bytes (`Download`) —, else nothing. */
164
+ function streamed(output, scope) {
165
+ if (output["x-sincpro-streamed"] === undefined)
166
+ return undefined;
167
+ scope.imports.add("Download");
168
+ return "Download";
169
+ }
136
170
  /** What the server says of an operation: its words, then what it is, for whom, on which field. */
137
171
  function operationDoc(metamodel, local, operation) {
138
172
  const runsOn = sorted(operation.targets).map(([entity, field]) => `\`${entity}\` in \`${field}\``);
@@ -172,7 +206,9 @@ export function domainFileOf(metamodel) {
172
206
  const local = localOf(metamodel, identity);
173
207
  const named = answerOf(metamodel, identity);
174
208
  claim(local, `operation ${identity}`);
175
- const answer = operation.output === null ? "unknown" : typeOf(operation.output, scope, 0, false);
209
+ const answer = operation.output === null
210
+ ? "unknown"
211
+ : (streamed(operation.output, scope) ?? typeOf(operation.output, scope, 0, false));
176
212
  const written = [
177
213
  `${operationDoc(metamodel, local, operation)}export type ${local} = ${typeOf(operation.input, scope, 0, false)};`,
178
214
  ];
@@ -199,9 +235,14 @@ export function domainFileOf(metamodel) {
199
235
  claim(identifierOf(name), `definition ${name}`);
200
236
  return written.get(name);
201
237
  });
202
- const imports = [...scope.imports].sort().map((name) => {
238
+ // One line per package, its names sorted.
239
+ const byPackage = new Map();
240
+ for (const name of [...scope.imports].sort()) {
203
241
  const imported = IMPORTED[name];
204
- return `import type { ${imported.name} } from "${imported.from}";`;
205
- });
242
+ byPackage.set(imported.from, [...(byPackage.get(imported.from) ?? []), imported.name]);
243
+ }
244
+ const imports = [...byPackage.keys()]
245
+ .sort()
246
+ .map((from) => `import type { ${byPackage.get(from).join(", ")} } from "${from}";`);
206
247
  return `${[headerOf("The DTOs", metamodel), ...(imports.length === 0 ? [] : [imports.join("\n")]), ...models, ...shared, ...operations].join("\n\n")}\n`;
207
248
  }
@@ -5,7 +5,8 @@ import { type GenerateOptions } from "./rest.js";
5
5
  *
6
6
  * 1. Per model, by name: the one `search` a transport reads for it (`own` when there are
7
7
  * several) — none, and the model gets no entity, said in the file's own doc.
8
- * 2. Its `get`, `edit` and `preview` the same way, the record's field written `{id}`.
8
+ * 2. Its `get`, `edit` and `preview` the same way, the record's field written `{id}`, and its
9
+ * `export` — every record of a criteria as a file — by its path.
9
10
  * 3. Every write of role `command` that concerns it: with `target` when its body names the
10
11
  * record, as it is when it creates one; its body typed without that field.
11
12
  * Final: an `entity<T, C>()` per model listed, keyed `<domain>:<kind>`, and `entities`, all of
@@ -61,10 +61,18 @@ function entitySourceOf(metamodel, model, domain, options, claim) {
61
61
  const missing = [];
62
62
  const routes = [` list: ${JSON.stringify(list.found.rest.path)},`];
63
63
  const one = oneOf(reads("get", isGet), "get");
64
+ const edit = oneOf(reads("edit", isWrite), "edit");
64
65
  if (one !== undefined && "found" in one) {
65
66
  const field = one.found.operation.targets[model];
67
+ // sincpro_framework's `Get` answers the caller's own draft only when asked (`draft`), and an
68
+ // entity edited through `Edit` keeps its drafts there: its record is read with them, or a
69
+ // draft kept on one visit never comes back on the next.
70
+ const drafts = edit !== undefined &&
71
+ "found" in edit &&
72
+ one.found.operation.input.properties?.["draft"]?.type === "boolean";
73
+ const path = `${recordPath(one.found.rest, field)}${drafts ? "?draft=true" : ""}`;
66
74
  if (field !== undefined && one.found.rest.fields.length === 1)
67
- routes.push(` one: ${JSON.stringify(recordPath(one.found.rest, field))},`);
75
+ routes.push(` one: ${JSON.stringify(path)},`);
68
76
  else
69
77
  missing.push(`one: \`${one.found.local}\` is at ${one.found.rest.path}`);
70
78
  }
@@ -82,6 +90,13 @@ function entitySourceOf(metamodel, model, domain, options, claim) {
82
90
  const route = `${write.found.rest.method} ${recordPath(write.found.rest, field)}`;
83
91
  routes.push(` ${role}: ${JSON.stringify(route)},`);
84
92
  }
93
+ const exported = oneOf(reads("export", isGet), "export");
94
+ if (exported !== undefined) {
95
+ if ("missing" in exported)
96
+ missing.push(`export: ${exported.missing}`);
97
+ else
98
+ routes.push(` export: ${JSON.stringify(exported.found.rest.path)},`);
99
+ }
85
100
  const commands = reached.filter((one) => one.operation.role === "command" &&
86
101
  isWrite(one.rest.method) &&
87
102
  one.operation.entities.includes(model));
@@ -127,7 +142,8 @@ function entitySourceOf(metamodel, model, domain, options, claim) {
127
142
  *
128
143
  * 1. Per model, by name: the one `search` a transport reads for it (`own` when there are
129
144
  * several) — none, and the model gets no entity, said in the file's own doc.
130
- * 2. Its `get`, `edit` and `preview` the same way, the record's field written `{id}`.
145
+ * 2. Its `get`, `edit` and `preview` the same way, the record's field written `{id}`, and its
146
+ * `export` — every record of a criteria as a file — by its path.
131
147
  * 3. Every write of role `command` that concerns it: with `target` when its body names the
132
148
  * record, as it is when it creates one; its body typed without that field.
133
149
  * Final: an `entity<T, C>()` per model listed, keyed `<domain>:<kind>`, and `entities`, all of
@@ -26,7 +26,11 @@ export type Schema = {
26
26
  type?: string;
27
27
  relation?: string | null;
28
28
  identity?: string;
29
+ /** A to-many whose records are other aggregates: listed and searched, never edited as lines. */
30
+ reference?: boolean;
29
31
  };
32
+ /** An answer that is bytes as they are produced (an export): the media types it may be. */
33
+ "x-sincpro-streamed"?: readonly string[];
30
34
  };
31
35
  export type Operation = {
32
36
  /** What it is for its entity: `search`, `get`, `edit`, `command`… */
@@ -0,0 +1,110 @@
1
+ import type { AnyRecord, Count, Criteria } from "@sincpro/criteria";
2
+ /**
3
+ * A to-many as a reading answers it — sincpro_framework's `EntityCollection`: the records, how
4
+ * many there are, and the cursor to go on inside this parent. Never a bare list, which would
5
+ * say the page is all there is.
6
+ *
7
+ * customer.invoices -> { items: [i1, …, i40], count: { value: 1000, exact: false }, cursor: "eyJ…" }
8
+ *
9
+ * Context: a customer may have a million invoices, and the read brings forty of them with
10
+ * «at least a thousand» — the backend counts up to a cap (`RELATION_COUNT_CAP`) because an exact
11
+ * total reads every child. A screen that counted `items` showed forty and called it the total;
12
+ * one that iterated an array never knew there was more. The same type on every platform, so the
13
+ * web, the phone and a script read a relation the same way.
14
+ *
15
+ * `count` is `null` when the read asked for none (`CountMode.NONE`): the cursor alone says there
16
+ * is more. A collection is written back only whole — the backend refuses a page of it, because
17
+ * written back it would remove what it did not bring.
18
+ */
19
+ export type EntityCollection<T> = {
20
+ readonly items: readonly T[];
21
+ readonly count?: Count | null;
22
+ readonly cursor?: string | null;
23
+ };
24
+ /**
25
+ * A collection with every part said — what `collectionOf` answers —, edited the way a string or
26
+ * a date is: `append`, `extend`, `remove` and `replace` answer a new collection and never change
27
+ * this one, as sincpro_framework's `EntityCollection` does.
28
+ *
29
+ * const lines = collectionOf(invoice.lines);
30
+ * const next = lines.append(newLine).remove(oldLine.id); // lines is as it was
31
+ *
32
+ * An edit keeps what the collection says: a page stays a page (its cursor; a floor stays a
33
+ * floor), an exact count moves with what was added or removed. `remove` and `replace` of a record
34
+ * it does not hold throw, as Python's `list.remove` does. Written as JSON it is the wire's shape:
35
+ * `{ items, count, cursor }`.
36
+ */
37
+ export declare class HeldCollection<T> implements EntityCollection<T> {
38
+ #private;
39
+ readonly items: readonly T[];
40
+ readonly count: Count | null;
41
+ readonly cursor: string | null;
42
+ constructor(items: readonly T[], count?: Count | null, cursor?: string | null, identity?: string);
43
+ /** How many records are held — this page, never the total (`count` is that). */
44
+ get length(): number;
45
+ /** Whether it holds less than there is (`isPartial`). */
46
+ get partial(): boolean;
47
+ [Symbol.iterator](): Iterator<T>;
48
+ /** A new collection with `records` at the end. */
49
+ append(...records: T[]): HeldCollection<T>;
50
+ /** A new collection with every one of `records` at the end, in order. */
51
+ extend(records: Iterable<T>): HeldCollection<T>;
52
+ /** A new collection without these records — each named by itself or by its identity. */
53
+ remove(...records: unknown[]): HeldCollection<T>;
54
+ /** A new collection where each of `records` takes the place of the held one with its identity. */
55
+ replace(...records: T[]): HeldCollection<T>;
56
+ toJSON(): EntityCollection<T>;
57
+ }
58
+ /** Whether `value` is a collection as a reading answers one (`{items, …}`). */
59
+ export declare function isCollection(value: unknown): value is EntityCollection<unknown>;
60
+ /**
61
+ * Whatever a relation arrived as, as a collection with every part said.
62
+ *
63
+ * collectionOf({ items: [a, b], count: { value: 30, exact: true }, cursor: "x" })
64
+ * -> the same, `cursor` and `count` said
65
+ * collectionOf([a, b]) -> { items: [a, b], count: { value: 2, exact: true }, cursor: null }
66
+ * collectionOf(undefined) -> { items: [], count: { value: 0, exact: true }, cursor: null }
67
+ *
68
+ * An array — a domain written by hand, a record built in memory — holds all there is. `identity`
69
+ * is the field `remove` and `replace` match records by (the model's `identity`, `"id"`).
70
+ */
71
+ export declare function collectionOf<T>(value: EntityCollection<T> | readonly T[] | null | undefined, identity?: string): HeldCollection<T>;
72
+ /**
73
+ * Whether the collection holds less than there is — sincpro_framework's `is_partial`, the same
74
+ * rule: a cursor says there is more; no count claims nothing; a floor, or a count above what it
75
+ * holds, says there is more.
76
+ *
77
+ * isPartial({ items: [a], cursor: "x" }) -> true
78
+ * isPartial({ items: [a], count: { value: 1000, exact: false } }) -> true
79
+ * isPartial({ items: [a, b], count: { value: 2, exact: true } }) -> false
80
+ * isPartial({ items: [a] }) -> false
81
+ */
82
+ export declare function isPartial(collection: EntityCollection<unknown>): boolean;
83
+ /**
84
+ * How many there are, as well as the collection knows: its count, or — when the read asked for
85
+ * none — what it holds, exact only when nothing says there is more.
86
+ *
87
+ * totalOf({ items: [a, b], count: { value: 1000, exact: false } }) -> { value: 1000, exact: false }
88
+ * totalOf({ items: [a, b], cursor: "x" }) -> { value: 2, exact: false }
89
+ */
90
+ export declare function totalOf(collection: EntityCollection<unknown>): Count;
91
+ /** How a relation ties two records, as a model's definition says it (`FieldMeta`). */
92
+ export type RelationKeys = {
93
+ identified_by?: string | null;
94
+ parent_field?: string | null;
95
+ related_field?: string | null;
96
+ };
97
+ /**
98
+ * The question «the related records of this one», over the related entity — sincpro_framework's
99
+ * `References.criteria()`: what pages a relation past what its record brought, searches it and
100
+ * orders it, a list like any other. `criteria` narrows it; it never widens it.
101
+ *
102
+ * relatedCriteria(meta.fields.invoices, customer, "id")
103
+ * -> { selection: { field: "customer_id", operator: "=", value: customer.id } }
104
+ * relatedCriteria(field, customer, "id", { order: [{ field: "issued_on", descending: true }] })
105
+ *
106
+ * The two fields the definition names (`parent_field`, `related_field`) are read when it names
107
+ * them — a relation by a business key —; `identified_by` against the record's own key otherwise.
108
+ * A relation whose definition says neither cannot be asked, and says so.
109
+ */
110
+ export declare function relatedCriteria<T extends AnyRecord>(keys: RelationKeys, record: AnyRecord, identity?: string, criteria?: Criteria<T>): Criteria<T>;
@@ -0,0 +1,169 @@
1
+ /**
2
+ * A collection with every part said — what `collectionOf` answers —, edited the way a string or
3
+ * a date is: `append`, `extend`, `remove` and `replace` answer a new collection and never change
4
+ * this one, as sincpro_framework's `EntityCollection` does.
5
+ *
6
+ * const lines = collectionOf(invoice.lines);
7
+ * const next = lines.append(newLine).remove(oldLine.id); // lines is as it was
8
+ *
9
+ * An edit keeps what the collection says: a page stays a page (its cursor; a floor stays a
10
+ * floor), an exact count moves with what was added or removed. `remove` and `replace` of a record
11
+ * it does not hold throw, as Python's `list.remove` does. Written as JSON it is the wire's shape:
12
+ * `{ items, count, cursor }`.
13
+ */
14
+ export class HeldCollection {
15
+ items;
16
+ count;
17
+ cursor;
18
+ /** The field each record's identity is in — what `remove` and `replace` match by. */
19
+ #identity;
20
+ constructor(items, count = null, cursor = null, identity = "id") {
21
+ this.items = Object.freeze([...items]);
22
+ this.count = count;
23
+ this.cursor = cursor;
24
+ this.#identity = identity;
25
+ Object.freeze(this);
26
+ }
27
+ /** How many records are held — this page, never the total (`count` is that). */
28
+ get length() {
29
+ return this.items.length;
30
+ }
31
+ /** Whether it holds less than there is (`isPartial`). */
32
+ get partial() {
33
+ return isPartial(this);
34
+ }
35
+ [Symbol.iterator]() {
36
+ return this.items[Symbol.iterator]();
37
+ }
38
+ /** A new collection with `records` at the end. */
39
+ append(...records) {
40
+ return this.#edited([...this.items, ...records], records.length);
41
+ }
42
+ /** A new collection with every one of `records` at the end, in order. */
43
+ extend(records) {
44
+ return this.append(...records);
45
+ }
46
+ /** A new collection without these records — each named by itself or by its identity. */
47
+ remove(...records) {
48
+ const unwanted = new Set(records.map((one) => this.#keyOf(one)));
49
+ const held = new Set(this.items.map((one) => this.#keyOf(one)));
50
+ const missing = [...unwanted].filter((key) => !held.has(key));
51
+ if (missing.length > 0)
52
+ throw new Error(`not in the collection: ${missing.join(", ")}`);
53
+ return this.#edited(this.items.filter((one) => !unwanted.has(this.#keyOf(one))), -unwanted.size);
54
+ }
55
+ /** A new collection where each of `records` takes the place of the held one with its identity. */
56
+ replace(...records) {
57
+ const byKey = new Map(records.map((one) => [this.#keyOf(one), one]));
58
+ const held = new Set(this.items.map((one) => this.#keyOf(one)));
59
+ const missing = [...byKey.keys()].filter((key) => !held.has(key));
60
+ if (missing.length > 0)
61
+ throw new Error(`not in the collection: ${missing.join(", ")} — append adds a record`);
62
+ return this.#edited(this.items.map((one) => byKey.get(this.#keyOf(one)) ?? one), 0);
63
+ }
64
+ toJSON() {
65
+ return { items: this.items, count: this.count, cursor: this.cursor };
66
+ }
67
+ #keyOf(record) {
68
+ if (typeof record !== "object" || record === null)
69
+ return record;
70
+ const key = record[this.#identity];
71
+ // A record not stored yet has no identity: it is itself, never every other new one too.
72
+ return key === undefined || key === null || key === "" ? record : key;
73
+ }
74
+ #edited(items, moved) {
75
+ const count = this.count !== null && this.count.exact
76
+ ? { value: Math.max(this.count.value + moved, 0), exact: true }
77
+ : this.count;
78
+ return new HeldCollection(items, count, this.cursor, this.#identity);
79
+ }
80
+ }
81
+ /** Whether `value` is a collection as a reading answers one (`{items, …}`). */
82
+ export function isCollection(value) {
83
+ if (typeof value !== "object" || value === null || Array.isArray(value))
84
+ return false;
85
+ if (!("items" in value))
86
+ return false;
87
+ const items = value.items;
88
+ // A page whose records did not come (`items: null`) is still a page: its count says so.
89
+ return Array.isArray(items) || items === null || items === undefined;
90
+ }
91
+ /**
92
+ * Whatever a relation arrived as, as a collection with every part said.
93
+ *
94
+ * collectionOf({ items: [a, b], count: { value: 30, exact: true }, cursor: "x" })
95
+ * -> the same, `cursor` and `count` said
96
+ * collectionOf([a, b]) -> { items: [a, b], count: { value: 2, exact: true }, cursor: null }
97
+ * collectionOf(undefined) -> { items: [], count: { value: 0, exact: true }, cursor: null }
98
+ *
99
+ * An array — a domain written by hand, a record built in memory — holds all there is. `identity`
100
+ * is the field `remove` and `replace` match records by (the model's `identity`, `"id"`).
101
+ */
102
+ export function collectionOf(value, identity = "id") {
103
+ if (value instanceof HeldCollection && identity === "id")
104
+ return value;
105
+ if (Array.isArray(value))
106
+ return new HeldCollection(value, { value: value.length, exact: true }, null, identity);
107
+ if (isCollection(value)) {
108
+ const page = value;
109
+ return new HeldCollection(page.items ?? [], page.count ?? null, page.cursor ?? null, identity);
110
+ }
111
+ return new HeldCollection([], { value: 0, exact: true }, null, identity);
112
+ }
113
+ /**
114
+ * Whether the collection holds less than there is — sincpro_framework's `is_partial`, the same
115
+ * rule: a cursor says there is more; no count claims nothing; a floor, or a count above what it
116
+ * holds, says there is more.
117
+ *
118
+ * isPartial({ items: [a], cursor: "x" }) -> true
119
+ * isPartial({ items: [a], count: { value: 1000, exact: false } }) -> true
120
+ * isPartial({ items: [a, b], count: { value: 2, exact: true } }) -> false
121
+ * isPartial({ items: [a] }) -> false
122
+ */
123
+ export function isPartial(collection) {
124
+ if (collection.cursor != null)
125
+ return true;
126
+ const count = collection.count;
127
+ if (count == null)
128
+ return false;
129
+ return !count.exact || count.value > (collection.items ?? []).length;
130
+ }
131
+ /**
132
+ * How many there are, as well as the collection knows: its count, or — when the read asked for
133
+ * none — what it holds, exact only when nothing says there is more.
134
+ *
135
+ * totalOf({ items: [a, b], count: { value: 1000, exact: false } }) -> { value: 1000, exact: false }
136
+ * totalOf({ items: [a, b], cursor: "x" }) -> { value: 2, exact: false }
137
+ */
138
+ export function totalOf(collection) {
139
+ return (collection.count ?? {
140
+ value: (collection.items ?? []).length,
141
+ exact: collection.cursor == null,
142
+ });
143
+ }
144
+ /**
145
+ * The question «the related records of this one», over the related entity — sincpro_framework's
146
+ * `References.criteria()`: what pages a relation past what its record brought, searches it and
147
+ * orders it, a list like any other. `criteria` narrows it; it never widens it.
148
+ *
149
+ * relatedCriteria(meta.fields.invoices, customer, "id")
150
+ * -> { selection: { field: "customer_id", operator: "=", value: customer.id } }
151
+ * relatedCriteria(field, customer, "id", { order: [{ field: "issued_on", descending: true }] })
152
+ *
153
+ * The two fields the definition names (`parent_field`, `related_field`) are read when it names
154
+ * them — a relation by a business key —; `identified_by` against the record's own key otherwise.
155
+ * A relation whose definition says neither cannot be asked, and says so.
156
+ */
157
+ export function relatedCriteria(keys, record, identity = "id", criteria = {}) {
158
+ const there = keys.related_field ?? keys.identified_by;
159
+ const here = keys.related_field != null ? (keys.parent_field ?? identity) : identity;
160
+ if (there == null || there === "")
161
+ throw new Error("this relation's definition names no key to ask it by (identified_by, related_field)");
162
+ const mine = { field: there, operator: "=", value: record[here] };
163
+ return {
164
+ ...criteria,
165
+ selection: (criteria.selection === undefined
166
+ ? mine
167
+ : { all: [criteria.selection, mine] }),
168
+ };
169
+ }
package/dist/index.d.ts CHANGED
@@ -7,7 +7,8 @@
7
7
  * client it calls and the entity it reads come from the same place.
8
8
  *
9
9
  * common/ the values of the wire, as the backend writes them: ids, a day and an instant,
10
- * `Decimal`, `Money`, and how any value is written and read (`valuesOf`, `rebuilt`)
10
+ * `Decimal`, `Money`, a to-many as a page of its records (`EntityCollection`), and
11
+ * how any value is written and read (`valuesOf`, `rebuilt`)
11
12
  * client/ the seam with a server: the transport and its REST and in-memory implementations,
12
13
  * entities with typed commands, the service's client contract
13
14
  * model/ model definitions: written by hand, read from a JSON Schema, kept once known
@@ -20,11 +21,12 @@
20
21
  * `@sincpro/client-framework/testing`: neither is part of what an application runs.
21
22
  */
22
23
  export { type ClientContract, entityKeyOf, kindOf, metasOf, versionsOf, } from "./client/contract.js";
23
- export { bodyFor, type CommandAnswers, type CommandBodies, type CommandRoute, type Entity, entity, type EntityRef, type EntityRoutes, listRoute, NEW_PARAM, pathFor, PICK_PARAM, QUERY_PARAM, SCOPE_PARAM, scopeOfRef, startingValuesOf, type SummaryFields, type SummaryPart, TRAIL_PARAM, WITH_PARAM, type WriteRoute, writeRoute, } from "./client/entity.js";
24
+ export { bodyFor, type CommandAnswers, type CommandBodies, type CommandRoute, type Entity, entity, type EntityRef, type EntityRoutes, type ExportFormat, exportRoute, listRoute, NEW_PARAM, pathFor, PICK_PARAM, QUERY_PARAM, SCOPE_PARAM, scopeOfRef, startingValuesOf, type SummaryFields, type SummaryPart, TRAIL_PARAM, WITH_PARAM, type WriteRoute, writeRoute, } from "./client/entity.js";
24
25
  export { type MemoryOptions, type MemoryTable, memoryTransport, } from "./client/memory.js";
25
26
  export { type RestOptions, restTransport, urlOf, type VersionDrift, } from "./client/rest.js";
26
27
  export { type RetryKeys, retryKeys, unanswered, } from "./client/retry.js";
27
- export { type CallOptions, type Draft, type Method, type One, readRoute, type SendOptions, type Transport, TransportError, } from "./client/transport.js";
28
+ export { type CallOptions, type Decoder, type Download, downloadRoute, type Draft, type Method, type One, readRoute, type SendOptions, type Transport, TransportError, } from "./client/transport.js";
29
+ export { collectionOf, type EntityCollection, type HeldCollection, isCollection, isPartial, relatedCriteria, type RelationKeys, totalOf, } from "./common/collection.js";
28
30
  export { Decimal, type DecimalLike } from "./common/decimal.js";
29
31
  export { isEntityId, momentOf, newEntityId } from "./common/ids.js";
30
32
  export { Money } from "./common/money.js";
package/dist/index.js CHANGED
@@ -7,7 +7,8 @@
7
7
  * client it calls and the entity it reads come from the same place.
8
8
  *
9
9
  * common/ the values of the wire, as the backend writes them: ids, a day and an instant,
10
- * `Decimal`, `Money`, and how any value is written and read (`valuesOf`, `rebuilt`)
10
+ * `Decimal`, `Money`, a to-many as a page of its records (`EntityCollection`), and
11
+ * how any value is written and read (`valuesOf`, `rebuilt`)
11
12
  * client/ the seam with a server: the transport and its REST and in-memory implementations,
12
13
  * entities with typed commands, the service's client contract
13
14
  * model/ model definitions: written by hand, read from a JSON Schema, kept once known
@@ -20,11 +21,12 @@
20
21
  * `@sincpro/client-framework/testing`: neither is part of what an application runs.
21
22
  */
22
23
  export { entityKeyOf, kindOf, metasOf, versionsOf, } from "./client/contract.js";
23
- export { bodyFor, entity, listRoute, NEW_PARAM, pathFor, PICK_PARAM, QUERY_PARAM, SCOPE_PARAM, scopeOfRef, startingValuesOf, TRAIL_PARAM, WITH_PARAM, writeRoute, } from "./client/entity.js";
24
+ export { bodyFor, entity, exportRoute, listRoute, NEW_PARAM, pathFor, PICK_PARAM, QUERY_PARAM, SCOPE_PARAM, scopeOfRef, startingValuesOf, TRAIL_PARAM, WITH_PARAM, writeRoute, } from "./client/entity.js";
24
25
  export { memoryTransport, } from "./client/memory.js";
25
26
  export { restTransport, urlOf, } from "./client/rest.js";
26
27
  export { retryKeys, unanswered, } from "./client/retry.js";
27
- export { readRoute, TransportError, } from "./client/transport.js";
28
+ export { downloadRoute, readRoute, TransportError, } from "./client/transport.js";
29
+ export { collectionOf, isCollection, isPartial, relatedCriteria, totalOf, } from "./common/collection.js";
28
30
  export { Decimal } from "./common/decimal.js";
29
31
  export { isEntityId, momentOf, newEntityId } from "./common/ids.js";
30
32
  export { Money } from "./common/money.js";
@@ -69,6 +69,7 @@ export declare const field: {
69
69
  holds?: Expression | null;
70
70
  computes?: import("@sincpro/criteria").Formula | null;
71
71
  navigable?: boolean;
72
+ reference?: boolean;
72
73
  total?: string | null;
73
74
  many?: boolean;
74
75
  relation?: string | null;
@@ -58,6 +58,9 @@ export function bindTransport(transport, execution) {
58
58
  const read = transport.read;
59
59
  if (read !== undefined)
60
60
  bound.read = (route, signal, options) => read.call(transport, route, signal, call(options));
61
+ const download = transport.download;
62
+ if (download !== undefined)
63
+ bound.download = (route, signal, options) => download.call(transport, route, signal, call(options));
61
64
  return bound;
62
65
  }
63
66
  /**
@@ -20,6 +20,13 @@ class EntityReads extends Feature {
20
20
  return Promise.reject(new Error(`this transport cannot read ${route}: it has no read()`));
21
21
  return read.call(this.transport, route, signal, options);
22
22
  }
23
+ download(route, signal, options) {
24
+ this.span.set({ "http.route": route });
25
+ const download = this.transport.download;
26
+ if (download === undefined)
27
+ return Promise.reject(new Error(`this transport cannot download ${route}: it has no download()`));
28
+ return download.call(this.transport, route, signal, options);
29
+ }
23
30
  }
24
31
  /** What a screen writes — a save, a draft, a preview, a Command: each request a use case. */
25
32
  class EntityWrites extends Feature {
@@ -39,7 +46,7 @@ class EntityWrites extends Feature {
39
46
  * screen reads through the transport was none of that — no span, no `traceparent` (the backend's
40
47
  * trace started there, with no client in it), no company, and a 500 reported to nobody. Here
41
48
  * each read and write runs as a use case of `screens` — `EntityReads.list`, `.one`, `.read`,
42
- * `EntityWrites.send` —, so it gets all three the way any other does: the span (its route said
49
+ * `.download`, `EntityWrites.send` —, so it gets all three the way any other does: the span (its route said
43
50
  * on it), the trace and the process's context on the request, and a report only when it failed
44
51
  * (a 4xx the server explained is the screen's to show). A bounded context's own dependencies are
45
52
  * never given this: there its use case is the one that runs.
@@ -62,6 +69,8 @@ export function screensTransport(transport, name = "screens") {
62
69
  };
63
70
  if (transport.read !== undefined)
64
71
  through.read = (route, signal, options) => reads.read(route, signal, options);
72
+ if (transport.download !== undefined)
73
+ through.download = (route, signal, options) => reads.download(route, signal, options);
65
74
  MADE.add(through);
66
75
  return through;
67
76
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sincpro/client-framework",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "The client side of sincpro_framework, for every platform: Feature and ApplicationService traced and reported by default, applications made of domains, and the typed client generated from a service's contract. No React, no DOM.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -25,6 +25,10 @@
25
25
  "./sentry": {
26
26
  "types": "./dist/adapters/sentry.d.ts",
27
27
  "default": "./dist/adapters/sentry.js"
28
+ },
29
+ "./parquet": {
30
+ "types": "./dist/adapters/parquet.d.ts",
31
+ "default": "./dist/adapters/parquet.js"
28
32
  }
29
33
  },
30
34
  "bin": {
@@ -68,13 +72,17 @@
68
72
  },
69
73
  "peerDependencies": {
70
74
  "@opentelemetry/api": "^1.9.0",
71
- "@sincpro/criteria": "^2.0.0",
75
+ "@sincpro/criteria": "^2.1.0",
76
+ "hyparquet": "^1.32.0",
72
77
  "typescript": ">=5.5 <7"
73
78
  },
74
79
  "peerDependenciesMeta": {
75
80
  "@opentelemetry/api": {
76
81
  "optional": true
77
82
  },
83
+ "hyparquet": {
84
+ "optional": true
85
+ },
78
86
  "typescript": {
79
87
  "optional": true
80
88
  }
@@ -83,11 +91,12 @@
83
91
  "@eslint/js": "^10.0.1",
84
92
  "@opentelemetry/api": "1.10.0",
85
93
  "@opentelemetry/sdk-trace": "3.1.0",
86
- "@sincpro/criteria": "^2.0.0",
94
+ "@sincpro/criteria": "^2.1.0",
87
95
  "@types/node": "^26.6.6",
88
96
  "eslint": "^10.14.0",
89
97
  "eslint-config-prettier": "^10.1.8",
90
98
  "eslint-plugin-simple-import-sort": "^14.0.0",
99
+ "hyparquet": "^1.32.0",
91
100
  "prettier": "^3.9.10",
92
101
  "tsc-alias": "^1.9.9",
93
102
  "typescript": "~6.0.3",