@sincpro/client-framework 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +85 -0
- package/LICENSE +21 -0
- package/MANIFESTO.md +127 -0
- package/README.md +100 -2
- package/dist/adapters/opentelemetry.d.ts +21 -0
- package/dist/adapters/opentelemetry.js +68 -0
- package/dist/adapters/sentry.d.ts +28 -0
- package/dist/adapters/sentry.js +36 -0
- package/dist/client/contract.d.ts +34 -0
- package/dist/client/contract.js +41 -0
- package/dist/client/discovery.d.ts +47 -0
- package/dist/client/discovery.js +68 -0
- package/dist/client/entity.d.ts +242 -0
- package/dist/client/entity.js +187 -0
- package/dist/client/ids.d.ts +17 -0
- package/dist/client/ids.js +52 -0
- package/dist/client/memory.d.ts +46 -0
- package/dist/client/memory.js +108 -0
- package/dist/client/rest.d.ts +49 -0
- package/dist/client/rest.js +143 -0
- package/dist/client/retry.d.ts +24 -0
- package/dist/client/retry.js +25 -0
- package/dist/client/transport.d.ts +89 -0
- package/dist/client/transport.js +26 -0
- package/dist/codegen/cli.d.ts +2 -0
- package/dist/codegen/cli.js +82 -0
- package/dist/codegen/client.d.ts +14 -0
- package/dist/codegen/client.js +180 -0
- package/dist/codegen/domain.d.ts +14 -0
- package/dist/codegen/domain.js +195 -0
- package/dist/codegen/entities.d.ts +14 -0
- package/dist/codegen/entities.js +158 -0
- package/dist/codegen/index.d.ts +10 -0
- package/dist/codegen/index.js +8 -0
- package/dist/codegen/metamodel.d.ts +89 -0
- package/dist/codegen/metamodel.js +1 -0
- package/dist/codegen/names.d.ts +39 -0
- package/dist/codegen/names.js +113 -0
- package/dist/codegen/rest.d.ts +31 -0
- package/dist/codegen/rest.js +27 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +31 -0
- package/dist/model/meta.d.ts +121 -0
- package/dist/model/meta.js +130 -0
- package/dist/model/metas.d.ts +17 -0
- package/dist/model/metas.js +7 -0
- package/dist/model/schema.d.ts +39 -0
- package/dist/model/schema.js +128 -0
- package/dist/observability/ports.d.ts +64 -0
- package/dist/observability/ports.js +21 -0
- package/dist/services/execution.d.ts +63 -0
- package/dist/services/execution.js +88 -0
- package/dist/services/feature.d.ts +60 -0
- package/dist/services/feature.js +220 -0
- package/dist/services/runtime.d.ts +48 -0
- package/dist/services/runtime.js +30 -0
- package/dist/testing/index.d.ts +35 -0
- package/dist/testing/index.js +37 -0
- package/package.json +92 -4
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { metaFromContract, versionOf } from "@sincpro/criteria";
|
|
2
|
+
function nameOfRef(ref) {
|
|
3
|
+
return ref === undefined ? undefined : ref.slice(ref.lastIndexOf("/") + 1);
|
|
4
|
+
}
|
|
5
|
+
/** Whether a document path (`/v1/projects/{id}`) answers a request path (`/v1/projects/p1`). */
|
|
6
|
+
function answers(template, path) {
|
|
7
|
+
const pattern = template
|
|
8
|
+
.split("/")
|
|
9
|
+
.map((part) => /^\{[^}]+\}$/.test(part) ? "[^/]+" : part.replace(/[.*+?^$()|[\]\\]/g, "\\$&"))
|
|
10
|
+
.join("/");
|
|
11
|
+
return new RegExp(`^${pattern}$`).test(path);
|
|
12
|
+
}
|
|
13
|
+
/** The entity a schema describes, or the one its records hold: an answer's records field. */
|
|
14
|
+
function entityIn(schema, schemas, seen = new Set()) {
|
|
15
|
+
if (schema === undefined)
|
|
16
|
+
return undefined;
|
|
17
|
+
const named = nameOfRef(schema.$ref);
|
|
18
|
+
if (named !== undefined) {
|
|
19
|
+
if (seen.has(named))
|
|
20
|
+
return undefined;
|
|
21
|
+
seen.add(named);
|
|
22
|
+
const target = schemas[named];
|
|
23
|
+
return target?.["x-sincpro"]?.entity !== undefined
|
|
24
|
+
? named
|
|
25
|
+
: entityIn(target, schemas, seen);
|
|
26
|
+
}
|
|
27
|
+
for (const one of [schema.items, ...(schema.anyOf ?? [])]) {
|
|
28
|
+
const found = entityIn(one, schemas, seen);
|
|
29
|
+
if (found !== undefined)
|
|
30
|
+
return found;
|
|
31
|
+
}
|
|
32
|
+
for (const one of Object.values(schema.properties ?? {})) {
|
|
33
|
+
const found = entityIn(one, schemas, seen);
|
|
34
|
+
if (found !== undefined)
|
|
35
|
+
return found;
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The model a request to `path` answers, by the name of its contract in the document: the
|
|
41
|
+
* entity the operation says it belongs to, else the one its answer holds.
|
|
42
|
+
*
|
|
43
|
+
* modelAt(published, "/v1/projects/p1", "get") // "Project"
|
|
44
|
+
*/
|
|
45
|
+
export function modelAt(published, path, method) {
|
|
46
|
+
const schemas = published.components?.schemas ?? {};
|
|
47
|
+
for (const [template, item] of Object.entries(published.paths ?? {})) {
|
|
48
|
+
if (!answers(template, path))
|
|
49
|
+
continue;
|
|
50
|
+
const operation = item[method.toLowerCase()];
|
|
51
|
+
if (operation === undefined)
|
|
52
|
+
continue;
|
|
53
|
+
const declared = operation["x-sincpro-entities"]?.[0];
|
|
54
|
+
if (declared !== undefined && schemas[declared] !== undefined)
|
|
55
|
+
return declared;
|
|
56
|
+
const answered = operation.responses?.["200"]?.content?.["application/json"]?.schema;
|
|
57
|
+
return entityIn(answered, schemas);
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
/** The definition of the model named `name`, read from its contract, and its version. */
|
|
62
|
+
export function definitionOf(published, name) {
|
|
63
|
+
const schemas = (published.components?.schemas ?? {});
|
|
64
|
+
const contract = schemas[name];
|
|
65
|
+
if (contract?.["x-sincpro"] === undefined)
|
|
66
|
+
return undefined;
|
|
67
|
+
return { meta: metaFromContract(contract, schemas), version: versionOf(contract) };
|
|
68
|
+
}
|
|
@@ -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;
|