@sincpro/client-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 (55) hide show
  1. package/AGENTS.md +78 -0
  2. package/LICENSE +21 -0
  3. package/MANIFESTO.md +124 -0
  4. package/README.md +88 -2
  5. package/dist/client/contract.d.ts +34 -0
  6. package/dist/client/contract.js +41 -0
  7. package/dist/client/discovery.d.ts +47 -0
  8. package/dist/client/discovery.js +68 -0
  9. package/dist/client/entity.d.ts +242 -0
  10. package/dist/client/entity.js +187 -0
  11. package/dist/client/ids.d.ts +17 -0
  12. package/dist/client/ids.js +52 -0
  13. package/dist/client/memory.d.ts +46 -0
  14. package/dist/client/memory.js +108 -0
  15. package/dist/client/rest.d.ts +49 -0
  16. package/dist/client/rest.js +143 -0
  17. package/dist/client/retry.d.ts +24 -0
  18. package/dist/client/retry.js +25 -0
  19. package/dist/client/transport.d.ts +89 -0
  20. package/dist/client/transport.js +26 -0
  21. package/dist/codegen/cli.d.ts +2 -0
  22. package/dist/codegen/cli.js +82 -0
  23. package/dist/codegen/client.d.ts +14 -0
  24. package/dist/codegen/client.js +180 -0
  25. package/dist/codegen/domain.d.ts +14 -0
  26. package/dist/codegen/domain.js +195 -0
  27. package/dist/codegen/entities.d.ts +14 -0
  28. package/dist/codegen/entities.js +158 -0
  29. package/dist/codegen/index.d.ts +10 -0
  30. package/dist/codegen/index.js +8 -0
  31. package/dist/codegen/metamodel.d.ts +89 -0
  32. package/dist/codegen/metamodel.js +1 -0
  33. package/dist/codegen/names.d.ts +39 -0
  34. package/dist/codegen/names.js +113 -0
  35. package/dist/codegen/rest.d.ts +31 -0
  36. package/dist/codegen/rest.js +27 -0
  37. package/dist/index.d.ts +31 -0
  38. package/dist/index.js +31 -0
  39. package/dist/model/meta.d.ts +121 -0
  40. package/dist/model/meta.js +130 -0
  41. package/dist/model/metas.d.ts +17 -0
  42. package/dist/model/metas.js +7 -0
  43. package/dist/model/schema.d.ts +39 -0
  44. package/dist/model/schema.js +128 -0
  45. package/dist/observability/ports.d.ts +64 -0
  46. package/dist/observability/ports.js +21 -0
  47. package/dist/services/execution.d.ts +63 -0
  48. package/dist/services/execution.js +88 -0
  49. package/dist/services/feature.d.ts +60 -0
  50. package/dist/services/feature.js +206 -0
  51. package/dist/services/runtime.d.ts +48 -0
  52. package/dist/services/runtime.js +30 -0
  53. package/dist/testing/index.d.ts +35 -0
  54. package/dist/testing/index.js +37 -0
  55. package/package.json +76 -4
@@ -0,0 +1,242 @@
1
+ import type { Method } from "./transport.js";
2
+ import { type AnyRecord, type Expression } from "@sincpro/criteria";
3
+ /**
4
+ * The parts any record may have beside its name: what a link, a card or a record's header says
5
+ * about it, whatever the entity.
6
+ */
7
+ export type SummaryPart = "description" | "state" | "type" | "image" | "createdAt" | "createdBy" | "updatedAt" | "updatedBy";
8
+ /**
9
+ * Which field of the entity plays each part, when it is not the conventional one. `person`: its
10
+ * records are people or companies.
11
+ *
12
+ * Context: declared once with the rest of the entity (`entity(…, { summary })`) because it is
13
+ * what the entity IS, not how one screen draws it; how a platform reads a record's summary from
14
+ * it is the platform's.
15
+ */
16
+ export type SummaryFields = Partial<Record<SummaryPart, string>> & {
17
+ person?: boolean;
18
+ };
19
+ /**
20
+ * What each Command of an entity is sent with, by its name: `{ send: { message: string } }`.
21
+ *
22
+ * Context: the default is any name and any body — every entity declared before Commands had
23
+ * types keeps compiling. A typed entity is still one of these (`Entity<T, C>` is assignable to
24
+ * `Entity<T>`), so every piece that takes an entity takes it.
25
+ */
26
+ export type CommandBodies = Readonly<Record<string, AnyRecord>>;
27
+ /**
28
+ * What each Command of an entity answers, by its name: `{ send: { run_id: string } }` — what a
29
+ * generated entity reads off each operation's `output`.
30
+ *
31
+ * Context: `useCommand(issues, "send").run(…)` returns it with no type argument written; a
32
+ * Command it does not name answers `unknown`, as every Command did before.
33
+ */
34
+ export type CommandAnswers = Readonly<Record<string, unknown>>;
35
+ /**
36
+ * Everything a screen needs to know about where an entity lives on the server, declared once.
37
+ *
38
+ * Context: a screen should not know a URL. It names the entity, and the entity says where its
39
+ * list is, where one record is, which Commands it takes, how it is saved and previewed, where a
40
+ * draft is kept and where its history is. `key` is how the workspace refers to it
41
+ * (`domain:kind`, the shape of a composer `ResourceRef`).
42
+ *
43
+ * The routes are declared, not guessed: sincpro_framework publishes a route per use case and
44
+ * its model definition does not list them (yet). A route is `"METHOD /path/{id}"` for a write.
45
+ *
46
+ * export const invoices = entity<Invoice>("billing:invoice", {
47
+ * list: "/invoices",
48
+ * create: "POST /invoices",
49
+ * save: "PUT /invoices/{id}",
50
+ * preview: "POST /invoices/preview",
51
+ * commands: { confirm: "POST /invoices/{id}/confirm", cancel: "POST /invoices/{id}/cancel" },
52
+ * history: { route: "/billing-events", type: "Invoice" },
53
+ * });
54
+ *
55
+ * Its Commands may be typed too — what each one is sent with, by name (`CommandBodies`), and
56
+ * what each one answers (`CommandAnswers`) — and then `useCommand` and `CommandButton` take
57
+ * only those names and those bodies, and give back that answer:
58
+ *
59
+ * entity<Issue, { send: { message: string } }, { send: { run_id: string } }>(…)
60
+ */
61
+ export type Entity<T extends AnyRecord = AnyRecord, C extends CommandBodies = CommandBodies, A extends CommandAnswers = CommandAnswers> = {
62
+ /** `domain:kind`, e.g. `"billing:invoice"`. */
63
+ key: string;
64
+ domain: string;
65
+ kind: string;
66
+ /** Where the workspace finds it: the list without an id, one record with it — the shape of a
67
+ * composer `ResourceRef`, built here so no screen writes `{ domain, kind, id }` by hand. */
68
+ ref: (id?: string) => EntityRef;
69
+ /** A record that does not exist yet — the form that creates one. Its view is the record's.
70
+ * With `values`, the form starts with them over the model's defaults: an invoice created
71
+ * from its customer starts with that customer (`startingValuesOf`). */
72
+ newRef: (values?: AnyRecord) => EntityRef;
73
+ /** Its list opened to CHOOSE one: a row answers whoever asked (composer's `api.pick`). With
74
+ * `query`, the list opens on that literal search — what was typed in the picker; with
75
+ * `scope`, it offers only what the picker may choose (`scopeOfRef`). */
76
+ chooserRef: (query?: string, scope?: Expression) => EntityRef;
77
+ /** The route of its list. */
78
+ list: string;
79
+ /** The route of one record. */
80
+ one: (id: string) => string;
81
+ /** Its Commands, by name. */
82
+ commands: Readonly<Record<keyof C & string, WriteRoute>>;
83
+ /** How a new record is created: the list's own route, usually. */
84
+ create?: WriteRoute;
85
+ /** How an edit is saved: the record's own route with `{id}`. */
86
+ save?: WriteRoute;
87
+ /** «What does this record become?» before saving — sincpro_framework's `Preview`. */
88
+ preview?: WriteRoute;
89
+ /** Where this caller keeps an edit between requests — sincpro_framework's `Drafts`. */
90
+ draft?: WriteRoute;
91
+ /** sincpro_framework's `Edit`: ONE route that keeps the draft, saves and discards, by its
92
+ * `action`. Declared, a save sends the record whole as the form holds it — not the fields
93
+ * that changed under `save` — and it is also where the draft is kept. */
94
+ edit?: WriteRoute;
95
+ /** Its business events: the context's events route, and the `entity_type` they carry. */
96
+ history?: {
97
+ route: string;
98
+ type: string;
99
+ };
100
+ /** Which fields are its description, state, picture… when they are not the conventional
101
+ * ones (`summaryOf`). Empty: the convention decides. */
102
+ summary: SummaryFields;
103
+ /** The aggregate it is, as a relation of another model names it (`relation: "Cliente"`).
104
+ * Without it, the `aggregate` of its model definition, once known. */
105
+ aggregate?: string;
106
+ /** Only carries the record type; never read. */
107
+ readonly _record?: T;
108
+ /** Only carries what each Command is sent with; never read. */
109
+ readonly _commands?: C;
110
+ /** Only carries what each Command answers; never read. */
111
+ readonly _answers?: A;
112
+ };
113
+ /** A composer `ResourceRef`, structurally: `core/` does not import the composer. */
114
+ export type EntityRef = {
115
+ domain: string;
116
+ kind: string;
117
+ id?: string;
118
+ params?: Readonly<Record<string, string>>;
119
+ };
120
+ /**
121
+ * The params that say what a reference without an id is for: `new` is the form of a record
122
+ * that does not exist yet, `pick` is the list opened to choose one. Params, because they are
123
+ * what a composer reference carries and survives a reload with.
124
+ */
125
+ export declare const NEW_PARAM = "new";
126
+ export declare const PICK_PARAM = "pick";
127
+ /** What was typed where the chooser was asked for: its list opens on that search. */
128
+ export declare const QUERY_PARAM = "q";
129
+ /** The list a record was opened from (its `Trail`): «3 / 64» and the arrows to its neighbours. */
130
+ export declare const TRAIL_PARAM = "from";
131
+ /** What a new record starts with, as JSON: one param keeps each value's type (a number stays
132
+ * a number), where a param per field would turn all of them into text. */
133
+ export declare const WITH_PARAM = "with";
134
+ /** What a chooser may offer at all, as JSON: the picker's cut — its criteria, the keys it
135
+ * excludes, its named list's scope — that the list it opens ANDs under everything. */
136
+ export declare const SCOPE_PARAM = "scope";
137
+ /**
138
+ * What a new record's reference says it starts with (`newRef(values)`), or nothing.
139
+ *
140
+ * Context: a reference survives a reload and a link, so its params can be anything a person
141
+ * typed in the address bar — a value that is not a JSON object is not a start, never an error.
142
+ */
143
+ export declare function startingValuesOf(ref: Pick<EntityRef, "params">): AnyRecord;
144
+ /**
145
+ * The cut a chooser's reference carries (`chooserRef(query, scope)`), or nothing.
146
+ *
147
+ * Context: a picker that may offer only some records — the contacts of the chosen customer, the
148
+ * tenants a project is not shared with yet — opened the workspace's chooser on the WHOLE
149
+ * entity, and offered the rest. The cut travels in the reference, so it survives a reload; read
150
+ * from an address bar it is untyped, so it is read the way criteria reads a filter from outside
151
+ * (`readExpression`), and one that does not read is no cut, never an error. It narrows what is
152
+ * OFFERED; the server still decides what may be stored.
153
+ */
154
+ export declare function scopeOfRef(ref: Pick<EntityRef, "params">): Expression | undefined;
155
+ /**
156
+ * A write: its method and its path, `{id}` where the record's key goes. A Command may also say
157
+ * which field of its body names the record it runs on (`target`: `issue_id`), and then the
158
+ * record's id is written there when it is sent (`bodyFor`).
159
+ */
160
+ export type WriteRoute = {
161
+ method: Method;
162
+ path: string;
163
+ target?: string;
164
+ };
165
+ /**
166
+ * A Command's route: `"POST /invoices/{id}/confirm"`, or, when its body names the record —
167
+ * sincpro_framework's `targets`, what a generated entity declares —, the route and that field:
168
+ * `{ route: "POST /issues", target: "project_id" }`.
169
+ */
170
+ export type CommandRoute = string | {
171
+ route: string;
172
+ target: string;
173
+ };
174
+ /** Each Command's route, by name. Typed Commands MUST each have their route — a name a screen
175
+ * may run with nowhere to send it is caught here, not on the click. */
176
+ type CommandRoutes<C extends CommandBodies> = string extends keyof C ? {
177
+ commands?: Readonly<Record<string, CommandRoute>>;
178
+ } : {
179
+ commands: Readonly<Record<keyof C & string, CommandRoute>>;
180
+ };
181
+ export type EntityRoutes<C extends CommandBodies = CommandBodies> = CommandRoutes<C> & {
182
+ list: string;
183
+ /** With `{id}` where the key goes. Without it, `<list>/<id>` — sincpro_framework's convention. */
184
+ one?: string;
185
+ /** `"POST /invoices"`: where a new record goes. */
186
+ create?: string;
187
+ save?: string;
188
+ preview?: string;
189
+ draft?: string;
190
+ /** `"PUT /invoices/{id}/edit"`: the use case that answers sincpro_framework's `Edit`. */
191
+ edit?: string;
192
+ history?: {
193
+ route: string;
194
+ type: string;
195
+ };
196
+ /** `{ image: "logo", state: "estado_pago", person: true }` — not a route, but declared once
197
+ * with the rest of what a screen should not have to know. */
198
+ summary?: SummaryFields;
199
+ /** `"Cliente"`: what a many2one of another model calls it, so a form resolves it alone. */
200
+ aggregate?: string;
201
+ };
202
+ /** `"POST /invoices/{id}/confirm"` → its method and path. A write without a method is a bug. */
203
+ export declare function writeRoute(text: string): WriteRoute;
204
+ /**
205
+ * The path with the record's key in place of `{id}`, and every other `{name}` taken from the
206
+ * body's field of that name: `POST /projects/{id}/shares/{tenant_id}/revoke` with
207
+ * `{ tenant_id: "acme" }`.
208
+ *
209
+ * Context: sincpro_framework reads a path parameter as a field of the use case's DTO (its
210
+ * wire merges `{...body, ...path_params}`), so the body already names every key a route
211
+ * needs — a second argument for them would say the same thing twice. A key the body lacks is
212
+ * the screen's bug, said before anything is sent. Without an `id`, `{id}` stays: a create or a
213
+ * preview of a new record has none.
214
+ */
215
+ export declare function pathFor(route: WriteRoute, id?: string, body?: AnyRecord): string;
216
+ /**
217
+ * What a Command of a record is sent with: the body as given, and — when its route says which
218
+ * field names the record (`target`) — the record's id in that field.
219
+ *
220
+ * bodyFor({ method: "POST", path: "/issues", target: "project_id" }, "p1", { title: "x" })
221
+ * -> { title: "x", project_id: "p1" }
222
+ *
223
+ * Context: sincpro_framework says, per operation, which input field holds the key of each
224
+ * entity it runs on (`targets`). A path that carries it (`/issues/{issue_id}/messages`) and a
225
+ * body that carries it (`POST /issues` with `project_id`) are the same thing to the server — a
226
+ * path parameter is read as a field of the DTO — so it goes in the body, and `pathFor` takes it
227
+ * from there. Without an id (a Command that creates) the body is sent as it is.
228
+ */
229
+ export declare function bodyFor(route: WriteRoute, id: string | undefined, body: AnyRecord): AnyRecord;
230
+ /**
231
+ * A list's route with its own parameters: `{ tenant_id: "acme" }` → `/projects?tenant_id=acme`.
232
+ * An empty value is no parameter — a filter left blank. A list under a record names its key in
233
+ * the path (`/tenants/{tenant_id}/members`): that parameter fills it, and only the rest goes in
234
+ * the query.
235
+ *
236
+ * Context: sincpro_framework reads a path parameter as a field of the search's DTO, the same
237
+ * field a query parameter would fill, so the screen gives them all as one set of parameters. A
238
+ * key the path needs and the parameters lack is the screen's bug, said before anything is sent.
239
+ */
240
+ export declare function listRoute(list: string, params?: Readonly<Record<string, string>>): string;
241
+ export declare function entity<T extends AnyRecord = AnyRecord, C extends CommandBodies = CommandBodies, A extends CommandAnswers = CommandAnswers>(key: string, routes: EntityRoutes<C>): Entity<T, C, A>;
242
+ export {};
@@ -0,0 +1,187 @@
1
+ import { readExpression } from "@sincpro/criteria";
2
+ /**
3
+ * The params that say what a reference without an id is for: `new` is the form of a record
4
+ * that does not exist yet, `pick` is the list opened to choose one. Params, because they are
5
+ * what a composer reference carries and survives a reload with.
6
+ */
7
+ export const NEW_PARAM = "new";
8
+ export const PICK_PARAM = "pick";
9
+ /** What was typed where the chooser was asked for: its list opens on that search. */
10
+ export const QUERY_PARAM = "q";
11
+ /** The list a record was opened from (its `Trail`): «3 / 64» and the arrows to its neighbours. */
12
+ export const TRAIL_PARAM = "from";
13
+ /** What a new record starts with, as JSON: one param keeps each value's type (a number stays
14
+ * a number), where a param per field would turn all of them into text. */
15
+ export const WITH_PARAM = "with";
16
+ /** What a chooser may offer at all, as JSON: the picker's cut — its criteria, the keys it
17
+ * excludes, its named list's scope — that the list it opens ANDs under everything. */
18
+ export const SCOPE_PARAM = "scope";
19
+ /**
20
+ * What a new record's reference says it starts with (`newRef(values)`), or nothing.
21
+ *
22
+ * Context: a reference survives a reload and a link, so its params can be anything a person
23
+ * typed in the address bar — a value that is not a JSON object is not a start, never an error.
24
+ */
25
+ export function startingValuesOf(ref) {
26
+ const written = ref.params?.[WITH_PARAM];
27
+ if (written === undefined)
28
+ return {};
29
+ try {
30
+ const read = JSON.parse(written);
31
+ return read !== null && typeof read === "object" && !Array.isArray(read)
32
+ ? read
33
+ : {};
34
+ }
35
+ catch {
36
+ return {};
37
+ }
38
+ }
39
+ /**
40
+ * The cut a chooser's reference carries (`chooserRef(query, scope)`), or nothing.
41
+ *
42
+ * Context: a picker that may offer only some records — the contacts of the chosen customer, the
43
+ * tenants a project is not shared with yet — opened the workspace's chooser on the WHOLE
44
+ * entity, and offered the rest. The cut travels in the reference, so it survives a reload; read
45
+ * from an address bar it is untyped, so it is read the way criteria reads a filter from outside
46
+ * (`readExpression`), and one that does not read is no cut, never an error. It narrows what is
47
+ * OFFERED; the server still decides what may be stored.
48
+ */
49
+ export function scopeOfRef(ref) {
50
+ const written = ref.params?.[SCOPE_PARAM];
51
+ if (written === undefined)
52
+ return undefined;
53
+ try {
54
+ return readExpression(JSON.parse(written));
55
+ }
56
+ catch {
57
+ return undefined;
58
+ }
59
+ }
60
+ const METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
61
+ /** `"POST /invoices/{id}/confirm"` → its method and path. A write without a method is a bug. */
62
+ export function writeRoute(text) {
63
+ const [method, path] = text.trim().split(/\s+/, 2);
64
+ if (!METHODS.has(method) || path === undefined)
65
+ throw new Error(`"${text}" is not "METHOD /path": a write says how it is sent`);
66
+ return { method: method, path };
67
+ }
68
+ const PATH_KEY = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
69
+ /**
70
+ * The path with the record's key in place of `{id}`, and every other `{name}` taken from the
71
+ * body's field of that name: `POST /projects/{id}/shares/{tenant_id}/revoke` with
72
+ * `{ tenant_id: "acme" }`.
73
+ *
74
+ * Context: sincpro_framework reads a path parameter as a field of the use case's DTO (its
75
+ * wire merges `{...body, ...path_params}`), so the body already names every key a route
76
+ * needs — a second argument for them would say the same thing twice. A key the body lacks is
77
+ * the screen's bug, said before anything is sent. Without an `id`, `{id}` stays: a create or a
78
+ * preview of a new record has none.
79
+ */
80
+ export function pathFor(route, id, body) {
81
+ return route.path.replace(PATH_KEY, (whole, name) => {
82
+ if (name === "id")
83
+ return id === undefined ? whole : encodeURIComponent(id);
84
+ const value = body?.[name];
85
+ if (value === undefined || value === null || value === "")
86
+ throw new Error(`"${route.method} ${route.path}" needs "${name}" in the body`);
87
+ return encodeURIComponent(String(value));
88
+ });
89
+ }
90
+ /**
91
+ * What a Command of a record is sent with: the body as given, and — when its route says which
92
+ * field names the record (`target`) — the record's id in that field.
93
+ *
94
+ * bodyFor({ method: "POST", path: "/issues", target: "project_id" }, "p1", { title: "x" })
95
+ * -> { title: "x", project_id: "p1" }
96
+ *
97
+ * Context: sincpro_framework says, per operation, which input field holds the key of each
98
+ * entity it runs on (`targets`). A path that carries it (`/issues/{issue_id}/messages`) and a
99
+ * body that carries it (`POST /issues` with `project_id`) are the same thing to the server — a
100
+ * path parameter is read as a field of the DTO — so it goes in the body, and `pathFor` takes it
101
+ * from there. Without an id (a Command that creates) the body is sent as it is.
102
+ */
103
+ export function bodyFor(route, id, body) {
104
+ if (route.target === undefined || id === undefined || id === "")
105
+ return body;
106
+ return { ...body, [route.target]: id };
107
+ }
108
+ /** A Command's route as declared: a `"METHOD /path"`, or that and the field naming its record. */
109
+ function commandRoute(declared) {
110
+ if (typeof declared === "string")
111
+ return writeRoute(declared);
112
+ return { ...writeRoute(declared.route), target: declared.target };
113
+ }
114
+ /**
115
+ * A list's route with its own parameters: `{ tenant_id: "acme" }` → `/projects?tenant_id=acme`.
116
+ * An empty value is no parameter — a filter left blank. A list under a record names its key in
117
+ * the path (`/tenants/{tenant_id}/members`): that parameter fills it, and only the rest goes in
118
+ * the query.
119
+ *
120
+ * Context: sincpro_framework reads a path parameter as a field of the search's DTO, the same
121
+ * field a query parameter would fill, so the screen gives them all as one set of parameters. A
122
+ * key the path needs and the parameters lack is the screen's bug, said before anything is sent.
123
+ */
124
+ export function listRoute(list, params) {
125
+ const given = params ?? {};
126
+ const placed = new Set();
127
+ const path = list.replace(PATH_KEY, (_whole, name) => {
128
+ const value = given[name];
129
+ if (value === undefined || value === "")
130
+ throw new Error(`the list "${list}" needs "${name}" in its parameters`);
131
+ placed.add(name);
132
+ return encodeURIComponent(value);
133
+ });
134
+ const query = new URLSearchParams(Object.entries(given).filter(([name, value]) => value !== "" && !placed.has(name))).toString();
135
+ if (query === "")
136
+ return path;
137
+ return `${path}${path.includes("?") ? "&" : "?"}${query}`;
138
+ }
139
+ export function entity(key, routes) {
140
+ if (!/^[^:\s]+:[^:\s]+$/.test(key))
141
+ throw new Error(`entity key "${key}" is not domain:kind`);
142
+ const list = routes.list.split("?")[0] ?? routes.list;
143
+ const template = routes.one ?? `${list}/{id}`;
144
+ const [domain = "", kind = ""] = key.split(":");
145
+ return {
146
+ key,
147
+ domain,
148
+ kind,
149
+ ref: (id) => ({ domain, kind, ...(id === undefined || id === "" ? {} : { id }) }),
150
+ newRef: (values) => ({
151
+ domain,
152
+ kind,
153
+ params: {
154
+ [NEW_PARAM]: "1",
155
+ ...(values === undefined || Object.keys(values).length === 0
156
+ ? {}
157
+ : { [WITH_PARAM]: JSON.stringify(values) }),
158
+ },
159
+ }),
160
+ chooserRef: (query, scope) => ({
161
+ domain,
162
+ kind,
163
+ params: {
164
+ [PICK_PARAM]: "1",
165
+ ...(query === undefined || query.trim() === ""
166
+ ? {}
167
+ : { [QUERY_PARAM]: query.trim() }),
168
+ ...(scope === undefined ? {} : { [SCOPE_PARAM]: JSON.stringify(scope) }),
169
+ },
170
+ }),
171
+ list: routes.list,
172
+ one: (id) => template.replace("{id}", encodeURIComponent(id)),
173
+ // Built from exactly the names `routes.commands` was typed with.
174
+ commands: Object.fromEntries(Object.entries(routes.commands ?? {}).map(([name, declared]) => [
175
+ name,
176
+ commandRoute(declared),
177
+ ])),
178
+ ...(routes.create === undefined ? {} : { create: writeRoute(routes.create) }),
179
+ ...(routes.save === undefined ? {} : { save: writeRoute(routes.save) }),
180
+ ...(routes.preview === undefined ? {} : { preview: writeRoute(routes.preview) }),
181
+ ...(routes.draft === undefined ? {} : { draft: writeRoute(routes.draft) }),
182
+ ...(routes.edit === undefined ? {} : { edit: writeRoute(routes.edit) }),
183
+ ...(routes.history === undefined ? {} : { history: routes.history }),
184
+ summary: routes.summary ?? {},
185
+ ...(routes.aggregate === undefined ? {} : { aggregate: routes.aggregate }),
186
+ };
187
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * A new record's key, minted where the record is born: a UUID v7 as 32 hex characters —
3
+ * sincpro_framework's `new_entity_id`.
4
+ *
5
+ * Context: sincpro_framework's `Edit` creates a new record under the id the CLIENT names («a
6
+ * UUID v7 it mints»), and the draft and the stored record share it from the first draft save.
7
+ * A form that waited for the server to name it could neither keep a draft of a new record nor
8
+ * reach the edit route at all (`PUT /invoices/{id}/edit` needs the id in its path). The layout
9
+ * is the backend's (RFC 9562): 48 bits of unix milliseconds, the version, a 12-bit counter
10
+ * within the millisecond, the variant and 62 random bits — so ids minted here sort by when
11
+ * they were minted, like the server's, and ordering by `id` is still ordering by creation.
12
+ *
13
+ * newEntityId() // "019a4c2e7f1074a8b13fa739ccbdcd53"
14
+ */
15
+ export declare function newEntityId(now?: number): string;
16
+ /** A key that is a UUID v7 as sincpro_framework writes it: 32 hex, version 7, variant `10`. */
17
+ export declare function isEntityId(text: string): boolean;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * A new record's key, minted where the record is born: a UUID v7 as 32 hex characters —
3
+ * sincpro_framework's `new_entity_id`.
4
+ *
5
+ * Context: sincpro_framework's `Edit` creates a new record under the id the CLIENT names («a
6
+ * UUID v7 it mints»), and the draft and the stored record share it from the first draft save.
7
+ * A form that waited for the server to name it could neither keep a draft of a new record nor
8
+ * reach the edit route at all (`PUT /invoices/{id}/edit` needs the id in its path). The layout
9
+ * is the backend's (RFC 9562): 48 bits of unix milliseconds, the version, a 12-bit counter
10
+ * within the millisecond, the variant and 62 random bits — so ids minted here sort by when
11
+ * they were minted, like the server's, and ordering by `id` is still ordering by creation.
12
+ *
13
+ * newEntityId() // "019a4c2e7f1074a8b13fa739ccbdcd53"
14
+ */
15
+ const COUNTER_MAX = 0xfff;
16
+ let lastMillis = -1;
17
+ let counter = 0;
18
+ function randomBytes(count) {
19
+ return globalThis.crypto.getRandomValues(new Uint8Array(count));
20
+ }
21
+ function hex(value, digits) {
22
+ return value.toString(16).padStart(digits, "0");
23
+ }
24
+ export function newEntityId(now = Date.now()) {
25
+ // The clock never runs backwards for these ids: the same millisecond (or an earlier one, a
26
+ // clock moved back) counts up; a full counter borrows the next millisecond.
27
+ let millis = now;
28
+ if (millis <= lastMillis) {
29
+ millis = lastMillis;
30
+ counter += 1;
31
+ if (counter > COUNTER_MAX) {
32
+ millis += 1;
33
+ counter = 0;
34
+ }
35
+ }
36
+ else {
37
+ // A fresh millisecond starts the counter at a random point of its lower half: room to count.
38
+ const [high = 0, low = 0] = randomBytes(2);
39
+ counter = ((high << 8) | low) & 0x7ff;
40
+ }
41
+ lastMillis = millis;
42
+ const tail = randomBytes(8);
43
+ tail[0] = ((tail[0] ?? 0) & 0x3f) | 0x80; // the variant, `10`
44
+ return (hex(millis, 12) +
45
+ "7" + // the version
46
+ hex(counter, 3) +
47
+ Array.from(tail, (byte) => hex(byte, 2)).join(""));
48
+ }
49
+ /** A key that is a UUID v7 as sincpro_framework writes it: 32 hex, version 7, variant `10`. */
50
+ export function isEntityId(text) {
51
+ return /^[0-9a-f]{12}7[0-9a-f]{3}[89ab][0-9a-f]{15}$/.test(text);
52
+ }
@@ -0,0 +1,46 @@
1
+ import { type Draft, type Method, type Transport } from "./transport.js";
2
+ import type { AnyRecord, Criteria, Meta, Problem } from "@sincpro/criteria";
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
+ /** Answer a read that is neither a page nor a record (`GET /me`), its route with its query.
35
+ * Without it, the transport has no `read`. */
36
+ read?: (route: string) => unknown;
37
+ /** The draft this caller kept of a record, answered with it — sincpro_framework's `Drafts`. */
38
+ draft?: (route: string) => Draft | null;
39
+ /** Every list request, as it arrived: what a story or a test checks was really asked. */
40
+ observe?: (route: string, criteria: Criteria) => void;
41
+ /** Refuse a route the way a server would: return the problem it answers with. */
42
+ refuse?: (route: string) => (Problem & {
43
+ status?: number;
44
+ }) | undefined;
45
+ };
46
+ export declare function memoryTransport(tables: Readonly<Record<string, MemoryTable>>, options?: MemoryOptions): Transport;
@@ -0,0 +1,108 @@
1
+ import { TransportError, } from "./transport.js";
2
+ import { answer, answerGroups } from "@sincpro/criteria/engine";
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
+ const answered = new Map();
16
+ async function check(route, signal) {
17
+ await wait(options.latency ?? 0, signal);
18
+ const refused = options.refuse?.(route);
19
+ if (refused !== undefined)
20
+ throw new TransportError(route, refused.status ?? 400, refused);
21
+ }
22
+ function tableAt(route) {
23
+ const path = route.split("?")[0] ?? route;
24
+ const table = tables[path];
25
+ if (table === undefined)
26
+ throw new TransportError(route, 404, {
27
+ kind: "not_found",
28
+ reason: "ROUTE_NOT_FOUND",
29
+ next_step: "fix_request",
30
+ retryable: false,
31
+ errors: [],
32
+ extensions: {},
33
+ detail: `No table answers ${path}.`,
34
+ });
35
+ return table;
36
+ }
37
+ return {
38
+ async list(route, criteria, signal) {
39
+ options.observe?.(route, criteria);
40
+ await check(route, signal);
41
+ const table = tableAt(route);
42
+ // A grouped question is answered with the groups — counted over EVERY row that matches,
43
+ // each with the criteria that opens it — the way the server's aggregation answers it.
44
+ const grouped = (criteria.grouping?.group_by?.length ?? 0) > 0;
45
+ const page = grouped
46
+ ? answerGroups(table.rows, criteria, table.meta)
47
+ : answer(table.rows, criteria, table.meta);
48
+ // A copy, as over a wire: a screen holding a row must not see the server change it.
49
+ return { ...page, rows: structuredClone(page.rows) };
50
+ },
51
+ async one(route, signal) {
52
+ await check(route, signal);
53
+ const slash = route.lastIndexOf("/");
54
+ const table = tableAt(route.slice(0, slash));
55
+ const id = decodeURIComponent(route.slice(slash + 1));
56
+ const key = table.meta.get_id || table.meta.identity;
57
+ const record = table.rows.find((row) => String(row[key]) === id);
58
+ if (record === undefined)
59
+ throw new TransportError(route, 404, {
60
+ kind: "not_found",
61
+ reason: "AGGREGATE_NOT_FOUND",
62
+ next_step: "do_not_retry",
63
+ retryable: false,
64
+ errors: [],
65
+ extensions: {},
66
+ detail: `${table.meta.aggregate} ${id} does not exist.`,
67
+ });
68
+ return {
69
+ record: structuredClone(record),
70
+ meta: table.meta,
71
+ dropped: [],
72
+ draft: options.draft?.(route) ?? null,
73
+ };
74
+ },
75
+ async send(method, route, body, signal, sending) {
76
+ await check(route, signal);
77
+ // A key already answered is answered again, without running the Command twice — what
78
+ // sincpro_framework's `@idempotency` does with a repeated `Idempotency-Key`.
79
+ const key = sending?.idempotencyKey;
80
+ if (key !== undefined && answered.has(key))
81
+ return structuredClone(answered.get(key));
82
+ if (options.handle === undefined)
83
+ throw new TransportError(route, 404, {
84
+ kind: "not_found",
85
+ reason: "ROUTE_NOT_FOUND",
86
+ next_step: "fix_request",
87
+ retryable: false,
88
+ errors: [],
89
+ extensions: {},
90
+ detail: `Nothing answers ${method} ${route}.`,
91
+ });
92
+ // A deep copy: what the caller sent is not what the server keeps.
93
+ const answer = await options.handle(method, route, structuredClone(body));
94
+ if (key !== undefined)
95
+ answered.set(key, structuredClone(answer));
96
+ return answer;
97
+ },
98
+ ...(options.read === undefined
99
+ ? {}
100
+ : {
101
+ async read(route, signal) {
102
+ await check(route, signal);
103
+ // A copy, as every answer: over a wire nobody holds the server's object.
104
+ return structuredClone(await options.read(route));
105
+ },
106
+ }),
107
+ };
108
+ }