@sincpro/web-framework 0.0.0-stage → 0.2.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 (113) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +445 -2
  3. package/dist/core/cache.d.ts +30 -0
  4. package/dist/core/cache.js +47 -0
  5. package/dist/core/copy.d.ts +93 -0
  6. package/dist/core/copy.js +141 -0
  7. package/dist/core/edit.d.ts +40 -0
  8. package/dist/core/edit.js +81 -0
  9. package/dist/core/entity.d.ts +113 -0
  10. package/dist/core/entity.js +57 -0
  11. package/dist/core/form.d.ts +21 -0
  12. package/dist/core/form.js +30 -0
  13. package/dist/core/history.d.ts +20 -0
  14. package/dist/core/history.js +40 -0
  15. package/dist/core/index.d.ts +26 -0
  16. package/dist/core/index.js +26 -0
  17. package/dist/core/list.d.ts +64 -0
  18. package/dist/core/list.js +22 -0
  19. package/dist/core/lookup.d.ts +19 -0
  20. package/dist/core/lookup.js +14 -0
  21. package/dist/core/measure.d.ts +16 -0
  22. package/dist/core/measure.js +38 -0
  23. package/dist/core/memory.d.ts +43 -0
  24. package/dist/core/memory.js +99 -0
  25. package/dist/core/meta.d.ts +114 -0
  26. package/dist/core/meta.js +130 -0
  27. package/dist/core/metas.d.ts +18 -0
  28. package/dist/core/metas.js +7 -0
  29. package/dist/core/present.d.ts +48 -0
  30. package/dist/core/present.js +165 -0
  31. package/dist/core/related.d.ts +22 -0
  32. package/dist/core/related.js +21 -0
  33. package/dist/core/relation.d.ts +28 -0
  34. package/dist/core/relation.js +30 -0
  35. package/dist/core/rest.d.ts +30 -0
  36. package/dist/core/rest.js +60 -0
  37. package/dist/core/retry.d.ts +24 -0
  38. package/dist/core/retry.js +25 -0
  39. package/dist/core/schema.d.ts +46 -0
  40. package/dist/core/schema.js +128 -0
  41. package/dist/core/summary.d.ts +50 -0
  42. package/dist/core/summary.js +54 -0
  43. package/dist/core/trail.d.ts +44 -0
  44. package/dist/core/trail.js +70 -0
  45. package/dist/core/transport.d.ts +71 -0
  46. package/dist/core/transport.js +43 -0
  47. package/dist/index.d.ts +10 -0
  48. package/dist/index.js +10 -0
  49. package/dist/react/context.d.ts +38 -0
  50. package/dist/react/context.js +26 -0
  51. package/dist/react/index.d.ts +14 -0
  52. package/dist/react/index.js +14 -0
  53. package/dist/react/useCommand.d.ts +30 -0
  54. package/dist/react/useCommand.js +60 -0
  55. package/dist/react/useGroups.d.ts +40 -0
  56. package/dist/react/useGroups.js +69 -0
  57. package/dist/react/useHistory.d.ts +5 -0
  58. package/dist/react/useHistory.js +41 -0
  59. package/dist/react/useListing.d.ts +93 -0
  60. package/dist/react/useListing.js +156 -0
  61. package/dist/react/useLookup.d.ts +47 -0
  62. package/dist/react/useLookup.js +68 -0
  63. package/dist/react/useRecord.d.ts +89 -0
  64. package/dist/react/useRecord.js +272 -0
  65. package/dist/react/useRelated.d.ts +21 -0
  66. package/dist/react/useRelated.js +32 -0
  67. package/dist/react/useSummary.d.ts +15 -0
  68. package/dist/react/useSummary.js +22 -0
  69. package/dist/web/Refused.d.ts +9 -0
  70. package/dist/web/Refused.js +14 -0
  71. package/dist/web/index.d.ts +23 -0
  72. package/dist/web/index.js +23 -0
  73. package/dist/web/list/EntityList.d.ts +92 -0
  74. package/dist/web/list/EntityList.js +229 -0
  75. package/dist/web/list/EntitySearchBar.d.ts +34 -0
  76. package/dist/web/list/EntitySearchBar.js +73 -0
  77. package/dist/web/list/columns.d.ts +22 -0
  78. package/dist/web/list/columns.js +27 -0
  79. package/dist/web/list/groups.d.ts +15 -0
  80. package/dist/web/list/groups.js +73 -0
  81. package/dist/web/list/rowCommands.d.ts +41 -0
  82. package/dist/web/list/rowCommands.js +56 -0
  83. package/dist/web/list/search.d.ts +21 -0
  84. package/dist/web/list/search.js +72 -0
  85. package/dist/web/record/CommandButton.d.ts +22 -0
  86. package/dist/web/record/CommandButton.js +40 -0
  87. package/dist/web/record/EntityPicker.d.ts +56 -0
  88. package/dist/web/record/EntityPicker.js +73 -0
  89. package/dist/web/record/EntityTags.d.ts +38 -0
  90. package/dist/web/record/EntityTags.js +41 -0
  91. package/dist/web/record/RelatedList.d.ts +19 -0
  92. package/dist/web/record/RelatedList.js +26 -0
  93. package/dist/web/record/edit.d.ts +42 -0
  94. package/dist/web/record/edit.js +122 -0
  95. package/dist/web/record/opening.d.ts +26 -0
  96. package/dist/web/record/opening.js +10 -0
  97. package/dist/web/record/record.d.ts +25 -0
  98. package/dist/web/record/record.js +56 -0
  99. package/dist/web/record/summary.d.ts +22 -0
  100. package/dist/web/record/summary.js +55 -0
  101. package/dist/web/workspace/SincproApp.d.ts +40 -0
  102. package/dist/web/workspace/SincproApp.js +45 -0
  103. package/dist/web/workspace/composer.d.ts +55 -0
  104. package/dist/web/workspace/composer.js +94 -0
  105. package/dist/web/workspace/open.d.ts +61 -0
  106. package/dist/web/workspace/open.js +62 -0
  107. package/dist/web/workspace/record.d.ts +30 -0
  108. package/dist/web/workspace/record.js +80 -0
  109. package/dist/web/workspace/skin.d.ts +2 -0
  110. package/dist/web/workspace/skin.js +45 -0
  111. package/dist/web/workspace/workspace.d.ts +44 -0
  112. package/dist/web/workspace/workspace.js +82 -0
  113. package/package.json +86 -4
@@ -0,0 +1,165 @@
1
+ import { choiceLabelOf, labelOf, } from "@sincpro/criteria";
2
+ import { frameworkCopy } from "./copy.js";
3
+ import { relatedOf } from "./related.js";
4
+ /** Present when a field is named that the model does not declare: drawn plain, never thrown. */
5
+ const UNKNOWN = {
6
+ type: "unknown",
7
+ kind: "scalar",
8
+ nullable: true,
9
+ sortable: false,
10
+ ops: [],
11
+ };
12
+ function fieldOf(meta, name) {
13
+ return meta?.fields[name] ?? UNKNOWN;
14
+ }
15
+ function helpOf(field, locale) {
16
+ const help = field.help;
17
+ if (help === undefined)
18
+ return undefined;
19
+ return (locale ? help[locale] : undefined) ?? help["default"];
20
+ }
21
+ export function viewOf(meta, name, locale) {
22
+ const field = fieldOf(meta, name);
23
+ return {
24
+ name,
25
+ label: labelOf(meta, name, locale),
26
+ help: helpOf(field, locale),
27
+ type: field.type,
28
+ kind: field.kind,
29
+ numeric: field.type === "integer" || field.type === "number",
30
+ sortable: field.sortable,
31
+ required: field.required ?? false,
32
+ readonly: field.readonly ?? false,
33
+ };
34
+ }
35
+ /**
36
+ * The fields a screen shows when it names none: every scalar the model holds, in the order it
37
+ * declares them. Relations and value objects are left out — they are not a column, they are a
38
+ * page of their own (`RecordLines`) or a section.
39
+ */
40
+ export function defaultFields(meta) {
41
+ if (!meta)
42
+ return [];
43
+ return Object.entries(meta.fields)
44
+ .filter(([, field]) => field.kind === "scalar")
45
+ .map(([name]) => name);
46
+ }
47
+ /** The bookkeeping every framework entity carries; shown on demand, never by default. */
48
+ export const BOOKKEEPING = new Set(["version", "updated_at"]);
49
+ /** `"2026-03-12"` as a LOCAL date: `new Date("2026-03-12")` is UTC midnight, the day before
50
+ * anywhere west of Greenwich. */
51
+ function localDate(text) {
52
+ const [year, month, day] = text.split("-").map(Number);
53
+ return new Date(year ?? 0, (month ?? 1) - 1, day ?? 1);
54
+ }
55
+ function decimals(text) {
56
+ const dot = text.indexOf(".");
57
+ return dot < 0 ? 0 : text.length - dot - 1;
58
+ }
59
+ /**
60
+ * One formatter per locale and shape, kept. Building an `Intl` formatter costs far more than
61
+ * using one: built per cell, 2 000 rows × 8 fields took ~150 ms — a list that stutters on
62
+ * every page. The set of shapes is tiny (three date/number styles × decimals × locales).
63
+ */
64
+ const formatters = new Map();
65
+ function dateFormat(locale, withTime) {
66
+ const key = `d|${locale ?? ""}|${withTime}`;
67
+ let format = formatters.get(key);
68
+ if (format === undefined) {
69
+ format = new Intl.DateTimeFormat(locale, withTime ? { dateStyle: "medium", timeStyle: "short" } : { dateStyle: "medium" });
70
+ formatters.set(key, format);
71
+ }
72
+ return format;
73
+ }
74
+ function numberFormat(locale, kept) {
75
+ const key = `n|${locale ?? ""}|${kept ?? ""}`;
76
+ let format = formatters.get(key);
77
+ if (format === undefined) {
78
+ format = new Intl.NumberFormat(locale, kept === undefined ? {} : { minimumFractionDigits: kept, maximumFractionDigits: kept });
79
+ formatters.set(key, format);
80
+ }
81
+ return format;
82
+ }
83
+ /** The record a many2one points at, read by the display its definition names. */
84
+ function displayOf(field, value) {
85
+ const definition = field.definition;
86
+ const display = definition?.display || undefined;
87
+ const key = display ?? definition?.identity ?? "id";
88
+ const shown = value[key] ?? value["display_name"] ?? value["name"] ?? value["id"];
89
+ return shown === undefined || shown === null ? "" : String(shown);
90
+ }
91
+ /**
92
+ * The text a value reads as. `null` when there is nothing to read — the caller draws its own
93
+ * «empty» (a dash in a fact, nothing in a cell).
94
+ *
95
+ * textOf(meta, "state", "paid", "es") -> "Pagada" (choice_labels)
96
+ * textOf(meta, "active", true, "es") -> "sí"
97
+ * textOf(meta, "due", "2026-03-12", "es") -> "12 mar 2026"
98
+ * textOf(meta, "total", "1150.5", "es") -> "1150,5" (exact: digits kept)
99
+ * textOf(meta, "lines", {items, count: {value: 30}}) -> "30 elementos"
100
+ */
101
+ export function textOf(meta, name, value, locale) {
102
+ if (value === null || value === undefined || value === "")
103
+ return null;
104
+ const field = fieldOf(meta, name);
105
+ const copy = frameworkCopy();
106
+ if (field.choices && field.choices.length > 0)
107
+ return choiceLabelOf(meta, name, value, locale);
108
+ switch (field.type) {
109
+ case "boolean":
110
+ return value ? copy.value.yes : copy.value.no;
111
+ case "date":
112
+ return typeof value === "string"
113
+ ? dateFormat(locale, false).format(localDate(value))
114
+ : String(value);
115
+ case "datetime": {
116
+ const date = new Date(String(value));
117
+ return Number.isNaN(date.getTime())
118
+ ? String(value)
119
+ : dateFormat(locale, true).format(date);
120
+ }
121
+ case "integer":
122
+ case "number": {
123
+ const number = Number(value);
124
+ if (Number.isNaN(number))
125
+ return String(value);
126
+ const kept = typeof value === "string" ? decimals(value) : undefined;
127
+ return numberFormat(locale, kept).format(number);
128
+ }
129
+ case "text[]":
130
+ return Array.isArray(value)
131
+ ? value.length === 0
132
+ ? null
133
+ : value.join(", ")
134
+ : String(value);
135
+ case "one2many":
136
+ case "many2many": {
137
+ const related = relatedOf(value);
138
+ return copy.value.items(related.count.value, related.count.exact);
139
+ }
140
+ case "many2one":
141
+ return typeof value === "object" ? displayOf(field, value) : String(value);
142
+ case "embedded":
143
+ return null;
144
+ default:
145
+ return typeof value === "object" ? JSON.stringify(value) : String(value);
146
+ }
147
+ }
148
+ /**
149
+ * The value a column orders and groups on: the raw scalar, never the text. ISO dates order
150
+ * as text; a decimal that travels as text orders as a number.
151
+ */
152
+ export function sortKeyOf(meta, name, value) {
153
+ if (value === null || value === undefined)
154
+ return null;
155
+ const field = fieldOf(meta, name);
156
+ if (field.type === "number" || field.type === "integer") {
157
+ const number = Number(value);
158
+ return Number.isNaN(number) ? null : number;
159
+ }
160
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean")
161
+ return value;
162
+ if (field.type === "one2many" || field.type === "many2many")
163
+ return relatedOf(value).count.value;
164
+ return textOf(meta, name, value);
165
+ }
@@ -0,0 +1,22 @@
1
+ import type { Count } from "@sincpro/criteria";
2
+ /**
3
+ * The records a relation holds inside its parent, however the server sent them.
4
+ *
5
+ * Context: sincpro_framework answers a one2many of a record as a PAGE of its own —
6
+ * `{items, count, cursor}` — so a tenant with a thousand members does not answer all of them;
7
+ * an older answer, or a domain declared by hand, sends a bare array. A screen that reads only
8
+ * `items` and counts them shows the first page and calls it the total (forge's console did:
9
+ * `count={repositories.length}`). This reads both shapes and keeps the real count.
10
+ *
11
+ * relatedOf({ items: [a, b], count: { value: 1000, exact: true }, cursor: "x" })
12
+ * -> { items: [a, b], count: { value: 1000, exact: true }, cursor: "x", partial: true }
13
+ * relatedOf([a, b]) -> { items: [a, b], count: { value: 2, exact: true }, cursor: null, partial: false }
14
+ */
15
+ export type Related<T> = {
16
+ items: T[];
17
+ count: Count;
18
+ cursor: string | null;
19
+ /** There are more than `items` holds: the record brought one page of them. */
20
+ partial: boolean;
21
+ };
22
+ export declare function relatedOf<T>(value: unknown): Related<T>;
@@ -0,0 +1,21 @@
1
+ export function relatedOf(value) {
2
+ if (Array.isArray(value))
3
+ return {
4
+ items: value,
5
+ count: { value: value.length, exact: true },
6
+ cursor: null,
7
+ partial: false,
8
+ };
9
+ if (typeof value === "object" && value !== null && "items" in value) {
10
+ const page = value;
11
+ const items = page.items ?? [];
12
+ const count = page.count ?? { value: items.length, exact: page.cursor == null };
13
+ return {
14
+ items,
15
+ count,
16
+ cursor: page.cursor ?? null,
17
+ partial: count.value > items.length || !count.exact || page.cursor != null,
18
+ };
19
+ }
20
+ return { items: [], count: { value: 0, exact: true }, cursor: null, partial: false };
21
+ }
@@ -0,0 +1,28 @@
1
+ import type { FieldMeta, Meta } from "@sincpro/criteria";
2
+ import type { Entity } from "./entity.js";
3
+ /**
4
+ * Which record a field of a form is about, read from the model — so a form draws a many2one
5
+ * as a picker of the right entity without being told.
6
+ *
7
+ * Context: sincpro_framework publishes a many2one twice: the RELATION (`customer`, expanded as
8
+ * the record it points at, read-only) and its scalar KEY (`customer_id`, what is written),
9
+ * joined by `identified_by`. A form may name either; both mean «the customer». A relation
10
+ * names the other side by its AGGREGATE («Cliente»), which says nothing about where it is read
11
+ * — that is the entity the application declared (`entityFor`).
12
+ */
13
+ export type RelationField = {
14
+ /** The relation field: `customer`. */
15
+ relation: string;
16
+ /** The scalar written for it: `customer_id` (the relation itself when it has none). */
17
+ key: string;
18
+ /** The aggregate on the other side: `"Cliente"`. */
19
+ target: string;
20
+ many: boolean;
21
+ field: FieldMeta;
22
+ };
23
+ export declare function relationFieldOf(meta: Meta | null | undefined, name: string): RelationField | undefined;
24
+ /**
25
+ * The declared entity a relation points at: the one that says it is that aggregate, else the
26
+ * one whose model definition is, else the one whose key it is (`"ventas:cliente"`).
27
+ */
28
+ export declare function entityFor(entities: readonly Entity[], target: string, metaOf?: (key: string) => Meta | undefined): Entity | undefined;
@@ -0,0 +1,30 @@
1
+ export function relationFieldOf(meta, name) {
2
+ if (!meta)
3
+ return undefined;
4
+ const of = (relation, field) => field.kind !== "relation" || !field.relation
5
+ ? undefined
6
+ : {
7
+ relation,
8
+ key: field.identified_by || relation,
9
+ target: field.relation,
10
+ many: field.many === true,
11
+ field,
12
+ };
13
+ const own = meta.fields[name];
14
+ if (own?.kind === "relation")
15
+ return of(name, own);
16
+ // A scalar that a relation is identified by: `customer_id` IS the customer.
17
+ for (const [relation, field] of Object.entries(meta.fields))
18
+ if (field.kind === "relation" && field.identified_by === name)
19
+ return of(relation, field);
20
+ return undefined;
21
+ }
22
+ /**
23
+ * The declared entity a relation points at: the one that says it is that aggregate, else the
24
+ * one whose model definition is, else the one whose key it is (`"ventas:cliente"`).
25
+ */
26
+ export function entityFor(entities, target, metaOf = () => undefined) {
27
+ return (entities.find((one) => one.aggregate === target) ??
28
+ entities.find((one) => metaOf(one.key)?.aggregate === target) ??
29
+ entities.find((one) => one.key === target));
30
+ }
@@ -0,0 +1,30 @@
1
+ import { type Criteria } from "@sincpro/criteria";
2
+ import { type Transport } from "./transport.js";
3
+ /**
4
+ * The REST wire of sincpro_framework (`FastApiGateway`).
5
+ *
6
+ * Context: two things `restSource` from `@sincpro/criteria` 0.3 gets wrong against
7
+ * sincpro_framework 3.27, both found by forge's console, which had to write its own client:
8
+ *
9
+ * - it appends `?criteria=` to the route as it is, which breaks a route that already carries
10
+ * its own query (`/projects?tenant_id=acme`) — here the criteria joins that query;
11
+ * - it sends `pack()` (base64url), and the REST entrypoint reads the parameter as plain JSON
12
+ * and refuses anything else («criteria is not JSON») — here it travels as JSON.
13
+ *
14
+ * Once criteria fixes both, this keeps only what it adds: `one()`.
15
+ *
16
+ * const transport = restTransport({
17
+ * base: "/v1",
18
+ * headers: () => ({ Authorization: `Bearer ${session.secret}` }),
19
+ * });
20
+ */
21
+ export type RestOptions = {
22
+ /** Prepended to every route: `"/v1"`, `"https://forge.sincpro.com.bo/v1"`. */
23
+ base?: string;
24
+ /** Sent with every request. A function is called fresh each time — a token that rotates. */
25
+ headers?: Record<string, string> | (() => Record<string, string>);
26
+ /** Defaults to the global `fetch`. Inject one for tests. */
27
+ fetch?: typeof fetch;
28
+ };
29
+ export declare function urlOf<T>(base: string, route: string, criteria?: Criteria<T>): string;
30
+ export declare function restTransport(options?: RestOptions): Transport;
@@ -0,0 +1,60 @@
1
+ import { pageFrom, plain, readProblem, } from "@sincpro/criteria";
2
+ import { TransportError, } from "./transport.js";
3
+ /** The keys of an answer that are not the record: what is left is the record's own field. */
4
+ const ENVELOPE = new Set(["cursor", "count", "entity_meta_data", "dropped", "draft"]);
5
+ export function urlOf(base, route, criteria) {
6
+ const url = `${base}${route}`;
7
+ if (criteria === undefined)
8
+ return url;
9
+ const joiner = url.includes("?") ? "&" : "?";
10
+ return `${url}${joiner}criteria=${encodeURIComponent(JSON.stringify(criteria))}`;
11
+ }
12
+ export function restTransport(options = {}) {
13
+ const base = options.base ?? "";
14
+ const doFetch = options.fetch ?? ((input, init) => fetch(input, init));
15
+ async function get(route, url, signal, write) {
16
+ const own = typeof options.headers === "function" ? options.headers() : (options.headers ?? {});
17
+ const headers = write
18
+ ? {
19
+ ...own,
20
+ "Content-Type": "application/json",
21
+ ...(write.key === undefined ? {} : { "Idempotency-Key": write.key }),
22
+ }
23
+ : own;
24
+ const response = await doFetch(url, {
25
+ headers,
26
+ ...(write ? { method: write.method, body: JSON.stringify(write.body) } : {}),
27
+ ...(signal ? { signal } : {}),
28
+ });
29
+ const text = await response.text();
30
+ if (!response.ok)
31
+ throw new TransportError(route, response.status, readProblem(text), undefined);
32
+ return text ? JSON.parse(text) : null;
33
+ }
34
+ return {
35
+ async list(route, criteria, signal) {
36
+ const answer = await get(route, urlOf(base, route, plain(criteria)), signal);
37
+ return pageFrom(answer);
38
+ },
39
+ async one(route, signal) {
40
+ const answer = (await get(route, urlOf(base, route), signal));
41
+ const own = Object.keys(answer).filter((key) => !ENVELOPE.has(key));
42
+ const [key] = own;
43
+ if (own.length !== 1 || key === undefined)
44
+ throw new TransportError(route, 200, null, `${route} answered ${own.length} fields of its own (${own.join(", ")}); a record answers one`);
45
+ return {
46
+ record: answer[key],
47
+ meta: answer["entity_meta_data"] ?? null,
48
+ dropped: answer["dropped"] ?? [],
49
+ draft: answer["draft"] ?? null,
50
+ };
51
+ },
52
+ async send(method, route, body, signal, sending) {
53
+ return (await get(route, urlOf(base, route), signal, {
54
+ method,
55
+ body,
56
+ key: sending?.idempotencyKey,
57
+ }));
58
+ },
59
+ };
60
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The `Idempotency-Key` of a gesture: one per thing a person meant to do, the SAME one while
3
+ * that thing is retried.
4
+ *
5
+ * Context: a Command that got no answer — the network dropped, a proxy answered 502 — may have
6
+ * run. Sending it again with a new key could pay twice; with the same key the server answers
7
+ * what it already did (sincpro_framework's `@idempotency`). So a key lives until the server
8
+ * gives a definite answer: done, or refused for a reason a retry does not change. Changing the
9
+ * body is a different gesture, and gets a new key — the server refuses a key that comes back
10
+ * with another payload.
11
+ *
12
+ * const keys = retryKeys();
13
+ * const key = keys.keyFor(body); // the same while the previous one went unanswered
14
+ * try { await send(…, { idempotencyKey: key }); keys.settle(null); }
15
+ * catch (error) { keys.settle(error); } // kept when nobody knows what happened
16
+ */
17
+ export type RetryKeys = {
18
+ keyFor: (body: unknown) => string;
19
+ /** What the last attempt ended in: `null` when it was answered. */
20
+ settle: (error: unknown) => void;
21
+ };
22
+ /** Whether a failure leaves it unknown if the Command ran — so a retry must reuse its key. */
23
+ export declare function unanswered(error: unknown): boolean;
24
+ export declare function retryKeys(make?: () => string): RetryKeys;
@@ -0,0 +1,25 @@
1
+ import { TransportError } from "./transport.js";
2
+ /** Whether a failure leaves it unknown if the Command ran — so a retry must reuse its key. */
3
+ export function unanswered(error) {
4
+ if (!(error instanceof TransportError))
5
+ return true;
6
+ if (error.status === null || error.status >= 500)
7
+ return true;
8
+ // The same key is still running elsewhere: retry once it completes, with that key.
9
+ return error.problem?.kind === "in_progress";
10
+ }
11
+ export function retryKeys(make = () => globalThis.crypto.randomUUID()) {
12
+ let pending = null;
13
+ return {
14
+ keyFor: (body) => {
15
+ const sent = JSON.stringify(body ?? null);
16
+ if (pending === null || pending.body !== sent)
17
+ pending = { body: sent, key: make() };
18
+ return pending.key;
19
+ },
20
+ settle: (error) => {
21
+ if (error === null || !unanswered(error))
22
+ pending = null;
23
+ },
24
+ };
25
+ }
@@ -0,0 +1,46 @@
1
+ import type { Meta } from "@sincpro/criteria";
2
+ /**
3
+ * A model definition read from a JSON Schema — what an OpenAPI document holds.
4
+ *
5
+ * Context: what sincpro_framework's OpenAPI 3.1 describes and what it does not, checked
6
+ * against a running forge:
7
+ *
8
+ * - the records a READ answers are invisible: the framework serialises them with its own
9
+ * envelope, so `GET /projects` and `GET /projects/{id}` answer schema `{}` and no
10
+ * `Project` schema exists. A list's definition comes from `entity_meta_data`, at runtime
11
+ * or preloaded (`FrameworkProvider metas`) — not from here;
12
+ * - the COMMANDS and the value objects are described well, and better than the meta in one
13
+ * place: inside a value object `entity_meta_data` sends a `Literal` and a `list[str]` as
14
+ * `unknown`, where the schema says `enum: ["es", "en"]` and `array<string>`.
15
+ *
16
+ * So this reads a component schema into a `Meta` — the form of a Command, a value object — and
17
+ * `completeMeta` fills, from it, what the server's meta left `unknown`.
18
+ *
19
+ * const spec = metaFromSchema("ProjectSpecification", doc.components.schemas);
20
+ * const fixed = completeMeta(project.fields.specification.definition, spec);
21
+ */
22
+ export type JsonSchema = {
23
+ type?: string | string[];
24
+ format?: string;
25
+ enum?: readonly unknown[];
26
+ items?: JsonSchema;
27
+ $ref?: string;
28
+ anyOf?: readonly JsonSchema[];
29
+ oneOf?: readonly JsonSchema[];
30
+ properties?: Readonly<Record<string, JsonSchema>>;
31
+ required?: readonly string[];
32
+ title?: string;
33
+ description?: string;
34
+ default?: unknown;
35
+ };
36
+ export type SchemaComponents = Readonly<Record<string, JsonSchema>>;
37
+ /** The definition a component schema describes. `identity` empty: a value object has none. */
38
+ export declare function metaFromSchema(name: string, components: SchemaComponents, options?: {
39
+ identity?: string;
40
+ }): Meta;
41
+ /**
42
+ * The server's definition, with what it left `unknown` taken from the schema — recursively
43
+ * into value objects. What the server DID say always wins: its labels, its operators, its
44
+ * hints. A field the schema does not know is left as the server sent it.
45
+ */
46
+ export declare function completeMeta(meta: Meta, schema: Meta): Meta;
@@ -0,0 +1,128 @@
1
+ import { field } from "./meta.js";
2
+ const UNKNOWN = {
3
+ type: "unknown",
4
+ kind: "scalar",
5
+ nullable: true,
6
+ sortable: false,
7
+ ops: [],
8
+ };
9
+ function nameOfRef(ref) {
10
+ return ref.slice(ref.lastIndexOf("/") + 1);
11
+ }
12
+ /** `anyOf: [X, {type: "null"}]` is `X`, nullable — how pydantic writes `X | None`. */
13
+ function unwrap(schema) {
14
+ const options = schema.anyOf ?? schema.oneOf;
15
+ if (options === undefined)
16
+ return { schema, nullable: false };
17
+ const real = options.filter((one) => one.type !== "null");
18
+ const [only] = real;
19
+ return real.length === 1 && only !== undefined
20
+ ? { schema: { ...only, ...pick(schema) }, nullable: real.length < options.length }
21
+ : { schema, nullable: real.length < options.length };
22
+ }
23
+ /** The words of the outer schema win over the ones of what it wraps. */
24
+ function pick(schema) {
25
+ return {
26
+ ...(schema.title === undefined ? {} : { title: schema.title }),
27
+ ...(schema.description === undefined ? {} : { description: schema.description }),
28
+ ...(schema.default === undefined ? {} : { default: schema.default }),
29
+ };
30
+ }
31
+ function fieldFrom(raw, required, components, seen) {
32
+ const { schema: unwrapped, nullable } = unwrap(raw);
33
+ const target = unwrapped.$ref !== undefined
34
+ ? { ...components[nameOfRef(unwrapped.$ref)], ...pick(unwrapped) }
35
+ : unwrapped;
36
+ const options = {
37
+ ...(target.title === undefined ? {} : { label: target.title }),
38
+ ...(target.description === undefined ? {} : { help: target.description }),
39
+ ...(target.default === undefined ? {} : { default: target.default }),
40
+ ...(nullable ? { nullable } : {}),
41
+ required: required && target.default === undefined && !nullable,
42
+ };
43
+ if (unwrapped.$ref !== undefined) {
44
+ const name = nameOfRef(unwrapped.$ref);
45
+ if (components[name] === undefined || seen.has(name))
46
+ return UNKNOWN;
47
+ if (target.properties === undefined)
48
+ return target.enum ? field.choice(target.enum.map(String), options) : UNKNOWN;
49
+ return field.embedded(metaOf(name, target, components, new Set([...seen, name])), options);
50
+ }
51
+ if (target.enum)
52
+ return field.choice(target.enum.map(String), options);
53
+ const type = Array.isArray(target.type)
54
+ ? target.type.find((t) => t !== "null")
55
+ : target.type;
56
+ switch (type) {
57
+ case "string":
58
+ if (target.format === "date")
59
+ return field.date(options);
60
+ if (target.format === "date-time")
61
+ return field.datetime(options);
62
+ return field.text(options);
63
+ case "integer":
64
+ return field.integer(options);
65
+ case "number":
66
+ return field.number(options);
67
+ case "boolean":
68
+ return field.boolean(options);
69
+ case "array": {
70
+ const item = target.items === undefined ? undefined : unwrap(target.items).schema;
71
+ if (item?.$ref !== undefined) {
72
+ const inner = fieldFrom(item, false, components, seen);
73
+ return inner.kind === "embedded" && inner.definition
74
+ ? field.embedded(inner.definition, { ...options, many: true })
75
+ : field.tags(options);
76
+ }
77
+ return item?.type === "string" || item?.enum ? field.tags(options) : UNKNOWN;
78
+ }
79
+ default:
80
+ return UNKNOWN;
81
+ }
82
+ }
83
+ function metaOf(name, schema, components, seen) {
84
+ const required = new Set(schema.required ?? []);
85
+ const fields = Object.fromEntries(Object.entries(schema.properties ?? {}).map(([key, property]) => [
86
+ key,
87
+ fieldFrom(property, required.has(key), components, seen),
88
+ ]));
89
+ return {
90
+ aggregate: name,
91
+ identity: "",
92
+ default_order: "",
93
+ fields,
94
+ ...(schema.title === undefined ? {} : { name: { default: schema.title } }),
95
+ };
96
+ }
97
+ /** The definition a component schema describes. `identity` empty: a value object has none. */
98
+ export function metaFromSchema(name, components, options = {}) {
99
+ const schema = components[name];
100
+ if (schema === undefined)
101
+ throw new Error(`no schema named ${name}`);
102
+ const meta = metaOf(name, schema, components, new Set([name]));
103
+ return options.identity
104
+ ? { ...meta, identity: options.identity, get_id: options.identity }
105
+ : meta;
106
+ }
107
+ /**
108
+ * The server's definition, with what it left `unknown` taken from the schema — recursively
109
+ * into value objects. What the server DID say always wins: its labels, its operators, its
110
+ * hints. A field the schema does not know is left as the server sent it.
111
+ */
112
+ export function completeMeta(meta, schema) {
113
+ const fields = Object.fromEntries(Object.entries(meta.fields).map(([name, own]) => {
114
+ const known = schema.fields[name];
115
+ if (known === undefined)
116
+ return [name, own];
117
+ if (own.type === "unknown")
118
+ return [name, { ...known, ...labelsOf(own) }];
119
+ if (own.kind === "embedded" && own.definition && known.definition)
120
+ return [name, { ...own, definition: completeMeta(own.definition, known.definition) }];
121
+ return [name, own];
122
+ }));
123
+ return { ...meta, fields };
124
+ }
125
+ /** A label the server declared is kept over the schema's generated title. */
126
+ function labelsOf(own) {
127
+ return own.label && Object.keys(own.label).length > 0 ? { label: own.label } : {};
128
+ }
@@ -0,0 +1,50 @@
1
+ import type { AnyRecord, Meta } from "@sincpro/criteria";
2
+ /**
3
+ * What is known about ANY record, the same for every entity: its name and, when it has them,
4
+ * a description, a state, a type, a picture and who made and last changed it.
5
+ *
6
+ * Context: a link, a chip, a card, the summary that floats from a name and the header of a
7
+ * record all say the same few things about a record — only how much of it changes. Read once
8
+ * here, from the model, they cannot disagree, and a new entity looks right without a line
9
+ * written for it. What a record does not have is `null`, and nothing is drawn for it.
10
+ *
11
+ * Which field plays each part: the one the entity declares (`entity(…, { summary })`), else
12
+ * the first conventional name the model holds (`description`, `state`/`status`, `created_at`,
13
+ * …). The name is the model's own `display`. When the backend publishes these parts in its
14
+ * model definition, they are read from there and the convention steps aside.
15
+ */
16
+ export type SummaryPart = "description" | "state" | "type" | "image" | "createdAt" | "createdBy" | "updatedAt" | "updatedBy";
17
+ /** Which field of the entity plays each part, when it is not the conventional one.
18
+ * `person`: its records are people or companies — a round face, initials without a photo. */
19
+ export type SummaryFields = Partial<Record<SummaryPart, string>> & {
20
+ person?: boolean;
21
+ };
22
+ export type RecordSummary = {
23
+ id: string;
24
+ name: string;
25
+ description: string | null;
26
+ state: string | null;
27
+ type: string | null;
28
+ /** The picture's address, as the server sent it. */
29
+ image: string | null;
30
+ person: boolean;
31
+ created: {
32
+ at: string | null;
33
+ by: string | null;
34
+ };
35
+ updated: {
36
+ at: string | null;
37
+ by: string | null;
38
+ };
39
+ };
40
+ /** The field that plays each part. With a model, only fields it declares; without one (a
41
+ * record that came alone), the record's own keys. */
42
+ export declare function summaryFieldsOf(meta: Meta | null | undefined, record: AnyRecord, declared?: SummaryFields): Partial<Record<SummaryPart, string>>;
43
+ /**
44
+ * summaryOf(meta, invoice, { locale: "es" })
45
+ * -> { name: "F-2026-0004", state: "Pagada", created: { at: "4 ene 2026, 14:13", … } }
46
+ */
47
+ export declare function summaryOf(meta: Meta | null | undefined, record: AnyRecord, options?: {
48
+ fields?: SummaryFields;
49
+ locale?: string;
50
+ }): RecordSummary;