@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,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client contract of a bounded context, as sincpro_framework exports it
|
|
3
|
+
* (`export_client_contract`, PRD_33): its metamodel — models, the definitions they share, every
|
|
4
|
+
* operation a client may call — and how each wire reaches each operation (`bindings`). A bare
|
|
5
|
+
* metamodel (`export_metamodel`) is the same without `format` and `bindings`.
|
|
6
|
+
*
|
|
7
|
+
* Context: only what the generator reads is typed here. The file is the server's, written with
|
|
8
|
+
* its keys sorted, so the same code writes the same bytes; a key this does not name is kept in
|
|
9
|
+
* the snapshot and ignored by the generator.
|
|
10
|
+
*/
|
|
11
|
+
export type Schema = {
|
|
12
|
+
type?: string | readonly string[];
|
|
13
|
+
format?: string;
|
|
14
|
+
enum?: readonly unknown[];
|
|
15
|
+
const?: unknown;
|
|
16
|
+
items?: Schema;
|
|
17
|
+
$ref?: string;
|
|
18
|
+
anyOf?: readonly Schema[];
|
|
19
|
+
oneOf?: readonly Schema[];
|
|
20
|
+
properties?: Readonly<Record<string, Schema>>;
|
|
21
|
+
required?: readonly string[];
|
|
22
|
+
additionalProperties?: boolean | Schema;
|
|
23
|
+
title?: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
"x-sincpro"?: {
|
|
26
|
+
type?: string;
|
|
27
|
+
relation?: string | null;
|
|
28
|
+
identity?: string;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
export type Operation = {
|
|
32
|
+
/** What it is for its entity: `search`, `get`, `edit`, `command`… */
|
|
33
|
+
role: string;
|
|
34
|
+
/** It writes nothing: retrying or caching it is safe. */
|
|
35
|
+
query: boolean;
|
|
36
|
+
entities: readonly string[];
|
|
37
|
+
/** The field of `input` that names the record of each entity it runs on. */
|
|
38
|
+
targets: Readonly<Record<string, string>>;
|
|
39
|
+
dry_run: boolean;
|
|
40
|
+
description?: string | null;
|
|
41
|
+
input: Schema;
|
|
42
|
+
/** What it answers; `null` when the server declares nothing. */
|
|
43
|
+
output: Schema | null;
|
|
44
|
+
/** The entity's own operation of its role, when it has several (`@entity_tags(own=True)`). */
|
|
45
|
+
own?: boolean;
|
|
46
|
+
deprecated?: boolean;
|
|
47
|
+
/** The day it stops being served, `YYYY-MM-DD`. */
|
|
48
|
+
sunset?: string | null;
|
|
49
|
+
/** The operation that replaces it, by identity. */
|
|
50
|
+
replacement?: string | null;
|
|
51
|
+
};
|
|
52
|
+
/** How REST reaches an operation: its method and its whole path, `{field}` where a field goes. */
|
|
53
|
+
export type RestBinding = {
|
|
54
|
+
method: string;
|
|
55
|
+
path: string;
|
|
56
|
+
/** The fields of the input the path carries. */
|
|
57
|
+
path_fields: readonly string[];
|
|
58
|
+
};
|
|
59
|
+
export type Bindings = {
|
|
60
|
+
rest?: Readonly<Record<string, RestBinding>>;
|
|
61
|
+
rpc?: Readonly<Record<string, {
|
|
62
|
+
method: string;
|
|
63
|
+
}>>;
|
|
64
|
+
grpc?: Readonly<Record<string, {
|
|
65
|
+
method: string;
|
|
66
|
+
}>>;
|
|
67
|
+
mcp?: Readonly<Record<string, {
|
|
68
|
+
tool: string;
|
|
69
|
+
}>>;
|
|
70
|
+
};
|
|
71
|
+
export type Metamodel = {
|
|
72
|
+
/** `sincpro.client-contract/1` for an exported client contract. */
|
|
73
|
+
format?: string;
|
|
74
|
+
/** The bounded context's own name: what every operation's identity starts with. */
|
|
75
|
+
bounded_context?: string;
|
|
76
|
+
/** The name the gateways publish it under (`FastApiGateway({"project": …})`): what a client
|
|
77
|
+
* names the context by. */
|
|
78
|
+
alias?: string;
|
|
79
|
+
/** The bounded context's name, as sincpro_framework 3.32 wrote it before `bounded_context`. */
|
|
80
|
+
context?: string;
|
|
81
|
+
version: string;
|
|
82
|
+
/** Every shape the contract names: an entity (it has an `identity`) or a value it holds. */
|
|
83
|
+
models: Readonly<Record<string, Schema>>;
|
|
84
|
+
/** By the Command's identity, `"<bounded_context>.<Class>"`. */
|
|
85
|
+
operations: Readonly<Record<string, Operation>>;
|
|
86
|
+
$defs?: Readonly<Record<string, Schema>>;
|
|
87
|
+
/** By wire, then by the Command's identity. */
|
|
88
|
+
bindings?: Bindings;
|
|
89
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Metamodel } from "./metamodel.js";
|
|
2
|
+
/**
|
|
3
|
+
* What a generated file imports, built in parts. The build rewrites this package's own
|
|
4
|
+
* specifiers to relative paths (`tsc-alias`), and it reads `from "…"` inside a string too: written
|
|
5
|
+
* whole, the generated files came out importing `"../index.js"` and `"./domain.js"`.
|
|
6
|
+
*/
|
|
7
|
+
export declare const FRAMEWORK: string;
|
|
8
|
+
export declare const DOMAIN_MODULE: string;
|
|
9
|
+
/** A name of the server's as a TypeScript identifier: `shop__orders__Line` stays, `-` is `_`. */
|
|
10
|
+
export declare function identifierOf(name: string): string;
|
|
11
|
+
/** A property key as TypeScript writes it: bare when it can be, quoted otherwise. */
|
|
12
|
+
export declare function keyOf(name: string): string;
|
|
13
|
+
export declare function sorted<V>(record: Readonly<Record<string, V>>): [string, V][];
|
|
14
|
+
/** `{ Issue: "issue_id" }`: a record of texts as TypeScript writes it, keys sorted. */
|
|
15
|
+
export declare function recordLiteral(record: Readonly<Record<string, string>>): string;
|
|
16
|
+
/** The bounded context's own name: `bounded_context`, or `context` in a 3.32 contract. */
|
|
17
|
+
export declare function contextOf(metamodel: Metamodel): string;
|
|
18
|
+
/** What a client names the context by: its `alias`, else its own name. */
|
|
19
|
+
export declare function aliasOf(metamodel: Metamodel): string;
|
|
20
|
+
/** A model the contract names is an entity when it has an identity; else a value it holds. */
|
|
21
|
+
export declare function isEntity(metamodel: Metamodel, model: string): boolean;
|
|
22
|
+
/** An operation's name in its context: its identity without `<bounded_context>.`. */
|
|
23
|
+
export declare function localOf(metamodel: Metamodel, identity: string): string;
|
|
24
|
+
/** The method an operation is called by: `CommandSendIssue` → `sendIssue`. */
|
|
25
|
+
export declare function methodOf(local: string): string;
|
|
26
|
+
/** `Issue` → `issues`, `ProjectEvent` → `projectEvents`, `Policy` → `policies`. */
|
|
27
|
+
export declare function pluralOf(name: string): string;
|
|
28
|
+
/** A JSDoc block, or nothing: the paragraphs given, each one kept. */
|
|
29
|
+
export declare function docOf(lines: readonly (string | null | undefined)[], pad: string): string;
|
|
30
|
+
/** The first lines of every generated file: what it is, where it came from, not to edit it. */
|
|
31
|
+
export declare function headerOf(what: string, metamodel: Metamodel): string;
|
|
32
|
+
/** What a deprecated operation says: since when it goes and what replaces it. */
|
|
33
|
+
export declare function deprecationOf(metamodel: Metamodel, operation: {
|
|
34
|
+
deprecated?: boolean;
|
|
35
|
+
sunset?: string | null;
|
|
36
|
+
replacement?: string | null;
|
|
37
|
+
}): string | undefined;
|
|
38
|
+
/** Refuses a second thing under a name already given, naming both. */
|
|
39
|
+
export declare function claimed(): (name: string, by: string) => void;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How the generator names what the server names: a TypeScript identifier for a class, a method
|
|
3
|
+
* for an operation, a value for an entity, the key the workspace refers to an entity by.
|
|
4
|
+
*
|
|
5
|
+
* Context: every name here is derived, the same way every time, from what the contract already
|
|
6
|
+
* says — never chosen — so regenerating never renames by itself. Two things that come out with
|
|
7
|
+
* one name are refused by whoever writes them, naming both (`claimed`): a silent rename would
|
|
8
|
+
* break whoever imported the first.
|
|
9
|
+
*/
|
|
10
|
+
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
11
|
+
/**
|
|
12
|
+
* What a generated file imports, built in parts. The build rewrites this package's own
|
|
13
|
+
* specifiers to relative paths (`tsc-alias`), and it reads `from "…"` inside a string too: written
|
|
14
|
+
* whole, the generated files came out importing `"../index.js"` and `"./domain.js"`.
|
|
15
|
+
*/
|
|
16
|
+
export const FRAMEWORK = ["@sincpro", "client-framework"].join("/");
|
|
17
|
+
export const DOMAIN_MODULE = [".", "domain"].join("/");
|
|
18
|
+
/** A name of the server's as a TypeScript identifier: `shop__orders__Line` stays, `-` is `_`. */
|
|
19
|
+
export function identifierOf(name) {
|
|
20
|
+
const cleaned = name.replace(/[^A-Za-z0-9_$]/g, "_");
|
|
21
|
+
return /^[0-9]/.test(cleaned) ? `_${cleaned}` : cleaned;
|
|
22
|
+
}
|
|
23
|
+
/** A property key as TypeScript writes it: bare when it can be, quoted otherwise. */
|
|
24
|
+
export function keyOf(name) {
|
|
25
|
+
return IDENTIFIER.test(name) ? name : JSON.stringify(name);
|
|
26
|
+
}
|
|
27
|
+
export function sorted(record) {
|
|
28
|
+
return Object.entries(record).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
29
|
+
}
|
|
30
|
+
/** `{ Issue: "issue_id" }`: a record of texts as TypeScript writes it, keys sorted. */
|
|
31
|
+
export function recordLiteral(record) {
|
|
32
|
+
const entries = sorted(record).map(([key, value]) => `${keyOf(key)}: ${JSON.stringify(value)}`);
|
|
33
|
+
return entries.length === 0 ? "{}" : `{ ${entries.join(", ")} }`;
|
|
34
|
+
}
|
|
35
|
+
/** The bounded context's own name: `bounded_context`, or `context` in a 3.32 contract. */
|
|
36
|
+
export function contextOf(metamodel) {
|
|
37
|
+
const name = metamodel.bounded_context ?? metamodel.context;
|
|
38
|
+
if (name === undefined)
|
|
39
|
+
throw new Error("the contract names no bounded context");
|
|
40
|
+
return name;
|
|
41
|
+
}
|
|
42
|
+
/** What a client names the context by: its `alias`, else its own name. */
|
|
43
|
+
export function aliasOf(metamodel) {
|
|
44
|
+
return metamodel.alias ?? contextOf(metamodel);
|
|
45
|
+
}
|
|
46
|
+
/** A model the contract names is an entity when it has an identity; else a value it holds. */
|
|
47
|
+
export function isEntity(metamodel, model) {
|
|
48
|
+
return metamodel.models[model]?.["x-sincpro"]?.identity !== undefined;
|
|
49
|
+
}
|
|
50
|
+
/** An operation's name in its context: its identity without `<bounded_context>.`. */
|
|
51
|
+
export function localOf(metamodel, identity) {
|
|
52
|
+
const prefix = `${contextOf(metamodel)}.`;
|
|
53
|
+
return identifierOf(identity.startsWith(prefix) ? identity.slice(prefix.length) : identity);
|
|
54
|
+
}
|
|
55
|
+
/** The method an operation is called by: `CommandSendIssue` → `sendIssue`. */
|
|
56
|
+
export function methodOf(local) {
|
|
57
|
+
const bare = local.replace(/^(Command|Query)(?=[A-Z])/, "");
|
|
58
|
+
return `${bare.charAt(0).toLowerCase()}${bare.slice(1)}`;
|
|
59
|
+
}
|
|
60
|
+
/** `Issue` → `issues`, `ProjectEvent` → `projectEvents`, `Policy` → `policies`. */
|
|
61
|
+
export function pluralOf(name) {
|
|
62
|
+
const value = `${name.charAt(0).toLowerCase()}${name.slice(1)}`;
|
|
63
|
+
if (/[^aeiou]y$/.test(value))
|
|
64
|
+
return `${value.slice(0, -1)}ies`;
|
|
65
|
+
if (/(s|x|z|ch|sh)$/.test(value))
|
|
66
|
+
return `${value}es`;
|
|
67
|
+
return `${value}s`;
|
|
68
|
+
}
|
|
69
|
+
/** A JSDoc block, or nothing: the paragraphs given, each one kept. */
|
|
70
|
+
export function docOf(lines, pad) {
|
|
71
|
+
const text = lines
|
|
72
|
+
.filter((one) => typeof one === "string" && one.trim() !== "")
|
|
73
|
+
.join("\n\n")
|
|
74
|
+
.replace(/\*\//g, "*\\/")
|
|
75
|
+
.trim();
|
|
76
|
+
if (text === "")
|
|
77
|
+
return "";
|
|
78
|
+
const parts = text.split("\n");
|
|
79
|
+
if (parts.length === 1)
|
|
80
|
+
return `${pad}/** ${parts[0]} */\n`;
|
|
81
|
+
const body = parts.map((one) => `${pad} *${one === "" ? "" : ` ${one}`}`).join("\n");
|
|
82
|
+
return `${pad}/**\n${body}\n${pad} */\n`;
|
|
83
|
+
}
|
|
84
|
+
/** The first lines of every generated file: what it is, where it came from, not to edit it. */
|
|
85
|
+
export function headerOf(what, metamodel) {
|
|
86
|
+
const alias = aliasOf(metamodel);
|
|
87
|
+
const own = contextOf(metamodel);
|
|
88
|
+
return [
|
|
89
|
+
`// ${what} of «${alias}»${alias === own ? "" : ` (${own})`}, generated by @sincpro/client-framework.`,
|
|
90
|
+
`// Version ${metamodel.version}.`,
|
|
91
|
+
`// Do not edit: regenerate it (\`sincpro-client generate\`).`,
|
|
92
|
+
].join("\n");
|
|
93
|
+
}
|
|
94
|
+
/** What a deprecated operation says: since when it goes and what replaces it. */
|
|
95
|
+
export function deprecationOf(metamodel, operation) {
|
|
96
|
+
if (operation.deprecated !== true)
|
|
97
|
+
return undefined;
|
|
98
|
+
const sunset = operation.sunset ? ` It stops being served on ${operation.sunset}.` : "";
|
|
99
|
+
const replacement = operation.replacement
|
|
100
|
+
? ` Use \`${localOf(metamodel, operation.replacement)}\`.`
|
|
101
|
+
: "";
|
|
102
|
+
return `@deprecated${sunset}${replacement}`;
|
|
103
|
+
}
|
|
104
|
+
/** Refuses a second thing under a name already given, naming both. */
|
|
105
|
+
export function claimed() {
|
|
106
|
+
const named = new Map();
|
|
107
|
+
return (name, by) => {
|
|
108
|
+
const before = named.get(name);
|
|
109
|
+
if (before !== undefined)
|
|
110
|
+
throw new Error(`${by} and ${before} are both "${name}" in TypeScript: rename one`);
|
|
111
|
+
named.set(name, by);
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Metamodel, Operation } from "./metamodel.js";
|
|
2
|
+
/**
|
|
3
|
+
* How REST reaches each operation of a contract, as the generated client and entities write it.
|
|
4
|
+
*
|
|
5
|
+
* Context: the export names each path whole (`/v1/issues/{issue_id}`), version included, while
|
|
6
|
+
* an application's transport already prepends it (`restTransport({ base: "/v1" })`). `base`
|
|
7
|
+
* takes it off every path, and a path outside it is refused — a client written with half its
|
|
8
|
+
* routes under the wrong base would fail one call at a time.
|
|
9
|
+
*/
|
|
10
|
+
export type GenerateOptions = {
|
|
11
|
+
/** The first part of every entity's key: `project` makes `project:issue`. The contract's
|
|
12
|
+
* `alias` by default. */
|
|
13
|
+
domain?: string;
|
|
14
|
+
/** What every REST path starts with and the transport prepends: `/v1`. */
|
|
15
|
+
base?: string;
|
|
16
|
+
};
|
|
17
|
+
export type Reached = {
|
|
18
|
+
method: string;
|
|
19
|
+
/** The path under `base`, `{field}` where a field of the input goes. */
|
|
20
|
+
path: string;
|
|
21
|
+
/** The fields of the input the path carries. */
|
|
22
|
+
fields: readonly string[];
|
|
23
|
+
};
|
|
24
|
+
/** What a transport can send: GET is read by `list` (a page) or `one` (a record) only. */
|
|
25
|
+
export declare const WRITES: Set<string>;
|
|
26
|
+
export declare function restOf(metamodel: Metamodel, identity: string, options: GenerateOptions): Reached | undefined;
|
|
27
|
+
/**
|
|
28
|
+
* The one entity a read belongs to: its only entity, or — when it concerns several — the one
|
|
29
|
+
* whose model its answer carries. `undefined` when that still leaves more than one.
|
|
30
|
+
*/
|
|
31
|
+
export declare function ownerOf(metamodel: Metamodel, operation: Operation): string | undefined;
|
|
@@ -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;
|