@sincpro/client-framework 0.4.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.
Files changed (46) hide show
  1. package/README.md +59 -9
  2. package/dist/adapters/parquet.d.ts +50 -0
  3. package/dist/adapters/parquet.js +151 -0
  4. package/dist/client/entity.d.ts +16 -1
  5. package/dist/client/entity.js +16 -1
  6. package/dist/client/memory.js +10 -6
  7. package/dist/client/rest.d.ts +7 -1
  8. package/dist/client/rest.js +46 -1
  9. package/dist/client/transport.d.ts +37 -1
  10. package/dist/client/transport.js +6 -0
  11. package/dist/codegen/check.d.ts +64 -0
  12. package/dist/codegen/check.js +195 -0
  13. package/dist/codegen/cli.js +73 -6
  14. package/dist/codegen/client.js +30 -9
  15. package/dist/codegen/domain.d.ts +2 -1
  16. package/dist/codegen/domain.js +63 -10
  17. package/dist/codegen/entities.d.ts +2 -1
  18. package/dist/codegen/entities.js +28 -5
  19. package/dist/codegen/index.d.ts +2 -0
  20. package/dist/codegen/index.js +2 -0
  21. package/dist/codegen/metamodel.d.ts +4 -0
  22. package/dist/codegen/names.d.ts +12 -0
  23. package/dist/codegen/names.js +58 -0
  24. package/dist/codegen/scaffold.d.ts +24 -0
  25. package/dist/codegen/scaffold.js +123 -0
  26. package/dist/common/collection.d.ts +110 -0
  27. package/dist/common/collection.js +169 -0
  28. package/dist/common/decimal.js +8 -0
  29. package/dist/common/serialization.d.ts +5 -1
  30. package/dist/common/serialization.js +17 -4
  31. package/dist/common/time.d.ts +2 -1
  32. package/dist/common/time.js +9 -2
  33. package/dist/context/codec.d.ts +2 -0
  34. package/dist/context/codec.js +16 -0
  35. package/dist/index.d.ts +8 -4
  36. package/dist/index.js +8 -4
  37. package/dist/model/meta.d.ts +1 -0
  38. package/dist/observability/ports.d.ts +2 -1
  39. package/dist/services/bounded-context.js +4 -15
  40. package/dist/services/execution.js +5 -2
  41. package/dist/services/feature.d.ts +4 -1
  42. package/dist/services/report.d.ts +15 -0
  43. package/dist/services/report.js +53 -0
  44. package/dist/services/screens.d.ts +2 -0
  45. package/dist/services/screens.js +76 -0
  46. package/package.json +16 -3
package/README.md CHANGED
@@ -179,6 +179,23 @@ await project.Issues.close(input); // span project/Issues.close
179
179
  await project.CloseAndNotify.execute(input); // project/CloseAndNotify → its Features under it
180
180
  ```
181
181
 
182
+ The generator writes this skeleton for you, once, from the service's contract — and never touches
183
+ a file that is already there, because from then on it is yours:
184
+
185
+ ```bash
186
+ sincpro-client generate project.contract.json --out src/domains/project/generated --domain project
187
+ sincpro-client scaffold project.contract.json --out src/domains/project # dependencies, bases, services, index
188
+ ```
189
+
190
+ And the layers stay layers: `sincpro-client check` resolves every import of the application with
191
+ its own TypeScript and `tsconfig` (aliases included) and fails when a bounded context reaches into
192
+ another's insides, a service imports React or web-ui, `common` imports a bounded context, or the
193
+ entrypoints skip a bounded context's `index.ts` — put it in `make doctor`:
194
+
195
+ ```bash
196
+ sincpro-client check --root src
197
+ ```
198
+
182
199
  Every public method is a use case: a span named as the backend names its own
183
200
  (`project/Issues.close`, identity `project.Issues.close`), and a report when it fails in a way
184
201
  nobody expected. One gesture is one trace, from the click to the database:
@@ -224,6 +241,10 @@ processContext.set({ tenant_id: "acme" }); // every use case runs with it and se
224
241
  project.context.set({ warehouse_id: 4 }); // only what «project» runs
225
242
  ```
226
243
 
244
+ What a screen reads and writes is a use case too when it goes through `screensTransport(transport)`:
245
+ each request is `screens/EntityReads.list`, `…/EntityWrites.send` — traced, with the context on
246
+ it, and reported when it failed.
247
+
227
248
  A service reads it as `this.context` — the process's values, under them its bounded context's,
228
249
  under them a scope's, read once where the gesture started — and every request it sends carries
229
250
  it in `sincpro-context`. A screen hears it change with `processContext.watch(…)`.
@@ -263,6 +284,10 @@ const tracer = openTelemetryTracer(provider.getTracer("my-app")); // the app's p
263
284
  const reporter = sentryReporter(client); // the app's Sentry-compatible client (GlitchTip too)
264
285
  ```
265
286
 
287
+ What fails outside a use case — a screen that broke while drawing, a subscriber, a worker — goes to
288
+ the same reporter with `reportError(error, where)`: once (an error a use case already reported is
289
+ not sent again), with the process's context.
290
+
266
291
  Nothing is installed or shipped unless it is imported, and without a tracer or a reporter every use
267
292
  case runs the same — nothing traces, nothing reports. In a test, `memoryTracer()` and
268
293
  `memoryReporter()` (`@sincpro/client-framework/testing`).
@@ -272,15 +297,18 @@ case runs the same — nothing traces, nothing reports. In a test, `memoryTracer
272
297
  The backend writes every message with one set of rules, and every client reads and writes them
273
298
  the same — so they live here, not in each platform:
274
299
 
275
- | Value | Travels as | Here |
276
- | ----------- | ------------------------------------------ | ------------------------------------------------ |
277
- | a decimal | text, every digit: `"10.50"` | `Decimal` — exact, on `BigInt`; rounds half up |
278
- | a money | `{ "amount": "10.50", "currency": "BOB" }` | `Money` — never adds two currencies |
279
- | a day | `"2026-10-02"`, never converted | `isDay`, `dayOf(instant, zone)` for «today» |
280
- | an instant | ISO text: `"2026-10-02T14:30:00Z"` | `wallTimeOf` / `instantOf` on the company's zone |
281
- | an id | a UUID v7 as 32 hex | `newEntityId`, `isEntityId`, `momentOf` |
282
- | bytes | `{ "$bytes": "<base64>" }` | `valuesOf` / `readValues` |
283
- | 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] })` |
284
312
 
285
313
  ```ts
286
314
  import { Money, pack, unpack, valuesOf } from "@sincpro/client-framework";
@@ -297,6 +325,25 @@ Not restrictive, like the backend: reading keeps every key it finds, a typed rea
297
325
  once, naming where. Showing a value to a person — `Intl.NumberFormat`, a date picker — is each
298
326
  platform's, never this package's.
299
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
+
300
347
  ## Where it sits
301
348
 
302
349
  ```mermaid
@@ -344,6 +391,9 @@ three ports, in its `main`:
344
391
  | `ContextStore` | memory, `sessionStorage`, or the screens' store | MMKV, or memory hydrated from AsyncStorage | `memoryContexts` |
345
392
  | `Transport` | `restTransport` over `fetch` | `restTransport`, or SQLite offline | `memoryTransport` |
346
393
 
394
+ How @sincpro/mobile adopts all of this — what maps to what, the MMKV store, the order to move
395
+ in — is [`docs/MOBILE.md`](docs/MOBILE.md).
396
+
347
397
  A store's reads are synchronous — a use case reads its context where a gesture starts and never
348
398
  waits for it — so an asynchronous storage is held in memory and written through.
349
399
 
@@ -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
  };
@@ -1,13 +1,15 @@
1
1
  import { TransportError, } from "./transport.js";
2
- import { readValues, valuesOf } from "../common/serialization.js";
2
+ import { pack, readValues } from "../common/serialization.js";
3
3
  import { answer, answerGroups } from "@sincpro/criteria/engine";
4
4
  /**
5
- * A deep copy written and read by the wire's rules (`valuesOf`, `readValues`): what a real server
5
+ * A deep copy written and read by the wire's rules (`pack`, `readValues`): what a real server
6
6
  * would have received and answered — a `Money` as `{ amount, currency }`, a `Decimal` as its text,
7
7
  * a `Date` as an instant, bytes as bytes — the same on every platform. `structuredClone` kept a
8
8
  * `Date` a `Date` in a browser and a JSON round trip made it text on a phone; neither is a wire.
9
9
  */
10
- const copyOf = (value) => value === undefined ? value : readValues(valuesOf(value));
10
+ const copyOf = (value) =>
11
+ // Through text, as a wire is: an answer that is a string («"2.50"», an instant) stays one.
12
+ value === undefined ? value : readValues(pack(value));
11
13
  function wait(ms, signal) {
12
14
  if (ms <= 0)
13
15
  return Promise.resolve();
@@ -58,9 +60,11 @@ export function memoryTransport(tables, options = {}) {
58
60
  },
59
61
  async one(route, signal) {
60
62
  await check(route, signal);
61
- const slash = route.lastIndexOf("/");
62
- const table = tableAt(route.slice(0, slash));
63
- 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));
64
68
  const key = table.meta.get_id || table.meta.identity;
65
69
  const record = table.rows.find((row) => String(row[key]) === id);
66
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.
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The import rules of a Sincpro application, checked — what `sincpro-client check` runs in an
3
+ * application's `make doctor`, on the web and on a phone alike.
4
+ *
5
+ * src/domains/<bounded context>/{generated,domain,adapters,services,infrastructure,components,entrypoints}
6
+ * src/domains/common/ what bounded contexts share
7
+ * src/entrypoints/ where the outside comes in
8
+ *
9
+ * | Layer | Never |
10
+ * | ----------------- | ---------------------------------------------------------------------- |
11
+ * | generated, domain | React, the DOM's libraries (web-ui, composer), its own adapters |
12
+ * | adapters | React, web-ui, composer |
13
+ * | services | React, web-ui, composer |
14
+ * | infrastructure | its own components |
15
+ * | components | composer, a router |
16
+ * | common | any bounded context |
17
+ * | any layer | another bounded context — but its `index.ts`, from `infrastructure/` |
18
+ * | entrypoints/ root | a bounded context's insides — only its `index.ts` (and `common`) |
19
+ *
20
+ * Context: the architecture says these rules are enforced, not recommended; a rule only read in a
21
+ * page is the first to go under a deadline. Each import is resolved the way the application's
22
+ * compiler resolves it — its own `tsconfig.json`, its path aliases (`@/domains/…`) — with the
23
+ * application's TypeScript, so a relative path and an alias are the same import. Nothing here is
24
+ * a platform's: a React Native app has the same layers (`react-native` counts as React).
25
+ */
26
+ export type Violation = {
27
+ /** The importing file, from the root. */
28
+ file: string;
29
+ /** What it imports, as written. */
30
+ imports: string;
31
+ why: string;
32
+ };
33
+ /** Where a file is in the application's tree. */
34
+ export type Place = {
35
+ kind: "context";
36
+ context: string;
37
+ layer: string | null;
38
+ index: boolean;
39
+ } | {
40
+ kind: "entrypoint";
41
+ } | {
42
+ kind: "other";
43
+ };
44
+ /**
45
+ * `domains/sales/services/close.ts` → the bounded context `sales`, the layer `services` — wherever
46
+ * the `domains/` folder is (`src/domains/…`, a workspace's `domains/sales/src/services/…`).
47
+ */
48
+ export declare function placeOf(path: string): Place;
49
+ /**
50
+ * Why `from` may not import `specifier`, which resolves to `to` (`null`: a package), or nothing
51
+ * when it may.
52
+ */
53
+ export declare function violationOf(from: Place, specifier: string, to: Place | null): string | undefined;
54
+ /** What stops the check before it starts: no root, no config, a config that does not parse. */
55
+ export declare class CheckError extends Error {
56
+ }
57
+ /**
58
+ * Every import under `root` that breaks a rule, resolved by the application's own TypeScript and
59
+ * `tsconfig` (its path aliases and its references included). An alias that does not resolve is a
60
+ * break too — read as a package it would switch every rule off.
61
+ */
62
+ export declare function checkImports(root: string, options?: {
63
+ tsconfig?: string;
64
+ }): Promise<Violation[]>;