@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.
@@ -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
+ }
@@ -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 capabilities: Partial<Record<FormatCapability, CapabilityInfo>>;
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
- const format = classDef.format as FormatBlock;
116
- this.extensions = (format.extensions ?? []).map((e) => e.toLowerCase());
117
- this.mediaType = format.mediaType ?? null;
118
- this.documentKinds = format.documentKinds ?? [];
119
- this.exportTarget = format.exportTarget === true;
120
- this.remote = format.remote === true;
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: FormatCapability, ...args: unknown[]): Promise<unknown> {
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<FormatCapability, CapabilityInfo>> {
268
- const out: Partial<Record<FormatCapability, CapabilityInfo>> = {};
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 FormatCapability | undefined;
272
- if (!role || !FORMAT_CAPABILITIES.includes(role)) {
337
+ const role = method.role as ExtensionCapability | undefined;
338
+ if (!role || !EXTENSION_CAPABILITIES.includes(role)) {
273
339
  continue;
274
340
  }
275
- out[role] = {
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 (