@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.
- package/LICENSE +21 -0
- package/README.md +445 -2
- package/dist/core/cache.d.ts +30 -0
- package/dist/core/cache.js +47 -0
- package/dist/core/copy.d.ts +93 -0
- package/dist/core/copy.js +141 -0
- package/dist/core/edit.d.ts +40 -0
- package/dist/core/edit.js +81 -0
- package/dist/core/entity.d.ts +113 -0
- package/dist/core/entity.js +57 -0
- package/dist/core/form.d.ts +21 -0
- package/dist/core/form.js +30 -0
- package/dist/core/history.d.ts +20 -0
- package/dist/core/history.js +40 -0
- package/dist/core/index.d.ts +26 -0
- package/dist/core/index.js +26 -0
- package/dist/core/list.d.ts +64 -0
- package/dist/core/list.js +22 -0
- package/dist/core/lookup.d.ts +19 -0
- package/dist/core/lookup.js +14 -0
- package/dist/core/measure.d.ts +16 -0
- package/dist/core/measure.js +38 -0
- package/dist/core/memory.d.ts +43 -0
- package/dist/core/memory.js +99 -0
- package/dist/core/meta.d.ts +114 -0
- package/dist/core/meta.js +130 -0
- package/dist/core/metas.d.ts +18 -0
- package/dist/core/metas.js +7 -0
- package/dist/core/present.d.ts +48 -0
- package/dist/core/present.js +165 -0
- package/dist/core/related.d.ts +22 -0
- package/dist/core/related.js +21 -0
- package/dist/core/relation.d.ts +28 -0
- package/dist/core/relation.js +30 -0
- package/dist/core/rest.d.ts +30 -0
- package/dist/core/rest.js +60 -0
- package/dist/core/retry.d.ts +24 -0
- package/dist/core/retry.js +25 -0
- package/dist/core/schema.d.ts +46 -0
- package/dist/core/schema.js +128 -0
- package/dist/core/summary.d.ts +50 -0
- package/dist/core/summary.js +54 -0
- package/dist/core/trail.d.ts +44 -0
- package/dist/core/trail.js +70 -0
- package/dist/core/transport.d.ts +71 -0
- package/dist/core/transport.js +43 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/react/context.d.ts +38 -0
- package/dist/react/context.js +26 -0
- package/dist/react/index.d.ts +14 -0
- package/dist/react/index.js +14 -0
- package/dist/react/useCommand.d.ts +30 -0
- package/dist/react/useCommand.js +60 -0
- package/dist/react/useGroups.d.ts +40 -0
- package/dist/react/useGroups.js +69 -0
- package/dist/react/useHistory.d.ts +5 -0
- package/dist/react/useHistory.js +41 -0
- package/dist/react/useListing.d.ts +93 -0
- package/dist/react/useListing.js +156 -0
- package/dist/react/useLookup.d.ts +47 -0
- package/dist/react/useLookup.js +68 -0
- package/dist/react/useRecord.d.ts +89 -0
- package/dist/react/useRecord.js +272 -0
- package/dist/react/useRelated.d.ts +21 -0
- package/dist/react/useRelated.js +32 -0
- package/dist/react/useSummary.d.ts +15 -0
- package/dist/react/useSummary.js +22 -0
- package/dist/web/Refused.d.ts +9 -0
- package/dist/web/Refused.js +14 -0
- package/dist/web/index.d.ts +23 -0
- package/dist/web/index.js +23 -0
- package/dist/web/list/EntityList.d.ts +92 -0
- package/dist/web/list/EntityList.js +229 -0
- package/dist/web/list/EntitySearchBar.d.ts +34 -0
- package/dist/web/list/EntitySearchBar.js +73 -0
- package/dist/web/list/columns.d.ts +22 -0
- package/dist/web/list/columns.js +27 -0
- package/dist/web/list/groups.d.ts +15 -0
- package/dist/web/list/groups.js +73 -0
- package/dist/web/list/rowCommands.d.ts +41 -0
- package/dist/web/list/rowCommands.js +56 -0
- package/dist/web/list/search.d.ts +21 -0
- package/dist/web/list/search.js +72 -0
- package/dist/web/record/CommandButton.d.ts +22 -0
- package/dist/web/record/CommandButton.js +40 -0
- package/dist/web/record/EntityPicker.d.ts +56 -0
- package/dist/web/record/EntityPicker.js +73 -0
- package/dist/web/record/EntityTags.d.ts +38 -0
- package/dist/web/record/EntityTags.js +41 -0
- package/dist/web/record/RelatedList.d.ts +19 -0
- package/dist/web/record/RelatedList.js +26 -0
- package/dist/web/record/edit.d.ts +42 -0
- package/dist/web/record/edit.js +122 -0
- package/dist/web/record/opening.d.ts +26 -0
- package/dist/web/record/opening.js +10 -0
- package/dist/web/record/record.d.ts +25 -0
- package/dist/web/record/record.js +56 -0
- package/dist/web/record/summary.d.ts +22 -0
- package/dist/web/record/summary.js +55 -0
- package/dist/web/workspace/SincproApp.d.ts +40 -0
- package/dist/web/workspace/SincproApp.js +45 -0
- package/dist/web/workspace/composer.d.ts +55 -0
- package/dist/web/workspace/composer.js +94 -0
- package/dist/web/workspace/open.d.ts +61 -0
- package/dist/web/workspace/open.js +62 -0
- package/dist/web/workspace/record.d.ts +30 -0
- package/dist/web/workspace/record.js +80 -0
- package/dist/web/workspace/skin.d.ts +2 -0
- package/dist/web/workspace/skin.js +45 -0
- package/dist/web/workspace/workspace.d.ts +44 -0
- package/dist/web/workspace/workspace.js +82 -0
- 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;
|