@metaobjectsdev/metadata 0.6.0-rc.1 → 0.7.0-rc.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 +54 -3
- package/dist/attr-schema-validate.js +7 -7
- package/dist/attr-schema-validate.js.map +1 -1
- package/dist/core/export-json.d.ts +6 -7
- package/dist/core/export-json.d.ts.map +1 -1
- package/dist/core/export-json.js +15 -17
- package/dist/core/export-json.js.map +1 -1
- package/dist/core/index.d.ts +4 -2
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +6 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/parser-yaml.d.ts.map +1 -1
- package/dist/core/parser-yaml.js +13 -4
- package/dist/core/parser-yaml.js.map +1 -1
- package/dist/errors.d.ts +28 -8
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +31 -5
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -2
- package/dist/index.js.map +1 -1
- package/dist/json-path.d.ts +8 -0
- package/dist/json-path.d.ts.map +1 -0
- package/dist/json-path.js +39 -0
- package/dist/json-path.js.map +1 -0
- package/dist/loader/meta-data-loader.d.ts +47 -6
- package/dist/loader/meta-data-loader.d.ts.map +1 -1
- package/dist/loader/meta-data-loader.js +106 -8
- package/dist/loader/meta-data-loader.js.map +1 -1
- package/dist/loader/meta-data-source.d.ts +6 -2
- package/dist/loader/meta-data-source.d.ts.map +1 -1
- package/dist/loader/meta-data-source.js +10 -6
- package/dist/loader/meta-data-source.js.map +1 -1
- package/dist/loader/shortcuts.d.ts +9 -0
- package/dist/loader/shortcuts.d.ts.map +1 -0
- package/dist/loader/shortcuts.js +19 -0
- package/dist/loader/shortcuts.js.map +1 -0
- package/dist/loader/sources/directory-source.d.ts +15 -0
- package/dist/loader/sources/directory-source.d.ts.map +1 -0
- package/dist/loader/sources/directory-source.js +80 -0
- package/dist/loader/sources/directory-source.js.map +1 -0
- package/dist/loader/sources/file-source.d.ts +12 -0
- package/dist/loader/sources/file-source.d.ts.map +1 -0
- package/dist/loader/sources/file-source.js +46 -0
- package/dist/loader/sources/file-source.js.map +1 -0
- package/dist/loader/sources/index.d.ts +5 -0
- package/dist/loader/sources/index.d.ts.map +1 -0
- package/dist/loader/sources/index.js +5 -0
- package/dist/loader/sources/index.js.map +1 -0
- package/dist/loader/sources/uri-source.d.ts +9 -0
- package/dist/loader/sources/uri-source.d.ts.map +1 -0
- package/dist/loader/sources/uri-source.js +42 -0
- package/dist/loader/sources/uri-source.js.map +1 -0
- package/dist/loader/validation-passes.d.ts.map +1 -1
- package/dist/loader/validation-passes.js +27 -27
- package/dist/loader/validation-passes.js.map +1 -1
- package/dist/naming.d.ts +15 -2
- package/dist/naming.d.ts.map +1 -1
- package/dist/naming.js +20 -6
- package/dist/naming.js.map +1 -1
- package/dist/parser-core.d.ts +2 -4
- package/dist/parser-core.d.ts.map +1 -1
- package/dist/parser-core.js +111 -42
- package/dist/parser-core.js.map +1 -1
- package/dist/parser-json.d.ts.map +1 -1
- package/dist/parser-json.js +10 -2
- package/dist/parser-json.js.map +1 -1
- package/dist/persistence/source/validate-source-roles.js +2 -2
- package/dist/persistence/source/validate-source-roles.js.map +1 -1
- package/dist/semantic-diff.d.ts +5 -0
- package/dist/semantic-diff.d.ts.map +1 -0
- package/dist/semantic-diff.js +49 -0
- package/dist/semantic-diff.js.map +1 -0
- package/dist/shared/meta-data.d.ts +10 -0
- package/dist/shared/meta-data.d.ts.map +1 -1
- package/dist/shared/meta-data.js +23 -0
- package/dist/shared/meta-data.js.map +1 -1
- package/dist/source.d.ts +68 -0
- package/dist/source.d.ts.map +1 -0
- package/dist/source.js +13 -0
- package/dist/source.js.map +1 -0
- package/dist/subtype-rules.js +1 -1
- package/dist/subtype-rules.js.map +1 -1
- package/dist/super-resolve.d.ts +2 -0
- package/dist/super-resolve.d.ts.map +1 -1
- package/dist/super-resolve.js +1 -1
- package/dist/super-resolve.js.map +1 -1
- package/package.json +1 -1
- package/src/attr-schema-validate.ts +7 -7
- package/src/core/export-json.ts +15 -18
- package/src/core/index.ts +8 -2
- package/src/core/parser-yaml.ts +15 -4
- package/src/errors.ts +42 -8
- package/src/index.ts +28 -3
- package/src/json-path.ts +46 -0
- package/src/loader/meta-data-loader.ts +148 -10
- package/src/loader/meta-data-source.ts +10 -6
- package/src/loader/shortcuts.ts +31 -0
- package/src/loader/sources/directory-source.ts +90 -0
- package/src/{core → loader/sources}/file-source.ts +3 -3
- package/src/loader/sources/index.ts +6 -0
- package/src/loader/sources/uri-source.ts +44 -0
- package/src/loader/validation-passes.ts +28 -24
- package/src/naming.ts +39 -7
- package/src/parser-core.ts +113 -43
- package/src/parser-json.ts +11 -2
- package/src/persistence/source/validate-source-roles.ts +2 -2
- package/src/semantic-diff.ts +48 -0
- package/src/shared/meta-data.ts +28 -0
- package/src/source.ts +61 -0
- package/src/subtype-rules.ts +1 -1
- package/src/super-resolve.ts +3 -1
- package/src/core/file-meta-data-loader.ts +0 -89
|
@@ -14,15 +14,34 @@ import { coreProviders } from "../core-types.js";
|
|
|
14
14
|
import { composeRegistry } from "../provider.js";
|
|
15
15
|
import { TYPE_METADATA, SUBTYPE_ROOT } from "../shared/base-types.js";
|
|
16
16
|
import { ParseError } from "../errors.js";
|
|
17
|
+
import type { LoaderWarning } from "../source.js";
|
|
18
|
+
import { codeSource } from "../source.js";
|
|
17
19
|
import { parseJson } from "../parser-json.js";
|
|
18
20
|
import { validateDataGridSortFields, validateFilterableHasIndex, validateOriginPaths, validateDataGridFilterValues, validateFieldObjectStorage, validateTemplatePayloadRefs } from "./validation-passes.js";
|
|
19
21
|
import { validateSourceRoles } from "../persistence/source/validate-source-roles.js";
|
|
20
22
|
import { resolveDeferredSupers } from "../super-resolve.js";
|
|
21
23
|
import { validateSubtypeRules } from "../subtype-rules.js";
|
|
22
24
|
import { validateAttrSchema } from "../attr-schema-validate.js";
|
|
23
|
-
import type { MetaDataSource } from "./meta-data-source.js";
|
|
25
|
+
import type { MetaDataFormat, MetaDataSource } from "./meta-data-source.js";
|
|
26
|
+
import { InMemoryStringSource } from "./meta-data-source.js";
|
|
24
27
|
import type { ParseOptions, ParseResult } from "../parser-core.js";
|
|
25
28
|
|
|
29
|
+
// Local mirror of DirectorySource's options shape. Deliberately inlined here
|
|
30
|
+
// (instead of `import type`'d from ./sources/directory-source.js) so the
|
|
31
|
+
// browser-safety crawler — which walks every `import|export from` it sees,
|
|
32
|
+
// type-only or not — never follows a path into a node:fs-using file.
|
|
33
|
+
// Keep field-for-field in sync with `DirectoryOptions` in `./sources/directory-source.ts`.
|
|
34
|
+
type DirectoryFactoryOptions = {
|
|
35
|
+
exclude?: string[];
|
|
36
|
+
recurse?: boolean;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// YAML parser and node:fs-backed Source impls are loaded lazily (dynamic
|
|
40
|
+
// import) inside the methods that need them. Reason: the package root
|
|
41
|
+
// (src/index.ts) re-exports MetaDataLoader and must stay browser-safe —
|
|
42
|
+
// the browser-safety test asserts that no file reachable from index.ts
|
|
43
|
+
// statically imports `yaml` or `node:fs(/promises)`.
|
|
44
|
+
|
|
26
45
|
// ---------------------------------------------------------------------------
|
|
27
46
|
// Public API types
|
|
28
47
|
// ---------------------------------------------------------------------------
|
|
@@ -41,7 +60,13 @@ export interface LoadOptions {
|
|
|
41
60
|
|
|
42
61
|
export interface LoadResult {
|
|
43
62
|
root: MetaRoot;
|
|
44
|
-
|
|
63
|
+
/** Cross-port-aligned warning envelopes per ADR-0009.
|
|
64
|
+
* FR5a creates the channel; FR5c (overlay-merge duplicate detection)
|
|
65
|
+
* will be the first feature to populate it. Legacy string warnings
|
|
66
|
+
* collected during parse/validation are wrapped at the loader boundary
|
|
67
|
+
* with `code: "WARN_LEGACY"` and `source: { format: "code" }` so the
|
|
68
|
+
* channel always presents the envelope shape to consumers. */
|
|
69
|
+
warnings: LoaderWarning[];
|
|
45
70
|
errors: Error[];
|
|
46
71
|
}
|
|
47
72
|
|
|
@@ -75,6 +100,71 @@ export class MetaDataLoader {
|
|
|
75
100
|
return composeRegistry(coreProviders);
|
|
76
101
|
}
|
|
77
102
|
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Static factories — the 99% case (cross-language consistent)
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Load every supported file (`.json` / `.yaml` / `.yml`) under `dir` in
|
|
109
|
+
* deterministic ordinal-basename order. Recurses by default.
|
|
110
|
+
*
|
|
111
|
+
* Convenience for the typical "load a directory of metadata" path. The
|
|
112
|
+
* `DirectorySource` impl is loaded lazily to keep the package root
|
|
113
|
+
* browser-safe (the underlying source uses node:fs).
|
|
114
|
+
*
|
|
115
|
+
* A missing/unreadable directory is surfaced as a collected entry in
|
|
116
|
+
* `result.errors`; the loader returns a synthetic empty root rather than
|
|
117
|
+
* throwing — preserves the `meta export` CLI exit-code contract.
|
|
118
|
+
*/
|
|
119
|
+
static async fromDirectory(
|
|
120
|
+
dir: string,
|
|
121
|
+
opts?: DirectoryFactoryOptions & LoadOptions,
|
|
122
|
+
): Promise<LoadResult> {
|
|
123
|
+
const { exclude, recurse, ...loaderOpts } = opts ?? {};
|
|
124
|
+
// Conditional spreads honor exactOptionalPropertyTypes — only forward keys
|
|
125
|
+
// when the caller supplied a value, so DirectorySource's own defaults apply.
|
|
126
|
+
const dirOpts: DirectoryFactoryOptions = {
|
|
127
|
+
...(exclude !== undefined && { exclude }),
|
|
128
|
+
...(recurse !== undefined && { recurse }),
|
|
129
|
+
};
|
|
130
|
+
const { DirectorySource } = await import("./sources/directory-source.js");
|
|
131
|
+
const loader = new MetaDataLoader(loaderOpts);
|
|
132
|
+
try {
|
|
133
|
+
const sources = await new DirectorySource(dir, dirOpts).expand();
|
|
134
|
+
return loader.load(sources);
|
|
135
|
+
} catch (err) {
|
|
136
|
+
// Match the pre-unification contract: a missing/unreadable directory is
|
|
137
|
+
// surfaced as a collected error on the LoadResult, not a throw. The
|
|
138
|
+
// pipeline still completes with a synthetic empty root.
|
|
139
|
+
const emptyResult = await loader.load([]);
|
|
140
|
+
const expandErr =
|
|
141
|
+
err instanceof Error
|
|
142
|
+
? err
|
|
143
|
+
: new Error(`MetaDataLoader.fromDirectory: ${String(err)}`);
|
|
144
|
+
return { ...emptyResult, errors: [expandErr, ...emptyResult.errors] };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Load each URI as a {@link UriSource}. Supports `file://`, `http://`,
|
|
150
|
+
* `https://` schemes. The source impl is loaded lazily to keep the package
|
|
151
|
+
* root browser-safe.
|
|
152
|
+
*/
|
|
153
|
+
static async fromUris(uris: string[], opts?: LoadOptions): Promise<LoadResult> {
|
|
154
|
+
const { UriSource } = await import("./sources/uri-source.js");
|
|
155
|
+
const sources = uris.map((u) => new UriSource(u));
|
|
156
|
+
return new MetaDataLoader(opts).load(sources);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Load a single in-memory string of the given format. */
|
|
160
|
+
static async fromString(
|
|
161
|
+
content: string,
|
|
162
|
+
format: MetaDataFormat,
|
|
163
|
+
opts?: LoadOptions,
|
|
164
|
+
): Promise<LoadResult> {
|
|
165
|
+
return new MetaDataLoader(opts).load([new InMemoryStringSource(content, { format })]);
|
|
166
|
+
}
|
|
167
|
+
|
|
78
168
|
// ---------------------------------------------------------------------------
|
|
79
169
|
// Lifecycle
|
|
80
170
|
// ---------------------------------------------------------------------------
|
|
@@ -156,10 +246,16 @@ export class MetaDataLoader {
|
|
|
156
246
|
// ---------------------------------------------------------------------------
|
|
157
247
|
|
|
158
248
|
/**
|
|
159
|
-
* Parse one source's raw content into a ParseResult.
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
249
|
+
* Parse one source's raw content into a ParseResult. Dispatches on the
|
|
250
|
+
* source's declared `format` — `"json"` runs the canonical JSON parser,
|
|
251
|
+
* `"yaml"` desugars the authoring YAML into canonical JSON via parseYaml.
|
|
252
|
+
* Cross-language consistent: the same format vocabulary is honored by the
|
|
253
|
+
* Java / C# / Python MetaDataLoaders.
|
|
254
|
+
*
|
|
255
|
+
* The YAML parser is loaded lazily so the browser-safe root entry never
|
|
256
|
+
* statically pulls in the `yaml` dependency — see the module-header comment.
|
|
257
|
+
* `parseYaml` is preloaded inside `load()` if any source declares YAML
|
|
258
|
+
* format so the call here can stay synchronous.
|
|
163
259
|
*/
|
|
164
260
|
protected parseSource(
|
|
165
261
|
content: string,
|
|
@@ -169,12 +265,35 @@ export class MetaDataLoader {
|
|
|
169
265
|
if (source.format === "json") {
|
|
170
266
|
return parseJson(content, parseOpts);
|
|
171
267
|
}
|
|
268
|
+
if (source.format === "yaml") {
|
|
269
|
+
const fn = MetaDataLoader._yamlParser;
|
|
270
|
+
if (fn === undefined) {
|
|
271
|
+
throw new Error(
|
|
272
|
+
`MetaDataLoader: YAML parser was not preloaded — this is an internal bug. ` +
|
|
273
|
+
`Source "${source.id}" declares format "yaml".`,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
return fn(content, parseOpts);
|
|
277
|
+
}
|
|
172
278
|
throw new Error(
|
|
173
|
-
`MetaDataLoader
|
|
174
|
-
`"${source.id}"
|
|
279
|
+
`MetaDataLoader: unsupported source format "${source.format}" ` +
|
|
280
|
+
`on source "${source.id}"`,
|
|
175
281
|
);
|
|
176
282
|
}
|
|
177
283
|
|
|
284
|
+
// Cached lazy YAML parser — populated by _ensureYamlParser() before
|
|
285
|
+
// parseSource is invoked on a YAML source. Module-level cache (one import
|
|
286
|
+
// per process) keeps the per-load cost negligible.
|
|
287
|
+
private static _yamlParser:
|
|
288
|
+
| ((content: string, opts: ParseOptions) => ParseResult)
|
|
289
|
+
| undefined;
|
|
290
|
+
|
|
291
|
+
private static async _ensureYamlParser(): Promise<void> {
|
|
292
|
+
if (MetaDataLoader._yamlParser !== undefined) return;
|
|
293
|
+
const mod = await import("../core/parser-yaml.js");
|
|
294
|
+
MetaDataLoader._yamlParser = mod.parseYaml;
|
|
295
|
+
}
|
|
296
|
+
|
|
178
297
|
// ---------------------------------------------------------------------------
|
|
179
298
|
// load — async pipeline over MetaDataSource[]
|
|
180
299
|
// ---------------------------------------------------------------------------
|
|
@@ -203,6 +322,14 @@ export class MetaDataLoader {
|
|
|
203
322
|
const warnings: string[] = [];
|
|
204
323
|
const errors: Error[] = [];
|
|
205
324
|
|
|
325
|
+
// Pre-load the YAML parser via dynamic import if any source declares
|
|
326
|
+
// YAML format. This keeps `parseSource` synchronous and the package root
|
|
327
|
+
// (src/index.ts) browser-safe — yaml is never statically imported from a
|
|
328
|
+
// file reachable from the package entry. See `_ensureYamlParser`.
|
|
329
|
+
if (sources.some((s) => s.format === "yaml")) {
|
|
330
|
+
await MetaDataLoader._ensureYamlParser();
|
|
331
|
+
}
|
|
332
|
+
|
|
206
333
|
let root: MetaRoot | undefined;
|
|
207
334
|
|
|
208
335
|
// Parse all sources with super resolution DEFERRED so cross-file super
|
|
@@ -255,7 +382,7 @@ export class MetaDataLoader {
|
|
|
255
382
|
errors.push(
|
|
256
383
|
new ParseError(
|
|
257
384
|
`the SuperClass '${failure.ref}' does not exist (referenced by ${failure.nodeFqn})`,
|
|
258
|
-
{ code: "ERR_UNRESOLVED_SUPER" },
|
|
385
|
+
{ code: "ERR_UNRESOLVED_SUPER", source: failure.source },
|
|
259
386
|
),
|
|
260
387
|
);
|
|
261
388
|
}
|
|
@@ -316,6 +443,17 @@ export class MetaDataLoader {
|
|
|
316
443
|
}
|
|
317
444
|
|
|
318
445
|
this._root = root;
|
|
319
|
-
|
|
446
|
+
// Wrap legacy string warnings collected from parser-core / validators in
|
|
447
|
+
// LoaderWarning envelopes at the loader boundary. The parser/validator
|
|
448
|
+
// surface keeps its `string[]` shape internally (parser-core is shared
|
|
449
|
+
// with parseJson() / parseYaml() callers who consume string warnings
|
|
450
|
+
// directly). FR5c will retire WARN_LEGACY by routing the duplicate-
|
|
451
|
+
// declaration site through a proper envelope-shaped emit helper.
|
|
452
|
+
const envelopeWarnings: LoaderWarning[] = warnings.map((msg) => ({
|
|
453
|
+
code: "WARN_LEGACY",
|
|
454
|
+
message: msg,
|
|
455
|
+
source: codeSource("MetaDataLoader"),
|
|
456
|
+
}));
|
|
457
|
+
return { root, warnings: envelopeWarnings, errors };
|
|
320
458
|
}
|
|
321
459
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// MetaDataSource — the raw-document unit consumed by the loader pipeline.
|
|
2
2
|
//
|
|
3
|
-
// A loader (
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// A loader (MetaDataLoader, fed by FileSource/DirectorySource/UriSource/
|
|
4
|
+
// InMemoryStringSource) parses each source's content. read() is async so file
|
|
5
|
+
// and URI sources can do I/O; InMemoryStringSource resolves immediately.
|
|
6
6
|
|
|
7
7
|
/** Format of a source's content. Selects the parser. */
|
|
8
8
|
export type MetaDataFormat = "json" | "yaml";
|
|
@@ -17,15 +17,19 @@ export interface MetaDataSource {
|
|
|
17
17
|
read(): Promise<string>;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
/**
|
|
21
|
-
|
|
20
|
+
/**
|
|
21
|
+
* A metadata source backed by an in-memory string. The default identity is
|
|
22
|
+
* `"<inline>"` — matches the cross-language convention shared by the Java /
|
|
23
|
+
* C# / Python ports.
|
|
24
|
+
*/
|
|
25
|
+
export class InMemoryStringSource implements MetaDataSource {
|
|
22
26
|
readonly id: string;
|
|
23
27
|
readonly format: MetaDataFormat;
|
|
24
28
|
private readonly _content: string;
|
|
25
29
|
|
|
26
30
|
constructor(content: string, opts?: { id?: string; format?: MetaDataFormat }) {
|
|
27
31
|
this._content = content;
|
|
28
|
-
this.id = opts?.id ?? "<
|
|
32
|
+
this.id = opts?.id ?? "<inline>";
|
|
29
33
|
this.format = opts?.format ?? "json";
|
|
30
34
|
}
|
|
31
35
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Module-level loader shortcuts — one-liners that delegate to the
|
|
2
|
+
// MetaDataLoader.from* static factories. Per the cross-language convention:
|
|
3
|
+
// TS and Python expose both class statics AND module-level shortcuts; Java
|
|
4
|
+
// and C# stay class-only.
|
|
5
|
+
//
|
|
6
|
+
// These functions do not statically import any node:fs- or yaml-using file;
|
|
7
|
+
// MetaDataLoader.from* loads the underlying source impls via dynamic import,
|
|
8
|
+
// preserving the package root's browser-safety contract.
|
|
9
|
+
|
|
10
|
+
import { MetaDataLoader } from "./meta-data-loader.js";
|
|
11
|
+
import type { LoadOptions, LoadResult } from "./meta-data-loader.js";
|
|
12
|
+
import type { MetaDataFormat } from "./meta-data-source.js";
|
|
13
|
+
|
|
14
|
+
export function loadDirectory(
|
|
15
|
+
dir: string,
|
|
16
|
+
opts?: { exclude?: string[]; recurse?: boolean } & LoadOptions,
|
|
17
|
+
): Promise<LoadResult> {
|
|
18
|
+
return MetaDataLoader.fromDirectory(dir, opts);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function loadUris(uris: string[], opts?: LoadOptions): Promise<LoadResult> {
|
|
22
|
+
return MetaDataLoader.fromUris(uris, opts);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function loadString(
|
|
26
|
+
content: string,
|
|
27
|
+
format: MetaDataFormat,
|
|
28
|
+
opts?: LoadOptions,
|
|
29
|
+
): Promise<LoadResult> {
|
|
30
|
+
return MetaDataLoader.fromString(content, format, opts);
|
|
31
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// DirectorySource — expands a directory into a sorted list of FileSource.
|
|
2
|
+
//
|
|
3
|
+
// Discovers .json / .yaml / .yml files (case-insensitive on extension).
|
|
4
|
+
// Recurses by default — matches Java/Python/C# DirectorySource behavior.
|
|
5
|
+
// Sort order is ordinal-by-basename so the overlay merge is deterministic
|
|
6
|
+
// across environments and language ports.
|
|
7
|
+
|
|
8
|
+
import { readdir, stat } from "node:fs/promises";
|
|
9
|
+
import { basename, extname, join } from "node:path";
|
|
10
|
+
import { FileSource } from "./file-source.js";
|
|
11
|
+
import type { MetaDataSource } from "../meta-data-source.js";
|
|
12
|
+
|
|
13
|
+
export interface DirectoryOptions {
|
|
14
|
+
/** Filename patterns to exclude. Supports literal match and `*` / `**` globs. */
|
|
15
|
+
exclude?: string[];
|
|
16
|
+
/** Recurse into subdirectories. Default: true. */
|
|
17
|
+
recurse?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const SUPPORTED_EXTS = new Set([".json", ".yaml", ".yml"]);
|
|
21
|
+
|
|
22
|
+
/** Minimal glob matcher supporting `*` (any chars except `/`) and `**` (any chars). */
|
|
23
|
+
function matchSimpleGlob(pattern: string, value: string): boolean {
|
|
24
|
+
const regexStr = pattern
|
|
25
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
26
|
+
.replace(/\*\*/g, "::DOUBLESTAR::")
|
|
27
|
+
.replace(/\*/g, "[^/]*")
|
|
28
|
+
.replace(/::DOUBLESTAR::/g, ".*");
|
|
29
|
+
return new RegExp(`^${regexStr}$`).test(value);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class DirectorySource {
|
|
33
|
+
constructor(
|
|
34
|
+
public readonly directory: string,
|
|
35
|
+
public readonly opts: DirectoryOptions = {},
|
|
36
|
+
) {}
|
|
37
|
+
|
|
38
|
+
async expand(): Promise<MetaDataSource[]> {
|
|
39
|
+
const recurse = this.opts.recurse ?? true;
|
|
40
|
+
const exclude = this.opts.exclude ?? [];
|
|
41
|
+
|
|
42
|
+
const files = await this.collect(this.directory, recurse);
|
|
43
|
+
const filtered: string[] = [];
|
|
44
|
+
for (const p of files) {
|
|
45
|
+
if (!SUPPORTED_EXTS.has(extname(p).toLowerCase())) continue;
|
|
46
|
+
const name = basename(p);
|
|
47
|
+
if (exclude.some((pat) => matchSimpleGlob(pat, name))) continue;
|
|
48
|
+
filtered.push(p);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Sort by basename (ordinal) for deterministic overlay order. The
|
|
52
|
+
// pre-unification FileMetaDataLoader sorted readdir entries (basenames)
|
|
53
|
+
// for the same reason — that contract carries forward here.
|
|
54
|
+
filtered.sort((a, b) => {
|
|
55
|
+
const an = basename(a);
|
|
56
|
+
const bn = basename(b);
|
|
57
|
+
return an < bn ? -1 : an > bn ? 1 : 0;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return filtered.map((p) => new FileSource(p));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private async collect(dir: string, recurse: boolean): Promise<string[]> {
|
|
64
|
+
let entries: string[];
|
|
65
|
+
try {
|
|
66
|
+
entries = await readdir(dir);
|
|
67
|
+
} catch (err) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`DirectorySource: cannot read ${dir}: ${(err as Error).message}`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
const out: string[] = [];
|
|
73
|
+
for (const entry of entries) {
|
|
74
|
+
const full = join(dir, entry);
|
|
75
|
+
let s;
|
|
76
|
+
try {
|
|
77
|
+
s = await stat(full);
|
|
78
|
+
} catch {
|
|
79
|
+
// Entry vanished between readdir and stat (TOCTOU) or is not accessible.
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (s.isDirectory()) {
|
|
83
|
+
if (recurse) out.push(...(await this.collect(full, recurse)));
|
|
84
|
+
} else if (s.isFile()) {
|
|
85
|
+
out.push(full);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// FileSource — a MetaDataSource backed by a file on disk. Server-side only
|
|
2
|
-
// (touches node:fs); lives under src/
|
|
3
|
-
//
|
|
2
|
+
// (touches node:fs); lives under src/loader/sources/ alongside the other
|
|
3
|
+
// MetaDataSource implementations.
|
|
4
4
|
|
|
5
5
|
import { basename, extname } from "node:path";
|
|
6
|
-
import type { MetaDataFormat, MetaDataSource } from "../
|
|
6
|
+
import type { MetaDataFormat, MetaDataSource } from "../meta-data-source.js";
|
|
7
7
|
|
|
8
8
|
/** Infer a source format from a file extension. `.yaml`/`.yml` → "yaml";
|
|
9
9
|
* everything else (including `.json`) → "json", the canonical default. */
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Barrel re-export for the MetaDataSource implementations.
|
|
2
|
+
|
|
3
|
+
export { FileSource } from "./file-source.js";
|
|
4
|
+
export { DirectorySource } from "./directory-source.js";
|
|
5
|
+
export type { DirectoryOptions } from "./directory-source.js";
|
|
6
|
+
export { UriSource } from "./uri-source.js";
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// UriSource — a MetaDataSource that fetches content from a URI.
|
|
2
|
+
// Supports file://, http://, and https:// schemes.
|
|
3
|
+
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { extname } from "node:path";
|
|
7
|
+
import type { MetaDataFormat, MetaDataSource } from "../meta-data-source.js";
|
|
8
|
+
|
|
9
|
+
function inferFormat(uri: string): MetaDataFormat {
|
|
10
|
+
// URL constructor handles file://, http://, https:// uniformly.
|
|
11
|
+
// Strip query/fragment by reading .pathname.
|
|
12
|
+
try {
|
|
13
|
+
const pathname = new URL(uri).pathname;
|
|
14
|
+
const ext = extname(pathname).toLowerCase();
|
|
15
|
+
return ext === ".yaml" || ext === ".yml" ? "yaml" : "json";
|
|
16
|
+
} catch {
|
|
17
|
+
// If the URI isn't a valid URL, fall back to JSON.
|
|
18
|
+
return "json";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class UriSource implements MetaDataSource {
|
|
23
|
+
readonly id: string;
|
|
24
|
+
readonly format: MetaDataFormat;
|
|
25
|
+
|
|
26
|
+
constructor(public readonly uri: string, format?: MetaDataFormat) {
|
|
27
|
+
this.id = uri;
|
|
28
|
+
this.format = format ?? inferFormat(uri);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async read(): Promise<string> {
|
|
32
|
+
if (this.uri.startsWith("file://")) {
|
|
33
|
+
return readFile(fileURLToPath(this.uri), "utf-8");
|
|
34
|
+
}
|
|
35
|
+
if (this.uri.startsWith("http://") || this.uri.startsWith("https://")) {
|
|
36
|
+
const res = await fetch(this.uri);
|
|
37
|
+
if (!res.ok) {
|
|
38
|
+
throw new Error(`UriSource: ${this.uri} -> HTTP ${res.status}`);
|
|
39
|
+
}
|
|
40
|
+
return res.text();
|
|
41
|
+
}
|
|
42
|
+
throw new Error(`UriSource: unsupported scheme on ${this.uri}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import type { MetaData } from "../shared/meta-data.js";
|
|
13
13
|
import { ParseError } from "../errors.js";
|
|
14
|
+
import type { ErrorSource } from "../source.js";
|
|
14
15
|
import {
|
|
15
16
|
TYPE_OBJECT,
|
|
16
17
|
TYPE_FIELD,
|
|
@@ -72,7 +73,7 @@ export function validateDataGridSortFields(root: MetaData): ParseError[] {
|
|
|
72
73
|
new ParseError(
|
|
73
74
|
`dataGrid layout "${layout.name}" on entity "${obj.name}" has @defaultSortField "${sortField}" ` +
|
|
74
75
|
`but no such field exists on "${obj.name}". Available fields: ${[...fieldNames].join(", ")}`,
|
|
75
|
-
{ code: "ERR_BAD_DEFAULT_SORT_FIELD" },
|
|
76
|
+
{ code: "ERR_BAD_DEFAULT_SORT_FIELD", source: layout.source },
|
|
76
77
|
),
|
|
77
78
|
);
|
|
78
79
|
}
|
|
@@ -102,7 +103,7 @@ export function validateTemplatePayloadRefs(root: MetaData): ParseError[] {
|
|
|
102
103
|
errors.push(
|
|
103
104
|
new ParseError(
|
|
104
105
|
`template "${tmpl.name}" @payloadRef "${payloadRef}" does not resolve to a known object in this model`,
|
|
105
|
-
{ code: "ERR_INVALID_TEMPLATE" },
|
|
106
|
+
{ code: "ERR_INVALID_TEMPLATE", source: tmpl.source },
|
|
106
107
|
),
|
|
107
108
|
);
|
|
108
109
|
continue;
|
|
@@ -118,7 +119,7 @@ export function validateTemplatePayloadRefs(root: MetaData): ParseError[] {
|
|
|
118
119
|
new ParseError(
|
|
119
120
|
`template "${tmpl.name}" @requiredSlots "${slot}" is not a field on payload "${payloadRef}". ` +
|
|
120
121
|
`Available fields: ${[...fieldNames].join(", ")}`,
|
|
121
|
-
{ code: "ERR_INVALID_TEMPLATE" },
|
|
122
|
+
{ code: "ERR_INVALID_TEMPLATE", source: tmpl.source },
|
|
122
123
|
),
|
|
123
124
|
);
|
|
124
125
|
}
|
|
@@ -196,6 +197,7 @@ function _validateFromPath(
|
|
|
196
197
|
root: MetaData,
|
|
197
198
|
projectionName: string,
|
|
198
199
|
fieldName: string,
|
|
200
|
+
originSource: ErrorSource,
|
|
199
201
|
errors: ParseError[],
|
|
200
202
|
label: string = "origin.passthrough.@from",
|
|
201
203
|
): void {
|
|
@@ -204,7 +206,7 @@ function _validateFromPath(
|
|
|
204
206
|
errors.push(
|
|
205
207
|
new ParseError(
|
|
206
208
|
`${label} "${fromAttr}" on ${projectionName}.${fieldName}: must be of form "Entity.field".`,
|
|
207
|
-
{ code: "ERR_INVALID_ORIGIN" },
|
|
209
|
+
{ code: "ERR_INVALID_ORIGIN", source: originSource },
|
|
208
210
|
),
|
|
209
211
|
);
|
|
210
212
|
return;
|
|
@@ -216,7 +218,7 @@ function _validateFromPath(
|
|
|
216
218
|
errors.push(
|
|
217
219
|
new ParseError(
|
|
218
220
|
`${label} "${fromAttr}" on ${projectionName}.${fieldName}: no such entity "${entityName}".`,
|
|
219
|
-
{ code: "ERR_INVALID_ORIGIN" },
|
|
221
|
+
{ code: "ERR_INVALID_ORIGIN", source: originSource },
|
|
220
222
|
),
|
|
221
223
|
);
|
|
222
224
|
return;
|
|
@@ -226,7 +228,7 @@ function _validateFromPath(
|
|
|
226
228
|
errors.push(
|
|
227
229
|
new ParseError(
|
|
228
230
|
`${label} "${fromAttr}" on ${projectionName}.${fieldName}: no such field "${targetFieldName}" on ${entityName}.`,
|
|
229
|
-
{ code: "ERR_INVALID_ORIGIN" },
|
|
231
|
+
{ code: "ERR_INVALID_ORIGIN", source: originSource },
|
|
230
232
|
),
|
|
231
233
|
);
|
|
232
234
|
}
|
|
@@ -237,6 +239,7 @@ function _validateViaPath(
|
|
|
237
239
|
root: MetaData,
|
|
238
240
|
projectionName: string,
|
|
239
241
|
fieldName: string,
|
|
242
|
+
originSource: ErrorSource,
|
|
240
243
|
errors: ParseError[],
|
|
241
244
|
): void {
|
|
242
245
|
const segments = viaAttr.split(".");
|
|
@@ -244,7 +247,7 @@ function _validateViaPath(
|
|
|
244
247
|
errors.push(
|
|
245
248
|
new ParseError(
|
|
246
249
|
`origin.@via "${viaAttr}" on ${projectionName}.${fieldName}: must be of form "Entity.relationship[.relationship...]".`,
|
|
247
|
-
{ code: "ERR_INVALID_ORIGIN" },
|
|
250
|
+
{ code: "ERR_INVALID_ORIGIN", source: originSource },
|
|
248
251
|
),
|
|
249
252
|
);
|
|
250
253
|
return;
|
|
@@ -255,7 +258,7 @@ function _validateViaPath(
|
|
|
255
258
|
errors.push(
|
|
256
259
|
new ParseError(
|
|
257
260
|
`origin.@via "${viaAttr}" on ${projectionName}.${fieldName}: no such entity "${entityName}".`,
|
|
258
|
-
{ code: "ERR_INVALID_ORIGIN" },
|
|
261
|
+
{ code: "ERR_INVALID_ORIGIN", source: originSource },
|
|
259
262
|
),
|
|
260
263
|
);
|
|
261
264
|
return;
|
|
@@ -266,7 +269,7 @@ function _validateViaPath(
|
|
|
266
269
|
errors.push(
|
|
267
270
|
new ParseError(
|
|
268
271
|
`origin.@via "${viaAttr}" on ${projectionName}.${fieldName}: no such relationship "${relName}" on ${currentObj.name}.`,
|
|
269
|
-
{ code: "ERR_INVALID_ORIGIN" },
|
|
272
|
+
{ code: "ERR_INVALID_ORIGIN", source: originSource },
|
|
270
273
|
),
|
|
271
274
|
);
|
|
272
275
|
return;
|
|
@@ -276,7 +279,7 @@ function _validateViaPath(
|
|
|
276
279
|
errors.push(
|
|
277
280
|
new ParseError(
|
|
278
281
|
`origin.@via "${viaAttr}" on ${projectionName}.${fieldName}: relationship "${relName}" on ${currentObj.name} is missing @objectRef.`,
|
|
279
|
-
{ code: "ERR_INVALID_ORIGIN" },
|
|
282
|
+
{ code: "ERR_INVALID_ORIGIN", source: originSource },
|
|
280
283
|
),
|
|
281
284
|
);
|
|
282
285
|
return;
|
|
@@ -286,7 +289,7 @@ function _validateViaPath(
|
|
|
286
289
|
errors.push(
|
|
287
290
|
new ParseError(
|
|
288
291
|
`origin.@via "${viaAttr}" on ${projectionName}.${fieldName}: relationship "${relName}" points to non-existent entity "${refTarget}".`,
|
|
289
|
-
{ code: "ERR_INVALID_ORIGIN" },
|
|
292
|
+
{ code: "ERR_INVALID_ORIGIN", source: originSource },
|
|
290
293
|
),
|
|
291
294
|
);
|
|
292
295
|
return;
|
|
@@ -306,15 +309,15 @@ export function validateOriginPaths(root: MetaData): ParseError[] {
|
|
|
306
309
|
errors.push(
|
|
307
310
|
new ParseError(
|
|
308
311
|
`origin.passthrough on ${obj.name}.${field.name}: missing @from.`,
|
|
309
|
-
{ code: "ERR_INVALID_ORIGIN" },
|
|
312
|
+
{ code: "ERR_INVALID_ORIGIN", source: origin.source },
|
|
310
313
|
),
|
|
311
314
|
);
|
|
312
315
|
continue;
|
|
313
316
|
}
|
|
314
|
-
_validateFromPath(from, root, obj.name, field.name, errors);
|
|
317
|
+
_validateFromPath(from, root, obj.name, field.name, origin.source, errors);
|
|
315
318
|
const via = origin.ownAttr(ORIGIN_PASSTHROUGH_ATTR_VIA);
|
|
316
319
|
if (typeof via === "string" && via !== "") {
|
|
317
|
-
_validateViaPath(via, root, obj.name, field.name, errors);
|
|
320
|
+
_validateViaPath(via, root, obj.name, field.name, origin.source, errors);
|
|
318
321
|
}
|
|
319
322
|
} else if (origin.subType === ORIGIN_SUBTYPE_AGGREGATE) {
|
|
320
323
|
const of_ = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_OF);
|
|
@@ -322,23 +325,23 @@ export function validateOriginPaths(root: MetaData): ParseError[] {
|
|
|
322
325
|
errors.push(
|
|
323
326
|
new ParseError(
|
|
324
327
|
`origin.aggregate on ${obj.name}.${field.name}: missing @of.`,
|
|
325
|
-
{ code: "ERR_INVALID_ORIGIN" },
|
|
328
|
+
{ code: "ERR_INVALID_ORIGIN", source: origin.source },
|
|
326
329
|
),
|
|
327
330
|
);
|
|
328
331
|
continue;
|
|
329
332
|
}
|
|
330
|
-
_validateFromPath(of_, root, obj.name, field.name, errors, "origin.aggregate.@of");
|
|
333
|
+
_validateFromPath(of_, root, obj.name, field.name, origin.source, errors, "origin.aggregate.@of");
|
|
331
334
|
const via = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_VIA);
|
|
332
335
|
if (typeof via !== "string" || via === "") {
|
|
333
336
|
errors.push(
|
|
334
337
|
new ParseError(
|
|
335
338
|
`origin.aggregate on ${obj.name}.${field.name}: missing @via (aggregates require a relationship path).`,
|
|
336
|
-
{ code: "ERR_INVALID_ORIGIN" },
|
|
339
|
+
{ code: "ERR_INVALID_ORIGIN", source: origin.source },
|
|
337
340
|
),
|
|
338
341
|
);
|
|
339
342
|
continue;
|
|
340
343
|
}
|
|
341
|
-
_validateViaPath(via, root, obj.name, field.name, errors);
|
|
344
|
+
_validateViaPath(via, root, obj.name, field.name, origin.source, errors);
|
|
342
345
|
}
|
|
343
346
|
}
|
|
344
347
|
}
|
|
@@ -371,7 +374,7 @@ export function validateFieldObjectStorage(root: MetaData): ParseError[] {
|
|
|
371
374
|
errors.push(
|
|
372
375
|
new ParseError(
|
|
373
376
|
`field "${obj.name}.${field.name}" sets @storage but has no @objectRef`,
|
|
374
|
-
{ code: "ERR_STORAGE_WITHOUT_OBJECT_REF" },
|
|
377
|
+
{ code: "ERR_STORAGE_WITHOUT_OBJECT_REF", source: field.source },
|
|
375
378
|
),
|
|
376
379
|
);
|
|
377
380
|
}
|
|
@@ -379,7 +382,7 @@ export function validateFieldObjectStorage(root: MetaData): ParseError[] {
|
|
|
379
382
|
errors.push(
|
|
380
383
|
new ParseError(
|
|
381
384
|
`field "${obj.name}.${field.name}" sets @storage "flattened" with isArray=true; flattened storage requires a single nested value`,
|
|
382
|
-
{ code: "ERR_STORAGE_FLATTENED_ARRAY" },
|
|
385
|
+
{ code: "ERR_STORAGE_FLATTENED_ARRAY", source: field.source },
|
|
383
386
|
),
|
|
384
387
|
);
|
|
385
388
|
}
|
|
@@ -413,7 +416,7 @@ export function validateDataGridFilterValues(root: MetaData): ParseError[] {
|
|
|
413
416
|
const filter = layout.ownAttr(LAYOUT_DATA_GRID_ATTR_FILTER);
|
|
414
417
|
// Type errors (e.g. legacy string form) are reported by validateAttrSchema.
|
|
415
418
|
if (typeof filter !== "object" || filter === null || Array.isArray(filter)) continue;
|
|
416
|
-
checkFilterClauses(filter as Record<string, unknown>, allow, obj.name, layout.name, errors);
|
|
419
|
+
checkFilterClauses(filter as Record<string, unknown>, allow, obj.name, layout.name, layout.source, errors);
|
|
417
420
|
}
|
|
418
421
|
}
|
|
419
422
|
return errors;
|
|
@@ -424,6 +427,7 @@ function checkFilterClauses(
|
|
|
424
427
|
allow: Map<string, readonly string[]>,
|
|
425
428
|
entityName: string,
|
|
426
429
|
layoutName: string,
|
|
430
|
+
layoutSource: ErrorSource,
|
|
427
431
|
errors: ParseError[],
|
|
428
432
|
): void {
|
|
429
433
|
for (const [key, clause] of Object.entries(filter)) {
|
|
@@ -431,7 +435,7 @@ function checkFilterClauses(
|
|
|
431
435
|
if (Array.isArray(clause)) {
|
|
432
436
|
for (const sub of clause) {
|
|
433
437
|
if (typeof sub === "object" && sub !== null && !Array.isArray(sub)) {
|
|
434
|
-
checkFilterClauses(sub as Record<string, unknown>, allow, entityName, layoutName, errors);
|
|
438
|
+
checkFilterClauses(sub as Record<string, unknown>, allow, entityName, layoutName, layoutSource, errors);
|
|
435
439
|
}
|
|
436
440
|
}
|
|
437
441
|
}
|
|
@@ -443,7 +447,7 @@ function checkFilterClauses(
|
|
|
443
447
|
new ParseError(
|
|
444
448
|
`dataGrid layout "${layoutName}" on entity "${entityName}" has @filter over ` +
|
|
445
449
|
`non-filterable field "${key}". Filterable fields: ${[...allow.keys()].join(", ") || "(none)"}`,
|
|
446
|
-
{ code: "ERR_BAD_ATTR_FILTER" },
|
|
450
|
+
{ code: "ERR_BAD_ATTR_FILTER", source: layoutSource },
|
|
447
451
|
),
|
|
448
452
|
);
|
|
449
453
|
continue;
|
|
@@ -457,7 +461,7 @@ function checkFilterClauses(
|
|
|
457
461
|
new ParseError(
|
|
458
462
|
`dataGrid layout "${layoutName}" on entity "${entityName}" @filter uses disallowed ` +
|
|
459
463
|
`op "${key}.${op}". Allowed ops for "${key}": ${allowedOps.join(", ")}`,
|
|
460
|
-
{ code: "ERR_BAD_ATTR_FILTER" },
|
|
464
|
+
{ code: "ERR_BAD_ATTR_FILTER", source: layoutSource },
|
|
461
465
|
),
|
|
462
466
|
);
|
|
463
467
|
}
|