@zocomputer/system-api 0.0.0-stage → 0.1.1
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/README.md +51 -2
- package/dist/catalog/index.d.ts +106 -0
- package/dist/catalog/index.js +237 -0
- package/dist/core/catalog.d.ts +155 -0
- package/dist/core/catalog.js +24 -0
- package/dist/core/client.d.ts +156 -0
- package/dist/core/client.js +87 -0
- package/dist/core/database.d.ts +124 -0
- package/dist/core/database.js +314 -0
- package/dist/core/envelope.d.ts +45 -0
- package/dist/core/envelope.js +112 -0
- package/dist/core/errors.d.ts +52 -0
- package/dist/core/errors.js +33 -0
- package/dist/core/index.d.ts +14 -0
- package/dist/core/index.js +13 -0
- package/dist/core/json-call.d.ts +4 -0
- package/dist/core/json-call.js +109 -0
- package/dist/core/json.d.ts +17 -0
- package/dist/core/json.js +4 -0
- package/dist/core/transport.d.ts +47 -0
- package/dist/core/transport.js +367 -0
- package/dist/core/validator.d.ts +52 -0
- package/dist/core/validator.js +1 -0
- package/dist/generate/cli.d.ts +44 -0
- package/dist/generate/cli.js +358 -0
- package/dist/generate/generate.d.ts +46 -0
- package/dist/generate/generate.js +215 -0
- package/dist/generate/identifiers.d.ts +27 -0
- package/dist/generate/identifiers.js +67 -0
- package/dist/generate/index.d.ts +15 -0
- package/dist/generate/index.js +15 -0
- package/dist/generate/manifest.d.ts +1 -0
- package/dist/generate/manifest.js +1 -0
- package/dist/generate/types.d.ts +57 -0
- package/dist/generate/types.js +357 -0
- package/dist/generated/catalog.d.ts +2 -0
- package/dist/generated/catalog.js +854 -0
- package/dist/generated/index.d.ts +2 -0
- package/dist/generated/index.js +4 -0
- package/dist/generated/manifest.json +799 -0
- package/dist/generated/operations.d.ts +8450 -0
- package/dist/generated/operations.js +3 -0
- package/dist/generated/schema.json +39451 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +11 -0
- package/dist/json/index.d.ts +81 -0
- package/dist/json/index.js +404 -0
- package/dist/validation/index.d.ts +19 -0
- package/dist/validation/index.js +149 -0
- package/dist/validation/json.d.ts +1 -0
- package/dist/validation/json.js +1 -0
- package/dist/validation/pattern.d.ts +56 -0
- package/dist/validation/pattern.js +382 -0
- package/dist/validation/schema.d.ts +103 -0
- package/dist/validation/schema.js +1300 -0
- package/openapi.json +40754 -0
- package/package.json +93 -4
package/README.md
CHANGED
|
@@ -1,3 +1,52 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Zo System API SDK
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The TypeScript SDK provides typed System API calls, exact JSON invocation,
|
|
4
|
+
catalog parsing and schema validation. Node and browser clients share the same
|
|
5
|
+
HTTP transport.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
Version 0.1.0 is a release candidate. After publication:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm install @zocomputer/system-api@0.1.0
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
For an unpublished release archive:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npm install ./zocomputer-system-api-0.1.0.tgz
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The package exports compiled JavaScript and declarations. Import the client
|
|
22
|
+
from `@zocomputer/system-api` or `@zocomputer/system-api/core`; use `@zocomputer/system-api/builtin` for
|
|
23
|
+
the built-in action catalog. Catalog parsing, exact JSON, schema validation
|
|
24
|
+
and code generation have separate `/catalog`, `/json`, `/validation` and
|
|
25
|
+
`/generate` entry points.
|
|
26
|
+
|
|
27
|
+
## Develop locally
|
|
28
|
+
|
|
29
|
+
From the repository root:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
cd clients
|
|
33
|
+
nvm use
|
|
34
|
+
npm ci
|
|
35
|
+
npm run build --workspace=@zocomputer/system-api
|
|
36
|
+
npm run typecheck --workspace=@zocomputer/system-api
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Build before running a consumer. npm links the SDK and CLI inside their own
|
|
40
|
+
workspace, so rebuilt exports are immediately available. Source tests resolve the
|
|
41
|
+
package's own imports to source through its TypeScript configuration; packed
|
|
42
|
+
consumers resolve only compiled files.
|
|
43
|
+
|
|
44
|
+
The public CLI package checks both release artifacts together:
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
npm --prefix clients run test:packed
|
|
48
|
+
npm --prefix clients run pack:release -- --out /tmp/zo-cli-release
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Neither command publishes a package. The SDK must be published before the CLI,
|
|
52
|
+
whose release pins its tested SDK version.
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse the normalized System API catalog export into the internal model the
|
|
3
|
+
* generator and runtime consumers use.
|
|
4
|
+
*
|
|
5
|
+
* The same shape is emitted by `dev_etc/tools/system_api_codegen.py`
|
|
6
|
+
* (`export_builtin_catalog`: `format_version`/`actions`/`resource_types`/
|
|
7
|
+
* `default_versions`) and by an authorized principal-scoped `CatalogBundle`
|
|
8
|
+
* export (which adds `protocol_version`, `view_revision`, `selection`,
|
|
9
|
+
* `availability`, `catalog_digest`, …). Only the fields the generator needs are
|
|
10
|
+
* read; every other top-level or per-action field is ignored and never treated
|
|
11
|
+
* as type input, so authorization metadata cannot influence generated types. A
|
|
12
|
+
* missing or malformed required field fails loudly with {@link CatalogFormatError}
|
|
13
|
+
* — the generator never guesses a default or a schema.
|
|
14
|
+
*
|
|
15
|
+
* Schemas are carried as the lossless {@link JsonValue} tree, not `JSON.parse`
|
|
16
|
+
* output: supported schema numbers are exact decimals up to 100 digits, and
|
|
17
|
+
* `JSON.parse` would silently round a bound/default/enum/const, changing the
|
|
18
|
+
* contract on offline regeneration. The exact bytes drive `schema.json` and
|
|
19
|
+
* digest identity; a lossy plain projection ({@link toPlainJson}) feeds the
|
|
20
|
+
* TypeScript renderer only after admission and is never persisted.
|
|
21
|
+
*/
|
|
22
|
+
import { type JsonValue } from "../json/index.ts";
|
|
23
|
+
/** The only normalized-export format this generator supports (codegen emits `1`). */
|
|
24
|
+
export declare const SUPPORTED_FORMAT_VERSION = 1;
|
|
25
|
+
/** Thrown when the catalog export handed to the generator is malformed. */
|
|
26
|
+
export declare class CatalogFormatError extends Error {
|
|
27
|
+
constructor(message: string);
|
|
28
|
+
}
|
|
29
|
+
/** A single published version of one action: its digest and I/O schemas. */
|
|
30
|
+
export interface OperationVersion {
|
|
31
|
+
readonly version: number;
|
|
32
|
+
readonly digest: string;
|
|
33
|
+
/** Lossless schema tree — exact decimals preserved for the contract/digest. */
|
|
34
|
+
readonly input: JsonValue;
|
|
35
|
+
readonly output: JsonValue;
|
|
36
|
+
/**
|
|
37
|
+
* The version's list query schema (`options.query`), when it declares query
|
|
38
|
+
* options. Outside the contract and its digest.
|
|
39
|
+
*/
|
|
40
|
+
readonly query?: JsonValue;
|
|
41
|
+
readonly description?: string;
|
|
42
|
+
readonly availability?: "eligible" | "conditional" | "definition";
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* One action with its declared versions (ascending) and, when the export pins an
|
|
46
|
+
* eligible one, its default-version. `defaultVersion` is optional: the service
|
|
47
|
+
* can expose a visible action with no eligible default, in which case a typed
|
|
48
|
+
* call must pass an explicit version (the generator emits no pin for it).
|
|
49
|
+
*/
|
|
50
|
+
export interface OperationEntry {
|
|
51
|
+
readonly action: string;
|
|
52
|
+
readonly defaultVersion?: number;
|
|
53
|
+
readonly versions: readonly OperationVersion[];
|
|
54
|
+
}
|
|
55
|
+
/** One published resource type with its public properties schema. */
|
|
56
|
+
export interface ResourceTypeEntry {
|
|
57
|
+
readonly type: string;
|
|
58
|
+
readonly version: number;
|
|
59
|
+
readonly propertiesSchema: JsonValue;
|
|
60
|
+
readonly description?: string;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The selection a live `CatalogBundle` was computed for, echoed by the service.
|
|
64
|
+
* Absent for the builtin export, which is intentionally not workspace-scoped.
|
|
65
|
+
*/
|
|
66
|
+
export interface CatalogSelectionEcho {
|
|
67
|
+
readonly workspace: string;
|
|
68
|
+
readonly catalog: string;
|
|
69
|
+
}
|
|
70
|
+
/** The normalized view of a catalog: sorted operations and resource types. */
|
|
71
|
+
export interface NormalizedCatalog {
|
|
72
|
+
readonly operations: readonly OperationEntry[];
|
|
73
|
+
readonly resourceTypes: readonly ResourceTypeEntry[];
|
|
74
|
+
/**
|
|
75
|
+
* The service's own selection echo, when the export carried one (live scoped/
|
|
76
|
+
* all bundles). This is the authoritative provenance — it is never synthesized
|
|
77
|
+
* from CLI flags. Absent for the builtin export.
|
|
78
|
+
*/
|
|
79
|
+
readonly selection?: CatalogSelectionEcho;
|
|
80
|
+
/** The service's canonical content digest (`catalog_digest`), when present. */
|
|
81
|
+
readonly catalogDigest?: string;
|
|
82
|
+
}
|
|
83
|
+
/** Parse and validate a normalized catalog export from a lossless JSON tree. */
|
|
84
|
+
export declare function parseNormalizedCatalog(source: JsonValue): NormalizedCatalog;
|
|
85
|
+
/**
|
|
86
|
+
* Load a normalized catalog export from raw bytes, losslessly. Returns both the
|
|
87
|
+
* parsed model and the exact original bytes: `schema.json` and the bundle digest
|
|
88
|
+
* are written from these bytes so offline regeneration reproduces the identical
|
|
89
|
+
* contract, byte for byte.
|
|
90
|
+
*/
|
|
91
|
+
export declare function loadNormalizedCatalog(bytes: Uint8Array | string): {
|
|
92
|
+
catalog: NormalizedCatalog;
|
|
93
|
+
bytes: Uint8Array;
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* Project a lossless {@link JsonValue} schema tree to a plain JSON value for the
|
|
97
|
+
* TypeScript renderer. `JsonNumber` collapses to a JS `number` (lossy for huge
|
|
98
|
+
* decimals) — acceptable because TypeScript describes only structural shape, and
|
|
99
|
+
* this projection is never persisted as the contract.
|
|
100
|
+
*
|
|
101
|
+
* Objects are materialized with a null prototype so a schema property or `$defs`
|
|
102
|
+
* key literally named `__proto__` is preserved as an own key instead of hitting
|
|
103
|
+
* the inherited setter (which would drop it and poison the projection's
|
|
104
|
+
* prototype) — the same trap the validator's schema normalization avoids.
|
|
105
|
+
*/
|
|
106
|
+
export declare function toPlainJson(value: JsonValue): unknown;
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse the normalized System API catalog export into the internal model the
|
|
3
|
+
* generator and runtime consumers use.
|
|
4
|
+
*
|
|
5
|
+
* The same shape is emitted by `dev_etc/tools/system_api_codegen.py`
|
|
6
|
+
* (`export_builtin_catalog`: `format_version`/`actions`/`resource_types`/
|
|
7
|
+
* `default_versions`) and by an authorized principal-scoped `CatalogBundle`
|
|
8
|
+
* export (which adds `protocol_version`, `view_revision`, `selection`,
|
|
9
|
+
* `availability`, `catalog_digest`, …). Only the fields the generator needs are
|
|
10
|
+
* read; every other top-level or per-action field is ignored and never treated
|
|
11
|
+
* as type input, so authorization metadata cannot influence generated types. A
|
|
12
|
+
* missing or malformed required field fails loudly with {@link CatalogFormatError}
|
|
13
|
+
* — the generator never guesses a default or a schema.
|
|
14
|
+
*
|
|
15
|
+
* Schemas are carried as the lossless {@link JsonValue} tree, not `JSON.parse`
|
|
16
|
+
* output: supported schema numbers are exact decimals up to 100 digits, and
|
|
17
|
+
* `JSON.parse` would silently round a bound/default/enum/const, changing the
|
|
18
|
+
* contract on offline regeneration. The exact bytes drive `schema.json` and
|
|
19
|
+
* digest identity; a lossy plain projection ({@link toPlainJson}) feeds the
|
|
20
|
+
* TypeScript renderer only after admission and is never persisted.
|
|
21
|
+
*/
|
|
22
|
+
import { JsonNumber, MAX_JSON_DEPTH, loadsJson, } from "../json/index.js";
|
|
23
|
+
/** The only normalized-export format this generator supports (codegen emits `1`). */
|
|
24
|
+
export const SUPPORTED_FORMAT_VERSION = 1;
|
|
25
|
+
/** Action/contract versions are positive 32-bit integers, matching the contract. */
|
|
26
|
+
const MAX_CONTRACT_VERSION = 2_147_483_647;
|
|
27
|
+
/** The export wraps each bounded schema, so it nests a few levels below a schema. */
|
|
28
|
+
const MAX_EXPORT_DEPTH = MAX_JSON_DEPTH + 8;
|
|
29
|
+
/** Thrown when the catalog export handed to the generator is malformed. */
|
|
30
|
+
export class CatalogFormatError extends Error {
|
|
31
|
+
constructor(message) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.name = "CatalogFormatError";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function isObject(value) {
|
|
37
|
+
return (value !== null &&
|
|
38
|
+
typeof value === "object" &&
|
|
39
|
+
!Array.isArray(value) &&
|
|
40
|
+
!(value instanceof JsonNumber));
|
|
41
|
+
}
|
|
42
|
+
function requireString(value, what) {
|
|
43
|
+
if (typeof value !== "string" || value.length === 0)
|
|
44
|
+
throw new CatalogFormatError(`${what} must be a non-empty string`);
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
function requireVersion(value, what) {
|
|
48
|
+
if (!(value instanceof JsonNumber) || !value.isInteger)
|
|
49
|
+
throw new CatalogFormatError(`${what} must be an integer`);
|
|
50
|
+
const n = Number(value.text);
|
|
51
|
+
if (n < 1 || n > MAX_CONTRACT_VERSION)
|
|
52
|
+
throw new CatalogFormatError(`${what} must be in [1, ${MAX_CONTRACT_VERSION}]`);
|
|
53
|
+
return n;
|
|
54
|
+
}
|
|
55
|
+
function requireSchema(value, what) {
|
|
56
|
+
if (!isObject(value))
|
|
57
|
+
throw new CatalogFormatError(`${what} must be a JSON object`);
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
function optionalDescription(value) {
|
|
61
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
62
|
+
}
|
|
63
|
+
/** Parse and validate a normalized catalog export from a lossless JSON tree. */
|
|
64
|
+
export function parseNormalizedCatalog(source) {
|
|
65
|
+
if (!isObject(source))
|
|
66
|
+
throw new CatalogFormatError("catalog export must be a JSON object");
|
|
67
|
+
const formatVersion = source["format_version"];
|
|
68
|
+
if (!(formatVersion instanceof JsonNumber) ||
|
|
69
|
+
!formatVersion.isInteger ||
|
|
70
|
+
Number(formatVersion.text) !== SUPPORTED_FORMAT_VERSION)
|
|
71
|
+
throw new CatalogFormatError(`unsupported catalog format (expected format_version ${SUPPORTED_FORMAT_VERSION})`);
|
|
72
|
+
const rawActions = source["actions"];
|
|
73
|
+
if (!Array.isArray(rawActions))
|
|
74
|
+
throw new CatalogFormatError("catalog export is missing an 'actions' array");
|
|
75
|
+
const rawDefaults = source["default_versions"];
|
|
76
|
+
if (!isObject(rawDefaults))
|
|
77
|
+
throw new CatalogFormatError("catalog export is missing a 'default_versions' object");
|
|
78
|
+
// `queries` is additive: an export without it declares no query options.
|
|
79
|
+
const rawQueries = source["queries"] ?? {};
|
|
80
|
+
if (!isObject(rawQueries))
|
|
81
|
+
throw new CatalogFormatError("'queries' must be a JSON object");
|
|
82
|
+
const rawAvailability = source["availability"] ?? {};
|
|
83
|
+
if (!isObject(rawAvailability))
|
|
84
|
+
throw new CatalogFormatError("'availability' must be a JSON object");
|
|
85
|
+
// Callable actions are selected by the service and builtin release exporter.
|
|
86
|
+
// Group their versions without maintaining a second list of policy actions.
|
|
87
|
+
const byAction = new Map();
|
|
88
|
+
for (const raw of rawActions) {
|
|
89
|
+
if (!isObject(raw))
|
|
90
|
+
throw new CatalogFormatError("each catalog action must be a JSON object");
|
|
91
|
+
const name = requireString(raw["name"], "an action's 'name'");
|
|
92
|
+
const version = requireVersion(raw["version"], `action '${name}' version`);
|
|
93
|
+
const rawQuery = rawQueries[`${name}@${version}`];
|
|
94
|
+
const availability = rawAvailability[`${name}@${version}`];
|
|
95
|
+
if (availability !== undefined &&
|
|
96
|
+
availability !== "eligible" &&
|
|
97
|
+
availability !== "conditional" &&
|
|
98
|
+
availability !== "definition")
|
|
99
|
+
throw new CatalogFormatError(`action '${name}' has invalid availability`);
|
|
100
|
+
const entry = {
|
|
101
|
+
version,
|
|
102
|
+
digest: requireString(raw["digest"], `action '${name}' digest`),
|
|
103
|
+
input: requireSchema(raw["input_schema"], `action '${name}' input_schema`),
|
|
104
|
+
output: requireSchema(raw["output_schema"], `action '${name}' output_schema`),
|
|
105
|
+
...(rawQuery !== undefined
|
|
106
|
+
? {
|
|
107
|
+
query: requireSchema(rawQuery, `queries['${name}@${version}']`),
|
|
108
|
+
}
|
|
109
|
+
: {}),
|
|
110
|
+
description: optionalDescription(raw["description"]),
|
|
111
|
+
...(availability !== undefined ? { availability } : {}),
|
|
112
|
+
};
|
|
113
|
+
const versions = byAction.get(name);
|
|
114
|
+
if (versions === undefined) {
|
|
115
|
+
byAction.set(name, [entry]);
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
if (versions.some((v) => v.version === version))
|
|
119
|
+
throw new CatalogFormatError(`catalog has a duplicate entry for action '${name}' version ${version}`);
|
|
120
|
+
versions.push(entry);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// A default pin is OPTIONAL: the service can expose a visible action with no
|
|
124
|
+
// eligible default (it filters an ineligible pin out of `default_versions`),
|
|
125
|
+
// and a present pin must be one of the action's declared versions. An unpinned
|
|
126
|
+
// action yields `defaultVersion: undefined` — a typed call against it requires
|
|
127
|
+
// an explicit version. Extra `default_versions` entries for actions not in
|
|
128
|
+
// this view are ignored, so an over-broad defaults map is tolerated.
|
|
129
|
+
const operations = [...byAction.entries()]
|
|
130
|
+
.map(([action, versions]) => {
|
|
131
|
+
const declared = versions.map((v) => v.version);
|
|
132
|
+
const rawDefault = rawDefaults[action];
|
|
133
|
+
let defaultVersion;
|
|
134
|
+
if (rawDefault !== undefined) {
|
|
135
|
+
defaultVersion = requireVersion(rawDefault, `default_versions['${action}']`);
|
|
136
|
+
if (!declared.includes(defaultVersion))
|
|
137
|
+
throw new CatalogFormatError(`default_versions['${action}'] pins version ${defaultVersion}, which is not a declared version`);
|
|
138
|
+
}
|
|
139
|
+
versions.sort((a, b) => a.version - b.version);
|
|
140
|
+
return { action, defaultVersion, versions };
|
|
141
|
+
})
|
|
142
|
+
.sort((a, b) => (a.action < b.action ? -1 : a.action > b.action ? 1 : 0));
|
|
143
|
+
const resourceTypes = parseResourceTypes(source["resource_types"]);
|
|
144
|
+
const selection = parseOptionalSelection(source["selection"]);
|
|
145
|
+
const rawDigest = source["catalog_digest"];
|
|
146
|
+
const catalogDigest = typeof rawDigest === "string" && rawDigest.length > 0
|
|
147
|
+
? rawDigest
|
|
148
|
+
: undefined;
|
|
149
|
+
return {
|
|
150
|
+
operations,
|
|
151
|
+
resourceTypes,
|
|
152
|
+
...(selection !== undefined ? { selection } : {}),
|
|
153
|
+
...(catalogDigest !== undefined ? { catalogDigest } : {}),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Parse the export's `selection` echo when present. A malformed echo fails
|
|
158
|
+
* loudly rather than being silently dropped — a live bundle that claims a
|
|
159
|
+
* selection must name a workspace and catalog. Absent entirely (builtin export)
|
|
160
|
+
* is fine and yields `undefined`.
|
|
161
|
+
*/
|
|
162
|
+
function parseOptionalSelection(raw) {
|
|
163
|
+
if (raw === undefined)
|
|
164
|
+
return undefined;
|
|
165
|
+
if (!isObject(raw))
|
|
166
|
+
throw new CatalogFormatError("'selection' must be a JSON object");
|
|
167
|
+
return {
|
|
168
|
+
workspace: requireString(raw["workspace_id"], "selection.workspace_id"),
|
|
169
|
+
catalog: requireString(raw["catalog"], "selection.catalog"),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
function parseResourceTypes(raw) {
|
|
173
|
+
if (raw === undefined)
|
|
174
|
+
return [];
|
|
175
|
+
if (!Array.isArray(raw))
|
|
176
|
+
throw new CatalogFormatError("'resource_types' must be an array");
|
|
177
|
+
const seen = new Set();
|
|
178
|
+
const entries = raw.map((item) => {
|
|
179
|
+
if (!isObject(item))
|
|
180
|
+
throw new CatalogFormatError("each resource type must be a JSON object");
|
|
181
|
+
const type = requireString(item["type"], "a resource type's 'type'");
|
|
182
|
+
const version = requireVersion(item["version"], `resource type '${type}' version`);
|
|
183
|
+
const key = `${type}\u0000${version}`;
|
|
184
|
+
if (seen.has(key))
|
|
185
|
+
throw new CatalogFormatError(`catalog has a duplicate resource type '${type}' version ${version}`);
|
|
186
|
+
seen.add(key);
|
|
187
|
+
return {
|
|
188
|
+
type,
|
|
189
|
+
version,
|
|
190
|
+
propertiesSchema: requireSchema(item["properties_schema"], `resource type '${type}' properties_schema`),
|
|
191
|
+
description: optionalDescription(item["description"]),
|
|
192
|
+
};
|
|
193
|
+
});
|
|
194
|
+
entries.sort((a, b) => a.type < b.type ? -1 : a.type > b.type ? 1 : a.version - b.version);
|
|
195
|
+
return entries;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Load a normalized catalog export from raw bytes, losslessly. Returns both the
|
|
199
|
+
* parsed model and the exact original bytes: `schema.json` and the bundle digest
|
|
200
|
+
* are written from these bytes so offline regeneration reproduces the identical
|
|
201
|
+
* contract, byte for byte.
|
|
202
|
+
*/
|
|
203
|
+
export function loadNormalizedCatalog(bytes) {
|
|
204
|
+
const raw = typeof bytes === "string" ? new TextEncoder().encode(bytes) : bytes;
|
|
205
|
+
let tree;
|
|
206
|
+
try {
|
|
207
|
+
tree = loadsJson(raw, { maxNodes: null, maxDepth: MAX_EXPORT_DEPTH });
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
throw new CatalogFormatError(`catalog export is not valid JSON: ${error.message}`);
|
|
211
|
+
}
|
|
212
|
+
return { catalog: parseNormalizedCatalog(tree), bytes: raw };
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Project a lossless {@link JsonValue} schema tree to a plain JSON value for the
|
|
216
|
+
* TypeScript renderer. `JsonNumber` collapses to a JS `number` (lossy for huge
|
|
217
|
+
* decimals) — acceptable because TypeScript describes only structural shape, and
|
|
218
|
+
* this projection is never persisted as the contract.
|
|
219
|
+
*
|
|
220
|
+
* Objects are materialized with a null prototype so a schema property or `$defs`
|
|
221
|
+
* key literally named `__proto__` is preserved as an own key instead of hitting
|
|
222
|
+
* the inherited setter (which would drop it and poison the projection's
|
|
223
|
+
* prototype) — the same trap the validator's schema normalization avoids.
|
|
224
|
+
*/
|
|
225
|
+
export function toPlainJson(value) {
|
|
226
|
+
if (value instanceof JsonNumber)
|
|
227
|
+
return Number(value.text);
|
|
228
|
+
if (Array.isArray(value))
|
|
229
|
+
return value.map(toPlainJson);
|
|
230
|
+
if (value !== null && typeof value === "object") {
|
|
231
|
+
const out = Object.create(null);
|
|
232
|
+
for (const [key, item] of Object.entries(value))
|
|
233
|
+
out[key] = toPlainJson(item);
|
|
234
|
+
return out;
|
|
235
|
+
}
|
|
236
|
+
return value;
|
|
237
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The runtime catalog ABI: the one type + value shape the generator emits and
|
|
3
|
+
* this runtime consumes. The typed catalog carries default-version pins and
|
|
4
|
+
* per-version contract digests only — no per-action JSON schemas — so a typed
|
|
5
|
+
* consumer never pulls in a validator. Digests are identity metadata, not
|
|
6
|
+
* payload authentication.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Shape every generated `Operations` map satisfies: action → version → I/O,
|
|
10
|
+
* and the list query (`options.query`) for a version that declares one.
|
|
11
|
+
*/
|
|
12
|
+
export type OperationsShape = {
|
|
13
|
+
readonly [action: string]: {
|
|
14
|
+
readonly [version: number]: {
|
|
15
|
+
readonly input: unknown;
|
|
16
|
+
readonly output: unknown;
|
|
17
|
+
readonly query?: unknown;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Shape of the default-version pins. Each action *may* map to one of its
|
|
23
|
+
* versions: a visible action can be exported without an eligible default (the
|
|
24
|
+
* service drops an ineligible pin), so the map is partial. An unpinned action
|
|
25
|
+
* carries no default and requires an explicit `version` at the call site.
|
|
26
|
+
*/
|
|
27
|
+
export type DefaultVersionsShape<Ops extends OperationsShape> = {
|
|
28
|
+
readonly [A in keyof Ops]?: keyof Ops[A] & number;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* The keys of `Defaults` that carry a *guaranteed* pin: present as a required
|
|
32
|
+
* property whose value type excludes `undefined`. A widened/optional
|
|
33
|
+
* `DefaultVersionsShape` (every key optional) reduces to `never`, so it can
|
|
34
|
+
* never expose an unsafe options-omitted call — only a concrete required pin
|
|
35
|
+
* does. Not part of the public surface (`core/index.ts` does not re-export it).
|
|
36
|
+
*/
|
|
37
|
+
export type GuaranteedPinKeys<Defaults> = {
|
|
38
|
+
[K in keyof Defaults]-?: Pick<Defaults, K> extends Required<Pick<Defaults, K>> ? undefined extends Defaults[K] ? never : K : never;
|
|
39
|
+
}[keyof Defaults];
|
|
40
|
+
/**
|
|
41
|
+
* The concrete default version of a pinned action, narrowed with `Extract` to a
|
|
42
|
+
* version actually declared for `Ops[A]`. `never` when the action is unpinned or
|
|
43
|
+
* its pin is not a declared version (a defensive guard; the generator emits only
|
|
44
|
+
* declared pins). Not part of the public surface.
|
|
45
|
+
*/
|
|
46
|
+
export type PinnedVersion<Ops extends OperationsShape, A extends keyof Ops, Defaults extends DefaultVersionsShape<Ops>> = A extends keyof Defaults ? Extract<Defaults[A], keyof Ops[A] & number> : never;
|
|
47
|
+
/** Per-version runtime metadata carried in the typed catalog. */
|
|
48
|
+
export interface VersionEntry {
|
|
49
|
+
/** Immutable contract digest (`sha256:...`) — identity, not integrity proof. */
|
|
50
|
+
readonly digest: string;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The typed per-action entries, tied to `Operations`/`DefaultVersions` at the
|
|
54
|
+
* type level: each declared action is present, `versions` has an entry for
|
|
55
|
+
* exactly its declared versions, and a *pinned* action's `defaultVersion` is
|
|
56
|
+
* exactly its pin. An *unpinned* action (absent from `Defaults`) carries no
|
|
57
|
+
* `defaultVersion` — the field is forbidden as a number (`?: never`) so a value
|
|
58
|
+
* cannot invent a default the service did not declare — while a value may still
|
|
59
|
+
* omit it. So a catalog value can neither disagree with, nor over-claim, the
|
|
60
|
+
* pins it advertises.
|
|
61
|
+
*/
|
|
62
|
+
export type TypedActions<Ops extends OperationsShape, Defaults extends DefaultVersionsShape<Ops>> = {
|
|
63
|
+
readonly [A in keyof Ops]: {
|
|
64
|
+
readonly versions: {
|
|
65
|
+
readonly [V in keyof Ops[A] & number]: VersionEntry;
|
|
66
|
+
};
|
|
67
|
+
} & (A extends GuaranteedPinKeys<Defaults> ? {
|
|
68
|
+
readonly defaultVersion: PinnedVersion<Ops, A, Defaults>;
|
|
69
|
+
} : {
|
|
70
|
+
readonly defaultVersion?: PinnedVersion<Ops, A, Defaults>;
|
|
71
|
+
});
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Runtime-facing action entry, used for the client's untyped catalog lookup.
|
|
75
|
+
* `defaultVersion` is optional: an unpinned action carries no default, and an
|
|
76
|
+
* options-omitted call against it is rejected at runtime (`version_required`).
|
|
77
|
+
*/
|
|
78
|
+
export interface ActionEntry {
|
|
79
|
+
readonly defaultVersion?: number;
|
|
80
|
+
readonly versions: {
|
|
81
|
+
readonly [version: number]: VersionEntry;
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
declare const OPS: unique symbol;
|
|
85
|
+
declare const DEFAULTS: unique symbol;
|
|
86
|
+
declare const CATALOG_KIND: unique symbol;
|
|
87
|
+
/**
|
|
88
|
+
* The generated runtime catalog. `Ops`/`Defaults` are phantom type parameters
|
|
89
|
+
* (carried by the branded symbols); `actions`/`generatorVersion` are the value.
|
|
90
|
+
*/
|
|
91
|
+
export interface TypedCatalog<Ops extends OperationsShape, Defaults extends DefaultVersionsShape<Ops>> {
|
|
92
|
+
/** Generator version that emitted this catalog, for reproducibility. */
|
|
93
|
+
readonly generatorVersion: string;
|
|
94
|
+
readonly actions: TypedActions<Ops, Defaults>;
|
|
95
|
+
readonly [OPS]?: Ops;
|
|
96
|
+
readonly [DEFAULTS]?: Defaults;
|
|
97
|
+
readonly [CATALOG_KIND]?: "typed";
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The structural base every {@link TypedCatalog} satisfies regardless of its pin
|
|
101
|
+
* map's shape: the generator version, a string-indexed actions map, and the
|
|
102
|
+
* typed brand. Used only as `createClient`'s accept-constraint so both pinned
|
|
103
|
+
* and unpinned concrete typed catalogs are assignable to it — the pin-aware
|
|
104
|
+
* {@link TypedActions} of a concrete `TypedCatalog` is not a subtype of the same
|
|
105
|
+
* type computed over the widened `DefaultVersionsShape`, so a deep base would
|
|
106
|
+
* reject unpinned values. Inference still reads `Ops`/`Defaults` off the
|
|
107
|
+
* concrete value via the branded phantoms. Not publicly re-exported.
|
|
108
|
+
*/
|
|
109
|
+
export interface TypedCatalogBase {
|
|
110
|
+
readonly generatorVersion: string;
|
|
111
|
+
readonly actions: {
|
|
112
|
+
readonly [action: string]: ActionEntry;
|
|
113
|
+
};
|
|
114
|
+
readonly [CATALOG_KIND]?: "typed";
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* A catalog loaded from the server at runtime: it supplies default-version pins
|
|
118
|
+
* and per-version digests without inventing compile-time types. A client built
|
|
119
|
+
* from one returns `unknown` (never `any`). The `"runtime"` brand keeps it
|
|
120
|
+
* distinct from `TypedCatalog` so the untyped call surface is reachable only
|
|
121
|
+
* through this catalog, never as a catch-all on a typed client.
|
|
122
|
+
*/
|
|
123
|
+
export interface RuntimeCatalog {
|
|
124
|
+
readonly generatorVersion: string;
|
|
125
|
+
readonly actions: {
|
|
126
|
+
readonly [action: string]: ActionEntry;
|
|
127
|
+
};
|
|
128
|
+
readonly [CATALOG_KIND]?: "runtime";
|
|
129
|
+
}
|
|
130
|
+
/** Extract the `Operations` map a typed catalog is associated with. */
|
|
131
|
+
export type OperationsOf<C> = C extends TypedCatalog<infer Ops, infer _Defaults> ? Ops : never;
|
|
132
|
+
/** Extract the `DefaultVersions` pins a typed catalog is associated with. */
|
|
133
|
+
export type DefaultsOf<C> = C extends TypedCatalog<infer _Ops, infer Defaults> ? Defaults : never;
|
|
134
|
+
/**
|
|
135
|
+
* Build a typed catalog value with its `Operations`/`DefaultVersions`
|
|
136
|
+
* association. The `Ops`/`Defaults` arguments are type-only, so this is a thin,
|
|
137
|
+
* dependency-free constructor; `TypedActions` enforces value/type agreement.
|
|
138
|
+
*/
|
|
139
|
+
export declare function defineCatalog<Ops extends OperationsShape, Defaults extends DefaultVersionsShape<Ops>>(catalog: {
|
|
140
|
+
generatorVersion: string;
|
|
141
|
+
actions: TypedActions<Ops, Defaults>;
|
|
142
|
+
}): TypedCatalog<Ops, Defaults>;
|
|
143
|
+
/**
|
|
144
|
+
* Build a runtime (untyped) catalog value from a server-loaded shape. Stamping
|
|
145
|
+
* the `RuntimeCatalog` return type is what routes it to `createClient`'s untyped
|
|
146
|
+
* overload — a bare object literal would satisfy `TypedCatalog` too and resolve
|
|
147
|
+
* to the typed client. Dependency-free; the value is returned unchanged.
|
|
148
|
+
*/
|
|
149
|
+
export declare function defineRuntimeCatalog(catalog: {
|
|
150
|
+
generatorVersion: string;
|
|
151
|
+
actions: {
|
|
152
|
+
readonly [action: string]: ActionEntry;
|
|
153
|
+
};
|
|
154
|
+
}): RuntimeCatalog;
|
|
155
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The runtime catalog ABI: the one type + value shape the generator emits and
|
|
3
|
+
* this runtime consumes. The typed catalog carries default-version pins and
|
|
4
|
+
* per-version contract digests only — no per-action JSON schemas — so a typed
|
|
5
|
+
* consumer never pulls in a validator. Digests are identity metadata, not
|
|
6
|
+
* payload authentication.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Build a typed catalog value with its `Operations`/`DefaultVersions`
|
|
10
|
+
* association. The `Ops`/`Defaults` arguments are type-only, so this is a thin,
|
|
11
|
+
* dependency-free constructor; `TypedActions` enforces value/type agreement.
|
|
12
|
+
*/
|
|
13
|
+
export function defineCatalog(catalog) {
|
|
14
|
+
return catalog;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Build a runtime (untyped) catalog value from a server-loaded shape. Stamping
|
|
18
|
+
* the `RuntimeCatalog` return type is what routes it to `createClient`'s untyped
|
|
19
|
+
* overload — a bare object literal would satisfy `TypedCatalog` too and resolve
|
|
20
|
+
* to the typed client. Dependency-free; the value is returned unchanged.
|
|
21
|
+
*/
|
|
22
|
+
export function defineRuntimeCatalog(catalog) {
|
|
23
|
+
return catalog;
|
|
24
|
+
}
|