@sincpro/client-framework 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/AGENTS.md +78 -0
  2. package/LICENSE +21 -0
  3. package/MANIFESTO.md +124 -0
  4. package/README.md +88 -2
  5. package/dist/client/contract.d.ts +34 -0
  6. package/dist/client/contract.js +41 -0
  7. package/dist/client/discovery.d.ts +47 -0
  8. package/dist/client/discovery.js +68 -0
  9. package/dist/client/entity.d.ts +242 -0
  10. package/dist/client/entity.js +187 -0
  11. package/dist/client/ids.d.ts +17 -0
  12. package/dist/client/ids.js +52 -0
  13. package/dist/client/memory.d.ts +46 -0
  14. package/dist/client/memory.js +108 -0
  15. package/dist/client/rest.d.ts +49 -0
  16. package/dist/client/rest.js +143 -0
  17. package/dist/client/retry.d.ts +24 -0
  18. package/dist/client/retry.js +25 -0
  19. package/dist/client/transport.d.ts +89 -0
  20. package/dist/client/transport.js +26 -0
  21. package/dist/codegen/cli.d.ts +2 -0
  22. package/dist/codegen/cli.js +82 -0
  23. package/dist/codegen/client.d.ts +14 -0
  24. package/dist/codegen/client.js +180 -0
  25. package/dist/codegen/domain.d.ts +14 -0
  26. package/dist/codegen/domain.js +195 -0
  27. package/dist/codegen/entities.d.ts +14 -0
  28. package/dist/codegen/entities.js +158 -0
  29. package/dist/codegen/index.d.ts +10 -0
  30. package/dist/codegen/index.js +8 -0
  31. package/dist/codegen/metamodel.d.ts +89 -0
  32. package/dist/codegen/metamodel.js +1 -0
  33. package/dist/codegen/names.d.ts +39 -0
  34. package/dist/codegen/names.js +113 -0
  35. package/dist/codegen/rest.d.ts +31 -0
  36. package/dist/codegen/rest.js +27 -0
  37. package/dist/index.d.ts +31 -0
  38. package/dist/index.js +31 -0
  39. package/dist/model/meta.d.ts +121 -0
  40. package/dist/model/meta.js +130 -0
  41. package/dist/model/metas.d.ts +17 -0
  42. package/dist/model/metas.js +7 -0
  43. package/dist/model/schema.d.ts +39 -0
  44. package/dist/model/schema.js +128 -0
  45. package/dist/observability/ports.d.ts +64 -0
  46. package/dist/observability/ports.js +21 -0
  47. package/dist/services/execution.d.ts +63 -0
  48. package/dist/services/execution.js +88 -0
  49. package/dist/services/feature.d.ts +60 -0
  50. package/dist/services/feature.js +206 -0
  51. package/dist/services/runtime.d.ts +48 -0
  52. package/dist/services/runtime.js +30 -0
  53. package/dist/testing/index.d.ts +35 -0
  54. package/dist/testing/index.js +37 -0
  55. package/package.json +76 -4
package/AGENTS.md ADDED
@@ -0,0 +1,78 @@
1
+ # AGENTS.md — working on @sincpro/client-framework
2
+
3
+ Rules for any agent (or person) changing this package. Read [`MANIFESTO.md`](MANIFESTO.md) first:
4
+ it says what this package is responsible for and what it is not. This file is how to keep it.
5
+
6
+ ## Before you change anything
7
+
8
+ - **Read the manifesto.** A piece that serves nothing there does not go in; a piece it forbids
9
+ does not either. If you think it should, change the manifesto first and say why.
10
+ - **Look at the backend.** This package mirrors sincpro_framework (`~/dev/sincpro_framework`):
11
+ `Feature`, `ApplicationService`, the context, the span per use case. When in doubt about a
12
+ word or a behaviour, read how the backend does it, not memory.
13
+ - **Look at who uses it.** @sincpro/web-framework (`~/dev/sincpro_web_framework`) and
14
+ @sincpro/mobile (`~/dev/mobile/sincpro_mobile`). A piece only one of them needs, and that
15
+ needs a platform, belongs there.
16
+
17
+ ## Where things go
18
+
19
+ | Folder | What |
20
+ | -------------------- | --------------------------------------------------------------------------------------- |
21
+ | `src/client/` | The seam with a server: `Transport`, REST, memory, `entity()`, the client contract, ids |
22
+ | `src/model/` | Model definitions: `defineMeta`, from a JSON Schema, the store of known ones |
23
+ | `src/services/` | `Feature`, `ApplicationService`, the runtime, the execution and what binds to it |
24
+ | `src/observability/` | The ports a platform plugs: `Tracer`, `ErrorReporter` |
25
+ | `src/codegen/` | The generator and its CLI (`sincpro-client generate`) — the only place Node is allowed |
26
+ | `src/testing/` | `memoryTracer`, `memoryReporter` — the ports in memory, for tests |
27
+ | `tests/` | vitest in Node; `tests/support/` holds the fake domains and forge's real contracts |
28
+
29
+ ## Hard rules (the build enforces them — `make doctor`)
30
+
31
+ - **Platform-neutral.** No React, no `react-native`, no web-ui, no composer, no @sincpro/mobile;
32
+ no `document`, `window`, `localStorage`, `navigator`. What a platform must give, it gives
33
+ through a port.
34
+ - **Node only in the generator.** `node:` imports, `process` and `Buffer` live in `src/codegen/`
35
+ and nowhere else; nothing in the runtime imports `codegen/`.
36
+ - **Self-imports, no extensions.** Inside `src/`, import `@sincpro/client-framework/<path>` —
37
+ never a relative path, never `.ts`.
38
+
39
+ ## Conventions
40
+
41
+ - Code, comments and docs in English: this package is open source.
42
+ - `Context:` blocks at the top of a file are the design record: why it is the way it is. Keep
43
+ them true.
44
+ - A change ships with a test in `tests/` that fails without it, run in Node.
45
+ - Public means exported from `src/index.ts`. No side effects at import (`sideEffects: false`).
46
+ - A port (an `interface`) only where there are two implementations; a class or a type otherwise.
47
+
48
+ ## Known traps
49
+
50
+ - **A run sees the instance through a view of its own.** Inside a method, `this.deps`,
51
+ `this.context` and `this.features` are that run's — bound to its execution. So a Feature keeps
52
+ nothing of a request on `this`, and declares no `#private` fields (they refuse the view); use
53
+ TypeScript's `private`.
54
+ - **A span is named by the key, not the class** (`bindServices(runtime, "project", { sendIssue })`
55
+ → `project/sendIssue`; an ApplicationService's Features by their keys too): a production build
56
+ renames classes. Only an unbound Feature falls back to its class name.
57
+ - **The trace reaches the wire through `deps`.** A dependency is bound to the run when it is a
58
+ `Transport` or carries `BIND` (every generated client does, through `bindable`); anything else
59
+ is handed as it is. A client built outside `deps` and closed over sends no trace.
60
+ - **What the generator writes is compared byte for byte** (`tests/support/forge-contract/`, out
61
+ of prettier and eslint). After changing the generator on purpose, run `make codegen-fixtures`
62
+ and read the diff.
63
+ - **A module name the generator writes is built in parts** (`codegen/names.ts`, `FRAMEWORK`): the
64
+ build's `tsc-alias` rewrites this package's specifiers inside strings too.
65
+ - **Reported means unexpected.** A `TransportError` the server answered with a 4xx is `refused`,
66
+ an `AbortError` is `aborted`; neither is reported (`outcomeOf`). Change that with
67
+ `RuntimeOptions.outcomeOf`, never by catching in a Feature.
68
+
69
+ ## Before you hand it back
70
+
71
+ ```bash
72
+ make verify-format # eslint --fix + prettier + typecheck + doctor
73
+ make test
74
+ make build
75
+ ```
76
+
77
+ Never bump `version` in `package.json` — the release pipeline does. Never commit; a person
78
+ does.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SINCPRO S.R.L
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/MANIFESTO.md ADDED
@@ -0,0 +1,124 @@
1
+ # Manifesto
2
+
3
+ > **@sincpro/client-framework is the client side of
4
+ > [sincpro_framework](https://github.com/Sincpro-SRL/sincpro_framework), for every platform:
5
+ > where a client's logic lives, how every use case is traced and reported, how an application is
6
+ > made of domains, and how it talks to a service through a client generated from that service's
7
+ > contract. No React, no DOM, no platform.**
8
+
9
+ This is the founding statement of the repository: what it is responsible for, what it is not,
10
+ how it is composed and where it is going. Every change is measured against it. If a change needs
11
+ something this page does not say, change this page first, and say why.
12
+
13
+ ## Why it exists
14
+
15
+ Sincpro builds business software with one backend framework and several clients: the web
16
+ ([@sincpro/web-framework](https://github.com/Sincpro-SRL/sincpro_web_framework)), mobile
17
+ ([@sincpro/mobile](https://github.com/Sincpro-SRL/sincpro_mobile)), server-side renders, scripts,
18
+ tests. Each one was growing the same core on its own — mobile wrote `Feature` and
19
+ `ApplicationService` with interceptors; the web wrote the transport, the entities and the
20
+ generator from the contract — and a use case written for one could not run on the other.
21
+
22
+ The backend already answers how a use case is written, traced and reported. This package gives
23
+ every client the same answer, once: **a Feature written here runs in a browser, on a phone, in a
24
+ server-side render, in a script and in a test, unchanged.**
25
+
26
+ ## What it is responsible for
27
+
28
+ 1. **Use cases.** `Feature` is one use case, with its dependencies injected; `ApplicationService`
29
+ is a Feature that also executes the Features of its own domain — never another
30
+ ApplicationService. All of a client's logic lives in them.
31
+ 2. **Interception.** Every public method of a use case passes through interceptors, and that is
32
+ what makes the defaults free: the trace, the error report, the context. The author of a
33
+ Feature writes none of it.
34
+ 3. **Observability, as ports.** `Tracer` and `ErrorReporter` are the contract: one span per use
35
+ case, named `<domain>/<Feature>` like the backend's; the unexpected reported with its trace id;
36
+ what the server already explained (a validation, a conflict) not reported. The platform plugs
37
+ the implementation — OpenTelemetry, GlitchTip, a queue on the phone, memory in a test.
38
+ 4. **Context.** The levels a call sees — the application, its domain, the call itself — and what
39
+ travels to the server with it (`traceparent`, `sincpro-context`). Carried by each call, never
40
+ kept in a global: two calls at once never mix their parents.
41
+ 5. **Domains and the application.** A bounded context is declared once — its client, its use
42
+ cases, what they depend on — and an application is the set of domains it loads. Domains never
43
+ import each other; they talk by reference.
44
+ 6. **The client.** The `Transport` port and its implementations (REST, memory), `entity()` with
45
+ typed commands and answers, the service's client contract, model definitions read once from
46
+ the server, client-side ids (UUID v7) and the server's `Problem`.
47
+ 7. **The generator.** From a service's client contract to each domain's types, client and
48
+ entities — generated, never edited, and as platform-neutral as everything else here.
49
+ 8. **Testing.** An in-memory transport, tracer and reporter, so a use case is proven in Node with
50
+ nothing else.
51
+
52
+ ## What it is not
53
+
54
+ - **Not a UI.** No React, no components, no hooks: drawing belongs to @sincpro/web-framework on
55
+ the web and to @sincpro/mobile on a phone.
56
+ - **Not a shell, a router or a window manager.** Where things open is the platform's.
57
+ - **Not a cache or a state manager.** Holding server state for screens is the platform's job,
58
+ with the libraries that do it best.
59
+ - **Not offline storage.** SQLite on a phone or IndexedDB in a point of sale are platform
60
+ adapters.
61
+ - **Not a telemetry or error-tracking SDK.** It defines the ports and calls them; it bundles no
62
+ vendor.
63
+ - **Not where business rules live.** The server keeps them; a client reads what it publishes.
64
+ - **Not an event bus**, and not a dispatcher: a use case is executed by calling it, and the
65
+ interceptors are what make that call traced and reported.
66
+
67
+ ## What we believe
68
+
69
+ 1. **Platform-neutral, or it does not go in.** It runs on Node, in a browser and on React Native.
70
+ What it needs from a platform it asks through a port, never by reaching for it. The build
71
+ enforces it.
72
+ 2. **The backend's words.** Feature, ApplicationService, domain, context, the span name — the
73
+ same as sincpro_framework, so whoever knows the backend already knows the client.
74
+ 3. **Nothing escapes the interceptors.** Reusing a use case is executing its Feature. A helper
75
+ that wraps one skips the trace and the report — that is how an incident ends up with nothing
76
+ to look at.
77
+ 4. **Context travels with the call.** Never in a global stack: clients run many things at once.
78
+ 5. **A port where there are two implementations, a class where there is one.** `Transport` has
79
+ REST and memory; `Tracer` has a platform's and a test's.
80
+ 6. **Generated, never edited.** Applications extend by composition; regenerating never loses
81
+ work.
82
+ 7. **The server is the source of truth.** A client reads the model, the preview and the refusal,
83
+ and never re-implements a rule.
84
+ 8. **Small, and paid for only when used.** No side effects at import, tree-shakable, and no
85
+ dependency it can avoid.
86
+ 9. **Grow by evidence.** Nothing before its second use; every change ships with a test that fails
87
+ without it.
88
+
89
+ ## How it is composed
90
+
91
+ ```text
92
+ @sincpro/criteria the contract with the server: Criteria, Meta, Page, Problem
93
+ ▲
94
+ @sincpro/client-framework this package — platform-neutral
95
+ │ Feature · ApplicationService · interceptors · context per call
96
+ │ Domain · application · Tracer and ErrorReporter ports
97
+ │ Transport · entity() · client contract · model definitions · ids
98
+ │ the generator · in-memory doubles for tests
99
+ ▲
100
+ @sincpro/web-framework the web: hooks, components on @sincpro/web-ui, the shell on
101
+ @sincpro/web-composer, the browser's tracing and error reporting
102
+ @sincpro/mobile mobile: the React Native shell, SQLite, its queue, its exporter
103
+ ```
104
+
105
+ Dependencies point down only. This package knows nothing above it.
106
+
107
+ ## Where it stands
108
+
109
+ **The first cut is here.** The client and the generator came out of @sincpro/web-framework,
110
+ where they already ran without the DOM. `Feature` and `ApplicationService` came from
111
+ @sincpro/mobile, with each call carrying its own execution instead of a global stack. And the
112
+ ports for tracing and error reporting are in, with the trace and the context written on every
113
+ request a use case sends.
114
+
115
+ **Next:** the domains and the application. The plan is written in @sincpro/web-framework
116
+ (`docs/prd/PRD_02_marco-de-trabajo.md`, in Spanish like the rest of Sincpro's design records).
117
+
118
+ ## How we decide
119
+
120
+ This page is the yardstick. A piece that serves nothing here does not go in. A piece this page
121
+ forbids does not go in either. To change what this page says, change the page first — in the
122
+ open, with the reason.
123
+
124
+ Open source, under the [MIT license](LICENSE).
package/README.md CHANGED
@@ -1,3 +1,89 @@
1
- # Temporary Holding Version
1
+ # @sincpro/client-framework
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **The client side of [sincpro_framework](https://github.com/Sincpro-SRL/sincpro_framework), for
4
+ every platform.** Write a use case once — a `Feature`, or an `ApplicationService` that composes
5
+ Features — and run it in a browser, on a phone, in a server-side render, in a script or in a test.
6
+ Every use case is traced and every unexpected error reported, continuing the backend's trace,
7
+ without its author writing either. A service's client contract becomes typed code for each of
8
+ its domains.
9
+
10
+ > **Status: 0.x, the first cut.** The API may still change before 1.0. In: the client, the
11
+ > generator, `Feature` and `ApplicationService` with their interceptors, and the ports for tracing
12
+ > and error reporting. Read the [manifesto](MANIFESTO.md) for what this package is responsible
13
+ > for, what it is not and where it is going.
14
+
15
+ ## A use case, traced and reported by default
16
+
17
+ ```ts
18
+ import {
19
+ ApplicationService,
20
+ bindServices,
21
+ createRuntime,
22
+ Feature,
23
+ } from "@sincpro/client-framework";
24
+
25
+ class SendIssue extends Feature<{ project: ProjectClient }> {
26
+ execute(input: dto.CommandSendIssue) {
27
+ return this.deps.project.sendIssue(input); // carries this run's traceparent
28
+ }
29
+ }
30
+
31
+ class CloseIssue extends ApplicationService<{ preview: PreviewClose; send: SendIssue }> {
32
+ async execute(input: CloseIssueInput) {
33
+ const preview = await this.features.preview.execute(input); // a child span
34
+ if (preview.warnings.length > 0) return { blocked: preview };
35
+ return this.features.send.execute({ ...input, message: "closed" }); // another child
36
+ }
37
+ }
38
+
39
+ const runtime = createRuntime({ tracer, reporter, context: () => ({ tenant_id: "acme" }) });
40
+ const project = bindServices(runtime, "project", {
41
+ closeIssue: new CloseIssue({ preview: new PreviewClose(deps), send: new SendIssue(deps) }),
42
+ });
43
+ await project.closeIssue.execute(input); // project/closeIssue → project/preview, project/send
44
+ ```
45
+
46
+ `tracer` and `reporter` are the platform's: OpenTelemetry and GlitchTip in a browser, a queue on
47
+ a phone, `memoryTracer()` and `memoryReporter()` (`@sincpro/client-framework/testing`) in a test.
48
+ A use case that was never bound runs too, with nothing tracing.
49
+
50
+ ## Where it sits
51
+
52
+ ```text
53
+ @sincpro/criteria the contract with the server
54
+ ▲
55
+ @sincpro/client-framework use cases · interceptors · context · ports · domains · the client
56
+ ▲
57
+ @sincpro/web-framework the web @sincpro/mobile a phone
58
+ ```
59
+
60
+ No React, no DOM, no platform: what a platform must provide — how a span is exported, where an
61
+ error is reported — it plugs through a port.
62
+
63
+ ## Development
64
+
65
+ Node 26 (`nvm use`).
66
+
67
+ ```bash
68
+ make init # pre-commit + dependencies
69
+ make test # vitest, in Node
70
+ make verify-format # format + lint + doctor; fails if the tree is left dirty (what CI runs)
71
+ make build # tsc into dist/
72
+ ```
73
+
74
+ `make doctor` enforces what the type checker cannot: the source imports itself, never a relative
75
+ path; no React, no UI, no browser global; Node only inside the generator. Read
76
+ [`AGENTS.md`](AGENTS.md) before changing anything, and [`CONTRIBUTING.md`](CONTRIBUTING.md)
77
+ before opening a pull request.
78
+
79
+ ## CI/CD
80
+
81
+ Every workflow in `.github/workflows/` delegates to
82
+ [`Sincpro-SRL/.github`](https://github.com/Sincpro-SRL/.github) and only calls `make` targets:
83
+ checks on every pull request and push to `main`, a release draft on `main`, publication to npm
84
+ when a release is published, and notifications. The version is written by the release pipeline,
85
+ never by hand.
86
+
87
+ ## License
88
+
89
+ [MIT](LICENSE)
@@ -0,0 +1,34 @@
1
+ import { type Contract, type Meta } from "@sincpro/criteria";
2
+ /**
3
+ * A bounded context's client contract, as the application keeps it: the snapshot
4
+ * `sincpro-web generate` writes (`contract.json`), or the document a service serves
5
+ * (`GET /client-contract/<context>.json`). Only its models are read here.
6
+ *
7
+ * Context: the generated entities are keyed `<domain>:<kind>` (`project:issue`) — the key the
8
+ * workspace refers to them by and the meta store keeps their definitions under —, so the rule
9
+ * that turns a model's name into its kind lives once, here, and both the generator and the
10
+ * preload read it.
11
+ */
12
+ export type ClientContract = {
13
+ models: Readonly<Record<string, Contract>>;
14
+ $defs?: Readonly<Record<string, Contract>>;
15
+ };
16
+ /** `ProjectEvent` → `project_event`: the kind in an entity's key. */
17
+ export declare function kindOf(model: string): string;
18
+ /** The key of a model's entity: `entityKeyOf("project", "ProjectEvent")` → `"project:project_event"`. */
19
+ export declare function entityKeyOf(domain: string, model: string): string;
20
+ /**
21
+ * The version of every entity of `contract`, by its model's name — what `restTransport`
22
+ * compares each answer with (`snapshot`) to say when the generated code is behind the server.
23
+ *
24
+ * restTransport({ base: "/v1", snapshot: versionsOf(contract), onVersionDrift: warn })
25
+ */
26
+ export declare function versionsOf(contract: ClientContract): Record<string, string>;
27
+ /**
28
+ * The definition of every model of `contract`, by its entity's key — what `FrameworkProvider
29
+ * metas` preloads, so the first screen draws its labels with no request at all. The transport
30
+ * still reads the live definition and asks again when an answer says another version.
31
+ *
32
+ * <FrameworkProvider transport={transport} metas={metasOf(contract, "project")}>
33
+ */
34
+ export declare function metasOf(contract: ClientContract, domain: string): Record<string, Meta>;
@@ -0,0 +1,41 @@
1
+ import { metaFromContract } from "@sincpro/criteria";
2
+ /** `ProjectEvent` → `project_event`: the kind in an entity's key. */
3
+ export function kindOf(model) {
4
+ return model
5
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
6
+ .replace(/([A-Z])([A-Z][a-z])/g, "$1_$2")
7
+ .toLowerCase();
8
+ }
9
+ /** The key of a model's entity: `entityKeyOf("project", "ProjectEvent")` → `"project:project_event"`. */
10
+ export function entityKeyOf(domain, model) {
11
+ return `${domain}:${kindOf(model)}`;
12
+ }
13
+ /**
14
+ * The version of every entity of `contract`, by its model's name — what `restTransport`
15
+ * compares each answer with (`snapshot`) to say when the generated code is behind the server.
16
+ *
17
+ * restTransport({ base: "/v1", snapshot: versionsOf(contract), onVersionDrift: warn })
18
+ */
19
+ export function versionsOf(contract) {
20
+ return Object.fromEntries(Object.entries(contract.models).flatMap(([name, model]) => {
21
+ const version = model["x-sincpro"]?.version;
22
+ return model["x-sincpro"]?.identity != null && typeof version === "string"
23
+ ? [[name, version]]
24
+ : [];
25
+ }));
26
+ }
27
+ /**
28
+ * The definition of every model of `contract`, by its entity's key — what `FrameworkProvider
29
+ * metas` preloads, so the first screen draws its labels with no request at all. The transport
30
+ * still reads the live definition and asks again when an answer says another version.
31
+ *
32
+ * <FrameworkProvider transport={transport} metas={metasOf(contract, "project")}>
33
+ */
34
+ export function metasOf(contract, domain) {
35
+ // A related model, or a value a record holds, is pointed at by its name (`#/models/Run`).
36
+ const related = { ...(contract.$defs ?? {}), ...contract.models };
37
+ return Object.fromEntries(Object.entries(contract.models)
38
+ // Only an entity has a key of its own; a value it holds is described inside it.
39
+ .filter(([, model]) => model["x-sincpro"]?.identity != null)
40
+ .map(([name, model]) => [entityKeyOf(domain, name), metaFromContract(model, related)]));
41
+ }
@@ -0,0 +1,47 @@
1
+ import { type Meta } from "@sincpro/criteria";
2
+ /**
3
+ * The discovery document of a bounded context, as far as this reads it: its paths, each
4
+ * operation with the entities it belongs to, and the published contract of every model.
5
+ *
6
+ * Context: sincpro_framework describes a model once — JSON Schema with `x-sincpro`, under the
7
+ * document's `components.schemas` — and every read answers only the version of it
8
+ * (`model_version`). So the definition a list, a form and a select are built from is read here,
9
+ * once, and asked for again only when an answer says another version.
10
+ */
11
+ export type DiscoveryDocument = {
12
+ paths?: Readonly<Record<string, Readonly<Record<string, DiscoveryOperation>>>>;
13
+ components?: {
14
+ schemas?: Readonly<Record<string, DocumentSchema>>;
15
+ };
16
+ };
17
+ export type DiscoveryOperation = {
18
+ "x-sincpro-entities"?: readonly string[];
19
+ responses?: Readonly<Record<string, {
20
+ content?: Readonly<Record<string, {
21
+ schema?: DocumentSchema;
22
+ }>>;
23
+ }>>;
24
+ };
25
+ type DocumentSchema = {
26
+ $ref?: string;
27
+ items?: DocumentSchema;
28
+ anyOf?: readonly DocumentSchema[];
29
+ properties?: Readonly<Record<string, DocumentSchema>>;
30
+ "x-sincpro"?: {
31
+ entity?: string;
32
+ version?: string;
33
+ };
34
+ };
35
+ /**
36
+ * The model a request to `path` answers, by the name of its contract in the document: the
37
+ * entity the operation says it belongs to, else the one its answer holds.
38
+ *
39
+ * modelAt(published, "/v1/projects/p1", "get") // "Project"
40
+ */
41
+ export declare function modelAt(published: DiscoveryDocument, path: string, method: string): string | undefined;
42
+ /** The definition of the model named `name`, read from its contract, and its version. */
43
+ export declare function definitionOf(published: DiscoveryDocument, name: string): {
44
+ meta: Meta;
45
+ version: string | null;
46
+ } | undefined;
47
+ export {};
@@ -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
+ }