@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SINCPRO S.R.L
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,446 @@
1
- # Temporary Holding Version
1
+ # @sincpro/web-framework
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **The web side of how Sincpro builds business software.** It takes what
4
+ [`sincpro_framework`](https://github.com/Sincpro-SRL/sincpro_framework) already resolves on the
5
+ server — entities, criteria, commands, drafts, events — and puts it on screen with
6
+ [`@sincpro/web-ui`](https://github.com/Sincpro-SRL/sincpro_web_ui), opened where
7
+ [`@sincpro/web-composer`](https://github.com/Sincpro-SRL/sincpro_web_composer) says. An
8
+ application declares its entities and names its fields; the rest follows.
9
+
10
+ ```bash
11
+ yarn add @sincpro/web-framework @sincpro/web-ui @sincpro/web-composer @sincpro/criteria @sincpro/criteria-react react react-dom
12
+ ```
13
+
14
+ Requires React 19 or newer.
15
+
16
+ ## What we believe
17
+
18
+ **An ERP is modular, so its framework must be open.** Somebody will always have the case nobody
19
+ foresaw — a reconciliation screen, a strange relation, a wizard over three entities. They must
20
+ be able to build it here, without asking us and without fighting us. So:
21
+
22
+ 1. **Defaults, never cages.** Every piece comes in three levels, and any of them can be skipped:
23
+
24
+ | Level | What you write | Example |
25
+ | ------------ | ------------------------------------------- | ---------------------------------------------------------- |
26
+ | Declarative | names of fields and entities | `<EntityList listing={l} columns={["number", "state"]} />` |
27
+ | Hooks | your own screen over our state | `useListing` + web-ui's `List` |
28
+ | Loose pieces | straight to criteria, the transport, web-ui | `transport.list(route, criteria)` |
29
+
30
+ Adding a piece obliges nothing else; removing it breaks nothing else.
31
+
32
+ 2. **We ship good practice, not business rules.** A rule lives in the backend. Here the
33
+ framework reads what the backend publishes — the model definition, the form hints, the
34
+ preview, the refusal — and never re-implements it. If a rule matters, the server keeps it.
35
+
36
+ 3. **Integration should be solved once, here.** Every Sincpro application used to write its
37
+ own client, its own date formatting, its own error box, its own "load more". That is the
38
+ same code everywhere, and it belongs in one place, written well.
39
+
40
+ 4. **The server describes, the screen decides.** The backend says what a field _is_ — its
41
+ word, its type, what it accepts, what it points at. Where it goes, how prominent it is and
42
+ what colour a state is stay in the screen's code. That keeps us out of server-side view
43
+ XML, which is where frameworks like Odoo stop being extensible.
44
+
45
+ 5. **Many entities, one screen.** Business work happens _between_ records: a list with its
46
+ detail beside it, an invoice with its lines, a statement reconciled against invoices. The
47
+ framework is built for that, not for one form at a time.
48
+
49
+ 6. **Use the whole Sincpro stack, not a copy of it.** Composer already knows where things open,
50
+ what is selected and which keys do what; web-ui already knows how a record looks. This
51
+ package uses them — it does not wrap them in a second, weaker version.
52
+
53
+ ## The stack
54
+
55
+ | Project | Brings |
56
+ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
57
+ | `sincpro_framework` | Entities, criteria, reads (`Get`, `LiteralSearch`, `Search`, `Preview`), commands and dry runs, drafts, events, permissions, protocols (REST, JSON-RPC, gRPC, MCP) |
58
+ | `@sincpro/criteria` | The contract with the server: `Criteria`, `Meta`, `Page`, `Problem` |
59
+ | `@sincpro/web-ui` | What a screen looks like: `List`, `Record`, `SearchBar`, `Picker`, `Activity` |
60
+ | `@sincpro/web-composer` | Where it opens and the context: page, peek, window, panels, shared selection, ⌘K, shortcuts |
61
+ | **this package** | The glue: entity + model definition + state + actions, drawn with web-ui, opened with composer |
62
+ | Your application | What an invoice is |
63
+
64
+ ## How it fits together
65
+
66
+ Three packages, one direction. **web-ui draws and tells** («they pressed this name, and asked
67
+ for a modal»); it never imports composer and never knows where anything opens. **Composer
68
+ decides where** — page, modal, preview, side panel, window, the breadcrumb, the phone — and never
69
+ draws a record. **This package connects them**, from what the server publishes about each model.
70
+
71
+ ```mermaid
72
+ flowchart LR
73
+ subgraph server["sincpro_framework · the backend"]
74
+ api["entities · criteria · commands<br/>drafts · events · model definition"]
75
+ end
76
+ subgraph fw["@sincpro/web-framework · connects"]
77
+ direction TB
78
+ core["core/<br/>Transport · entity() · Meta<br/>textOf · summaryOf"]
79
+ react["react/<br/>useListing · useRecord<br/>useLookup · useSummaryOf"]
80
+ web["web/<br/>EntityList · EntityPicker · RelatedRecord<br/>openIn · entityView · SincproApp"]
81
+ core --> react --> web
82
+ end
83
+ ui["@sincpro/web-ui · draws and tells<br/>List · Record · Picker<br/>RelationValue · RecordCard"]
84
+ composer["@sincpro/web-composer · decides where<br/>page · modal · preview · side · window<br/>crumbs · ⌘K · phone"]
85
+ app["your application<br/>entity() + its screens"]
86
+
87
+ api <-->|"criteria over the wire<br/>(@sincpro/criteria)"| core
88
+ web -->|"props read from the model"| ui
89
+ ui -->|"gestures: onOpenIn(to), onChoose,<br/>onBrowseAll, onCreate"| web
90
+ web -->|"api.open · api.pick · api.setTitle"| composer
91
+ composer -->|"mounts the entity's view"| web
92
+ app -->|"declares"| fw
93
+ ```
94
+
95
+ What happens when a person presses the customer of an invoice:
96
+
97
+ ```mermaid
98
+ sequenceDiagram
99
+ actor person as Person
100
+ participant value as RelationValue<br/>(web-ui)
101
+ participant related as RelatedRecord / openIn<br/>(this package)
102
+ participant composer as Composer
103
+ participant view as Customer view<br/>(entityView)
104
+ participant server as Server
105
+
106
+ person->>value: presses «Globex Bolivia»
107
+ value->>related: onOpenIn("modal") — the first of openTargets
108
+ related->>related: targetsFor(api): inside a modal? then beside, never a modal
109
+ related->>composer: api.open(ventas:cliente/C-004, { to: "modal", size: 760×560 })
110
+ composer->>view: mounts the registered view over the invoice
111
+ view->>server: useRecord → the record (and its model, once)
112
+ server-->>view: record + meta
113
+ view->>composer: api.setTitle("Globex Bolivia") — the modal and the crumb say its name
114
+ ```
115
+
116
+ Everything a screen shows about a value or a record is read from the model the same way, so a
117
+ cell, a fact, a card and a link cannot disagree:
118
+
119
+ ```mermaid
120
+ flowchart LR
121
+ meta["Meta<br/>(server, defineMeta or OpenAPI)"] --> text["textOf<br/>«Pagada» · 12 mar 2026 · 1 150,50"]
122
+ meta --> summary["summaryOf<br/>name · description · state · type<br/>image · created · updated"]
123
+ record["a record"] --> text
124
+ record --> summary
125
+ declared["entity(…, { summary })"] -.->|"its own field names"| summary
126
+ text --> cells["columns · facts · chips"]
127
+ summary --> card["cardOf → RecordCard"]
128
+ summary --> relation["relationOf → RelationValue"]
129
+ ```
130
+
131
+ ## Integrating it in an application
132
+
133
+ **1. Styles.** web-ui's tokens, and Tailwind told to look inside the packages (Tailwind v4 skips
134
+ `node_modules`, so their classes would never be generated). Composer brings its own sheet.
135
+
136
+ ```css
137
+ @import "@sincpro/web-ui/tokens.css";
138
+ @source "../node_modules/@sincpro/web-ui/dist";
139
+ @source "../node_modules/@sincpro/web-framework/dist";
140
+ @source "./";
141
+ ```
142
+
143
+ **2. The transport** — the one door to the backend. The session is the application's: it puts
144
+ its headers here.
145
+
146
+ ```ts
147
+ export const transport = restTransport({
148
+ base: "/v1",
149
+ headers: () => ({ Authorization: `Bearer ${session.token}` }),
150
+ });
151
+ ```
152
+
153
+ **3. The entities** — declared once: where each is read and written, and its key in the
154
+ workspace (`domain:kind`). `summary` only when its fields are not the conventional ones.
155
+
156
+ ```ts
157
+ export const customers = entity<Customer>("sales:customer", {
158
+ list: "/customers",
159
+ summary: { image: "logo", person: true },
160
+ });
161
+ export const invoices = entity<Invoice>("sales:invoice", {
162
+ list: "/invoices",
163
+ create: "POST /invoices",
164
+ save: "PUT /invoices/{id}",
165
+ commands: { post: "POST /invoices/{id}/post" },
166
+ history: { route: "/sales-events", type: "Invoice" },
167
+ });
168
+ ```
169
+
170
+ **4. The views** — each entity becomes a composer view: its list without an id, its record with
171
+ one, its search in ⌘K. Declared and nothing more, it already has both screens, drawn from its
172
+ model: `EntityList` (literal search on Enter, pages, «New») and `EntityRecord` (its name, type and
173
+ state on top; its fields in the model's order, a many2one as a picker of its entity; each
174
+ one2many in a tab; «save / discard», the conflict, the restored draft, who made it, its history;
175
+ «3 / 64» and the arrows to its neighbours when it was opened from a list).
176
+
177
+ ```ts
178
+ const views = [
179
+ entityView(invoices, { transport, name: "Invoices" }), // both screens, from the model
180
+ ];
181
+ ```
182
+
183
+ They are defaults, not cages: give your own screens, or keep `EntityRecord` and choose what it
184
+ shows.
185
+
186
+ ```ts
187
+ const views = [
188
+ entityView(invoices, {
189
+ transport,
190
+ name: "Invoices",
191
+ list: InvoiceList,
192
+ record: InvoiceView,
193
+ }),
194
+ entityView(customers, {
195
+ transport,
196
+ name: "Customers",
197
+ list: CustomerList,
198
+ record: CustomerView,
199
+ }),
200
+ ];
201
+ ```
202
+
203
+ **5. The application** — transport, language (web-ui's, composer's and the framework's words),
204
+ cache, toasts and the workspace, in one place:
205
+
206
+ ```tsx
207
+ <SincproApp
208
+ transport={transport}
209
+ views={views}
210
+ entities={[invoices, customers]} // a relation of a model resolves to one of these, alone
211
+ start={invoices.ref()}
212
+ language="es"
213
+ />
214
+ ```
215
+
216
+ An application with its own shell mounts the same pieces itself: `FrameworkProvider`, composer's
217
+ `Composer` with `webUiSkin`, and the three copy tables.
218
+
219
+ **6. The screens.** A list is the entity and the names of its fields — the server searches,
220
+ filters, orders, groups and pages; a row opens the record's page:
221
+
222
+ ```tsx
223
+ function InvoiceList({ api }: ViewProps) {
224
+ const listing = useListing(invoices);
225
+ return (
226
+ <EntityList
227
+ listing={listing}
228
+ api={api}
229
+ columns={["number", "customer", "state", "amount_total"]}
230
+ />
231
+ );
232
+ }
233
+ ```
234
+
235
+ A record keeps writing web-ui's `Record` tree. Its fields come from the model; a related record
236
+ is read with its summary and opened where it belongs; a many2one is chosen with a literal search
237
+ on the server, and «Buscar más…» opens the customers' own list to choose from:
238
+
239
+ ```tsx
240
+ function InvoiceView({ api, resource }: ViewProps) {
241
+ const invoice = useRecord(invoices, resource.id ?? "");
242
+ const crumbs = useCrumbs(api);
243
+ useRecordInWorkspace(api, invoice); // its real name in the crumb, «unsaved» asks first
244
+ const dirty = dirtyOf(invoice); // «Save / Discard» while something was typed
245
+ if (invoice.status !== "ready") return <Skeleton rows={4} label="Loading" />;
246
+ return (
247
+ <Record
248
+ identity={{ title: invoice.record.number }}
249
+ {...(dirty === undefined ? {} : { dirty })}
250
+ >
251
+ <Record.Rail>
252
+ <Record.Crumbs crumbs={crumbs} />
253
+ {/* Always there on an edited record: without them web-ui remounts the paper on «dirty». */}
254
+ <Record.Actions>
255
+ <CommandButton entity={invoices} id={invoice.id} command="post" label="Post" />
256
+ </Record.Actions>
257
+ </Record.Rail>
258
+ <Record.Paper>
259
+ <Field label="Customer">
260
+ <RelatedRecord api={api} entity={customers} value={invoice.record.customer} />
261
+ </Field>
262
+ <RecordField
263
+ record={invoice}
264
+ name="customer_id"
265
+ as={({ value, set }) => (
266
+ <EntityPicker
267
+ entity={customers}
268
+ value={String(value ?? "")}
269
+ onChange={set}
270
+ onBrowseAll={chooserIn(api, customers, set)}
271
+ />
272
+ )}
273
+ />
274
+ <RecordField record={invoice} name="notes" />
275
+ </Record.Paper>
276
+ </Record>
277
+ );
278
+ }
279
+ ```
280
+
281
+ Any record, at any size, from its summary:
282
+
283
+ ```tsx
284
+ const summarize = useSummaryOf(customers);
285
+ <RecordCard
286
+ {...cardOf(summarize(row))}
287
+ onOpen={() => openIn(api, customers.ref(row.id), "page")}
288
+ />;
289
+ ```
290
+
291
+ A domain with no sincpro_framework behind it declares its model by hand and is drawn the same
292
+ way:
293
+
294
+ ```ts
295
+ const tickets = defineMeta({
296
+ aggregate: "Ticket",
297
+ identity: "id",
298
+ display: "title",
299
+ search: ["title"],
300
+ fields: {
301
+ id: field.text({ readonly: true }),
302
+ title: field.text({ label: { es: "Título" }, required: true }),
303
+ stage: field.choice(["new", "done"], { labels: { new: { es: "Nuevo" } } }),
304
+ },
305
+ });
306
+ ```
307
+
308
+ ## The abstractions
309
+
310
+ Each answers one question, and each comes in the three levels — use the component, drop to the
311
+ hook and draw your own, or go straight to the loose piece.
312
+
313
+ | Question | Declarative | Hook | Loose piece |
314
+ | -------------------------------- | --------------------------------------------- | ------------------------------------- | ------------------------------------ |
315
+ | Where is the data? | `entity()` | — | `restTransport`, `memoryTransport` |
316
+ | What is each field? | `Meta` from the server, or `defineMeta` | — | `textOf`, `viewOf`, `metaFromSchema` |
317
+ | What is a record, at any size? | `cardOf`, `relationOf` | `useSummaryOf` | `summaryOf` |
318
+ | N records: search, filter, page | `EntityList`, `EntitySearchBar` | `useListing`, `useGroups` | `transport.list(route, criteria)` |
319
+ | A named slice (to collect, …) | `defineList` + `listView` | `useListing(list)` | `scopeOf` |
320
+ | A record's whole screen | `EntityRecord`, `entityView` without `record` | — | `formOf` |
321
+ | Previous / next from a record | `EntityRecord` (from a list) | — | `placeOf`, `stepIn` (`Trail`) |
322
+ | How much each group adds up | `EntityList sums` | `useGroups` (`sums`) | `sumsOf`, `detailOf` |
323
+ | One record: read, edit, save | `RecordField`, `factsOf`, `RecordLines` | `useRecord` | `transport.one`, `transport.send` |
324
+ | Its relations, inside it | `RelatedList` | `useRelated` | `relatedOf` |
325
+ | Choose one (literal search) | `EntityPicker`, `chooserIn` | `useLookup` | `lookupOf` + criteria's `matching` |
326
+ | Choose several (many2many) | `EntityTags` | `useLookup` (`values`) | `lookupOf` |
327
+ | A relation, from the model alone | `RecordField name="customer_id"` | `useEntityFor` | `relationFieldOf`, `entityFor` |
328
+ | Open it where it belongs | `RelatedRecord`, `entityView` | — | `openIn`, `targetsFor` |
329
+ | Act on it (with a dry run) | `CommandButton`, `commands` on rows | `useCommand` | `transport.send` |
330
+ | What happened to it | `Record.Activity` fed by `useHistory` | `useHistory` | `historyOf` |
331
+ | The application context | `SincproApp` | `useRecordInWorkspace`, `useFollowed` | `FrameworkProvider`, `webUiSkin` |
332
+
333
+ The rules behind them, in short:
334
+
335
+ - **A reference names any record** (`domain:kind/id`, `entity.ref(id)`). Two contexts talk by
336
+ reference, never by import; whatever must survive a reload travels in it.
337
+ - **A related record opens in a modal** the size of a record, and «Abrir en…» offers preview,
338
+ side and page. **From inside a modal**, beside or as a preview — never a modal over a modal.
339
+ `openIn` decides it from what composer says about the view, so no screen writes it.
340
+ - **What a record does not have is not drawn**: no picture, no image; no state, no tag.
341
+ - **Every word on screen** is the model's (`labelOf`, `choiceLabelOf`) or the framework's copy
342
+ table (`frameworkCopy()`), never a string in a component.
343
+
344
+ ### Inside
345
+
346
+ Three folders, one direction — `make doctor` keeps the arrows:
347
+
348
+ | Folder | Knows | Why apart |
349
+ | --------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------- |
350
+ | `core` | transport, cache, entity, model definition, what a value and a record read as | plain logic, tested in Node in milliseconds |
351
+ | `react` | `useListing`, `useRecord`, `useRelated`, `useSummaryOf`…, the provider | state that draws nothing: usable without `EntityList` |
352
+ | `web/list` | a collection: `EntityList`, `EntitySearchBar`, `columnsOf` | N records, any size |
353
+ | `web/record` | one record: `RecordField`, `factsOf`, `RelatedList`, `EntityPicker`, `cardOf` | works outside a workspace (no composer) |
354
+ | `web/workspace` | the composer: `entityView`, `openIn`, `RelatedRecord`, `<SincproApp>` | where it opens, the application context |
355
+
356
+ Everything is imported from `@sincpro/web-framework`.
357
+
358
+ ## Where it stands
359
+
360
+ | Piece | What it solves | Status |
361
+ | -------------------------------------- | ------------------------------------------------------------------------------------------ | ------ |
362
+ | `restTransport`, `memoryTransport` | The backend, or the same engine in memory | ✅ |
363
+ | `defineMeta`, `metaFromSchema` | A model by hand, or completed from OpenAPI | ✅ |
364
+ | `useListing`, `EntityList` | N records: criteria, search bar, builder, paging | ✅ |
365
+ | `useRecord`, `factsOf`, `RecordLines` | One record: facts, relations with their real total | ✅ |
366
+ | Preloaded definitions | `FrameworkProvider metas`: the model asked once, or never | ✅ |
367
+ | `useLookup`, `EntityPicker` | Literal search: a many2one select, ⌘K | ✅ |
368
+ | `useRecord` + `RecordField`, `dirtyOf` | Editing: hints evaluated live, preview, save with version (409), server drafts | ✅ |
369
+ | `useCommand`, `CommandButton` | An entity's actions, with a dry run before what destroys | ✅ |
370
+ | `useHistory` | Business events in `Record.Activity` (comments: backend first) | ✅ |
371
+ | `entityView` | An entity as a composer view: list, record, ⌘K search | ✅ |
372
+ | `<SincproApp>` | The application context in one place, the workspace dressed as web-ui (`webUiSkin`) | ✅ |
373
+ | `useRecordInWorkspace` | A record tells the workspace its real name and its unsaved work (composer 1.0) | ✅ |
374
+ | Paging by pages, ceiling on «more» | One page on screen, walked by the server's cursor: a million rows never freeze it | ✅ |
375
+ | `cachedTransport` | Repeated reads from memory, forgotten when a write touches the entity (on in `SincproApp`) | ✅ |
376
+ | `EntitySearchBar` | The search bar + criteria builder as a loose piece, for any `List` | ✅ |
377
+ | `EntityList` views | The same set as table, rows or board | ✅ |
378
+ | `useRelated`, `RelatedList` | A record's relation as a live list: searched and paged by the server, inside a form | ✅ |
379
+ | `EntityPicker` chooser | «see all…» opens the whole set, searched on the server: a many2one over millions | ✅ |
380
+ | `useRecord(entity, "")`, `create` | A new record: the model's defaults, saved to `create`, then it IS the record it became | ✅ |
381
+ | `retryKeys`, `Idempotency-Key` | A Command or a create retried after a lost answer runs once, not twice | ✅ |
382
+ | `entity.newRef()`, «New» in the list | The record's form with no id, in a page or a window; replaced by the record once created | ✅ |
383
+ | `EntityPicker criteria` | A many2one narrowed by another field: the invoices OF the chosen customer | ✅ |
384
+ | `chooserIn`, `entity.chooserRef()` | «see all…» as the composer's chooser: a row answers (`api.pick`) | ✅ |
385
+ | `useGroups`, «Group by» | Groups counted by the server over the whole set; a group's rows read when it is opened | ✅ |
386
+ | `defineList`, `listView` | A named list (a fixed scope, its columns and filters) as data, and as a workspace view | ✅ |
387
+ | `EntityPicker` `browse`, `columns` | The chooser always a click away, as a table searched literally by the server | ✅ |
388
+ | `useFollowed` | A detail panel follows the record the person is on, without knowing the list | ✅ |
389
+ | `commands`, `useRowCommands` | An entity's Commands on each row, idempotent, offered only where `when` holds | ✅ |
390
+ | `summaryOf`, `cardOf`, `relationOf` | Any record at any size: name, description, state, type, picture, who made and changed it | ✅ |
391
+ | `openIn`, `RelatedRecord` | A related record opens in a modal; «Abrir en…» for the rest; never a modal over a modal | ✅ |
392
+ | Literal search everywhere | Picker: the first ten, the server's search as you type; tables: «Buscar «X»» on Enter | ✅ |
393
+ | `EntityTags` | A many2many as chips over thousands: the server's search, faces, the chosen never again | ✅ |
394
+ | `RecordField` + `entities` | A many2one / one2many drawn from the model alone, opened where the view says | ✅ |
395
+ | `EntityRecord`, `formOf` | Any entity's record screen without writing it | ✅ |
396
+ | `Trail`, `Record.Pager` | Previous / next from a record, across the server's pages | ✅ |
397
+ | `EntityList sums` | Each group says how much it adds up, summed by the server over all of it | ✅ |
398
+
399
+ What it does **not** do: mobile (`sincpro_mobile` has its own core), sessions and login (the
400
+ application's), routing (composer's), string-named widget registries, technical logs on a
401
+ record's screen, business rules.
402
+
403
+ The full purpose — literal search, drafts, composer context, several entities per screen — is
404
+ in [`docs/PURPOSE.md`](docs/PURPOSE.md); what was found while building and what comes next is in
405
+ [`docs/ROADMAP.md`](docs/ROADMAP.md) (both in Spanish).
406
+
407
+ ## Built for large sets
408
+
409
+ Nothing is filtered, ordered, counted or grouped on the page: every gesture is a question the
410
+ server answers. A list holds ONE page by default and walks the server's keyset cursor (page
411
+ 4 000 costs what page 1 costs); «load more» stops at a ceiling and asks to narrow the search; the
412
+ count is the server's, capped; typing waits for a pause; a newer question cancels the older one;
413
+ the model definition is asked once per entity; repeated reads come from a cache that a write
414
+ clears. Story «15 · Un conjunto grande sin congelar» runs it over 200 000 rows.
415
+
416
+ ## The use cases are the tests
417
+
418
+ Every use case is a Storybook story run against an in-memory server that answers with the same
419
+ criteria engine as sincpro_framework, and each one carries a `play`. The story IS the test:
420
+ `make test` runs every story in a real Chrome with vitest (`@storybook/addon-vitest`), awaits its
421
+ play and checks its a11y — and the same tests run from Storybook's sidebar («Run tests»). The
422
+ plain logic is tested in Node by the same runner. A case the framework cannot do yet shows up
423
+ there first.
424
+
425
+ ## Development
426
+
427
+ ```bash
428
+ nvm use # Node 26, from .nvmrc
429
+ make init # pre-commit + yarn install
430
+ make test # vitest: the logic in Node + every use case (story + play) in Chrome
431
+ make dev # Storybook on :6008 — the use cases, «Run tests» in the sidebar
432
+ make build
433
+ ```
434
+
435
+ Every operation — local or CI — goes through the `Makefile`; run `make` to list the targets.
436
+ Working on this repository, human or agent: read [`AGENTS.md`](AGENTS.md) first.
437
+
438
+ ## CI/CD
439
+
440
+ The workflows in `.github/workflows/` only delegate to the shared `Sincpro-SRL/.github`
441
+ pipeline, which calls `make init`, `make verify-format`, `make test`,
442
+ `make update-version VERSION=x` and `make publish`.
443
+
444
+ ## License
445
+
446
+ MIT © Sincpro S.R.L.
@@ -0,0 +1,30 @@
1
+ import type { Transport } from "./transport.js";
2
+ /**
3
+ * A transport that remembers what it read, for a while, and forgets it when something writes.
4
+ *
5
+ * Context: in a workspace the same questions come back all the time — the list you go back to
6
+ * through the breadcrumb, the record reopened from ⌘K, the select that points at the same
7
+ * customers, two panels on the same entity. Each one was a request. This answers a repeated
8
+ * read from memory while it is fresh (`ttl`), and drops every answer of an entity the moment
9
+ * a write touches it: a save, a Command, a draft. Stale data after a write is worse than a
10
+ * request, so a write always wins.
11
+ *
12
+ * What it keys on is the question as it travels — the route and the criteria — so two
13
+ * different filters, orders or pages never share an answer. A read that fails is not kept.
14
+ * Only completed answers are remembered: a request aborted half-way is simply not cached.
15
+ *
16
+ * const transport = cachedTransport(restTransport({ base: "/v1" }), { ttl: 30_000 });
17
+ * transport.invalidate("/facturas"); // forget an entity by hand (a push from the server)
18
+ */
19
+ export type CachedTransport = Transport & {
20
+ /** Forget every answer whose route starts with this path: `"/facturas"` forgets the list,
21
+ * every page and filter of it, and every record under it. */
22
+ invalidate: (path?: string) => void;
23
+ };
24
+ export type CacheOptions = {
25
+ /** How long an answer is served from memory, in milliseconds. */
26
+ ttl?: number;
27
+ /** The clock — for tests. */
28
+ now?: () => number;
29
+ };
30
+ export declare function cachedTransport(transport: Transport, options?: CacheOptions): CachedTransport;
@@ -0,0 +1,47 @@
1
+ /** The entity a route belongs to: its first segment, `"/facturas/F-1/confirm"` → `"/facturas"`. */
2
+ function rootOf(route) {
3
+ const path = route.split("?")[0] ?? route;
4
+ const [, first = ""] = path.split("/");
5
+ return `/${first}`;
6
+ }
7
+ export function cachedTransport(transport, options = {}) {
8
+ const ttl = options.ttl ?? 30_000;
9
+ const now = options.now ?? (() => Date.now());
10
+ const entries = new Map();
11
+ async function remembered(key, read) {
12
+ const kept = entries.get(key);
13
+ if (kept !== undefined && now() - kept.at < ttl)
14
+ return kept.answer;
15
+ const answer = await read();
16
+ entries.set(key, { at: now(), answer });
17
+ return answer;
18
+ }
19
+ const invalidate = (path) => {
20
+ if (path === undefined)
21
+ return entries.clear();
22
+ for (const key of [...entries.keys()])
23
+ if (key.slice(key.indexOf(" ") + 1).startsWith(path))
24
+ entries.delete(key);
25
+ };
26
+ return {
27
+ list(route, criteria, signal) {
28
+ return remembered(`list ${route} ${JSON.stringify(criteria)}`, () => transport.list(route, criteria, signal));
29
+ },
30
+ one(route, signal) {
31
+ return remembered(`one ${route}`, () => transport.one(route, signal));
32
+ },
33
+ async send(method, route, body, signal, sending) {
34
+ // A preview writes nothing; anything else may have changed what was read.
35
+ const preview = route.endsWith("/preview");
36
+ try {
37
+ return await transport.send(method, route, body, signal, sending);
38
+ }
39
+ finally {
40
+ // Even a refused write may have changed something (a 409 says someone else did).
41
+ if (!preview)
42
+ invalidate(rootOf(route));
43
+ }
44
+ },
45
+ invalidate,
46
+ };
47
+ }
@@ -0,0 +1,93 @@
1
+ import type { FailureKind } from "@sincpro/criteria";
2
+ /**
3
+ * Every word the framework writes on its own, in one table — the way `@sincpro/web-ui` and the
4
+ * composer do it. English by default; an application installs its language ONCE with
5
+ * `installFrameworkCopy`, and nothing carries a hard-coded string.
6
+ *
7
+ * Context: it lives in `core/` and not next to the components because a value is turned into
8
+ * text here («sí», «3 elementos»), and a cell, a fact and a chip must read it the same.
9
+ */
10
+ export type FrameworkCopy = {
11
+ value: {
12
+ yes: string;
13
+ no: string;
14
+ /** A one2many/many2many cell: how many there are, `exact: false` meaning «at least». */
15
+ items: (count: number, exact: boolean) => string;
16
+ };
17
+ list: {
18
+ loadMore: string;
19
+ loading: string;
20
+ /** «12 of 120»: what is on screen against what matches. */
21
+ shown: (shown: number, total: number, exact: boolean) => string;
22
+ empty: string;
23
+ emptyFiltered: string;
24
+ search: string;
25
+ /** The search bar, which also takes `#` to pick a field. */
26
+ searchBar: string;
27
+ /** The title of the criteria builder's window. */
28
+ builder: string;
29
+ /** `more` mode reached its ceiling: what is on screen, and what to do instead. */
30
+ capped: (shown: number) => string;
31
+ /** Conditions the server could not answer: the list is WIDER than asked. */
32
+ dropped: (fields: readonly string[]) => string;
33
+ /** The group of the records that hold no value in the field grouped by. */
34
+ noValue: string;
35
+ /** More groups than shown: narrow the set instead of scrolling them. */
36
+ groupsCapped: (shown: number) => string;
37
+ /** «12 groups · 187 records»: what the grouping split. */
38
+ groups: (groups: number, records: number) => string;
39
+ };
40
+ record: {
41
+ /** A draft this caller kept is back on screen. */
42
+ restored: string;
43
+ /** Someone saved it after it was read: nothing was overwritten. */
44
+ conflict: string;
45
+ reload: string;
46
+ /** What is missing before it can be saved. */
47
+ missing: (fields: readonly string[]) => string;
48
+ keep: string;
49
+ loading: string;
50
+ /** Under an embedded list the record holds only one page of. */
51
+ partial: (shown: number, total: number) => string;
52
+ /** The title of a record that does not exist yet, after the model's name. */
53
+ creating: (name: string) => string;
54
+ /** The list's button that opens the form of a new record. */
55
+ create: string;
56
+ };
57
+ picker: {
58
+ /** The button beside a many2one that opens the chooser over the whole set. */
59
+ browse: string;
60
+ };
61
+ summary: {
62
+ /** The facts of a record's summary: when it was made and last changed. */
63
+ created: string;
64
+ updated: string;
65
+ /** «12 mar 2026 · Ana Rojas»: when, and who when it is known. */
66
+ when: (at: string, by: string | null) => string;
67
+ /** A card's last line: «Updated 12 Mar 2026 by Ana», or «Created …» if never changed. */
68
+ updatedLine: (at: string, by: string | null) => string;
69
+ createdLine: (at: string, by: string | null) => string;
70
+ };
71
+ command: {
72
+ /** The confirmation of an action that cannot be undone, after its dry run. */
73
+ confirm: string;
74
+ cancel: string;
75
+ /** While the dry run is asked. */
76
+ rehearsing: string;
77
+ };
78
+ failure: {
79
+ /** The server could not be reached at all — nothing it said is known. */
80
+ unreachable: string;
81
+ /** It answered with a status and no problem to read. */
82
+ status: (status: number) => string;
83
+ retry: string;
84
+ /** What each kind of refusal is called when the server sent no title of its own. */
85
+ kind: Record<FailureKind, string>;
86
+ };
87
+ };
88
+ export declare const ENGLISH_FRAMEWORK_COPY: FrameworkCopy;
89
+ export declare const SPANISH_FRAMEWORK_COPY: FrameworkCopy;
90
+ /** Install the framework's words once, where the application starts. */
91
+ export declare function installFrameworkCopy(copy: FrameworkCopy): void;
92
+ /** The words in use. A plain read: they are installed before anything draws. */
93
+ export declare const frameworkCopy: () => FrameworkCopy;