@jxsuite/schema 0.35.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/defs/expression-node.schema.ts +118 -9
- package/defs/extension-manifest.schema.ts +47 -0
- package/defs/field-schema.schema.ts +58 -0
- package/defs/function-def.schema.ts +7 -2
- package/defs/index.ts +10 -2
- package/defs/project-config.schema.ts +20 -11
- package/defs/statement.schema.ts +65 -0
- package/extension-manifest.schema.json +54 -0
- package/package.json +12 -4
- package/project-schema.json +18 -54
- package/schema.json +323 -56
- package/schemas/document.paths.schema.json +6 -0
- package/schemas/project.core.schema.json +441 -0
- package/schemas/project.fields.schema.json +14 -0
- package/src/extension-registry.ts +270 -0
- package/src/format-registry.ts +83 -13
- package/src/guards.ts +21 -0
- package/src/project-schemas.ts +262 -0
- package/src/schema.ts +118 -7
- package/src/validate-project.ts +144 -0
- package/types.ts +94 -11
- package/defs/content-type-def.schema.ts +0 -37
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extension registry — manifest-driven discovery of extension packages (specs/extensions.md).
|
|
3
|
+
*
|
|
4
|
+
* A project declares extensions in project.json (`"extensions": ["@jxsuite/parser", ...]`); each
|
|
5
|
+
* extension package ships a `jx-extension.json` manifest enumerating its classes and schema
|
|
6
|
+
* fragments. Hosts resolve `<specifier>/jx-extension.json` (bare specifiers through the package's
|
|
7
|
+
* exports map, project-first), read each listed `.class.json`, and dispatch through the declared
|
|
8
|
+
* admission blocks (`format` | `project` | `server` | `connector`) and capability roles.
|
|
9
|
+
*
|
|
10
|
+
* All I/O is injected via FormatHostIO so the same module serves node and browser hosts. The
|
|
11
|
+
* format-dispatch view reuses FormatRegistry (including its extension/capability conflict checks).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { FormatEntry, FormatRegistry } from "./format-registry";
|
|
15
|
+
import type { FormatHostIO } from "./format-registry";
|
|
16
|
+
|
|
17
|
+
export type {
|
|
18
|
+
CapabilityInfo,
|
|
19
|
+
ConnectorBlock,
|
|
20
|
+
ExtensionCapability,
|
|
21
|
+
FormatHostIO,
|
|
22
|
+
ProjectBlock,
|
|
23
|
+
ServerBlock,
|
|
24
|
+
} from "./format-registry";
|
|
25
|
+
export { EXTENSION_CAPABILITIES, FormatEntry, FormatRegistry } from "./format-registry";
|
|
26
|
+
|
|
27
|
+
/** The well-known manifest filename every extension package exports. */
|
|
28
|
+
export const EXTENSION_MANIFEST = "jx-extension.json";
|
|
29
|
+
|
|
30
|
+
export interface ExtensionManifest {
|
|
31
|
+
name: string;
|
|
32
|
+
title?: string;
|
|
33
|
+
description?: string;
|
|
34
|
+
classes?: Record<string, string>;
|
|
35
|
+
/**
|
|
36
|
+
* Schema fragments the package contributes: `project` (project.json sections), `document` ($paths
|
|
37
|
+
* shapes), and `fields` — a fragment whose `$defs` members are unioned into the per-project
|
|
38
|
+
* field-schema resource (extension field extras, specs/extensions.md §5.3).
|
|
39
|
+
*/
|
|
40
|
+
schemas?: { project?: string; document?: string; fields?: string };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ExtensionInfo {
|
|
44
|
+
/** The project.json `extensions` entry that produced this extension. */
|
|
45
|
+
specifier: string;
|
|
46
|
+
/** Resolved absolute path of the jx-extension.json manifest. */
|
|
47
|
+
manifestPath: string;
|
|
48
|
+
manifest: ExtensionManifest;
|
|
49
|
+
/** Class entries in manifest order. */
|
|
50
|
+
classes: FormatEntry[];
|
|
51
|
+
/** Resolved absolute fragment paths, when the manifest declares them. */
|
|
52
|
+
schemas: { project?: string; document?: string; fields?: string };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type SchemaFragmentKind = "project" | "document" | "fields";
|
|
56
|
+
|
|
57
|
+
export class ExtensionRegistry {
|
|
58
|
+
#extensions: ExtensionInfo[];
|
|
59
|
+
#classes: FormatEntry[];
|
|
60
|
+
#formats: FormatRegistry;
|
|
61
|
+
|
|
62
|
+
constructor(extensions: ExtensionInfo[]) {
|
|
63
|
+
this.#extensions = extensions;
|
|
64
|
+
this.#classes = extensions.flatMap((e) => e.classes);
|
|
65
|
+
|
|
66
|
+
// Class names are the $prototype-visible namespace: duplicates across extensions are a
|
|
67
|
+
// Configuration error (a project-local `imports` entry may still shadow a manifest class —
|
|
68
|
+
// That overlay happens at the host, not here).
|
|
69
|
+
const byName = new Map<string, string>();
|
|
70
|
+
for (const ext of extensions) {
|
|
71
|
+
for (const cls of ext.classes) {
|
|
72
|
+
const prior = byName.get(cls.name);
|
|
73
|
+
if (prior && prior !== ext.specifier) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`Extension conflict: "${prior}" and "${ext.specifier}" both provide a class named "${cls.name}"`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
byName.set(cls.name, ext.specifier);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Section keys are exclusive (specs/extensions.md §3.1).
|
|
83
|
+
const byKey = new Map<string, FormatEntry>();
|
|
84
|
+
for (const entry of this.#classes) {
|
|
85
|
+
if (!entry.project) {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const prior = byKey.get(entry.project.key);
|
|
89
|
+
if (prior) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`Extension conflict: "${prior.name}" and "${entry.name}" both claim the project section "${entry.project.key}"`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
byKey.set(entry.project.key, entry);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Server mounts own disjoint /_jx/ subtrees (specs/extensions.md §11).
|
|
98
|
+
const byBasePath = new Map<string, FormatEntry>();
|
|
99
|
+
for (const entry of this.#classes) {
|
|
100
|
+
if (!entry.server) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const { basePath } = entry.server;
|
|
104
|
+
if (!basePath.startsWith("/_jx/")) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`Extension "${entry.name}": server basePath "${basePath}" must be under /_jx/`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
const prior = byBasePath.get(basePath);
|
|
110
|
+
if (prior) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`Extension conflict: "${prior.name}" and "${entry.name}" both mount "${basePath}"`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
byBasePath.set(basePath, entry);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The format-dispatch view; its constructor enforces (extension, capability) exclusivity.
|
|
119
|
+
this.#formats = new FormatRegistry(this.#classes.filter((e) => e.extensions.length > 0));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
get extensions(): readonly ExtensionInfo[] {
|
|
123
|
+
return this.#extensions;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Every class entry across all extensions, in declaration order. */
|
|
127
|
+
get classes(): readonly FormatEntry[] {
|
|
128
|
+
return this.#classes;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** File-extension dispatch view (parse/serialize/discover/load lookups). */
|
|
132
|
+
get formats(): FormatRegistry {
|
|
133
|
+
return this.#formats;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
byName(name: string): FormatEntry | undefined {
|
|
137
|
+
return this.#classes.find((e) => e.name === name);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Classes owning a project.json section, in declaration order. */
|
|
141
|
+
projectContributions(): FormatEntry[] {
|
|
142
|
+
return this.#classes.filter((e) => e.project !== null);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
byProjectKey(key: string): FormatEntry | undefined {
|
|
146
|
+
return this.#classes.find((e) => e.project?.key === key);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The class whose `resolvePaths` capability declares this `$paths` discriminator key. */
|
|
150
|
+
byPathsDiscriminator(key: string): FormatEntry | undefined {
|
|
151
|
+
return this.#classes.find((e) => e.capabilities.resolvePaths?.discriminator === key);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Server-mount classes, sorted by `server.order` (ascending; default 100), then name. */
|
|
155
|
+
serverMounts(): FormatEntry[] {
|
|
156
|
+
return this.#classes
|
|
157
|
+
.filter((e) => e.server !== null)
|
|
158
|
+
.toSorted(
|
|
159
|
+
(a, b) =>
|
|
160
|
+
(a.server?.order ?? 100) - (b.server?.order ?? 100) || a.name.localeCompare(b.name),
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Connection-provider classes (connector block). */
|
|
165
|
+
connectors(): FormatEntry[] {
|
|
166
|
+
return this.#classes.filter((e) => e.connector !== null);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Resolved fragment paths of the given kind, in extension declaration order. */
|
|
170
|
+
schemaFragments(kind: SchemaFragmentKind): string[] {
|
|
171
|
+
return this.#extensions
|
|
172
|
+
.map((e) => e.schemas[kind])
|
|
173
|
+
.filter((p): p is string => typeof p === "string");
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Build a registry from a project's `extensions` list.
|
|
179
|
+
*
|
|
180
|
+
* Specifiers are bare package names (resolved project-first through the package's exports map) or
|
|
181
|
+
* relative paths. Unlike the legacy imports scan, everything here is explicit — an unresolvable
|
|
182
|
+
* manifest or class file is an error, not a silent skip.
|
|
183
|
+
*
|
|
184
|
+
* @param {string[] | undefined} extensions - The project.json `extensions` array
|
|
185
|
+
* @param {FormatHostIO} io - Injected host I/O
|
|
186
|
+
* @param {string} base - Reference file for relative resolution (convention: `<root>/project.json`)
|
|
187
|
+
*/
|
|
188
|
+
export async function buildExtensionRegistry(
|
|
189
|
+
extensions: string[] | undefined,
|
|
190
|
+
io: FormatHostIO,
|
|
191
|
+
base: string,
|
|
192
|
+
): Promise<ExtensionRegistry> {
|
|
193
|
+
const infos: ExtensionInfo[] = [];
|
|
194
|
+
for (const specifier of extensions ?? []) {
|
|
195
|
+
infos.push(await loadExtension(specifier, io, base));
|
|
196
|
+
}
|
|
197
|
+
return new ExtensionRegistry(infos);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function loadExtension(
|
|
201
|
+
specifier: string,
|
|
202
|
+
io: FormatHostIO,
|
|
203
|
+
base: string,
|
|
204
|
+
): Promise<ExtensionInfo> {
|
|
205
|
+
let manifestPath: string;
|
|
206
|
+
if (specifier.startsWith("./") || specifier.startsWith("../")) {
|
|
207
|
+
const dir = io.resolvePath(base, specifier);
|
|
208
|
+
manifestPath = io.resolvePath(`${dir}/package.json`, `./${EXTENSION_MANIFEST}`);
|
|
209
|
+
} else {
|
|
210
|
+
try {
|
|
211
|
+
manifestPath = io.resolvePath(base, `${specifier}/${EXTENSION_MANIFEST}`);
|
|
212
|
+
} catch (error) {
|
|
213
|
+
throw new Error(
|
|
214
|
+
`Extension "${specifier}" is not resolvable: it must export ${EXTENSION_MANIFEST} ` +
|
|
215
|
+
`(is the package installed, and does its exports map include it?): ${errorMessage(error)}`,
|
|
216
|
+
{ cause: error },
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
let manifest: ExtensionManifest;
|
|
222
|
+
try {
|
|
223
|
+
manifest = (await io.loadJson(manifestPath)) as unknown as ExtensionManifest;
|
|
224
|
+
} catch (error) {
|
|
225
|
+
throw new Error(
|
|
226
|
+
`Extension "${specifier}": cannot read manifest ${manifestPath}: ${errorMessage(error)}`,
|
|
227
|
+
{ cause: error },
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
if (!manifest || typeof manifest !== "object" || typeof manifest.name !== "string") {
|
|
231
|
+
throw new Error(`Extension "${specifier}": ${EXTENSION_MANIFEST} must declare a "name"`);
|
|
232
|
+
}
|
|
233
|
+
if (!specifier.startsWith(".") && manifest.name !== specifier) {
|
|
234
|
+
throw new Error(
|
|
235
|
+
`Extension "${specifier}": manifest name "${manifest.name}" does not match the specifier`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const classes: FormatEntry[] = [];
|
|
240
|
+
for (const [name, ref] of Object.entries(manifest.classes ?? {})) {
|
|
241
|
+
const classPath = io.resolvePath(manifestPath, ref);
|
|
242
|
+
let classDef: Record<string, unknown>;
|
|
243
|
+
try {
|
|
244
|
+
classDef = await io.loadJson(classPath);
|
|
245
|
+
} catch (error) {
|
|
246
|
+
throw new Error(
|
|
247
|
+
`Extension "${specifier}": cannot read class "${name}" at ${classPath}: ${errorMessage(error)}`,
|
|
248
|
+
{ cause: error },
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
classes.push(new FormatEntry(name, classPath, classDef, io));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const schemas: ExtensionInfo["schemas"] = {};
|
|
255
|
+
if (manifest.schemas?.project) {
|
|
256
|
+
schemas.project = io.resolvePath(manifestPath, manifest.schemas.project);
|
|
257
|
+
}
|
|
258
|
+
if (manifest.schemas?.document) {
|
|
259
|
+
schemas.document = io.resolvePath(manifestPath, manifest.schemas.document);
|
|
260
|
+
}
|
|
261
|
+
if (manifest.schemas?.fields) {
|
|
262
|
+
schemas.fields = io.resolvePath(manifestPath, manifest.schemas.fields);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return { specifier, manifestPath, manifest, classes, schemas };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function errorMessage(error: unknown): string {
|
|
269
|
+
return error instanceof Error ? error.message : String(error);
|
|
270
|
+
}
|
package/src/format-registry.ts
CHANGED
|
@@ -19,6 +19,34 @@ export const FORMAT_CAPABILITIES: readonly FormatCapability[] = [
|
|
|
19
19
|
"load",
|
|
20
20
|
];
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* All well-known static capability roles across admission blocks (specs/extensions.md §8): format
|
|
24
|
+
* roles plus project-section (`projectData`, `resolvePaths`), compile-away (`lower`), server-mount
|
|
25
|
+
* (`mount`), and connector (`dialect`, `deploySchema`, `bindings`, `testConnection`) roles.
|
|
26
|
+
*/
|
|
27
|
+
export type ExtensionCapability =
|
|
28
|
+
| FormatCapability
|
|
29
|
+
| "projectData"
|
|
30
|
+
| "resolvePaths"
|
|
31
|
+
| "lower"
|
|
32
|
+
| "mount"
|
|
33
|
+
| "dialect"
|
|
34
|
+
| "deploySchema"
|
|
35
|
+
| "bindings"
|
|
36
|
+
| "testConnection";
|
|
37
|
+
|
|
38
|
+
export const EXTENSION_CAPABILITIES: readonly ExtensionCapability[] = [
|
|
39
|
+
...FORMAT_CAPABILITIES,
|
|
40
|
+
"projectData",
|
|
41
|
+
"resolvePaths",
|
|
42
|
+
"lower",
|
|
43
|
+
"mount",
|
|
44
|
+
"dialect",
|
|
45
|
+
"deploySchema",
|
|
46
|
+
"bindings",
|
|
47
|
+
"testConnection",
|
|
48
|
+
];
|
|
49
|
+
|
|
22
50
|
export type FormatTiming = "compiler" | "server" | "client";
|
|
23
51
|
|
|
24
52
|
export type FormatDocumentKind = "page" | "component" | "content";
|
|
@@ -62,6 +90,32 @@ export interface CapabilityInfo {
|
|
|
62
90
|
identifier: string;
|
|
63
91
|
/** Environments allowed to call directly; others round-trip via the dev server. */
|
|
64
92
|
timing: FormatTiming[];
|
|
93
|
+
/** For `resolvePaths`: the `$paths` key that routes to this class. */
|
|
94
|
+
discriminator?: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A class owns a project.json section iff its descriptor carries a `project` block. */
|
|
98
|
+
export interface ProjectBlock {
|
|
99
|
+
key: string;
|
|
100
|
+
title?: string;
|
|
101
|
+
description?: string;
|
|
102
|
+
referenceable?: boolean;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A class mounts routes in the site worker/dev server iff it carries a `server` block. */
|
|
106
|
+
export interface ServerBlock {
|
|
107
|
+
basePath: string;
|
|
108
|
+
order?: number;
|
|
109
|
+
module?: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A class provides database connections iff it carries a `connector` block. */
|
|
113
|
+
export interface ConnectorBlock {
|
|
114
|
+
provider: string;
|
|
115
|
+
kind: "sqlite" | "postgres";
|
|
116
|
+
local?: string;
|
|
117
|
+
serve?: string;
|
|
118
|
+
[key: string]: unknown;
|
|
65
119
|
}
|
|
66
120
|
|
|
67
121
|
export interface FormatHostIO {
|
|
@@ -78,12 +132,16 @@ interface ClassMethodLike {
|
|
|
78
132
|
identifier?: string;
|
|
79
133
|
scope?: string;
|
|
80
134
|
timing?: string[];
|
|
135
|
+
discriminator?: string;
|
|
81
136
|
}
|
|
82
137
|
|
|
83
138
|
interface ClassDefLike {
|
|
84
139
|
title?: string;
|
|
85
140
|
$implementation?: string;
|
|
86
141
|
format?: FormatBlock;
|
|
142
|
+
project?: ProjectBlock;
|
|
143
|
+
server?: ServerBlock;
|
|
144
|
+
connector?: ConnectorBlock;
|
|
87
145
|
$studio?: StudioHints;
|
|
88
146
|
$defs?: { methods?: Record<string, ClassMethodLike> };
|
|
89
147
|
[key: string]: unknown;
|
|
@@ -101,7 +159,10 @@ export class FormatEntry {
|
|
|
101
159
|
readonly exportTarget: boolean;
|
|
102
160
|
readonly remote: boolean;
|
|
103
161
|
readonly studio: StudioHints | null;
|
|
104
|
-
readonly
|
|
162
|
+
readonly project: ProjectBlock | null;
|
|
163
|
+
readonly server: ServerBlock | null;
|
|
164
|
+
readonly connector: ConnectorBlock | null;
|
|
165
|
+
readonly capabilities: Partial<Record<ExtensionCapability, CapabilityInfo>>;
|
|
105
166
|
|
|
106
167
|
#io: FormatHostIO;
|
|
107
168
|
#implementation: Promise<Record<string, unknown>> | null = null;
|
|
@@ -112,13 +173,18 @@ export class FormatEntry {
|
|
|
112
173
|
this.classDef = classDef;
|
|
113
174
|
this.#io = io;
|
|
114
175
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
this.
|
|
119
|
-
this.
|
|
120
|
-
this.
|
|
176
|
+
// The format block is optional: entries also represent project/server/connector classes
|
|
177
|
+
// (specs/extensions.md §6). Absent block → no extensions claimed.
|
|
178
|
+
const { format } = classDef;
|
|
179
|
+
this.extensions = (format?.extensions ?? []).map((e) => e.toLowerCase());
|
|
180
|
+
this.mediaType = format?.mediaType ?? null;
|
|
181
|
+
this.documentKinds = format?.documentKinds ?? [];
|
|
182
|
+
this.exportTarget = format?.exportTarget === true;
|
|
183
|
+
this.remote = format?.remote === true;
|
|
121
184
|
this.studio = classDef.$studio ?? null;
|
|
185
|
+
this.project = classDef.project ?? null;
|
|
186
|
+
this.server = classDef.server ?? null;
|
|
187
|
+
this.connector = classDef.connector ?? null;
|
|
122
188
|
this.capabilities = extractCapabilities(classDef);
|
|
123
189
|
}
|
|
124
190
|
|
|
@@ -137,7 +203,7 @@ export class FormatEntry {
|
|
|
137
203
|
}
|
|
138
204
|
|
|
139
205
|
/** Invoke a capability's static method on the implementation class. */
|
|
140
|
-
async call(capability:
|
|
206
|
+
async call(capability: ExtensionCapability, ...args: unknown[]): Promise<unknown> {
|
|
141
207
|
const cap = this.capabilities[capability];
|
|
142
208
|
if (!cap) {
|
|
143
209
|
throw new Error(`Format class "${this.name}" does not declare a "${capability}" capability`);
|
|
@@ -264,18 +330,22 @@ export async function buildFormatRegistry(
|
|
|
264
330
|
|
|
265
331
|
function extractCapabilities(
|
|
266
332
|
classDef: ClassDefLike,
|
|
267
|
-
): Partial<Record<
|
|
268
|
-
const out: Partial<Record<
|
|
333
|
+
): Partial<Record<ExtensionCapability, CapabilityInfo>> {
|
|
334
|
+
const out: Partial<Record<ExtensionCapability, CapabilityInfo>> = {};
|
|
269
335
|
const methods = classDef.$defs?.methods ?? {};
|
|
270
336
|
for (const [key, method] of Object.entries(methods)) {
|
|
271
|
-
const role = method.role as
|
|
272
|
-
if (!role || !
|
|
337
|
+
const role = method.role as ExtensionCapability | undefined;
|
|
338
|
+
if (!role || !EXTENSION_CAPABILITIES.includes(role)) {
|
|
273
339
|
continue;
|
|
274
340
|
}
|
|
275
|
-
|
|
341
|
+
const info: CapabilityInfo = {
|
|
276
342
|
identifier: method.identifier ?? key,
|
|
277
343
|
timing: (method.timing as FormatTiming[] | undefined) ?? DEFAULT_TIMING,
|
|
278
344
|
};
|
|
345
|
+
if (typeof method.discriminator === "string") {
|
|
346
|
+
info.discriminator = method.discriminator;
|
|
347
|
+
}
|
|
348
|
+
out[role] = info;
|
|
279
349
|
}
|
|
280
350
|
return out;
|
|
281
351
|
}
|
package/src/guards.ts
CHANGED
|
@@ -19,6 +19,7 @@ import type {
|
|
|
19
19
|
JxPrototypeDef,
|
|
20
20
|
JxRef,
|
|
21
21
|
JxServerFnDef,
|
|
22
|
+
JxStatement,
|
|
22
23
|
JxStateObject,
|
|
23
24
|
JxStyle,
|
|
24
25
|
} from "../types";
|
|
@@ -49,6 +50,26 @@ export function isExpressionDef(value: unknown): value is JxExpressionDef {
|
|
|
49
50
|
return isJsonObject(value) && isJsonObject(value.$expression);
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Shape 5 with `parameters`: a named formula — a pure, reusable computation invoked via the `call`
|
|
55
|
+
* operator rather than lowered to a computed value.
|
|
56
|
+
*/
|
|
57
|
+
export function isNamedFormulaDef(
|
|
58
|
+
value: unknown,
|
|
59
|
+
): value is JxExpressionDef & { parameters: unknown[] } {
|
|
60
|
+
return isExpressionDef(value) && Array.isArray(value.parameters) && value.parameters.length > 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Shape 4 with a structured body: a Function entry whose `body` is a statement array (spec §20)
|
|
65
|
+
* rather than opaque JS source.
|
|
66
|
+
*/
|
|
67
|
+
export function hasStructuredBody(
|
|
68
|
+
value: unknown,
|
|
69
|
+
): value is JxFunctionDef & { body: JxStatement[] } {
|
|
70
|
+
return isFunctionDef(value) && Array.isArray(value.body);
|
|
71
|
+
}
|
|
72
|
+
|
|
52
73
|
/** A non-Function `$prototype` instance (Request, Storage, ContentCollection, classes, …). */
|
|
53
74
|
export function isPrototypeDef(value: unknown): value is JxPrototypeDef {
|
|
54
75
|
return (
|