@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.
- package/AGENTS.md +78 -0
- package/LICENSE +21 -0
- package/MANIFESTO.md +124 -0
- package/README.md +88 -2
- 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 +206 -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 +76 -4
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** What a transport can send: GET is read by `list` (a page) or `one` (a record) only. */
|
|
2
|
+
export const WRITES = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
3
|
+
export function restOf(metamodel, identity, options) {
|
|
4
|
+
const binding = metamodel.bindings?.rest?.[identity];
|
|
5
|
+
if (binding === undefined)
|
|
6
|
+
return undefined;
|
|
7
|
+
const base = options.base ?? "";
|
|
8
|
+
const under = base === "" || binding.path === base || binding.path.startsWith(`${base}/`);
|
|
9
|
+
if (!under)
|
|
10
|
+
throw new Error(`${identity} is at ${binding.path}, outside the base ${base}`);
|
|
11
|
+
return {
|
|
12
|
+
method: binding.method.toUpperCase(),
|
|
13
|
+
path: binding.path.slice(base.length) || "/",
|
|
14
|
+
fields: binding.path_fields,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The one entity a read belongs to: its only entity, or — when it concerns several — the one
|
|
19
|
+
* whose model its answer carries. `undefined` when that still leaves more than one.
|
|
20
|
+
*/
|
|
21
|
+
export function ownerOf(metamodel, operation) {
|
|
22
|
+
if (operation.entities.length === 1)
|
|
23
|
+
return operation.entities[0];
|
|
24
|
+
const answer = JSON.stringify(operation.output ?? {});
|
|
25
|
+
const carried = operation.entities.filter((one) => one in metamodel.models && answer.includes(`"#/models/${one}"`));
|
|
26
|
+
return carried.length === 1 ? carried[0] : undefined;
|
|
27
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public entry of @sincpro/client-framework: everything a client of a sincpro_framework
|
|
3
|
+
* service needs, on any platform.
|
|
4
|
+
*
|
|
5
|
+
* Context: one entry, not one per area — the package has no side effects at import
|
|
6
|
+
* (`sideEffects: false`), so an application pays only for what it uses, and a use case, the
|
|
7
|
+
* client it calls and the entity it reads come from the same place.
|
|
8
|
+
*
|
|
9
|
+
* client/ the seam with a server: the transport and its REST and in-memory implementations,
|
|
10
|
+
* entities with typed commands, the service's client contract, client-side ids
|
|
11
|
+
* model/ model definitions: written by hand, read from a JSON Schema, kept once known
|
|
12
|
+
* services/ Feature and ApplicationService, the runtime they run in, what each run carries
|
|
13
|
+
* observability/ the ports a platform plugs: a tracer and an error reporter
|
|
14
|
+
*
|
|
15
|
+
* The generator is `@sincpro/client-framework/codegen`, and the in-memory tracer and reporter
|
|
16
|
+
* `@sincpro/client-framework/testing`: neither is part of what an application runs.
|
|
17
|
+
*/
|
|
18
|
+
export { type ClientContract, entityKeyOf, kindOf, metasOf, versionsOf, } from "./client/contract.js";
|
|
19
|
+
export { bodyFor, type CommandAnswers, type CommandBodies, type CommandRoute, type Entity, entity, type EntityRef, type EntityRoutes, listRoute, NEW_PARAM, pathFor, PICK_PARAM, QUERY_PARAM, SCOPE_PARAM, scopeOfRef, startingValuesOf, type SummaryFields, type SummaryPart, TRAIL_PARAM, WITH_PARAM, type WriteRoute, writeRoute, } from "./client/entity.js";
|
|
20
|
+
export { isEntityId, newEntityId } from "./client/ids.js";
|
|
21
|
+
export { type MemoryOptions, type MemoryTable, memoryTransport, } from "./client/memory.js";
|
|
22
|
+
export { type RestOptions, restTransport, urlOf, type VersionDrift, } from "./client/rest.js";
|
|
23
|
+
export { type RetryKeys, retryKeys, unanswered, } from "./client/retry.js";
|
|
24
|
+
export { type CallOptions, type Draft, type Method, type One, readRoute, type SendOptions, type Transport, TransportError, } from "./client/transport.js";
|
|
25
|
+
export { defineMeta, field, type FieldOptions, type MetaSpec, type Words, } from "./model/meta.js";
|
|
26
|
+
export { type MetaStore, metaStore } from "./model/metas.js";
|
|
27
|
+
export { completeMeta, type JsonSchema, metaFromSchema, type SchemaComponents, } from "./model/schema.js";
|
|
28
|
+
export { type Attributes, type ErrorAbout, type ErrorReporter, NO_TRACER, type Outcome, type Span, type Tracer, } from "./observability/ports.js";
|
|
29
|
+
export { BIND, type Bindable, bindable, bindDependency, bindTransport, type Execution, headersOf, } from "./services/execution.js";
|
|
30
|
+
export { ApplicationService, bindServices, Feature, } from "./services/feature.js";
|
|
31
|
+
export { createRuntime, DETACHED, outcomeOf, type Runtime, type RuntimeOptions, } from "./services/runtime.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public entry of @sincpro/client-framework: everything a client of a sincpro_framework
|
|
3
|
+
* service needs, on any platform.
|
|
4
|
+
*
|
|
5
|
+
* Context: one entry, not one per area — the package has no side effects at import
|
|
6
|
+
* (`sideEffects: false`), so an application pays only for what it uses, and a use case, the
|
|
7
|
+
* client it calls and the entity it reads come from the same place.
|
|
8
|
+
*
|
|
9
|
+
* client/ the seam with a server: the transport and its REST and in-memory implementations,
|
|
10
|
+
* entities with typed commands, the service's client contract, client-side ids
|
|
11
|
+
* model/ model definitions: written by hand, read from a JSON Schema, kept once known
|
|
12
|
+
* services/ Feature and ApplicationService, the runtime they run in, what each run carries
|
|
13
|
+
* observability/ the ports a platform plugs: a tracer and an error reporter
|
|
14
|
+
*
|
|
15
|
+
* The generator is `@sincpro/client-framework/codegen`, and the in-memory tracer and reporter
|
|
16
|
+
* `@sincpro/client-framework/testing`: neither is part of what an application runs.
|
|
17
|
+
*/
|
|
18
|
+
export { entityKeyOf, kindOf, metasOf, versionsOf, } from "./client/contract.js";
|
|
19
|
+
export { bodyFor, entity, listRoute, NEW_PARAM, pathFor, PICK_PARAM, QUERY_PARAM, SCOPE_PARAM, scopeOfRef, startingValuesOf, TRAIL_PARAM, WITH_PARAM, writeRoute, } from "./client/entity.js";
|
|
20
|
+
export { isEntityId, newEntityId } from "./client/ids.js";
|
|
21
|
+
export { memoryTransport, } from "./client/memory.js";
|
|
22
|
+
export { restTransport, urlOf, } from "./client/rest.js";
|
|
23
|
+
export { retryKeys, unanswered, } from "./client/retry.js";
|
|
24
|
+
export { readRoute, TransportError, } from "./client/transport.js";
|
|
25
|
+
export { defineMeta, field, } from "./model/meta.js";
|
|
26
|
+
export { metaStore } from "./model/metas.js";
|
|
27
|
+
export { completeMeta, metaFromSchema, } from "./model/schema.js";
|
|
28
|
+
export { NO_TRACER, } from "./observability/ports.js";
|
|
29
|
+
export { BIND, bindable, bindDependency, bindTransport, headersOf, } from "./services/execution.js";
|
|
30
|
+
export { ApplicationService, bindServices, Feature, } from "./services/feature.js";
|
|
31
|
+
export { createRuntime, DETACHED, outcomeOf, } from "./services/runtime.js";
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { Expression, FieldMeta, FieldType, Meta, Operator } from "@sincpro/criteria";
|
|
2
|
+
/**
|
|
3
|
+
* A model definition written by hand, in the shape sincpro_framework publishes.
|
|
4
|
+
*
|
|
5
|
+
* Context: everything in this package reads a `Meta` — the labels, the columns, the controls,
|
|
6
|
+
* the search box. A service built with sincpro_framework sends one with every answer. A domain
|
|
7
|
+
* that has none (another backend, rows a screen already holds, a prototype
|
|
8
|
+
* with no server yet) declares it here, and from then on it is indistinguishable: the same
|
|
9
|
+
* list, the same record, the same criteria. `describeRows` from `@sincpro/criteria` guesses
|
|
10
|
+
* one from rows; this one is written on purpose, with the words a person reads.
|
|
11
|
+
*
|
|
12
|
+
* const tickets = defineMeta({
|
|
13
|
+
* aggregate: "Ticket", identity: "id", display: "title", search: ["title"],
|
|
14
|
+
* name: { es: "Tickets" },
|
|
15
|
+
* fields: {
|
|
16
|
+
* id: field.text({ readonly: true }),
|
|
17
|
+
* title: field.text({ label: { es: "Título" }, required: true }),
|
|
18
|
+
* stage: field.choice(["new", "done"], { labels: { new: { es: "Nuevo" } } }),
|
|
19
|
+
* opened_at: field.datetime({ label: { es: "Abierto" } }),
|
|
20
|
+
* },
|
|
21
|
+
* });
|
|
22
|
+
*/
|
|
23
|
+
/** A word in several languages, `{default?, es?, en?, …}`; a bare string is the default. */
|
|
24
|
+
export type Words = string | Readonly<Record<string, string>>;
|
|
25
|
+
export type FieldOptions = {
|
|
26
|
+
label?: Words;
|
|
27
|
+
help?: Words;
|
|
28
|
+
required?: boolean;
|
|
29
|
+
readonly?: boolean;
|
|
30
|
+
/** Holds no value sometimes. A nullable field is never sortable: keyset paging over it
|
|
31
|
+
* loses the rows that hold NULL, which is why the framework refuses to order by one. */
|
|
32
|
+
nullable?: boolean;
|
|
33
|
+
default?: unknown;
|
|
34
|
+
/** Form hints over the record, as sincpro_framework's `Presentation` publishes them. */
|
|
35
|
+
readonlyWhen?: Expression;
|
|
36
|
+
requiredWhen?: Expression;
|
|
37
|
+
visibleWhen?: Expression;
|
|
38
|
+
};
|
|
39
|
+
/** The field builders: one per type the framework publishes. */
|
|
40
|
+
export declare const field: {
|
|
41
|
+
text: (options?: FieldOptions) => FieldMeta;
|
|
42
|
+
integer: (options?: FieldOptions) => FieldMeta;
|
|
43
|
+
/** A decimal travels as text so no digit is lost (`"10.50"`): pass `exact: true`. */
|
|
44
|
+
number: (options?: FieldOptions & {
|
|
45
|
+
exact?: boolean;
|
|
46
|
+
}) => {
|
|
47
|
+
exact?: boolean;
|
|
48
|
+
type: FieldType;
|
|
49
|
+
nullable: boolean;
|
|
50
|
+
choices?: unknown[];
|
|
51
|
+
choice_labels?: Record<string, Record<string, string>>;
|
|
52
|
+
label?: Record<string, string>;
|
|
53
|
+
help?: Record<string, string>;
|
|
54
|
+
groupable?: boolean;
|
|
55
|
+
aggregates?: string[];
|
|
56
|
+
computed?: boolean;
|
|
57
|
+
owner?: string | null;
|
|
58
|
+
readonly?: boolean;
|
|
59
|
+
required?: boolean;
|
|
60
|
+
default?: unknown;
|
|
61
|
+
readonly_when?: Expression | null;
|
|
62
|
+
required_when?: Expression | null;
|
|
63
|
+
visible_when?: Expression | null;
|
|
64
|
+
sortable: boolean;
|
|
65
|
+
ops: Operator[];
|
|
66
|
+
widget?: string;
|
|
67
|
+
affects?: string[];
|
|
68
|
+
judged_by?: string[];
|
|
69
|
+
holds?: Expression | null;
|
|
70
|
+
computes?: import("@sincpro/criteria").Formula | null;
|
|
71
|
+
navigable?: boolean;
|
|
72
|
+
total?: string | null;
|
|
73
|
+
many?: boolean;
|
|
74
|
+
relation?: string | null;
|
|
75
|
+
identified_by?: string | null;
|
|
76
|
+
parent_field?: string | null;
|
|
77
|
+
related_field?: string | null;
|
|
78
|
+
definition?: Meta | null;
|
|
79
|
+
kind: import("@sincpro/criteria").FieldKind;
|
|
80
|
+
};
|
|
81
|
+
boolean: (options?: FieldOptions) => FieldMeta;
|
|
82
|
+
date: (options?: FieldOptions) => FieldMeta;
|
|
83
|
+
datetime: (options?: FieldOptions) => FieldMeta;
|
|
84
|
+
tags: (options?: FieldOptions) => FieldMeta;
|
|
85
|
+
/** An enum: the values in order, and the word each one is read as. */
|
|
86
|
+
choice: (values: readonly string[], options?: FieldOptions & {
|
|
87
|
+
labels?: Readonly<Record<string, Words>>;
|
|
88
|
+
}) => FieldMeta;
|
|
89
|
+
/** Points at ONE record of another aggregate, by the scalar key this record holds. */
|
|
90
|
+
many2one: (relation: string, keys: {
|
|
91
|
+
identifiedBy: string;
|
|
92
|
+
definition?: Meta;
|
|
93
|
+
}, options?: FieldOptions) => FieldMeta;
|
|
94
|
+
/** The records of another aggregate that point back here, by THEIR key. */
|
|
95
|
+
one2many: (relation: string, keys: {
|
|
96
|
+
identifiedBy: string;
|
|
97
|
+
definition?: Meta;
|
|
98
|
+
}, options?: FieldOptions) => FieldMeta;
|
|
99
|
+
many2many: (relation: string, keys: {
|
|
100
|
+
identifiedBy: string;
|
|
101
|
+
definition?: Meta;
|
|
102
|
+
}, options?: FieldOptions) => FieldMeta;
|
|
103
|
+
/** A value object kept inside the record — an address, a specification. No identity. */
|
|
104
|
+
embedded: (definition: Meta, options?: FieldOptions & {
|
|
105
|
+
many?: boolean;
|
|
106
|
+
}) => FieldMeta;
|
|
107
|
+
};
|
|
108
|
+
export type MetaSpec = {
|
|
109
|
+
aggregate: string;
|
|
110
|
+
identity: string;
|
|
111
|
+
fields: Record<string, FieldMeta>;
|
|
112
|
+
name?: Words;
|
|
113
|
+
/** The field shown beside the identity: what a select and a many2one show. */
|
|
114
|
+
display?: string;
|
|
115
|
+
/** `"-created_at"`, the way a URL writes it. Without it, the identity ascending. */
|
|
116
|
+
defaultOrder?: string;
|
|
117
|
+
/** The text fields a typed text is looked for in — the entity's search template. */
|
|
118
|
+
search?: readonly string[];
|
|
119
|
+
};
|
|
120
|
+
/** A whole definition, from its fields. See the file header. */
|
|
121
|
+
export declare function defineMeta(spec: MetaSpec): Meta;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
function hints(options) {
|
|
2
|
+
return {
|
|
3
|
+
...(options.readonlyWhen ? { readonly_when: options.readonlyWhen } : {}),
|
|
4
|
+
...(options.requiredWhen ? { required_when: options.requiredWhen } : {}),
|
|
5
|
+
...(options.visibleWhen ? { visible_when: options.visibleWhen } : {}),
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
/** The comparisons each type takes — the same table sincpro_framework publishes. */
|
|
9
|
+
const OPS = {
|
|
10
|
+
text: ["=", "!=", "like", "starts with", "in", "not in"],
|
|
11
|
+
integer: ["=", "!=", ">", ">=", "<", "<=", "between", "in", "not in"],
|
|
12
|
+
number: ["=", "!=", ">", ">=", "<", "<=", "between", "in", "not in"],
|
|
13
|
+
date: ["=", "!=", ">", ">=", "<", "<=", "between"],
|
|
14
|
+
datetime: ["=", "!=", ">", ">=", "<", "<=", "between"],
|
|
15
|
+
boolean: ["="],
|
|
16
|
+
"text[]": ["contains", "not contains", "=", "!="],
|
|
17
|
+
};
|
|
18
|
+
function words(value) {
|
|
19
|
+
if (value === undefined)
|
|
20
|
+
return undefined;
|
|
21
|
+
return typeof value === "string" ? { default: value } : { ...value };
|
|
22
|
+
}
|
|
23
|
+
function scalar(type, options = {}) {
|
|
24
|
+
const nullable = options.nullable ?? false;
|
|
25
|
+
const ops = [...(OPS[type] ?? [])];
|
|
26
|
+
if (nullable && type !== "text[]" && type !== "boolean")
|
|
27
|
+
ops.push("is null");
|
|
28
|
+
const listed = type === "text[]";
|
|
29
|
+
return {
|
|
30
|
+
type,
|
|
31
|
+
kind: "scalar",
|
|
32
|
+
nullable,
|
|
33
|
+
sortable: !nullable && !listed,
|
|
34
|
+
groupable: !listed,
|
|
35
|
+
ops,
|
|
36
|
+
...(options.label === undefined ? {} : { label: words(options.label) }),
|
|
37
|
+
...(options.help === undefined ? {} : { help: words(options.help) }),
|
|
38
|
+
...(options.required === undefined ? {} : { required: options.required }),
|
|
39
|
+
...(options.readonly === undefined ? {} : { readonly: options.readonly }),
|
|
40
|
+
...(options.default === undefined ? {} : { default: options.default }),
|
|
41
|
+
...hints(options),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function relational(type, relation, keys, options = {}) {
|
|
45
|
+
return {
|
|
46
|
+
type,
|
|
47
|
+
kind: "relation",
|
|
48
|
+
nullable: options.nullable ?? type === "many2one",
|
|
49
|
+
sortable: false,
|
|
50
|
+
ops: [],
|
|
51
|
+
many: type !== "many2one",
|
|
52
|
+
relation,
|
|
53
|
+
identified_by: keys.identifiedBy,
|
|
54
|
+
...(keys.definition === undefined ? {} : { definition: keys.definition }),
|
|
55
|
+
...(options.label === undefined ? {} : { label: words(options.label) }),
|
|
56
|
+
...(options.help === undefined ? {} : { help: words(options.help) }),
|
|
57
|
+
...(options.readonly === undefined ? {} : { readonly: options.readonly }),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/** The field builders: one per type the framework publishes. */
|
|
61
|
+
export const field = {
|
|
62
|
+
text: (options) => scalar("text", options),
|
|
63
|
+
integer: (options) => scalar("integer", options),
|
|
64
|
+
/** A decimal travels as text so no digit is lost (`"10.50"`): pass `exact: true`. */
|
|
65
|
+
number: (options) => ({
|
|
66
|
+
...scalar("number", options),
|
|
67
|
+
...(options?.exact ? { exact: true } : {}),
|
|
68
|
+
}),
|
|
69
|
+
boolean: (options) => scalar("boolean", options),
|
|
70
|
+
date: (options) => scalar("date", options),
|
|
71
|
+
datetime: (options) => scalar("datetime", options),
|
|
72
|
+
tags: (options) => scalar("text[]", options),
|
|
73
|
+
/** An enum: the values in order, and the word each one is read as. */
|
|
74
|
+
choice: (values, options = {}) => ({
|
|
75
|
+
...scalar("text", options),
|
|
76
|
+
ops: ["=", "!=", "in", "not in", ...(options.nullable ? ["is null"] : [])],
|
|
77
|
+
choices: [...values],
|
|
78
|
+
...(options.labels === undefined
|
|
79
|
+
? {}
|
|
80
|
+
: {
|
|
81
|
+
choice_labels: Object.fromEntries(Object.entries(options.labels).map(([value, said]) => [value, words(said) ?? {}])),
|
|
82
|
+
}),
|
|
83
|
+
}),
|
|
84
|
+
/** Points at ONE record of another aggregate, by the scalar key this record holds. */
|
|
85
|
+
many2one: (relation, keys, options) => relational("many2one", relation, keys, options),
|
|
86
|
+
/** The records of another aggregate that point back here, by THEIR key. */
|
|
87
|
+
one2many: (relation, keys, options) => relational("one2many", relation, keys, options),
|
|
88
|
+
many2many: (relation, keys, options) => relational("many2many", relation, keys, options),
|
|
89
|
+
/** A value object kept inside the record — an address, a specification. No identity. */
|
|
90
|
+
embedded: (definition, options = {}) => ({
|
|
91
|
+
type: "embedded",
|
|
92
|
+
kind: "embedded",
|
|
93
|
+
nullable: options.nullable ?? false,
|
|
94
|
+
sortable: false,
|
|
95
|
+
ops: [],
|
|
96
|
+
definition,
|
|
97
|
+
...(options.many ? { many: true } : {}),
|
|
98
|
+
...(options.label === undefined ? {} : { label: words(options.label) }),
|
|
99
|
+
}),
|
|
100
|
+
};
|
|
101
|
+
/** The template a typed text fills: `like` over each field, any of them matching. */
|
|
102
|
+
function searchTemplate(fields) {
|
|
103
|
+
if (fields.length === 0)
|
|
104
|
+
return null;
|
|
105
|
+
const like = (name) => ({
|
|
106
|
+
field: name,
|
|
107
|
+
operator: "like",
|
|
108
|
+
value: { $: "param", name: "text" },
|
|
109
|
+
});
|
|
110
|
+
const [only] = fields;
|
|
111
|
+
return {
|
|
112
|
+
selection: fields.length === 1 && only !== undefined ? like(only) : { any: fields.map(like) },
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/** A whole definition, from its fields. See the file header. */
|
|
116
|
+
export function defineMeta(spec) {
|
|
117
|
+
const missing = [spec.identity, spec.display, ...(spec.search ?? [])].filter((name) => name !== undefined && name !== "" && !(name in spec.fields));
|
|
118
|
+
if (missing.length > 0)
|
|
119
|
+
throw new Error(`${spec.aggregate}: ${missing.join(", ")} named but not declared`);
|
|
120
|
+
return {
|
|
121
|
+
aggregate: spec.aggregate,
|
|
122
|
+
identity: spec.identity,
|
|
123
|
+
default_order: spec.defaultOrder ?? spec.identity,
|
|
124
|
+
fields: spec.fields,
|
|
125
|
+
get_id: spec.identity,
|
|
126
|
+
...(spec.name === undefined ? {} : { name: words(spec.name) }),
|
|
127
|
+
...(spec.display === undefined ? {} : { display: spec.display }),
|
|
128
|
+
search: searchTemplate(spec.search ?? []),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Meta } from "@sincpro/criteria";
|
|
2
|
+
/**
|
|
3
|
+
* The model definitions already known, by entity key (`"billing:invoice"`).
|
|
4
|
+
*
|
|
5
|
+
* Context: sincpro_framework publishes each model once, in the discovery document the
|
|
6
|
+
* transport reads (`core/discovery`); an answer carries only its version. This keeps the
|
|
7
|
+
* definitions ACROSS readings, so the second list of the same entity, the record opened from it
|
|
8
|
+
* and the select that points at it draw their labels before the rows arrive.
|
|
9
|
+
*
|
|
10
|
+
* Seeded, it is the preload: definitions written at build time (a snapshot of the server's,
|
|
11
|
+
* or `defineMeta`) reach the first screen with no request at all.
|
|
12
|
+
*/
|
|
13
|
+
export type MetaStore = {
|
|
14
|
+
get(key: string): Meta | undefined;
|
|
15
|
+
set(key: string, meta: Meta): void;
|
|
16
|
+
};
|
|
17
|
+
export declare function metaStore(seed?: Readonly<Record<string, Meta>>): MetaStore;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Meta } from "@sincpro/criteria";
|
|
2
|
+
/**
|
|
3
|
+
* A model definition read from a JSON Schema — what an OpenAPI document holds.
|
|
4
|
+
*
|
|
5
|
+
* Context: a model a READ answers is published by sincpro_framework as its contract — JSON
|
|
6
|
+
* Schema with `x-sincpro` — and read by the transport (`metaFromContract`, `core/discovery`).
|
|
7
|
+
* What has no `x-sincpro` is a plain JSON Schema: a COMMAND's body, a value object it carries.
|
|
8
|
+
*
|
|
9
|
+
* So this reads such a component schema into a `Meta` — the form of a Command, a value object —
|
|
10
|
+
* and `completeMeta` fills, from it, what a definition left `unknown`.
|
|
11
|
+
*
|
|
12
|
+
* const spec = metaFromSchema("ProjectSpecification", doc.components.schemas);
|
|
13
|
+
* const fixed = completeMeta(project.fields.specification.definition, spec);
|
|
14
|
+
*/
|
|
15
|
+
export type JsonSchema = {
|
|
16
|
+
type?: string | string[];
|
|
17
|
+
format?: string;
|
|
18
|
+
enum?: readonly unknown[];
|
|
19
|
+
items?: JsonSchema;
|
|
20
|
+
$ref?: string;
|
|
21
|
+
anyOf?: readonly JsonSchema[];
|
|
22
|
+
oneOf?: readonly JsonSchema[];
|
|
23
|
+
properties?: Readonly<Record<string, JsonSchema>>;
|
|
24
|
+
required?: readonly string[];
|
|
25
|
+
title?: string;
|
|
26
|
+
description?: string;
|
|
27
|
+
default?: unknown;
|
|
28
|
+
};
|
|
29
|
+
export type SchemaComponents = Readonly<Record<string, JsonSchema>>;
|
|
30
|
+
/** The definition a component schema describes. `identity` empty: a value object has none. */
|
|
31
|
+
export declare function metaFromSchema(name: string, components: SchemaComponents, options?: {
|
|
32
|
+
identity?: string;
|
|
33
|
+
}): Meta;
|
|
34
|
+
/**
|
|
35
|
+
* The server's definition, with what it left `unknown` taken from the schema — recursively
|
|
36
|
+
* into value objects. What the server DID say always wins: its labels, its operators, its
|
|
37
|
+
* hints. A field the schema does not know is left as the server sent it.
|
|
38
|
+
*/
|
|
39
|
+
export declare function completeMeta(meta: Meta, schema: Meta): Meta;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { field } from "./meta.js";
|
|
2
|
+
const UNKNOWN = {
|
|
3
|
+
type: "unknown",
|
|
4
|
+
kind: "scalar",
|
|
5
|
+
nullable: true,
|
|
6
|
+
sortable: false,
|
|
7
|
+
ops: [],
|
|
8
|
+
};
|
|
9
|
+
function nameOfRef(ref) {
|
|
10
|
+
return ref.slice(ref.lastIndexOf("/") + 1);
|
|
11
|
+
}
|
|
12
|
+
/** `anyOf: [X, {type: "null"}]` is `X`, nullable — how pydantic writes `X | None`. */
|
|
13
|
+
function unwrap(schema) {
|
|
14
|
+
const options = schema.anyOf ?? schema.oneOf;
|
|
15
|
+
if (options === undefined)
|
|
16
|
+
return { schema, nullable: false };
|
|
17
|
+
const real = options.filter((one) => one.type !== "null");
|
|
18
|
+
const [only] = real;
|
|
19
|
+
return real.length === 1 && only !== undefined
|
|
20
|
+
? { schema: { ...only, ...pick(schema) }, nullable: real.length < options.length }
|
|
21
|
+
: { schema, nullable: real.length < options.length };
|
|
22
|
+
}
|
|
23
|
+
/** The words of the outer schema win over the ones of what it wraps. */
|
|
24
|
+
function pick(schema) {
|
|
25
|
+
return {
|
|
26
|
+
...(schema.title === undefined ? {} : { title: schema.title }),
|
|
27
|
+
...(schema.description === undefined ? {} : { description: schema.description }),
|
|
28
|
+
...(schema.default === undefined ? {} : { default: schema.default }),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function fieldFrom(raw, required, components, seen) {
|
|
32
|
+
const { schema: unwrapped, nullable } = unwrap(raw);
|
|
33
|
+
const target = unwrapped.$ref !== undefined
|
|
34
|
+
? { ...components[nameOfRef(unwrapped.$ref)], ...pick(unwrapped) }
|
|
35
|
+
: unwrapped;
|
|
36
|
+
const options = {
|
|
37
|
+
...(target.title === undefined ? {} : { label: target.title }),
|
|
38
|
+
...(target.description === undefined ? {} : { help: target.description }),
|
|
39
|
+
...(target.default === undefined ? {} : { default: target.default }),
|
|
40
|
+
...(nullable ? { nullable } : {}),
|
|
41
|
+
required: required && target.default === undefined && !nullable,
|
|
42
|
+
};
|
|
43
|
+
if (unwrapped.$ref !== undefined) {
|
|
44
|
+
const name = nameOfRef(unwrapped.$ref);
|
|
45
|
+
if (components[name] === undefined || seen.has(name))
|
|
46
|
+
return UNKNOWN;
|
|
47
|
+
if (target.properties === undefined)
|
|
48
|
+
return target.enum ? field.choice(target.enum.map(String), options) : UNKNOWN;
|
|
49
|
+
return field.embedded(metaOf(name, target, components, new Set([...seen, name])), options);
|
|
50
|
+
}
|
|
51
|
+
if (target.enum)
|
|
52
|
+
return field.choice(target.enum.map(String), options);
|
|
53
|
+
const type = Array.isArray(target.type)
|
|
54
|
+
? target.type.find((t) => t !== "null")
|
|
55
|
+
: target.type;
|
|
56
|
+
switch (type) {
|
|
57
|
+
case "string":
|
|
58
|
+
if (target.format === "date")
|
|
59
|
+
return field.date(options);
|
|
60
|
+
if (target.format === "date-time")
|
|
61
|
+
return field.datetime(options);
|
|
62
|
+
return field.text(options);
|
|
63
|
+
case "integer":
|
|
64
|
+
return field.integer(options);
|
|
65
|
+
case "number":
|
|
66
|
+
return field.number(options);
|
|
67
|
+
case "boolean":
|
|
68
|
+
return field.boolean(options);
|
|
69
|
+
case "array": {
|
|
70
|
+
const item = target.items === undefined ? undefined : unwrap(target.items).schema;
|
|
71
|
+
if (item?.$ref !== undefined) {
|
|
72
|
+
const inner = fieldFrom(item, false, components, seen);
|
|
73
|
+
return inner.kind === "embedded" && inner.definition
|
|
74
|
+
? field.embedded(inner.definition, { ...options, many: true })
|
|
75
|
+
: field.tags(options);
|
|
76
|
+
}
|
|
77
|
+
return item?.type === "string" || item?.enum ? field.tags(options) : UNKNOWN;
|
|
78
|
+
}
|
|
79
|
+
default:
|
|
80
|
+
return UNKNOWN;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function metaOf(name, schema, components, seen) {
|
|
84
|
+
const required = new Set(schema.required ?? []);
|
|
85
|
+
const fields = Object.fromEntries(Object.entries(schema.properties ?? {}).map(([key, property]) => [
|
|
86
|
+
key,
|
|
87
|
+
fieldFrom(property, required.has(key), components, seen),
|
|
88
|
+
]));
|
|
89
|
+
return {
|
|
90
|
+
aggregate: name,
|
|
91
|
+
identity: "",
|
|
92
|
+
default_order: "",
|
|
93
|
+
fields,
|
|
94
|
+
...(schema.title === undefined ? {} : { name: { default: schema.title } }),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/** The definition a component schema describes. `identity` empty: a value object has none. */
|
|
98
|
+
export function metaFromSchema(name, components, options = {}) {
|
|
99
|
+
const schema = components[name];
|
|
100
|
+
if (schema === undefined)
|
|
101
|
+
throw new Error(`no schema named ${name}`);
|
|
102
|
+
const meta = metaOf(name, schema, components, new Set([name]));
|
|
103
|
+
return options.identity
|
|
104
|
+
? { ...meta, identity: options.identity, get_id: options.identity }
|
|
105
|
+
: meta;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The server's definition, with what it left `unknown` taken from the schema — recursively
|
|
109
|
+
* into value objects. What the server DID say always wins: its labels, its operators, its
|
|
110
|
+
* hints. A field the schema does not know is left as the server sent it.
|
|
111
|
+
*/
|
|
112
|
+
export function completeMeta(meta, schema) {
|
|
113
|
+
const fields = Object.fromEntries(Object.entries(meta.fields).map(([name, own]) => {
|
|
114
|
+
const known = schema.fields[name];
|
|
115
|
+
if (known === undefined)
|
|
116
|
+
return [name, own];
|
|
117
|
+
if (own.type === "unknown")
|
|
118
|
+
return [name, { ...known, ...labelsOf(own) }];
|
|
119
|
+
if (own.kind === "embedded" && own.definition && known.definition)
|
|
120
|
+
return [name, { ...own, definition: completeMeta(own.definition, known.definition) }];
|
|
121
|
+
return [name, own];
|
|
122
|
+
}));
|
|
123
|
+
return { ...meta, fields };
|
|
124
|
+
}
|
|
125
|
+
/** A label the server declared is kept over the schema's generated title. */
|
|
126
|
+
function labelsOf(own) {
|
|
127
|
+
return own.label && Object.keys(own.label).length > 0 ? { label: own.label } : {};
|
|
128
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a platform plugs so every use case is traced and every unexpected error reported.
|
|
3
|
+
*
|
|
4
|
+
* Context: sincpro_framework opens one span per use case and reports what nobody expected; a
|
|
5
|
+
* client has to do the same, and continue the backend's trace. The vendor — OpenTelemetry in a
|
|
6
|
+
* browser, a queue on a phone, GlitchTip, memory in a test — is the platform's, so this package
|
|
7
|
+
* defines the contract and calls it, and bundles none. Two implementations at least (a
|
|
8
|
+
* platform's and a test's) are why these are ports.
|
|
9
|
+
*
|
|
10
|
+
* Nothing here may break a use case: a tracer or a reporter that throws is ignored, and the use
|
|
11
|
+
* case answers as it would have.
|
|
12
|
+
*/
|
|
13
|
+
/** What is said about a span: plain values only, the way OpenTelemetry takes them. */
|
|
14
|
+
export type Attributes = Readonly<Record<string, string | number | boolean>>;
|
|
15
|
+
/**
|
|
16
|
+
* How a use case ended.
|
|
17
|
+
*
|
|
18
|
+
* - `ok`: it answered.
|
|
19
|
+
* - `refused`: the server said no and explained why — a validation, a conflict, a permission.
|
|
20
|
+
* Expected; never reported.
|
|
21
|
+
* - `aborted`: whoever asked stopped waiting (a screen that closed). Never reported.
|
|
22
|
+
* - `failed`: anything else — a bug, a server that broke, a server nobody could reach. Reported.
|
|
23
|
+
*/
|
|
24
|
+
export type Outcome = "ok" | "refused" | "aborted" | "failed";
|
|
25
|
+
/** One span: a use case, open while it runs. */
|
|
26
|
+
export interface Span {
|
|
27
|
+
/** 32 hex — the id the server continues and a person quotes as a support code. Empty when
|
|
28
|
+
* nothing traces. */
|
|
29
|
+
readonly traceId: string;
|
|
30
|
+
/** 16 hex. Empty when nothing traces. */
|
|
31
|
+
readonly spanId: string;
|
|
32
|
+
/** Whether this trace is kept: the server follows the same decision. */
|
|
33
|
+
readonly sampled: boolean;
|
|
34
|
+
/** Vendor state that travels beside the trace (`tracestate`), when there is any. */
|
|
35
|
+
readonly state?: string;
|
|
36
|
+
/** Says more about the use case while it runs. */
|
|
37
|
+
set(attributes: Attributes): void;
|
|
38
|
+
/** Closes it, once. `error` is what it failed or was refused with. */
|
|
39
|
+
end(outcome: Outcome, error?: unknown): void;
|
|
40
|
+
}
|
|
41
|
+
/** Opens spans. One per platform; `NO_TRACER` when nothing traces. */
|
|
42
|
+
export interface Tracer {
|
|
43
|
+
start(name: string, options: {
|
|
44
|
+
parent?: Span;
|
|
45
|
+
attributes?: Attributes;
|
|
46
|
+
}): Span;
|
|
47
|
+
}
|
|
48
|
+
/** What a report says about where the error happened. */
|
|
49
|
+
export type ErrorAbout = {
|
|
50
|
+
/** `<domain>/<Feature>`, the span's name. */
|
|
51
|
+
useCase: string;
|
|
52
|
+
/** The trace the error belongs to — the same id the server logged, when it got that far. */
|
|
53
|
+
traceId: string;
|
|
54
|
+
/** The execution's own id. */
|
|
55
|
+
executionId: string;
|
|
56
|
+
/** What the use case ran with (`Runtime` `context`). */
|
|
57
|
+
context: Readonly<Record<string, unknown>>;
|
|
58
|
+
};
|
|
59
|
+
/** Reports what nobody expected. One per platform; none when nothing reports. */
|
|
60
|
+
export interface ErrorReporter {
|
|
61
|
+
report(error: unknown, about: ErrorAbout): void;
|
|
62
|
+
}
|
|
63
|
+
/** Traces nothing: what a use case runs with until a platform plugs a tracer. */
|
|
64
|
+
export declare const NO_TRACER: Tracer;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a platform plugs so every use case is traced and every unexpected error reported.
|
|
3
|
+
*
|
|
4
|
+
* Context: sincpro_framework opens one span per use case and reports what nobody expected; a
|
|
5
|
+
* client has to do the same, and continue the backend's trace. The vendor — OpenTelemetry in a
|
|
6
|
+
* browser, a queue on a phone, GlitchTip, memory in a test — is the platform's, so this package
|
|
7
|
+
* defines the contract and calls it, and bundles none. Two implementations at least (a
|
|
8
|
+
* platform's and a test's) are why these are ports.
|
|
9
|
+
*
|
|
10
|
+
* Nothing here may break a use case: a tracer or a reporter that throws is ignored, and the use
|
|
11
|
+
* case answers as it would have.
|
|
12
|
+
*/
|
|
13
|
+
const EMPTY_SPAN = {
|
|
14
|
+
traceId: "",
|
|
15
|
+
spanId: "",
|
|
16
|
+
sampled: false,
|
|
17
|
+
set: () => undefined,
|
|
18
|
+
end: () => undefined,
|
|
19
|
+
};
|
|
20
|
+
/** Traces nothing: what a use case runs with until a platform plugs a tracer. */
|
|
21
|
+
export const NO_TRACER = { start: () => EMPTY_SPAN };
|