@sincpro/client-framework 0.5.0 → 1.0.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 (134) hide show
  1. package/AGENTS.md +54 -25
  2. package/MANIFESTO.md +59 -39
  3. package/README.md +329 -328
  4. package/dist/client/{memory.d.ts → adapters/memory.d.ts} +1 -1
  5. package/dist/client/{memory.js → adapters/memory.js} +7 -5
  6. package/dist/client/adapters/parquet.d.ts +50 -0
  7. package/dist/client/adapters/parquet.js +151 -0
  8. package/dist/client/{rest.d.ts → adapters/rest.d.ts} +7 -1
  9. package/dist/client/{rest.js → adapters/rest.js} +48 -3
  10. package/dist/client/{entity.d.ts → domain/entity.d.ts} +22 -11
  11. package/dist/client/{entity.js → domain/entity.js} +16 -1
  12. package/dist/client/{transport.d.ts → domain/transport.d.ts} +37 -1
  13. package/dist/client/{transport.js → domain/transport.js} +6 -0
  14. package/dist/client/index.d.ts +16 -0
  15. package/dist/client/index.js +16 -0
  16. package/dist/client/services/propagation.d.ts +32 -0
  17. package/dist/{services/execution.js → client/services/propagation.js} +8 -21
  18. package/dist/client/{retry.js → services/retry.js} +1 -1
  19. package/dist/codegen/{metamodel.d.ts → domain/metamodel.d.ts} +21 -2
  20. package/dist/codegen/{names.d.ts → domain/names.d.ts} +2 -2
  21. package/dist/codegen/{names.js → domain/names.js} +3 -3
  22. package/dist/codegen/{cli.js → entrypoint/cli.js} +9 -8
  23. package/dist/codegen/index.d.ts +7 -7
  24. package/dist/codegen/index.js +5 -10
  25. package/dist/codegen/{client.d.ts → services/client.d.ts} +3 -1
  26. package/dist/codegen/{client.js → services/client.js} +34 -5
  27. package/dist/codegen/services/domain.d.ts +20 -0
  28. package/dist/codegen/{domain.js → services/domain.js} +121 -23
  29. package/dist/codegen/{entities.d.ts → services/entities.d.ts} +4 -2
  30. package/dist/codegen/{entities.js → services/entities.js} +34 -4
  31. package/dist/codegen/{rest.d.ts → services/rest.d.ts} +1 -1
  32. package/dist/codegen/{scaffold.d.ts → services/scaffold.d.ts} +2 -2
  33. package/dist/codegen/{scaffold.js → services/scaffold.js} +31 -36
  34. package/dist/common/collection.d.ts +110 -0
  35. package/dist/common/collection.js +169 -0
  36. package/dist/common/index.d.ts +10 -0
  37. package/dist/common/index.js +10 -0
  38. package/dist/context/adapters/memory.d.ts +6 -0
  39. package/dist/context/adapters/memory.js +34 -0
  40. package/dist/context/adapters/plain-codec.d.ts +3 -0
  41. package/dist/context/adapters/plain-codec.js +16 -0
  42. package/dist/context/{codec.d.ts → domain/codec.d.ts} +0 -1
  43. package/dist/context/{codec.js → domain/codec.js} +0 -14
  44. package/dist/context/domain/execution.d.ts +33 -0
  45. package/dist/context/domain/execution.js +11 -0
  46. package/dist/context/{store.d.ts → domain/store.d.ts} +0 -5
  47. package/dist/context/{store.js → domain/store.js} +1 -34
  48. package/dist/context/{context.d.ts → entrypoint/context.d.ts} +1 -1
  49. package/dist/context/index.d.ts +17 -0
  50. package/dist/context/index.js +15 -0
  51. package/dist/context/infrastructure/process.d.ts +13 -0
  52. package/dist/context/infrastructure/process.js +35 -0
  53. package/dist/core/domain/bus.d.ts +18 -0
  54. package/dist/core/domain/bus.js +18 -0
  55. package/dist/core/domain/dto.d.ts +32 -0
  56. package/dist/core/domain/dto.js +20 -0
  57. package/dist/core/domain/errors.d.ts +18 -0
  58. package/dist/core/domain/errors.js +1 -0
  59. package/dist/core/domain/services.d.ts +37 -0
  60. package/dist/core/domain/services.js +28 -0
  61. package/dist/core/entrypoint/bounded-context.d.ts +70 -0
  62. package/dist/core/entrypoint/bounded-context.js +252 -0
  63. package/dist/core/entrypoint/screens.d.ts +6 -0
  64. package/dist/core/entrypoint/screens.js +136 -0
  65. package/dist/core/index.d.ts +17 -0
  66. package/dist/core/index.js +16 -0
  67. package/dist/core/infrastructure/process.d.ts +56 -0
  68. package/dist/core/infrastructure/process.js +35 -0
  69. package/dist/core/services/binding.d.ts +6 -0
  70. package/dist/core/services/binding.js +13 -0
  71. package/dist/core/services/error-handlers.d.ts +11 -0
  72. package/dist/core/services/error-handlers.js +21 -0
  73. package/dist/core/services/execution.d.ts +35 -0
  74. package/dist/core/services/execution.js +137 -0
  75. package/dist/index.d.ts +16 -36
  76. package/dist/index.js +16 -36
  77. package/dist/model/{meta.d.ts → domain/meta.d.ts} +1 -0
  78. package/dist/model/index.d.ts +10 -0
  79. package/dist/model/index.js +10 -0
  80. package/dist/model/{metas.d.ts → infrastructure/metas.d.ts} +1 -1
  81. package/dist/model/{schema.d.ts → services/schema.d.ts} +1 -1
  82. package/dist/model/{schema.js → services/schema.js} +1 -1
  83. package/dist/observability/adapters/console.d.ts +12 -0
  84. package/dist/observability/adapters/console.js +35 -0
  85. package/dist/observability/adapters/memory.d.ts +41 -0
  86. package/dist/observability/adapters/memory.js +49 -0
  87. package/dist/{adapters → observability/adapters}/opentelemetry.d.ts +1 -1
  88. package/dist/{adapters → observability/adapters}/sentry.d.ts +1 -1
  89. package/dist/observability/domain/errors.d.ts +23 -0
  90. package/dist/observability/domain/errors.js +8 -0
  91. package/dist/observability/domain/logging.d.ts +42 -0
  92. package/dist/observability/domain/logging.js +1 -0
  93. package/dist/observability/domain/tracing.d.ts +49 -0
  94. package/dist/observability/domain/tracing.js +20 -0
  95. package/dist/observability/index.d.ts +16 -0
  96. package/dist/observability/index.js +4 -0
  97. package/dist/observability/infrastructure/registry.d.ts +21 -0
  98. package/dist/observability/infrastructure/registry.js +18 -0
  99. package/dist/observability/services/logging.d.ts +27 -0
  100. package/dist/observability/services/logging.js +105 -0
  101. package/dist/observability/services/outcome.d.ts +12 -0
  102. package/dist/observability/services/outcome.js +20 -0
  103. package/dist/{services/report.js → observability/services/reporting.js} +4 -3
  104. package/dist/testing/index.d.ts +3 -33
  105. package/dist/testing/index.js +5 -37
  106. package/package.json +25 -8
  107. package/dist/codegen/domain.d.ts +0 -15
  108. package/dist/observability/ports.d.ts +0 -65
  109. package/dist/observability/ports.js +0 -21
  110. package/dist/services/bounded-context.d.ts +0 -79
  111. package/dist/services/bounded-context.js +0 -261
  112. package/dist/services/execution.d.ts +0 -63
  113. package/dist/services/feature.d.ts +0 -85
  114. package/dist/services/feature.js +0 -86
  115. package/dist/services/process.d.ts +0 -60
  116. package/dist/services/process.js +0 -57
  117. package/dist/services/screens.d.ts +0 -2
  118. package/dist/services/screens.js +0 -67
  119. /package/dist/client/{contract.d.ts → domain/contract.d.ts} +0 -0
  120. /package/dist/client/{contract.js → domain/contract.js} +0 -0
  121. /package/dist/client/{discovery.d.ts → services/discovery.d.ts} +0 -0
  122. /package/dist/client/{discovery.js → services/discovery.js} +0 -0
  123. /package/dist/client/{retry.d.ts → services/retry.d.ts} +0 -0
  124. /package/dist/codegen/{metamodel.js → domain/metamodel.js} +0 -0
  125. /package/dist/codegen/{cli.d.ts → entrypoint/cli.d.ts} +0 -0
  126. /package/dist/codegen/{check.d.ts → services/check.d.ts} +0 -0
  127. /package/dist/codegen/{check.js → services/check.js} +0 -0
  128. /package/dist/codegen/{rest.js → services/rest.js} +0 -0
  129. /package/dist/context/{context.js → entrypoint/context.js} +0 -0
  130. /package/dist/model/{meta.js → domain/meta.js} +0 -0
  131. /package/dist/model/{metas.js → infrastructure/metas.js} +0 -0
  132. /package/dist/{adapters → observability/adapters}/opentelemetry.js +0 -0
  133. /package/dist/{adapters → observability/adapters}/sentry.js +0 -0
  134. /package/dist/{services/report.d.ts → observability/services/reporting.d.ts} +0 -0
@@ -1,4 +1,4 @@
1
- import { type Draft, type Method, type Transport } from "./transport.js";
1
+ import { type Draft, type Method, type Transport } from "../domain/transport.js";
2
2
  import type { AnyRecord, Criteria, Meta, Problem } from "@sincpro/criteria";
3
3
  /**
4
4
  * A server held in memory: tables of rows with their definition, answered with the same
@@ -1,5 +1,5 @@
1
- import { TransportError, } from "./transport.js";
2
- import { pack, readValues } from "../common/serialization.js";
1
+ import { TransportError, } from "../domain/transport.js";
2
+ import { pack, readValues } from "../../common/serialization.js";
3
3
  import { answer, answerGroups } from "@sincpro/criteria/engine";
4
4
  /**
5
5
  * A deep copy written and read by the wire's rules (`pack`, `readValues`): what a real server
@@ -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)
@@ -0,0 +1,50 @@
1
+ import type { Decoder } from "../domain/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,4 +1,4 @@
1
- import { type Transport } from "./transport.js";
1
+ import { type Decoder, type Transport } from "../domain/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;
@@ -1,9 +1,28 @@
1
- import { definitionOf, modelAt, } from "./discovery.js";
2
- import { TransportError, } from "./transport.js";
1
+ import { TransportError, } from "../domain/transport.js";
2
+ import { definitionOf, modelAt, } from "../services/discovery.js";
3
3
  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,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.
@@ -49,7 +49,7 @@ export type CommandAnswers = Readonly<Record<string, unknown>>;
49
49
  * save: "PUT /invoices/{id}",
50
50
  * preview: "POST /invoices/preview",
51
51
  * commands: { confirm: "POST /invoices/{id}/confirm", cancel: "POST /invoices/{id}/cancel" },
52
- * history: { route: "/billing-events", type: "Invoice" },
52
+ * history: "/invoices/{id}/events",
53
53
  * });
54
54
  *
55
55
  * Its Commands may be typed too — what each one is sent with, by name (`CommandBodies`), and
@@ -92,11 +92,11 @@ export type Entity<T extends AnyRecord = AnyRecord, C extends CommandBodies = Co
92
92
  * `action`. Declared, a save sends the record whole as the form holds it — not the fields
93
93
  * that changed under `save` — and it is also where the draft is kept. */
94
94
  edit?: WriteRoute;
95
- /** Its business events: the context's events route, and the `entity_type` they carry. */
96
- history?: {
97
- route: string;
98
- type: string;
99
- };
95
+ /** Its business events: where ONE record's events are read (sincpro_framework's
96
+ * `DomainEvents`), `{id}` where its key goes — a page of them, as a list answers. */
97
+ history?: string;
98
+ /** Every record of a criteria as a file: the route `exportRoute` fills, `downloadRoute` asks. */
99
+ export?: string;
100
100
  /** Which fields are its description, state, picture… when they are not the conventional
101
101
  * ones (`summaryOf`). Empty: the convention decides. */
102
102
  summary: SummaryFields;
@@ -189,10 +189,11 @@ export type EntityRoutes<C extends CommandBodies = CommandBodies> = CommandRoute
189
189
  draft?: string;
190
190
  /** `"PUT /invoices/{id}/edit"`: the use case that answers sincpro_framework's `Edit`. */
191
191
  edit?: string;
192
- history?: {
193
- route: string;
194
- type: string;
195
- };
192
+ /** `"/invoices/{id}/events"`: a record's events (`DomainEvents[Invoice, …]`), oldest first. */
193
+ history?: string;
194
+ /** `"/invoices/export"`: every record of a criteria as a file (`Export[Invoice, Streamed]`),
195
+ * asked through `exportRoute` and `downloadRoute`. */
196
+ export?: string;
196
197
  /** `{ image: "logo", state: "estado_pago", person: true }` — not a route, but declared once
197
198
  * with the rest of what a screen should not have to know. */
198
199
  summary?: SummaryFields;
@@ -238,5 +239,15 @@ export declare function bodyFor(route: WriteRoute, id: string | undefined, body:
238
239
  * key the path needs and the parameters lack is the screen's bug, said before anything is sent.
239
240
  */
240
241
  export declare function listRoute(list: string, params?: Readonly<Record<string, string>>): string;
242
+ /** What an export is written as: Parquet, an Arrow IPC stream, or JSON Lines. */
243
+ export type ExportFormat = "parquet" | "arrow" | "jsonl";
244
+ /**
245
+ * The route of an export: its path, its keys filled from `params`, the criteria and the format as
246
+ * its query — what `downloadRoute` asks.
247
+ *
248
+ * exportRoute("/invoices/export", { selection: { field: "paid", operator: "=", value: false } }, "jsonl")
249
+ * -> "/invoices/export?criteria=%7B…%7D&format=jsonl"
250
+ */
251
+ export declare function exportRoute<T>(path: string, criteria?: Criteria<T>, format?: ExportFormat, params?: Readonly<Record<string, string>>): string;
241
252
  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
253
  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,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,16 @@
1
+ /**
2
+ * The seam with a server: the `Transport` port and its implementations, entities with typed
3
+ * commands, the service's client contract, and how a run's trace and context reach the wire.
4
+ *
5
+ * domain/ the port and the server's words: `Transport`, `TransportError`, `entity()`, the
6
+ * client contract
7
+ * adapters/ REST, memory, and Parquet (optional entry `./parquet`)
8
+ * services/ discovery, retry keys, propagation (`traceparent`, `sincpro-context`)
9
+ */
10
+ export { type MemoryOptions, type MemoryTable, memoryTransport, } from "./adapters/memory.js";
11
+ export { type RestOptions, restTransport, urlOf, type VersionDrift, } from "./adapters/rest.js";
12
+ export { type ClientContract, entityKeyOf, kindOf, metasOf, versionsOf, } from "./domain/contract.js";
13
+ 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 "./domain/entity.js";
14
+ export { type CallOptions, type Decoder, type Download, downloadRoute, type Draft, type Method, type One, readRoute, type SendOptions, type Transport, TransportError, } from "./domain/transport.js";
15
+ export { bindable, bindTransport, headersOf, } from "./services/propagation.js";
16
+ export { type RetryKeys, retryKeys, unanswered, } from "./services/retry.js";
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The seam with a server: the `Transport` port and its implementations, entities with typed
3
+ * commands, the service's client contract, and how a run's trace and context reach the wire.
4
+ *
5
+ * domain/ the port and the server's words: `Transport`, `TransportError`, `entity()`, the
6
+ * client contract
7
+ * adapters/ REST, memory, and Parquet (optional entry `./parquet`)
8
+ * services/ discovery, retry keys, propagation (`traceparent`, `sincpro-context`)
9
+ */
10
+ export { memoryTransport, } from "./adapters/memory.js";
11
+ export { restTransport, urlOf, } from "./adapters/rest.js";
12
+ export { entityKeyOf, kindOf, metasOf, versionsOf, } from "./domain/contract.js";
13
+ export { bodyFor, entity, exportRoute, listRoute, NEW_PARAM, pathFor, PICK_PARAM, QUERY_PARAM, SCOPE_PARAM, scopeOfRef, startingValuesOf, TRAIL_PARAM, WITH_PARAM, writeRoute, } from "./domain/entity.js";
14
+ export { downloadRoute, readRoute, TransportError, } from "./domain/transport.js";
15
+ export { bindable, bindTransport, headersOf, } from "./services/propagation.js";
16
+ export { retryKeys, unanswered, } from "./services/retry.js";
@@ -0,0 +1,32 @@
1
+ import type { Transport } from "../domain/transport.js";
2
+ import { type Execution } from "../../context/domain/execution.js";
3
+ /** Whether a value is a `Transport`: what a run binds to itself even without `BIND`. */
4
+ export declare function isTransport(value: unknown): value is Transport;
5
+ /**
6
+ * What every request of an execution carries: its trace (`traceparent`, W3C) and its context
7
+ * (`sincpro-context`, the header sincpro_framework's entrances read, written with `plainCodec`).
8
+ * Nothing when there is no trace and no context that travels.
9
+ */
10
+ export declare function headersOf(execution: Execution): Record<string, string>;
11
+ /**
12
+ * The transport, writing the execution's headers on every call — the caller's own headers of a
13
+ * call still win.
14
+ *
15
+ * Context: the transport's own headers (`restTransport({ headers })`) are the session's — the
16
+ * token, the tenant — the same for every call; these are the use case's, different for two use
17
+ * cases running at once, so they travel per call (`CallOptions`).
18
+ */
19
+ export declare function bindTransport(transport: Transport, execution: Execution): Transport;
20
+ /**
21
+ * A client made over a transport, made again over the transport bound to each execution it is
22
+ * used in. What a generated `clientOf(transport)` answers, so a Feature that calls
23
+ * `this.client.sendIssue(…)` sends that request with its own trace.
24
+ *
25
+ * export function clientOf(transport: Transport) {
26
+ * return bindable(transport, (transport) => ({ sendIssue: (body) => transport.send(…) }));
27
+ * }
28
+ *
29
+ * Context: it takes the function that makes the client, not the client — rebuilding through
30
+ * `clientOf` itself would make TypeScript infer `clientOf` from its own return.
31
+ */
32
+ export declare function bindable<T extends object>(transport: Transport, make: (transport: Transport) => T): T;
@@ -1,16 +1,8 @@
1
- import { CONTEXT_HEADER, plainCodec, travels } from "../context/codec.js";
2
- /**
3
- * A dependency that has something to say about the execution it is used in: given that
4
- * execution, it answers itself bound to it. The transport and every generated client are;
5
- * any other dependency of a bounded context is handed as it is.
6
- */
7
- export const BIND = Symbol.for("sincpro.client-framework.bind");
8
- function isBindable(value) {
9
- return (value !== null &&
10
- (typeof value === "object" || typeof value === "function") &&
11
- typeof value[BIND] === "function");
12
- }
13
- function isTransport(value) {
1
+ import { plainCodec } from "../../context/adapters/plain-codec.js";
2
+ import { CONTEXT_HEADER, travels } from "../../context/domain/codec.js";
3
+ import { BIND } from "../../context/domain/execution.js";
4
+ /** Whether a value is a `Transport`: what a run binds to itself even without `BIND`. */
5
+ export function isTransport(value) {
14
6
  if (value === null || typeof value !== "object")
15
7
  return false;
16
8
  const one = value;
@@ -58,6 +50,9 @@ export function bindTransport(transport, execution) {
58
50
  const read = transport.read;
59
51
  if (read !== undefined)
60
52
  bound.read = (route, signal, options) => read.call(transport, route, signal, call(options));
53
+ const download = transport.download;
54
+ if (download !== undefined)
55
+ bound.download = (route, signal, options) => download.call(transport, route, signal, call(options));
61
56
  return bound;
62
57
  }
63
58
  /**
@@ -80,11 +75,3 @@ export function bindable(transport, make) {
80
75
  });
81
76
  return client;
82
77
  }
83
- /** A dependency as the execution should see it: bound when it can be, as it is otherwise. */
84
- export function bindDependency(value, execution) {
85
- if (isBindable(value))
86
- return value[BIND](execution);
87
- if (isTransport(value))
88
- return bindTransport(value, execution);
89
- return value;
90
- }
@@ -1,4 +1,4 @@
1
- import { TransportError } from "./transport.js";
1
+ import { TransportError } from "../domain/transport.js";
2
2
  /** Whether a failure leaves it unknown if the Command ran — so a retry must reuse its key. */
3
3
  export function unanswered(error) {
4
4
  if (!(error instanceof TransportError))