@metaobjectsdev/metadata 0.6.0 → 0.7.0-rc.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/README.md +54 -3
  2. package/dist/attr-schema-validate.js +7 -7
  3. package/dist/attr-schema-validate.js.map +1 -1
  4. package/dist/core/export-json.d.ts +6 -7
  5. package/dist/core/export-json.d.ts.map +1 -1
  6. package/dist/core/export-json.js +15 -17
  7. package/dist/core/export-json.js.map +1 -1
  8. package/dist/core/index.d.ts +4 -2
  9. package/dist/core/index.d.ts.map +1 -1
  10. package/dist/core/index.js +6 -2
  11. package/dist/core/index.js.map +1 -1
  12. package/dist/core/parser-yaml.d.ts.map +1 -1
  13. package/dist/core/parser-yaml.js +36 -11
  14. package/dist/core/parser-yaml.js.map +1 -1
  15. package/dist/core/yaml-desugar.d.ts.map +1 -1
  16. package/dist/core/yaml-desugar.js +54 -5
  17. package/dist/core/yaml-desugar.js.map +1 -1
  18. package/dist/core/yaml-positions-walker.d.ts +21 -0
  19. package/dist/core/yaml-positions-walker.d.ts.map +1 -0
  20. package/dist/core/yaml-positions-walker.js +75 -0
  21. package/dist/core/yaml-positions-walker.js.map +1 -0
  22. package/dist/core/yaml-positions.d.ts +19 -0
  23. package/dist/core/yaml-positions.d.ts.map +1 -0
  24. package/dist/core/yaml-positions.js +60 -0
  25. package/dist/core/yaml-positions.js.map +1 -0
  26. package/dist/core-types.d.ts.map +1 -1
  27. package/dist/core-types.js +7 -4
  28. package/dist/core-types.js.map +1 -1
  29. package/dist/errors.d.ts +32 -9
  30. package/dist/errors.d.ts.map +1 -1
  31. package/dist/errors.js +44 -5
  32. package/dist/errors.js.map +1 -1
  33. package/dist/index.d.ts +6 -3
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +9 -2
  36. package/dist/index.js.map +1 -1
  37. package/dist/json-path.d.ts +8 -0
  38. package/dist/json-path.d.ts.map +1 -0
  39. package/dist/json-path.js +39 -0
  40. package/dist/json-path.js.map +1 -0
  41. package/dist/loader/meta-data-loader.d.ts +47 -6
  42. package/dist/loader/meta-data-loader.d.ts.map +1 -1
  43. package/dist/loader/meta-data-loader.js +126 -8
  44. package/dist/loader/meta-data-loader.js.map +1 -1
  45. package/dist/loader/meta-data-source.d.ts +6 -2
  46. package/dist/loader/meta-data-source.d.ts.map +1 -1
  47. package/dist/loader/meta-data-source.js +10 -6
  48. package/dist/loader/meta-data-source.js.map +1 -1
  49. package/dist/loader/shortcuts.d.ts +9 -0
  50. package/dist/loader/shortcuts.d.ts.map +1 -0
  51. package/dist/loader/shortcuts.js +19 -0
  52. package/dist/loader/shortcuts.js.map +1 -0
  53. package/dist/loader/sources/directory-source.d.ts +15 -0
  54. package/dist/loader/sources/directory-source.d.ts.map +1 -0
  55. package/dist/loader/sources/directory-source.js +80 -0
  56. package/dist/loader/sources/directory-source.js.map +1 -0
  57. package/dist/loader/sources/file-source.d.ts +12 -0
  58. package/dist/loader/sources/file-source.d.ts.map +1 -0
  59. package/dist/loader/sources/file-source.js +46 -0
  60. package/dist/loader/sources/file-source.js.map +1 -0
  61. package/dist/loader/sources/index.d.ts +5 -0
  62. package/dist/loader/sources/index.d.ts.map +1 -0
  63. package/dist/loader/sources/index.js +5 -0
  64. package/dist/loader/sources/index.js.map +1 -0
  65. package/dist/loader/sources/uri-source.d.ts +9 -0
  66. package/dist/loader/sources/uri-source.d.ts.map +1 -0
  67. package/dist/loader/sources/uri-source.js +42 -0
  68. package/dist/loader/sources/uri-source.js.map +1 -0
  69. package/dist/loader/validation-passes.d.ts.map +1 -1
  70. package/dist/loader/validation-passes.js +92 -28
  71. package/dist/loader/validation-passes.js.map +1 -1
  72. package/dist/naming.d.ts +15 -2
  73. package/dist/naming.d.ts.map +1 -1
  74. package/dist/naming.js +20 -6
  75. package/dist/naming.js.map +1 -1
  76. package/dist/parser-core.d.ts +17 -4
  77. package/dist/parser-core.d.ts.map +1 -1
  78. package/dist/parser-core.js +371 -44
  79. package/dist/parser-core.js.map +1 -1
  80. package/dist/parser-json.d.ts.map +1 -1
  81. package/dist/parser-json.js +10 -2
  82. package/dist/parser-json.js.map +1 -1
  83. package/dist/persistence/source/validate-source-roles.js +2 -2
  84. package/dist/persistence/source/validate-source-roles.js.map +1 -1
  85. package/dist/semantic-diff.d.ts +5 -0
  86. package/dist/semantic-diff.d.ts.map +1 -0
  87. package/dist/semantic-diff.js +49 -0
  88. package/dist/semantic-diff.js.map +1 -0
  89. package/dist/shared/meta-data.d.ts +10 -0
  90. package/dist/shared/meta-data.d.ts.map +1 -1
  91. package/dist/shared/meta-data.js +23 -0
  92. package/dist/shared/meta-data.js.map +1 -1
  93. package/dist/source.d.ts +96 -0
  94. package/dist/source.d.ts.map +1 -0
  95. package/dist/source.js +38 -0
  96. package/dist/source.js.map +1 -0
  97. package/dist/subtype-rules.js +1 -1
  98. package/dist/subtype-rules.js.map +1 -1
  99. package/dist/super-resolve.d.ts +2 -0
  100. package/dist/super-resolve.d.ts.map +1 -1
  101. package/dist/super-resolve.js +1 -1
  102. package/dist/super-resolve.js.map +1 -1
  103. package/dist/template/template-constants.d.ts +3 -1
  104. package/dist/template/template-constants.d.ts.map +1 -1
  105. package/dist/template/template-constants.js +22 -6
  106. package/dist/template/template-constants.js.map +1 -1
  107. package/dist/template/template-schema.d.ts.map +1 -1
  108. package/dist/template/template-schema.js +41 -1
  109. package/dist/template/template-schema.js.map +1 -1
  110. package/package.json +1 -1
  111. package/src/attr-schema-validate.ts +7 -7
  112. package/src/core/export-json.ts +15 -18
  113. package/src/core/index.ts +8 -2
  114. package/src/core/parser-yaml.ts +38 -11
  115. package/src/core/yaml-desugar.ts +58 -4
  116. package/src/core/yaml-positions-walker.ts +101 -0
  117. package/src/core/yaml-positions.ts +80 -0
  118. package/src/core-types.ts +7 -4
  119. package/src/errors.ts +57 -8
  120. package/src/index.ts +28 -3
  121. package/src/json-path.ts +46 -0
  122. package/src/loader/meta-data-loader.ts +168 -10
  123. package/src/loader/meta-data-source.ts +10 -6
  124. package/src/loader/shortcuts.ts +31 -0
  125. package/src/loader/sources/directory-source.ts +90 -0
  126. package/src/{core → loader/sources}/file-source.ts +3 -3
  127. package/src/loader/sources/index.ts +6 -0
  128. package/src/loader/sources/uri-source.ts +44 -0
  129. package/src/loader/validation-passes.ts +96 -29
  130. package/src/naming.ts +39 -7
  131. package/src/parser-core.ts +412 -46
  132. package/src/parser-json.ts +11 -2
  133. package/src/persistence/source/validate-source-roles.ts +2 -2
  134. package/src/semantic-diff.ts +48 -0
  135. package/src/shared/meta-data.ts +28 -0
  136. package/src/source.ts +99 -0
  137. package/src/subtype-rules.ts +1 -1
  138. package/src/super-resolve.ts +3 -1
  139. package/src/template/template-constants.ts +23 -6
  140. package/src/template/template-schema.ts +43 -0
  141. package/src/core/file-meta-data-loader.ts +0 -89
@@ -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/core/ so the browser-safe root never
3
- // imports it.
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 "../loader/meta-data-source.js";
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 { resolvedSource, type ErrorSource } from "../source.js";
14
15
  import {
15
16
  TYPE_OBJECT,
16
17
  TYPE_FIELD,
@@ -24,6 +25,7 @@ import {
24
25
  TEMPLATE_ATTR_PAYLOAD_REF,
25
26
  TEMPLATE_ATTR_REQUIRED_SLOTS,
26
27
  } from "../template/template-constants.js";
28
+ import { OBJECT_SUBTYPE_VALUE } from "../core/object/object-constants.js";
27
29
  import {
28
30
  LAYOUT_SUBTYPE_DATA_GRID,
29
31
  LAYOUT_DATA_GRID_ATTR_DEFAULT_SORT_FIELD,
@@ -72,7 +74,7 @@ export function validateDataGridSortFields(root: MetaData): ParseError[] {
72
74
  new ParseError(
73
75
  `dataGrid layout "${layout.name}" on entity "${obj.name}" has @defaultSortField "${sortField}" ` +
74
76
  `but no such field exists on "${obj.name}". Available fields: ${[...fieldNames].join(", ")}`,
75
- { code: "ERR_BAD_DEFAULT_SORT_FIELD" },
77
+ { code: "ERR_BAD_DEFAULT_SORT_FIELD", source: layout.source },
76
78
  ),
77
79
  );
78
80
  }
@@ -98,11 +100,16 @@ export function validateTemplatePayloadRefs(root: MetaData): ParseError[] {
98
100
  const payloadRef = tmpl.ownAttr(TEMPLATE_ATTR_PAYLOAD_REF);
99
101
  if (typeof payloadRef !== "string") continue; // absence handled by the required-attr schema check
100
102
  const payload = root.ownChildren().find((c) => c.type === TYPE_OBJECT && c.name === payloadRef);
101
- if (!payload) {
103
+ if (!payload || payload.subType !== OBJECT_SUBTYPE_VALUE) {
104
+ // FR5d — @payloadRef is a reference; emit format=resolved with
105
+ // referrer=template FQN, target=the unresolved payloadRef string.
102
106
  errors.push(
103
107
  new ParseError(
104
- `template "${tmpl.name}" @payloadRef "${payloadRef}" does not resolve to a known object in this model`,
105
- { code: "ERR_INVALID_TEMPLATE" },
108
+ `template "${tmpl.name}" @payloadRef "${payloadRef}" does not resolve to an object.value at root`,
109
+ {
110
+ code: "ERR_INVALID_TEMPLATE",
111
+ source: resolvedSource(tmpl.source, tmpl.fqn(), payloadRef),
112
+ },
106
113
  ),
107
114
  );
108
115
  continue;
@@ -114,11 +121,17 @@ export function validateTemplatePayloadRefs(root: MetaData): ParseError[] {
114
121
  const slotList = Array.isArray(slots) ? slots : typeof slots === "string" ? [slots] : [];
115
122
  for (const slot of slotList) {
116
123
  if (typeof slot === "string" && !fieldNames.has(slot)) {
124
+ // FR5d — @requiredSlots is a field-on-payload reference; emit
125
+ // format=resolved with referrer=template FQN, target=`payloadRef.slot`
126
+ // (the dotted ref that did not resolve to a payload field).
117
127
  errors.push(
118
128
  new ParseError(
119
129
  `template "${tmpl.name}" @requiredSlots "${slot}" is not a field on payload "${payloadRef}". ` +
120
130
  `Available fields: ${[...fieldNames].join(", ")}`,
121
- { code: "ERR_INVALID_TEMPLATE" },
131
+ {
132
+ code: "ERR_INVALID_TEMPLATE",
133
+ source: resolvedSource(tmpl.source, tmpl.fqn(), `${payloadRef}.${slot}`),
134
+ },
122
135
  ),
123
136
  );
124
137
  }
@@ -194,17 +207,28 @@ function _findRelationship(obj: MetaData, name: string): MetaData | undefined {
194
207
  function _validateFromPath(
195
208
  fromAttr: string,
196
209
  root: MetaData,
197
- projectionName: string,
210
+ projection: MetaData,
198
211
  fieldName: string,
212
+ originSource: ErrorSource,
199
213
  errors: ParseError[],
200
214
  label: string = "origin.passthrough.@from",
201
215
  ): void {
216
+ const projectionName = projection.name;
217
+ // FR5d — referrer is `<projection-FQN>::<fieldName>` (the canonical
218
+ // "where the broken reference lives" identifier).
219
+ const referrer = `${projection.fqn()}::${fieldName}`;
202
220
  const dotIdx = fromAttr.indexOf(".");
203
221
  if (dotIdx < 1 || dotIdx === fromAttr.length - 1) {
222
+ // Malformed shape (not "Entity.field") — not a reference resolution
223
+ // failure per se, but emit format=resolved with target=the bad string
224
+ // so consumers see the same envelope shape across all FR5d sites.
204
225
  errors.push(
205
226
  new ParseError(
206
227
  `${label} "${fromAttr}" on ${projectionName}.${fieldName}: must be of form "Entity.field".`,
207
- { code: "ERR_INVALID_ORIGIN" },
228
+ {
229
+ code: "ERR_INVALID_ORIGIN",
230
+ source: resolvedSource(originSource, referrer, fromAttr),
231
+ },
208
232
  ),
209
233
  );
210
234
  return;
@@ -213,20 +237,28 @@ function _validateFromPath(
213
237
  const targetFieldName = fromAttr.slice(dotIdx + 1);
214
238
  const sourceObj = _findObject(root, entityName);
215
239
  if (!sourceObj) {
240
+ // FR5d — entity half of the ref didn't resolve. target = full ref.
216
241
  errors.push(
217
242
  new ParseError(
218
243
  `${label} "${fromAttr}" on ${projectionName}.${fieldName}: no such entity "${entityName}".`,
219
- { code: "ERR_INVALID_ORIGIN" },
244
+ {
245
+ code: "ERR_INVALID_ORIGIN",
246
+ source: resolvedSource(originSource, referrer, fromAttr),
247
+ },
220
248
  ),
221
249
  );
222
250
  return;
223
251
  }
224
252
  const sourceField = _findField(sourceObj, targetFieldName);
225
253
  if (!sourceField) {
254
+ // FR5d — entity resolved, field on it did not. target = full ref.
226
255
  errors.push(
227
256
  new ParseError(
228
257
  `${label} "${fromAttr}" on ${projectionName}.${fieldName}: no such field "${targetFieldName}" on ${entityName}.`,
229
- { code: "ERR_INVALID_ORIGIN" },
258
+ {
259
+ code: "ERR_INVALID_ORIGIN",
260
+ source: resolvedSource(originSource, referrer, fromAttr),
261
+ },
230
262
  ),
231
263
  );
232
264
  }
@@ -235,16 +267,23 @@ function _validateFromPath(
235
267
  function _validateViaPath(
236
268
  viaAttr: string,
237
269
  root: MetaData,
238
- projectionName: string,
270
+ projection: MetaData,
239
271
  fieldName: string,
272
+ originSource: ErrorSource,
240
273
  errors: ParseError[],
241
274
  ): void {
275
+ const projectionName = projection.name;
276
+ // FR5d — referrer is `<projection-FQN>::<fieldName>`.
277
+ const referrer = `${projection.fqn()}::${fieldName}`;
242
278
  const segments = viaAttr.split(".");
243
279
  if (segments.length < 2) {
244
280
  errors.push(
245
281
  new ParseError(
246
282
  `origin.@via "${viaAttr}" on ${projectionName}.${fieldName}: must be of form "Entity.relationship[.relationship...]".`,
247
- { code: "ERR_INVALID_ORIGIN" },
283
+ {
284
+ code: "ERR_INVALID_ORIGIN",
285
+ source: resolvedSource(originSource, referrer, viaAttr),
286
+ },
248
287
  ),
249
288
  );
250
289
  return;
@@ -255,18 +294,32 @@ function _validateViaPath(
255
294
  errors.push(
256
295
  new ParseError(
257
296
  `origin.@via "${viaAttr}" on ${projectionName}.${fieldName}: no such entity "${entityName}".`,
258
- { code: "ERR_INVALID_ORIGIN" },
297
+ {
298
+ code: "ERR_INVALID_ORIGIN",
299
+ source: resolvedSource(originSource, referrer, viaAttr),
300
+ },
259
301
  ),
260
302
  );
261
303
  return;
262
304
  }
305
+ // FR5d — track the deepest-valid-prefix as we walk. The prefix grows
306
+ // segment-by-segment; on a hop failure the error message names the prefix
307
+ // that DID resolve, so authors can fix multi-hop typos quickly.
308
+ // After the entity lookup above, the deepest valid prefix is just the
309
+ // entity name; each successful relationship hop appends a segment.
310
+ const validSegments: string[] = [entityName];
263
311
  for (const relName of relSegments) {
264
312
  const rel = _findRelationship(currentObj, relName);
265
313
  if (!rel) {
314
+ const prefix = validSegments.join(".");
266
315
  errors.push(
267
316
  new ParseError(
268
- `origin.@via "${viaAttr}" on ${projectionName}.${fieldName}: no such relationship "${relName}" on ${currentObj.name}.`,
269
- { code: "ERR_INVALID_ORIGIN" },
317
+ `origin.@via "${viaAttr}" on ${projectionName}.${fieldName}: no such relationship "${relName}" on ${currentObj.name}. ` +
318
+ `Deepest valid prefix was "${prefix}".`,
319
+ {
320
+ code: "ERR_INVALID_ORIGIN",
321
+ source: resolvedSource(originSource, referrer, viaAttr),
322
+ },
270
323
  ),
271
324
  );
272
325
  return;
@@ -276,21 +329,32 @@ function _validateViaPath(
276
329
  errors.push(
277
330
  new ParseError(
278
331
  `origin.@via "${viaAttr}" on ${projectionName}.${fieldName}: relationship "${relName}" on ${currentObj.name} is missing @objectRef.`,
279
- { code: "ERR_INVALID_ORIGIN" },
332
+ {
333
+ code: "ERR_INVALID_ORIGIN",
334
+ source: resolvedSource(originSource, referrer, viaAttr),
335
+ },
280
336
  ),
281
337
  );
282
338
  return;
283
339
  }
284
340
  const nextObj = _findObject(root, refTarget);
285
341
  if (!nextObj) {
342
+ // FR5d — relationship's @objectRef points at a missing entity. This
343
+ // is the @objectRef-resolution edge of the via-path walk (the "5th
344
+ // site" in FR5d's scope list for @objectRef references encountered
345
+ // transitively).
286
346
  errors.push(
287
347
  new ParseError(
288
348
  `origin.@via "${viaAttr}" on ${projectionName}.${fieldName}: relationship "${relName}" points to non-existent entity "${refTarget}".`,
289
- { code: "ERR_INVALID_ORIGIN" },
349
+ {
350
+ code: "ERR_INVALID_ORIGIN",
351
+ source: resolvedSource(originSource, referrer, refTarget),
352
+ },
290
353
  ),
291
354
  );
292
355
  return;
293
356
  }
357
+ validSegments.push(relName);
294
358
  currentObj = nextObj;
295
359
  }
296
360
  }
@@ -303,18 +367,20 @@ export function validateOriginPaths(root: MetaData): ParseError[] {
303
367
  if (origin.subType === ORIGIN_SUBTYPE_PASSTHROUGH) {
304
368
  const from = origin.ownAttr(ORIGIN_PASSTHROUGH_ATTR_FROM);
305
369
  if (typeof from !== "string" || from === "") {
370
+ // Missing-attr (not a reference resolution failure) — keep the
371
+ // node's own source envelope (json/yaml/merged).
306
372
  errors.push(
307
373
  new ParseError(
308
374
  `origin.passthrough on ${obj.name}.${field.name}: missing @from.`,
309
- { code: "ERR_INVALID_ORIGIN" },
375
+ { code: "ERR_INVALID_ORIGIN", source: origin.source },
310
376
  ),
311
377
  );
312
378
  continue;
313
379
  }
314
- _validateFromPath(from, root, obj.name, field.name, errors);
380
+ _validateFromPath(from, root, obj, field.name, origin.source, errors);
315
381
  const via = origin.ownAttr(ORIGIN_PASSTHROUGH_ATTR_VIA);
316
382
  if (typeof via === "string" && via !== "") {
317
- _validateViaPath(via, root, obj.name, field.name, errors);
383
+ _validateViaPath(via, root, obj, field.name, origin.source, errors);
318
384
  }
319
385
  } else if (origin.subType === ORIGIN_SUBTYPE_AGGREGATE) {
320
386
  const of_ = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_OF);
@@ -322,23 +388,23 @@ export function validateOriginPaths(root: MetaData): ParseError[] {
322
388
  errors.push(
323
389
  new ParseError(
324
390
  `origin.aggregate on ${obj.name}.${field.name}: missing @of.`,
325
- { code: "ERR_INVALID_ORIGIN" },
391
+ { code: "ERR_INVALID_ORIGIN", source: origin.source },
326
392
  ),
327
393
  );
328
394
  continue;
329
395
  }
330
- _validateFromPath(of_, root, obj.name, field.name, errors, "origin.aggregate.@of");
396
+ _validateFromPath(of_, root, obj, field.name, origin.source, errors, "origin.aggregate.@of");
331
397
  const via = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_VIA);
332
398
  if (typeof via !== "string" || via === "") {
333
399
  errors.push(
334
400
  new ParseError(
335
401
  `origin.aggregate on ${obj.name}.${field.name}: missing @via (aggregates require a relationship path).`,
336
- { code: "ERR_INVALID_ORIGIN" },
402
+ { code: "ERR_INVALID_ORIGIN", source: origin.source },
337
403
  ),
338
404
  );
339
405
  continue;
340
406
  }
341
- _validateViaPath(via, root, obj.name, field.name, errors);
407
+ _validateViaPath(via, root, obj, field.name, origin.source, errors);
342
408
  }
343
409
  }
344
410
  }
@@ -371,7 +437,7 @@ export function validateFieldObjectStorage(root: MetaData): ParseError[] {
371
437
  errors.push(
372
438
  new ParseError(
373
439
  `field "${obj.name}.${field.name}" sets @storage but has no @objectRef`,
374
- { code: "ERR_STORAGE_WITHOUT_OBJECT_REF" },
440
+ { code: "ERR_STORAGE_WITHOUT_OBJECT_REF", source: field.source },
375
441
  ),
376
442
  );
377
443
  }
@@ -379,7 +445,7 @@ export function validateFieldObjectStorage(root: MetaData): ParseError[] {
379
445
  errors.push(
380
446
  new ParseError(
381
447
  `field "${obj.name}.${field.name}" sets @storage "flattened" with isArray=true; flattened storage requires a single nested value`,
382
- { code: "ERR_STORAGE_FLATTENED_ARRAY" },
448
+ { code: "ERR_STORAGE_FLATTENED_ARRAY", source: field.source },
383
449
  ),
384
450
  );
385
451
  }
@@ -413,7 +479,7 @@ export function validateDataGridFilterValues(root: MetaData): ParseError[] {
413
479
  const filter = layout.ownAttr(LAYOUT_DATA_GRID_ATTR_FILTER);
414
480
  // Type errors (e.g. legacy string form) are reported by validateAttrSchema.
415
481
  if (typeof filter !== "object" || filter === null || Array.isArray(filter)) continue;
416
- checkFilterClauses(filter as Record<string, unknown>, allow, obj.name, layout.name, errors);
482
+ checkFilterClauses(filter as Record<string, unknown>, allow, obj.name, layout.name, layout.source, errors);
417
483
  }
418
484
  }
419
485
  return errors;
@@ -424,6 +490,7 @@ function checkFilterClauses(
424
490
  allow: Map<string, readonly string[]>,
425
491
  entityName: string,
426
492
  layoutName: string,
493
+ layoutSource: ErrorSource,
427
494
  errors: ParseError[],
428
495
  ): void {
429
496
  for (const [key, clause] of Object.entries(filter)) {
@@ -431,7 +498,7 @@ function checkFilterClauses(
431
498
  if (Array.isArray(clause)) {
432
499
  for (const sub of clause) {
433
500
  if (typeof sub === "object" && sub !== null && !Array.isArray(sub)) {
434
- checkFilterClauses(sub as Record<string, unknown>, allow, entityName, layoutName, errors);
501
+ checkFilterClauses(sub as Record<string, unknown>, allow, entityName, layoutName, layoutSource, errors);
435
502
  }
436
503
  }
437
504
  }
@@ -443,7 +510,7 @@ function checkFilterClauses(
443
510
  new ParseError(
444
511
  `dataGrid layout "${layoutName}" on entity "${entityName}" has @filter over ` +
445
512
  `non-filterable field "${key}". Filterable fields: ${[...allow.keys()].join(", ") || "(none)"}`,
446
- { code: "ERR_BAD_ATTR_FILTER" },
513
+ { code: "ERR_BAD_ATTR_FILTER", source: layoutSource },
447
514
  ),
448
515
  );
449
516
  continue;
@@ -457,7 +524,7 @@ function checkFilterClauses(
457
524
  new ParseError(
458
525
  `dataGrid layout "${layoutName}" on entity "${entityName}" @filter uses disallowed ` +
459
526
  `op "${key}.${op}". Allowed ops for "${key}": ${allowedOps.join(", ")}`,
460
- { code: "ERR_BAD_ATTR_FILTER" },
527
+ { code: "ERR_BAD_ATTR_FILTER", source: layoutSource },
461
528
  ),
462
529
  );
463
530
  }
package/src/naming.ts CHANGED
@@ -28,6 +28,31 @@ export function toSnakeCase(s: string): string {
28
28
  .toLowerCase();
29
29
  }
30
30
 
31
+ export function toKebabCase(s: string): string {
32
+ return toSnakeCase(s).replace(/_/g, "-");
33
+ }
34
+
35
+ /**
36
+ * Column-naming strategy applied by the persistence layer to fields with no
37
+ * explicit `@column` override. Persistence-layer config (set on
38
+ * ObjectManager / buildExpectedSchema / codegen config), not a metadata attr —
39
+ * the same metadata can drive snake_case (PG convention) or literal (EF
40
+ * convention) consumers. TS port defaults to snake_case; C# port defaults to
41
+ * literal.
42
+ */
43
+ export type ColumnNamingStrategy = "snake_case" | "literal" | "kebab-case";
44
+
45
+ /** Single source of truth for the TS-port default. */
46
+ export const DEFAULT_COLUMN_NAMING_STRATEGY: ColumnNamingStrategy = "snake_case";
47
+
48
+ export function applyColumnNamingStrategy(name: string, strategy: ColumnNamingStrategy): string {
49
+ switch (strategy) {
50
+ case "literal": return name;
51
+ case "kebab-case": return toKebabCase(name);
52
+ case "snake_case": return toSnakeCase(name);
53
+ }
54
+ }
55
+
31
56
  export function pluralize(s: string): string {
32
57
  if (/(s|x|z|ch|sh)$/i.test(s)) return s + "es";
33
58
  if (/[^aeiou]y$/i.test(s)) return s.slice(0, -1) + "ies";
@@ -35,20 +60,24 @@ export function pluralize(s: string): string {
35
60
  }
36
61
 
37
62
  export function resolveTableName(entity: MetaData): string {
38
- // Primary writable source carries the physical table name (@table).
63
+ // Primary source carries the physical table/view name (@table). Writability
64
+ // (table vs view/storedProc/tableFunction) only affects write-routing — for
65
+ // SELECT-side name resolution, a read-only primary source is the right answer.
39
66
  const source = entity.ownChildren().find(
40
- (c): c is MetaSource =>
41
- c instanceof MetaSource && c.isWritable() && c.role === SOURCE_ROLE_PRIMARY,
67
+ (c): c is MetaSource => c instanceof MetaSource && c.role === SOURCE_ROLE_PRIMARY,
42
68
  );
43
69
  const name = source?.tableName;
44
70
  if (typeof name === "string" && name !== "") return name;
45
71
  return pluralize(toSnakeCase(entity.name));
46
72
  }
47
73
 
48
- export function resolveColumnName(field: MetaData): string {
74
+ export function resolveColumnName(
75
+ field: MetaData,
76
+ strategy: ColumnNamingStrategy = DEFAULT_COLUMN_NAMING_STRATEGY,
77
+ ): string {
49
78
  const col = field.ownAttr(FIELD_ATTR_COLUMN);
50
79
  if (typeof col === "string" && col) return col;
51
- return toSnakeCase(field.name);
80
+ return applyColumnNamingStrategy(field.name, strategy);
52
81
  }
53
82
 
54
83
  /**
@@ -75,12 +104,15 @@ export interface EntityNameMap {
75
104
  dbToJs: Map<string, string>;
76
105
  }
77
106
 
78
- export function buildNameMap(entity: MetaData): EntityNameMap {
107
+ export function buildNameMap(
108
+ entity: MetaData,
109
+ strategy: ColumnNamingStrategy = DEFAULT_COLUMN_NAMING_STRATEGY,
110
+ ): EntityNameMap {
79
111
  const jsToDb = new Map<string, string>();
80
112
  const dbToJs = new Map<string, string>();
81
113
  for (const child of entity.ownChildren()) {
82
114
  if (child.type !== TYPE_FIELD) continue;
83
- const dbCol = resolveColumnName(child);
115
+ const dbCol = resolveColumnName(child, strategy);
84
116
  jsToDb.set(child.name, dbCol);
85
117
  dbToJs.set(dbCol, child.name);
86
118
  }