@sincpro/web-framework 0.0.0-stage → 0.1.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 (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +224 -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 +69 -0
  6. package/dist/core/copy.js +111 -0
  7. package/dist/core/edit.d.ts +27 -0
  8. package/dist/core/edit.js +51 -0
  9. package/dist/core/entity.d.ts +79 -0
  10. package/dist/core/entity.js +32 -0
  11. package/dist/core/history.d.ts +20 -0
  12. package/dist/core/history.js +40 -0
  13. package/dist/core/index.d.ts +19 -0
  14. package/dist/core/index.js +19 -0
  15. package/dist/core/lookup.d.ts +16 -0
  16. package/dist/core/lookup.js +10 -0
  17. package/dist/core/memory.d.ts +43 -0
  18. package/dist/core/memory.js +85 -0
  19. package/dist/core/meta.d.ts +114 -0
  20. package/dist/core/meta.js +130 -0
  21. package/dist/core/metas.d.ts +18 -0
  22. package/dist/core/metas.js +7 -0
  23. package/dist/core/present.d.ts +48 -0
  24. package/dist/core/present.js +165 -0
  25. package/dist/core/related.d.ts +22 -0
  26. package/dist/core/related.js +21 -0
  27. package/dist/core/rest.d.ts +30 -0
  28. package/dist/core/rest.js +50 -0
  29. package/dist/core/schema.d.ts +46 -0
  30. package/dist/core/schema.js +128 -0
  31. package/dist/core/transport.d.ts +59 -0
  32. package/dist/core/transport.js +43 -0
  33. package/dist/index.d.ts +10 -0
  34. package/dist/index.js +10 -0
  35. package/dist/react/context.d.ts +27 -0
  36. package/dist/react/context.js +18 -0
  37. package/dist/react/index.d.ts +12 -0
  38. package/dist/react/index.js +12 -0
  39. package/dist/react/useCommand.d.ts +28 -0
  40. package/dist/react/useCommand.js +52 -0
  41. package/dist/react/useHistory.d.ts +5 -0
  42. package/dist/react/useHistory.js +41 -0
  43. package/dist/react/useListing.d.ts +75 -0
  44. package/dist/react/useListing.js +121 -0
  45. package/dist/react/useLookup.d.ts +22 -0
  46. package/dist/react/useLookup.js +15 -0
  47. package/dist/react/useRecord.d.ts +74 -0
  48. package/dist/react/useRecord.js +176 -0
  49. package/dist/react/useRelated.d.ts +21 -0
  50. package/dist/react/useRelated.js +32 -0
  51. package/dist/web/Refused.d.ts +9 -0
  52. package/dist/web/Refused.js +14 -0
  53. package/dist/web/index.d.ts +17 -0
  54. package/dist/web/index.js +17 -0
  55. package/dist/web/list/EntityList.d.ts +69 -0
  56. package/dist/web/list/EntityList.js +82 -0
  57. package/dist/web/list/EntitySearchBar.d.ts +26 -0
  58. package/dist/web/list/EntitySearchBar.js +43 -0
  59. package/dist/web/list/columns.d.ts +22 -0
  60. package/dist/web/list/columns.js +27 -0
  61. package/dist/web/list/search.d.ts +21 -0
  62. package/dist/web/list/search.js +72 -0
  63. package/dist/web/record/CommandButton.d.ts +22 -0
  64. package/dist/web/record/CommandButton.js +40 -0
  65. package/dist/web/record/EntityPicker.d.ts +27 -0
  66. package/dist/web/record/EntityPicker.js +39 -0
  67. package/dist/web/record/RelatedList.d.ts +18 -0
  68. package/dist/web/record/RelatedList.js +16 -0
  69. package/dist/web/record/edit.d.ts +35 -0
  70. package/dist/web/record/edit.js +63 -0
  71. package/dist/web/record/record.d.ts +25 -0
  72. package/dist/web/record/record.js +56 -0
  73. package/dist/web/workspace/SincproApp.d.ts +37 -0
  74. package/dist/web/workspace/SincproApp.js +45 -0
  75. package/dist/web/workspace/composer.d.ts +33 -0
  76. package/dist/web/workspace/composer.js +37 -0
  77. package/dist/web/workspace/skin.d.ts +11 -0
  78. package/dist/web/workspace/skin.js +33 -0
  79. package/dist/web/workspace/workspace.d.ts +16 -0
  80. package/dist/web/workspace/workspace.js +29 -0
  81. package/package.json +86 -4
@@ -0,0 +1,79 @@
1
+ import type { AnyRecord } from "@sincpro/criteria";
2
+ import type { Method } from "./transport.js";
3
+ /**
4
+ * Everything a screen needs to know about where an entity lives on the server, declared once.
5
+ *
6
+ * Context: a screen should not know a URL. It names the entity, and the entity says where its
7
+ * list is, where one record is, which Commands it takes, how it is saved and previewed, where a
8
+ * draft is kept and where its history is. `key` is how the workspace refers to it
9
+ * (`domain:kind`, the shape of a composer `ResourceRef`).
10
+ *
11
+ * The routes are declared, not guessed: sincpro_framework publishes a route per use case and
12
+ * its model definition does not list them (yet). A route is `"METHOD /path/{id}"` for a write.
13
+ *
14
+ * export const invoices = entity<Invoice>("billing:invoice", {
15
+ * list: "/invoices",
16
+ * save: "PUT /invoices/{id}",
17
+ * preview: "POST /invoices/preview",
18
+ * commands: { confirm: "POST /invoices/{id}/confirm", cancel: "POST /invoices/{id}/cancel" },
19
+ * history: { route: "/billing-events", type: "Invoice" },
20
+ * });
21
+ */
22
+ export type Entity<T extends AnyRecord = AnyRecord> = {
23
+ /** `domain:kind`, e.g. `"billing:invoice"`. */
24
+ key: string;
25
+ domain: string;
26
+ kind: string;
27
+ /** Where the workspace finds it: the list without an id, one record with it — the shape of a
28
+ * composer `ResourceRef`, built here so no screen writes `{ domain, kind, id }` by hand. */
29
+ ref: (id?: string) => EntityRef;
30
+ /** The route of its list. */
31
+ list: string;
32
+ /** The route of one record. */
33
+ one: (id: string) => string;
34
+ /** Its Commands, by name. */
35
+ commands: Readonly<Record<string, WriteRoute>>;
36
+ /** How an edit is saved: the record's own route with `{id}`. */
37
+ save?: WriteRoute;
38
+ /** «What does this record become?» before saving — sincpro_framework's `Preview`. */
39
+ preview?: WriteRoute;
40
+ /** Where this caller keeps an edit between requests — sincpro_framework's `Drafts`. */
41
+ draft?: WriteRoute;
42
+ /** Its business events: the context's events route, and the `entity_type` they carry. */
43
+ history?: {
44
+ route: string;
45
+ type: string;
46
+ };
47
+ /** Only carries the record type; never read. */
48
+ readonly _record?: T;
49
+ };
50
+ /** A composer `ResourceRef`, structurally: `core/` does not import the composer. */
51
+ export type EntityRef = {
52
+ domain: string;
53
+ kind: string;
54
+ id?: string;
55
+ };
56
+ /** A write: its method and its path, `{id}` where the record's key goes. */
57
+ export type WriteRoute = {
58
+ method: Method;
59
+ path: string;
60
+ };
61
+ export type EntityRoutes = {
62
+ list: string;
63
+ /** With `{id}` where the key goes. Without it, `<list>/<id>` — sincpro_framework's convention. */
64
+ one?: string;
65
+ /** `"POST /invoices/{id}/confirm"`, by name. */
66
+ commands?: Readonly<Record<string, string>>;
67
+ save?: string;
68
+ preview?: string;
69
+ draft?: string;
70
+ history?: {
71
+ route: string;
72
+ type: string;
73
+ };
74
+ };
75
+ /** `"POST /invoices/{id}/confirm"` → its method and path. A write without a method is a bug. */
76
+ export declare function writeRoute(text: string): WriteRoute;
77
+ /** The path with the record's key in place of `{id}`. */
78
+ export declare function pathFor(route: WriteRoute, id?: string): string;
79
+ export declare function entity<T extends AnyRecord = AnyRecord>(key: string, routes: EntityRoutes): Entity<T>;
@@ -0,0 +1,32 @@
1
+ const METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
2
+ /** `"POST /invoices/{id}/confirm"` → its method and path. A write without a method is a bug. */
3
+ export function writeRoute(text) {
4
+ const [method, path] = text.trim().split(/\s+/, 2);
5
+ if (!METHODS.has(method) || path === undefined)
6
+ throw new Error(`"${text}" is not "METHOD /path": a write says how it is sent`);
7
+ return { method: method, path };
8
+ }
9
+ /** The path with the record's key in place of `{id}`. */
10
+ export function pathFor(route, id) {
11
+ return id === undefined ? route.path : route.path.replace("{id}", encodeURIComponent(id));
12
+ }
13
+ export function entity(key, routes) {
14
+ if (!/^[^:\s]+:[^:\s]+$/.test(key))
15
+ throw new Error(`entity key "${key}" is not domain:kind`);
16
+ const list = routes.list.split("?")[0] ?? routes.list;
17
+ const template = routes.one ?? `${list}/{id}`;
18
+ const [domain = "", kind = ""] = key.split(":");
19
+ return {
20
+ key,
21
+ domain,
22
+ kind,
23
+ ref: (id) => ({ domain, kind, ...(id === undefined || id === "" ? {} : { id }) }),
24
+ list: routes.list,
25
+ one: (id) => template.replace("{id}", encodeURIComponent(id)),
26
+ commands: Object.fromEntries(Object.entries(routes.commands ?? {}).map(([name, text]) => [name, writeRoute(text)])),
27
+ ...(routes.save === undefined ? {} : { save: writeRoute(routes.save) }),
28
+ ...(routes.preview === undefined ? {} : { preview: writeRoute(routes.preview) }),
29
+ ...(routes.draft === undefined ? {} : { draft: writeRoute(routes.draft) }),
30
+ ...(routes.history === undefined ? {} : { history: routes.history }),
31
+ };
32
+ }
@@ -0,0 +1,20 @@
1
+ import { type AnyRecord, type Meta } from "@sincpro/criteria";
2
+ /**
3
+ * A record's history as people read it — its business events, newest first.
4
+ *
5
+ * Context: sincpro_framework keeps every context's facts in one event table, read over
6
+ * `GET /<context>-events`, each fact naming the record it is about (`entity_type`,
7
+ * `entity_id`) and carrying a `label` in every language — the framework's `history_of`.
8
+ * forge's console read it with a dictionary of field names written by hand; here the words for
9
+ * the rest of a fact come from the events' own model definition, like any other field.
10
+ *
11
+ * These are business facts («Factura confirmada»), never technical logs: those belong to the
12
+ * observability stack, not to a record's screen.
13
+ */
14
+ export type HistoryEntry = {
15
+ id: string;
16
+ label: string;
17
+ at: string;
18
+ detail?: string;
19
+ };
20
+ export declare function historyOf(rows: readonly AnyRecord[], meta: Meta | null | undefined, locale?: string): HistoryEntry[];
@@ -0,0 +1,40 @@
1
+ import { labelOf } from "@sincpro/criteria";
2
+ /** What every fact carries: what remains is what this fact says. */
3
+ const ENVELOPE = new Set([
4
+ "id",
5
+ "created_at",
6
+ "updated_at",
7
+ "version",
8
+ "label",
9
+ "entity_type",
10
+ "entity_id",
11
+ "entity_version",
12
+ "correlation_id",
13
+ "causation_id",
14
+ ]);
15
+ function said(value) {
16
+ if (value === null || value === undefined || value === "")
17
+ return null;
18
+ if (Array.isArray(value))
19
+ return value.length === 0 ? null : value.map(String).join(", ");
20
+ return typeof value === "object" ? JSON.stringify(value) : String(value);
21
+ }
22
+ export function historyOf(rows, meta, locale) {
23
+ return rows.map((row) => {
24
+ const label = row["label"];
25
+ const word = typeof label === "string"
26
+ ? label
27
+ : ((locale ? label?.[locale] : undefined) ?? label?.["default"] ?? String(row["id"]));
28
+ const parts = Object.entries(row)
29
+ .filter(([key]) => !ENVELOPE.has(key))
30
+ .map(([key, value]) => [key, said(value)])
31
+ .filter(([, value]) => value !== null && value !== row["entity_id"])
32
+ .map(([key, value]) => `${labelOf(meta, key, locale)}: ${value}`);
33
+ return {
34
+ id: String(row["id"]),
35
+ label: word,
36
+ at: String(row["created_at"] ?? ""),
37
+ ...(parts.length === 0 ? {} : { detail: parts.join(" · ") }),
38
+ };
39
+ });
40
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The plain logic: no React, no DOM, no design system — so it is tested in Node, fast, with
3
+ * no browser. The transport, the entity, the model definition and what a value reads as.
4
+ * `make doctor` keeps it that way.
5
+ */
6
+ export { type CachedTransport, cachedTransport, type CacheOptions, } from "./cache.js";
7
+ export { ENGLISH_FRAMEWORK_COPY, type FrameworkCopy, frameworkCopy, installFrameworkCopy, SPANISH_FRAMEWORK_COPY, } from "./copy.js";
8
+ export { changesOf, errorsByField, type FieldState, fieldStateOf, type FieldStates, missingOf, } from "./edit.js";
9
+ export { type Entity, entity, type EntityRef, type EntityRoutes, pathFor, type WriteRoute, writeRoute, } from "./entity.js";
10
+ export { type HistoryEntry, historyOf } from "./history.js";
11
+ export { lookupOf, type LookupOption } from "./lookup.js";
12
+ export { type MemoryOptions, type MemoryTable, memoryTransport, } from "./memory.js";
13
+ export { defineMeta, field, type FieldOptions, type MetaSpec, type Words, } from "./meta.js";
14
+ export { type MetaStore, metaStore } from "./metas.js";
15
+ export { BOOKKEEPING, defaultFields, type FieldView, sortKeyOf, textOf, viewOf, } from "./present.js";
16
+ export { type Related, relatedOf } from "./related.js";
17
+ export { type RestOptions, restTransport, urlOf } from "./rest.js";
18
+ export { completeMeta, type JsonSchema, metaFromSchema, type SchemaComponents, } from "./schema.js";
19
+ export { type Draft, explain, type Method, type One, type Transport, TransportError, } from "./transport.js";
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The plain logic: no React, no DOM, no design system — so it is tested in Node, fast, with
3
+ * no browser. The transport, the entity, the model definition and what a value reads as.
4
+ * `make doctor` keeps it that way.
5
+ */
6
+ export { cachedTransport, } from "./cache.js";
7
+ export { ENGLISH_FRAMEWORK_COPY, frameworkCopy, installFrameworkCopy, SPANISH_FRAMEWORK_COPY, } from "./copy.js";
8
+ export { changesOf, errorsByField, fieldStateOf, missingOf, } from "./edit.js";
9
+ export { entity, pathFor, writeRoute, } from "./entity.js";
10
+ export { historyOf } from "./history.js";
11
+ export { lookupOf } from "./lookup.js";
12
+ export { memoryTransport, } from "./memory.js";
13
+ export { defineMeta, field, } from "./meta.js";
14
+ export { metaStore } from "./metas.js";
15
+ export { BOOKKEEPING, defaultFields, sortKeyOf, textOf, viewOf, } from "./present.js";
16
+ export { relatedOf } from "./related.js";
17
+ export { restTransport, urlOf } from "./rest.js";
18
+ export { completeMeta, metaFromSchema, } from "./schema.js";
19
+ export { explain, TransportError, } from "./transport.js";
@@ -0,0 +1,16 @@
1
+ import type { AnyRecord, Meta } from "@sincpro/criteria";
2
+ /**
3
+ * A record as a choice: what a many2one select, a «share with» and ⌘K show for it.
4
+ *
5
+ * Context: sincpro_framework answers this question on its own — `LiteralSearch`, the entity's
6
+ * `DEFAULT_LITERAL_SEARCH` and `DEFAULT_DISPLAY` — and publishes both in the model definition
7
+ * (`meta.search`, `meta.display`). So a choice is read from the model: its key is the field a
8
+ * record is fetched by, its label the display, and the key again beside it when the two differ
9
+ * («Globex Bolivia · C-004»), which is what tells two «Globex» apart.
10
+ */
11
+ export type LookupOption = {
12
+ id: string;
13
+ label: string;
14
+ detail?: string;
15
+ };
16
+ export declare function lookupOf(meta: Meta | null | undefined, rows: readonly AnyRecord[]): LookupOption[];
@@ -0,0 +1,10 @@
1
+ export function lookupOf(meta, rows) {
2
+ const key = meta?.get_id || meta?.identity || "id";
3
+ const display = meta?.display || key;
4
+ return rows.map((row) => {
5
+ const id = String(row[key] ?? "");
6
+ const shown = row[display];
7
+ const label = shown === undefined || shown === null || shown === "" ? id : String(shown);
8
+ return label === id ? { id, label } : { id, label, detail: id };
9
+ });
10
+ }
@@ -0,0 +1,43 @@
1
+ import type { AnyRecord, Criteria, Meta, Problem } from "@sincpro/criteria";
2
+ import { type Draft, type Method, type Transport } from "./transport.js";
3
+ /**
4
+ * A server held in memory: tables of rows with their definition, answered with the same
5
+ * engine sincpro_framework's criteria runs on.
6
+ *
7
+ * Context: not a test double. It is what a screen runs against before its server exists,
8
+ * what Storybook and the tests run against, and what a domain with no server at all uses —
9
+ * rows a screen already holds. It filters, orders, pages and counts
10
+ * exactly as the server would (`answer` from `@sincpro/criteria/engine`), drops what the
11
+ * model cannot answer and reports it, and refuses what the server would refuse. So the day a
12
+ * real server answers, only the transport changes.
13
+ *
14
+ * A record is found at `<list route>/<id>`, by the field the model fetches by (`get_id`, else
15
+ * the identity) — the convention sincpro_framework's REST routes follow.
16
+ *
17
+ * const transport = memoryTransport({
18
+ * "/invoices": { meta: invoiceMeta, rows: invoices },
19
+ * }, { latency: 300 });
20
+ */
21
+ export type MemoryTable = {
22
+ meta: Meta;
23
+ rows: readonly AnyRecord[];
24
+ };
25
+ export type MemoryOptions = {
26
+ /** Milliseconds every answer waits, so a screen's «loading» can be seen and tested. */
27
+ latency?: number;
28
+ /**
29
+ * Answer a Command — a write, an action, a preview — the way the service would. It may
30
+ * change the tables' rows (they are read on every request) and throw a `TransportError` to
31
+ * refuse. Without it, every Command is a 404.
32
+ */
33
+ handle?: (method: Method, route: string, body: unknown) => unknown;
34
+ /** The draft this caller kept of a record, answered with it — sincpro_framework's `Drafts`. */
35
+ draft?: (route: string) => Draft | null;
36
+ /** Every list request, as it arrived: what a story or a test checks was really asked. */
37
+ observe?: (route: string, criteria: Criteria) => void;
38
+ /** Refuse a route the way a server would: return the problem it answers with. */
39
+ refuse?: (route: string) => (Problem & {
40
+ status?: number;
41
+ }) | undefined;
42
+ };
43
+ export declare function memoryTransport(tables: Readonly<Record<string, MemoryTable>>, options?: MemoryOptions): Transport;
@@ -0,0 +1,85 @@
1
+ import { answer } from "@sincpro/criteria/engine";
2
+ import { TransportError, } from "./transport.js";
3
+ function wait(ms, signal) {
4
+ if (ms <= 0)
5
+ return Promise.resolve();
6
+ return new Promise((done, failed) => {
7
+ const timer = setTimeout(done, ms);
8
+ signal?.addEventListener("abort", () => {
9
+ clearTimeout(timer);
10
+ failed(signal.reason);
11
+ });
12
+ });
13
+ }
14
+ export function memoryTransport(tables, options = {}) {
15
+ async function check(route, signal) {
16
+ await wait(options.latency ?? 0, signal);
17
+ const refused = options.refuse?.(route);
18
+ if (refused !== undefined)
19
+ throw new TransportError(route, refused.status ?? 400, refused);
20
+ }
21
+ function tableAt(route) {
22
+ const path = route.split("?")[0] ?? route;
23
+ const table = tables[path];
24
+ if (table === undefined)
25
+ throw new TransportError(route, 404, {
26
+ kind: "not_found",
27
+ reason: "ROUTE_NOT_FOUND",
28
+ next_step: "fix_request",
29
+ retryable: false,
30
+ errors: [],
31
+ extensions: {},
32
+ detail: `No table answers ${path}.`,
33
+ });
34
+ return table;
35
+ }
36
+ return {
37
+ async list(route, criteria, signal) {
38
+ options.observe?.(route, criteria);
39
+ await check(route, signal);
40
+ const table = tableAt(route);
41
+ const page = answer(table.rows, criteria, table.meta);
42
+ // A copy, as over a wire: a screen holding a row must not see the server change it.
43
+ return { ...page, rows: structuredClone(page.rows) };
44
+ },
45
+ async one(route, signal) {
46
+ await check(route, signal);
47
+ const slash = route.lastIndexOf("/");
48
+ const table = tableAt(route.slice(0, slash));
49
+ const id = decodeURIComponent(route.slice(slash + 1));
50
+ const key = table.meta.get_id || table.meta.identity;
51
+ const record = table.rows.find((row) => String(row[key]) === id);
52
+ if (record === undefined)
53
+ throw new TransportError(route, 404, {
54
+ kind: "not_found",
55
+ reason: "AGGREGATE_NOT_FOUND",
56
+ next_step: "do_not_retry",
57
+ retryable: false,
58
+ errors: [],
59
+ extensions: {},
60
+ detail: `${table.meta.aggregate} ${id} does not exist.`,
61
+ });
62
+ return {
63
+ record: structuredClone(record),
64
+ meta: table.meta,
65
+ dropped: [],
66
+ draft: options.draft?.(route) ?? null,
67
+ };
68
+ },
69
+ async send(method, route, body, signal) {
70
+ await check(route, signal);
71
+ if (options.handle === undefined)
72
+ throw new TransportError(route, 404, {
73
+ kind: "not_found",
74
+ reason: "ROUTE_NOT_FOUND",
75
+ next_step: "fix_request",
76
+ retryable: false,
77
+ errors: [],
78
+ extensions: {},
79
+ detail: `Nothing answers ${method} ${route}.`,
80
+ });
81
+ // A deep copy: what the caller sent is not what the server keeps.
82
+ return (await options.handle(method, route, structuredClone(body)));
83
+ },
84
+ };
85
+ }
@@ -0,0 +1,114 @@
1
+ import type { Expression, FieldMeta, FieldType, Meta, Operator } from "@sincpro/criteria";
2
+ /**
3
+ * A model definition written by hand, in the shape sincpro_framework publishes.
4
+ *
5
+ * Context: everything in this package reads a `Meta` — the labels, the columns, the controls,
6
+ * the search box. A service built with sincpro_framework sends one with every answer. A domain
7
+ * that has none (another backend, rows a screen already holds, a prototype
8
+ * with no server yet) declares it here, and from then on it is indistinguishable: the same
9
+ * list, the same record, the same criteria. `describeRows` from `@sincpro/criteria` guesses
10
+ * one from rows; this one is written on purpose, with the words a person reads.
11
+ *
12
+ * const tickets = defineMeta({
13
+ * aggregate: "Ticket", identity: "id", display: "title", search: ["title"],
14
+ * name: { es: "Tickets" },
15
+ * fields: {
16
+ * id: field.text({ readonly: true }),
17
+ * title: field.text({ label: { es: "Título" }, required: true }),
18
+ * stage: field.choice(["new", "done"], { labels: { new: { es: "Nuevo" } } }),
19
+ * opened_at: field.datetime({ label: { es: "Abierto" } }),
20
+ * },
21
+ * });
22
+ */
23
+ /** A word in several languages, `{default?, es?, en?, …}`; a bare string is the default. */
24
+ export type Words = string | Readonly<Record<string, string>>;
25
+ export type FieldOptions = {
26
+ label?: Words;
27
+ help?: Words;
28
+ required?: boolean;
29
+ readonly?: boolean;
30
+ /** Holds no value sometimes. A nullable field is never sortable: keyset paging over it
31
+ * loses the rows that hold NULL, which is why the framework refuses to order by one. */
32
+ nullable?: boolean;
33
+ default?: unknown;
34
+ /** Form hints over the record, as sincpro_framework's `Presentation` publishes them. */
35
+ readonlyWhen?: Expression;
36
+ requiredWhen?: Expression;
37
+ visibleWhen?: Expression;
38
+ };
39
+ /** The field builders: one per type the framework publishes. */
40
+ export declare const field: {
41
+ text: (options?: FieldOptions) => FieldMeta;
42
+ integer: (options?: FieldOptions) => FieldMeta;
43
+ /** A decimal travels as text so no digit is lost (`"10.50"`): pass `exact: true`. */
44
+ number: (options?: FieldOptions & {
45
+ exact?: boolean;
46
+ }) => {
47
+ exact?: boolean;
48
+ type: FieldType;
49
+ nullable: boolean;
50
+ choices?: unknown[];
51
+ choice_labels?: Record<string, Record<string, string>>;
52
+ label?: Record<string, string>;
53
+ help?: Record<string, string>;
54
+ groupable?: boolean;
55
+ aggregates?: string[];
56
+ computed?: boolean;
57
+ owner?: string | null;
58
+ readonly?: boolean;
59
+ required?: boolean;
60
+ default?: unknown;
61
+ readonly_when?: Expression | null;
62
+ required_when?: Expression | null;
63
+ visible_when?: Expression | null;
64
+ sortable: boolean;
65
+ ops: Operator[];
66
+ many?: boolean;
67
+ relation?: string | null;
68
+ identified_by?: string | null;
69
+ parent_field?: string | null;
70
+ related_field?: string | null;
71
+ definition?: Meta | null;
72
+ kind: import("@sincpro/criteria").FieldKind;
73
+ };
74
+ boolean: (options?: FieldOptions) => FieldMeta;
75
+ date: (options?: FieldOptions) => FieldMeta;
76
+ datetime: (options?: FieldOptions) => FieldMeta;
77
+ tags: (options?: FieldOptions) => FieldMeta;
78
+ /** An enum: the values in order, and the word each one is read as. */
79
+ choice: (values: readonly string[], options?: FieldOptions & {
80
+ labels?: Readonly<Record<string, Words>>;
81
+ }) => FieldMeta;
82
+ /** Points at ONE record of another aggregate, by the scalar key this record holds. */
83
+ many2one: (relation: string, keys: {
84
+ identifiedBy: string;
85
+ definition?: Meta;
86
+ }, options?: FieldOptions) => FieldMeta;
87
+ /** The records of another aggregate that point back here, by THEIR key. */
88
+ one2many: (relation: string, keys: {
89
+ identifiedBy: string;
90
+ definition?: Meta;
91
+ }, options?: FieldOptions) => FieldMeta;
92
+ many2many: (relation: string, keys: {
93
+ identifiedBy: string;
94
+ definition?: Meta;
95
+ }, options?: FieldOptions) => FieldMeta;
96
+ /** A value object kept inside the record — an address, a specification. No identity. */
97
+ embedded: (definition: Meta, options?: FieldOptions & {
98
+ many?: boolean;
99
+ }) => FieldMeta;
100
+ };
101
+ export type MetaSpec = {
102
+ aggregate: string;
103
+ identity: string;
104
+ fields: Record<string, FieldMeta>;
105
+ name?: Words;
106
+ /** The field shown beside the identity: what a select and a many2one show. */
107
+ display?: string;
108
+ /** `"-created_at"`, the way a URL writes it. Without it, the identity ascending. */
109
+ defaultOrder?: string;
110
+ /** The text fields a typed text is looked for in — the entity's search template. */
111
+ search?: readonly string[];
112
+ };
113
+ /** A whole definition, from its fields. See the file header. */
114
+ export declare function defineMeta(spec: MetaSpec): Meta;
@@ -0,0 +1,130 @@
1
+ function hints(options) {
2
+ return {
3
+ ...(options.readonlyWhen ? { readonly_when: options.readonlyWhen } : {}),
4
+ ...(options.requiredWhen ? { required_when: options.requiredWhen } : {}),
5
+ ...(options.visibleWhen ? { visible_when: options.visibleWhen } : {}),
6
+ };
7
+ }
8
+ /** The comparisons each type takes — the same table sincpro_framework publishes. */
9
+ const OPS = {
10
+ text: ["=", "!=", "like", "starts with", "in", "not in"],
11
+ integer: ["=", "!=", ">", ">=", "<", "<=", "between", "in", "not in"],
12
+ number: ["=", "!=", ">", ">=", "<", "<=", "between", "in", "not in"],
13
+ date: ["=", "!=", ">", ">=", "<", "<=", "between"],
14
+ datetime: ["=", "!=", ">", ">=", "<", "<=", "between"],
15
+ boolean: ["="],
16
+ "text[]": ["contains", "not contains", "=", "!="],
17
+ };
18
+ function words(value) {
19
+ if (value === undefined)
20
+ return undefined;
21
+ return typeof value === "string" ? { default: value } : { ...value };
22
+ }
23
+ function scalar(type, options = {}) {
24
+ const nullable = options.nullable ?? false;
25
+ const ops = [...(OPS[type] ?? [])];
26
+ if (nullable && type !== "text[]" && type !== "boolean")
27
+ ops.push("is null");
28
+ const listed = type === "text[]";
29
+ return {
30
+ type,
31
+ kind: "scalar",
32
+ nullable,
33
+ sortable: !nullable && !listed,
34
+ groupable: !listed,
35
+ ops,
36
+ ...(options.label === undefined ? {} : { label: words(options.label) }),
37
+ ...(options.help === undefined ? {} : { help: words(options.help) }),
38
+ ...(options.required === undefined ? {} : { required: options.required }),
39
+ ...(options.readonly === undefined ? {} : { readonly: options.readonly }),
40
+ ...(options.default === undefined ? {} : { default: options.default }),
41
+ ...hints(options),
42
+ };
43
+ }
44
+ function relational(type, relation, keys, options = {}) {
45
+ return {
46
+ type,
47
+ kind: "relation",
48
+ nullable: options.nullable ?? type === "many2one",
49
+ sortable: false,
50
+ ops: [],
51
+ many: type !== "many2one",
52
+ relation,
53
+ identified_by: keys.identifiedBy,
54
+ ...(keys.definition === undefined ? {} : { definition: keys.definition }),
55
+ ...(options.label === undefined ? {} : { label: words(options.label) }),
56
+ ...(options.help === undefined ? {} : { help: words(options.help) }),
57
+ ...(options.readonly === undefined ? {} : { readonly: options.readonly }),
58
+ };
59
+ }
60
+ /** The field builders: one per type the framework publishes. */
61
+ export const field = {
62
+ text: (options) => scalar("text", options),
63
+ integer: (options) => scalar("integer", options),
64
+ /** A decimal travels as text so no digit is lost (`"10.50"`): pass `exact: true`. */
65
+ number: (options) => ({
66
+ ...scalar("number", options),
67
+ ...(options?.exact ? { exact: true } : {}),
68
+ }),
69
+ boolean: (options) => scalar("boolean", options),
70
+ date: (options) => scalar("date", options),
71
+ datetime: (options) => scalar("datetime", options),
72
+ tags: (options) => scalar("text[]", options),
73
+ /** An enum: the values in order, and the word each one is read as. */
74
+ choice: (values, options = {}) => ({
75
+ ...scalar("text", options),
76
+ ops: ["=", "!=", "in", "not in", ...(options.nullable ? ["is null"] : [])],
77
+ choices: [...values],
78
+ ...(options.labels === undefined
79
+ ? {}
80
+ : {
81
+ choice_labels: Object.fromEntries(Object.entries(options.labels).map(([value, said]) => [value, words(said) ?? {}])),
82
+ }),
83
+ }),
84
+ /** Points at ONE record of another aggregate, by the scalar key this record holds. */
85
+ many2one: (relation, keys, options) => relational("many2one", relation, keys, options),
86
+ /** The records of another aggregate that point back here, by THEIR key. */
87
+ one2many: (relation, keys, options) => relational("one2many", relation, keys, options),
88
+ many2many: (relation, keys, options) => relational("many2many", relation, keys, options),
89
+ /** A value object kept inside the record — an address, a specification. No identity. */
90
+ embedded: (definition, options = {}) => ({
91
+ type: "embedded",
92
+ kind: "embedded",
93
+ nullable: options.nullable ?? false,
94
+ sortable: false,
95
+ ops: [],
96
+ definition,
97
+ ...(options.many ? { many: true } : {}),
98
+ ...(options.label === undefined ? {} : { label: words(options.label) }),
99
+ }),
100
+ };
101
+ /** The template a typed text fills: `like` over each field, any of them matching. */
102
+ function searchTemplate(fields) {
103
+ if (fields.length === 0)
104
+ return null;
105
+ const like = (name) => ({
106
+ field: name,
107
+ operator: "like",
108
+ value: { $: "param", name: "text" },
109
+ });
110
+ const [only] = fields;
111
+ return {
112
+ where: fields.length === 1 && only !== undefined ? like(only) : { any: fields.map(like) },
113
+ };
114
+ }
115
+ /** A whole definition, from its fields. See the file header. */
116
+ export function defineMeta(spec) {
117
+ const missing = [spec.identity, spec.display, ...(spec.search ?? [])].filter((name) => name !== undefined && name !== "" && !(name in spec.fields));
118
+ if (missing.length > 0)
119
+ throw new Error(`${spec.aggregate}: ${missing.join(", ")} named but not declared`);
120
+ return {
121
+ aggregate: spec.aggregate,
122
+ identity: spec.identity,
123
+ default_order: spec.defaultOrder ?? spec.identity,
124
+ fields: spec.fields,
125
+ get_id: spec.identity,
126
+ ...(spec.name === undefined ? {} : { name: words(spec.name) }),
127
+ ...(spec.display === undefined ? {} : { display: spec.display }),
128
+ search: searchTemplate(spec.search ?? []),
129
+ };
130
+ }
@@ -0,0 +1,18 @@
1
+ import type { Meta } from "@sincpro/criteria";
2
+ /**
3
+ * The model definitions already known, by entity key (`"billing:invoice"`).
4
+ *
5
+ * Context: sincpro_framework sends `entity_meta_data` with every answer that does not say
6
+ * `meta: false`, and it is not small — forge's `Project` weighs ~97 KB even with zero rows.
7
+ * A reading already asks for it only on its first page; this keeps it ACROSS readings, so the
8
+ * second list of the same entity, the record opened from it and the select that points at it
9
+ * ask with `meta: false` and draw their labels before the rows arrive.
10
+ *
11
+ * Seeded, it is the preload: definitions written at build time (a snapshot of the server's,
12
+ * or `defineMeta`) reach the first screen with no request at all.
13
+ */
14
+ export type MetaStore = {
15
+ get(key: string): Meta | undefined;
16
+ set(key: string, meta: Meta): void;
17
+ };
18
+ export declare function metaStore(seed?: Readonly<Record<string, Meta>>): MetaStore;
@@ -0,0 +1,7 @@
1
+ export function metaStore(seed = {}) {
2
+ const known = new Map(Object.entries(seed));
3
+ return {
4
+ get: (key) => known.get(key),
5
+ set: (key, meta) => void known.set(key, meta),
6
+ };
7
+ }