@x12i/catalox 3.5.1 → 3.7.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.
Files changed (121) hide show
  1. package/README.md +155 -10
  2. package/dist/src/adapters/api/api-adapter.d.ts.map +1 -1
  3. package/dist/src/adapters/api/api-adapter.js +1 -0
  4. package/dist/src/adapters/api/api-adapter.js.map +1 -1
  5. package/dist/src/adapters/mongo/mongo-adapter.d.ts.map +1 -1
  6. package/dist/src/adapters/mongo/mongo-adapter.js +1 -0
  7. package/dist/src/adapters/mongo/mongo-adapter.js.map +1 -1
  8. package/dist/src/catalox/catalog-discovery.d.ts.map +1 -1
  9. package/dist/src/catalox/catalog-discovery.js +8 -6
  10. package/dist/src/catalox/catalog-discovery.js.map +1 -1
  11. package/dist/src/catalox/catalog-lifecycle.d.ts.map +1 -1
  12. package/dist/src/catalox/catalog-lifecycle.js +26 -19
  13. package/dist/src/catalox/catalog-lifecycle.js.map +1 -1
  14. package/dist/src/catalox/catalox-bound.d.ts +48 -1
  15. package/dist/src/catalox/catalox-bound.d.ts.map +1 -1
  16. package/dist/src/catalox/catalox-bound.js +24 -0
  17. package/dist/src/catalox/catalox-bound.js.map +1 -1
  18. package/dist/src/catalox/catalox.d.ts +94 -2
  19. package/dist/src/catalox/catalox.d.ts.map +1 -1
  20. package/dist/src/catalox/catalox.js +596 -75
  21. package/dist/src/catalox/catalox.js.map +1 -1
  22. package/dist/src/catalox/create-catalox.d.ts +4 -0
  23. package/dist/src/catalox/create-catalox.d.ts.map +1 -1
  24. package/dist/src/catalox/create-catalox.js +15 -0
  25. package/dist/src/catalox/create-catalox.js.map +1 -1
  26. package/dist/src/catalox/native-catalog-merge.d.ts.map +1 -1
  27. package/dist/src/catalox/native-catalog-merge.js +12 -11
  28. package/dist/src/catalox/native-catalog-merge.js.map +1 -1
  29. package/dist/src/contracts/agents.d.ts +19 -0
  30. package/dist/src/contracts/agents.d.ts.map +1 -0
  31. package/dist/src/contracts/agents.js +2 -0
  32. package/dist/src/contracts/agents.js.map +1 -0
  33. package/dist/src/contracts/catalog-types.d.ts +19 -0
  34. package/dist/src/contracts/catalog-types.d.ts.map +1 -0
  35. package/dist/src/contracts/catalog-types.js +2 -0
  36. package/dist/src/contracts/catalog-types.js.map +1 -0
  37. package/dist/src/contracts/catalogs.d.ts +26 -6
  38. package/dist/src/contracts/catalogs.d.ts.map +1 -1
  39. package/dist/src/contracts/catalogs.js.map +1 -1
  40. package/dist/src/contracts/descriptors.d.ts +11 -0
  41. package/dist/src/contracts/descriptors.d.ts.map +1 -1
  42. package/dist/src/contracts/design-objects.d.ts +38 -0
  43. package/dist/src/contracts/design-objects.d.ts.map +1 -0
  44. package/dist/src/contracts/design-objects.js +2 -0
  45. package/dist/src/contracts/design-objects.js.map +1 -0
  46. package/dist/src/contracts/discovery.d.ts +7 -8
  47. package/dist/src/contracts/discovery.d.ts.map +1 -1
  48. package/dist/src/contracts/domains.d.ts +19 -0
  49. package/dist/src/contracts/domains.d.ts.map +1 -0
  50. package/dist/src/contracts/domains.js +2 -0
  51. package/dist/src/contracts/domains.js.map +1 -0
  52. package/dist/src/contracts/ids.d.ts +2 -0
  53. package/dist/src/contracts/ids.d.ts.map +1 -1
  54. package/dist/src/contracts/index.d.ts +11 -5
  55. package/dist/src/contracts/index.d.ts.map +1 -1
  56. package/dist/src/contracts/index.js +1 -0
  57. package/dist/src/contracts/index.js.map +1 -1
  58. package/dist/src/contracts/inputs.d.ts +19 -0
  59. package/dist/src/contracts/inputs.d.ts.map +1 -1
  60. package/dist/src/contracts/items.d.ts +9 -7
  61. package/dist/src/contracts/items.d.ts.map +1 -1
  62. package/dist/src/contracts/presentation-binding.d.ts +116 -0
  63. package/dist/src/contracts/presentation-binding.d.ts.map +1 -0
  64. package/dist/src/contracts/presentation-binding.js +215 -0
  65. package/dist/src/contracts/presentation-binding.js.map +1 -0
  66. package/dist/src/contracts/presentation.d.ts +76 -0
  67. package/dist/src/contracts/presentation.d.ts.map +1 -1
  68. package/dist/src/contracts/references.d.ts +40 -0
  69. package/dist/src/contracts/references.d.ts.map +1 -1
  70. package/dist/src/contracts/render-map.d.ts +12 -0
  71. package/dist/src/contracts/render-map.d.ts.map +1 -1
  72. package/dist/src/firebase/agent-store.d.ts +18 -0
  73. package/dist/src/firebase/agent-store.d.ts.map +1 -0
  74. package/dist/src/firebase/agent-store.js +47 -0
  75. package/dist/src/firebase/agent-store.js.map +1 -0
  76. package/dist/src/firebase/catalog-type-store.d.ts +22 -0
  77. package/dist/src/firebase/catalog-type-store.d.ts.map +1 -0
  78. package/dist/src/firebase/catalog-type-store.js +51 -0
  79. package/dist/src/firebase/catalog-type-store.js.map +1 -0
  80. package/dist/src/firebase/definition-store.d.ts +4 -18
  81. package/dist/src/firebase/definition-store.d.ts.map +1 -1
  82. package/dist/src/firebase/definition-store.js.map +1 -1
  83. package/dist/src/firebase/design-object-store.d.ts +15 -0
  84. package/dist/src/firebase/design-object-store.d.ts.map +1 -0
  85. package/dist/src/firebase/design-object-store.js +51 -0
  86. package/dist/src/firebase/design-object-store.js.map +1 -0
  87. package/dist/src/firebase/domain-store.d.ts +18 -0
  88. package/dist/src/firebase/domain-store.d.ts.map +1 -0
  89. package/dist/src/firebase/domain-store.js +47 -0
  90. package/dist/src/firebase/domain-store.js.map +1 -0
  91. package/dist/src/firebase/index.d.ts +2 -0
  92. package/dist/src/firebase/index.d.ts.map +1 -1
  93. package/dist/src/firebase/index.js +2 -0
  94. package/dist/src/firebase/index.js.map +1 -1
  95. package/dist/src/firebase/presentation-profile-store.d.ts +18 -0
  96. package/dist/src/firebase/presentation-profile-store.d.ts.map +1 -0
  97. package/dist/src/firebase/presentation-profile-store.js +44 -0
  98. package/dist/src/firebase/presentation-profile-store.js.map +1 -0
  99. package/dist/src/firebase/reference-store.d.ts +1 -0
  100. package/dist/src/firebase/reference-store.d.ts.map +1 -1
  101. package/dist/src/firebase/reference-store.js +7 -0
  102. package/dist/src/firebase/reference-store.js.map +1 -1
  103. package/dist/src/migrations/backfill-catalog-model.d.ts +29 -0
  104. package/dist/src/migrations/backfill-catalog-model.d.ts.map +1 -0
  105. package/dist/src/migrations/backfill-catalog-model.js +124 -0
  106. package/dist/src/migrations/backfill-catalog-model.js.map +1 -0
  107. package/dist/src/migrations/migrate-native-catalog-layout.js +4 -4
  108. package/dist/src/migrations/migrate-native-catalog-layout.js.map +1 -1
  109. package/dist/src/validation/index.d.ts +2 -2
  110. package/dist/src/validation/index.d.ts.map +1 -1
  111. package/dist/src/validation/index.js +2 -2
  112. package/dist/src/validation/index.js.map +1 -1
  113. package/dist/src/validation/ui-spec-schema.d.ts +5 -0
  114. package/dist/src/validation/ui-spec-schema.d.ts.map +1 -1
  115. package/dist/src/validation/ui-spec-schema.js +139 -0
  116. package/dist/src/validation/ui-spec-schema.js.map +1 -1
  117. package/dist/src/validation/ui-spec-validate.d.ts +3 -0
  118. package/dist/src/validation/ui-spec-validate.d.ts.map +1 -1
  119. package/dist/src/validation/ui-spec-validate.js +13 -1
  120. package/dist/src/validation/ui-spec-validate.js.map +1 -1
  121. package/package.json +2 -2
@@ -1,5 +1,5 @@
1
1
  import { compactCatalogFilter } from "../contracts/catalogs.js";
2
- import { CatalogAccessDeniedError, CatalogAdapterError, CatalogBindingError, CatalogNotFoundError, } from "../contracts/errors.js";
2
+ import { CatalogAccessDeniedError, CatalogAdapterError, CatalogBindingError, CatalogNotFoundError, CatalogValidationError, } from "../contracts/errors.js";
3
3
  import { validateMappingSpec } from "../mapping/validate-mapping.js";
4
4
  import { executeMapping } from "../mapping/execute-mapping.js";
5
5
  import { ApiCatalogAdapter } from "../adapters/api/api-adapter.js";
@@ -12,11 +12,17 @@ import { parseJson, toJson } from "./json-io.js";
12
12
  import { createHash, randomUUID } from "node:crypto";
13
13
  import { renderInventoryReportMarkdown } from "./reporting/render-inventory-report.js";
14
14
  import { optionsFromCatalogItems, optionsFromStaticSource, resolveFilterBy } from "./field-source-resolution.js";
15
+ import { resolveCatalogPresentationBinding } from "../contracts/presentation-binding.js";
16
+ import { buildRendererSnippetDocId } from "../migrations/renderer-metadata.js";
17
+ import { backfillCatalogModel } from "../migrations/backfill-catalog-model.js";
15
18
  import { AppStore } from "../firebase/app-store.js";
16
19
  import { AdapterStore } from "../firebase/adapter-store.js";
17
20
  import { BindingStore } from "../firebase/binding-store.js";
18
21
  import { CatalogStore } from "../firebase/catalog-store.js";
19
22
  import { CatalogDataIndexStore } from "../firebase/catalog-data-index-store.js";
23
+ import { CatalogTypeStore } from "../firebase/catalog-type-store.js";
24
+ import { DomainStore } from "../firebase/domain-store.js";
25
+ import { AgentStore } from "../firebase/agent-store.js";
20
26
  import { DefinitionStore } from "../firebase/definition-store.js";
21
27
  import { DescriptorStore } from "../firebase/descriptor-store.js";
22
28
  import { FirestoreStore } from "../firebase/firestore-store.js";
@@ -46,6 +52,141 @@ export class Catalox {
46
52
  constructor(deps) {
47
53
  this.deps = deps;
48
54
  }
55
+ encodeRefSegment(value) {
56
+ // Keep IDs stable and URL-safe for use as Firestore doc ids.
57
+ return encodeURIComponent(String(value));
58
+ }
59
+ buildReferenceId(input) {
60
+ const parts = [
61
+ "ref",
62
+ this.encodeRefSegment(input.fromCatalogId),
63
+ this.encodeRefSegment(input.fromItemId),
64
+ this.encodeRefSegment(input.relationType),
65
+ this.encodeRefSegment(input.toCatalogId),
66
+ this.encodeRefSegment(input.toItemId),
67
+ ];
68
+ return parts.join("|");
69
+ }
70
+ normalizeRelationWrites(value) {
71
+ if (!Array.isArray(value))
72
+ return [];
73
+ const out = [];
74
+ for (const v of value) {
75
+ if (!v || typeof v !== "object")
76
+ continue;
77
+ const r = v;
78
+ if (r.toCatalogId == null || r.toItemId == null || r.relationType == null)
79
+ continue;
80
+ out.push({
81
+ toCatalogId: String(r.toCatalogId),
82
+ toItemId: String(r.toItemId),
83
+ relationType: String(r.relationType),
84
+ ...(r.label != null ? { label: String(r.label) } : {}),
85
+ ...(r.metadata != null && typeof r.metadata === "object" ? { metadata: r.metadata } : {}),
86
+ });
87
+ }
88
+ return out;
89
+ }
90
+ async validateRelationsAgainstDescriptor(context, params) {
91
+ const rules = params.descriptor?.relationRules;
92
+ if (!rules?.length)
93
+ return { isValid: true, issues: [] };
94
+ const ruleByType = new Map();
95
+ for (const r of rules) {
96
+ if (r && typeof r === "object" && r.relationType != null) {
97
+ ruleByType.set(String(r.relationType), r);
98
+ }
99
+ }
100
+ const issues = [];
101
+ const counts = new Map();
102
+ for (const ref of params.refs) {
103
+ const rt = String(ref.relationType);
104
+ counts.set(rt, (counts.get(rt) ?? 0) + 1);
105
+ const rule = ruleByType.get(rt);
106
+ if (!rule) {
107
+ issues.push({
108
+ code: "relation.disallowed_type",
109
+ severity: "error",
110
+ message: `relationType "${rt}" is not allowed by descriptor for catalog "${String(params.catalogId)}".`,
111
+ path: "relations",
112
+ });
113
+ continue;
114
+ }
115
+ if (Array.isArray(rule.targetCatalogIds) && rule.targetCatalogIds.length) {
116
+ const allowed = new Set(rule.targetCatalogIds.map((x) => String(x)));
117
+ if (!allowed.has(String(ref.toCatalogId))) {
118
+ issues.push({
119
+ code: "relation.disallowed_target_catalog",
120
+ severity: "error",
121
+ message: `relationType "${rt}" cannot target catalog "${String(ref.toCatalogId)}".`,
122
+ path: "relations",
123
+ });
124
+ }
125
+ }
126
+ if (Array.isArray(rule.targetCatalogTypes) && rule.targetCatalogTypes.length) {
127
+ try {
128
+ const targetDescriptor = await this.getCatalogDescriptor(context, ref.toCatalogId);
129
+ const targetType = targetDescriptor?.catalogType ? String(targetDescriptor.catalogType) : undefined;
130
+ const allowedTypes = new Set(rule.targetCatalogTypes.map((x) => String(x)));
131
+ if (!targetType || !allowedTypes.has(targetType)) {
132
+ issues.push({
133
+ code: "relation.disallowed_target_type",
134
+ severity: "error",
135
+ message: `relationType "${rt}" cannot target catalogType "${String(targetType ?? "unknown")}".`,
136
+ path: "relations",
137
+ });
138
+ }
139
+ }
140
+ catch {
141
+ issues.push({
142
+ code: "relation.target_type_unverifiable",
143
+ severity: "warning",
144
+ message: `relationType "${rt}" target catalogType could not be verified for "${String(ref.toCatalogId)}".`,
145
+ path: "relations",
146
+ });
147
+ }
148
+ }
149
+ }
150
+ for (const [rt, rule] of ruleByType.entries()) {
151
+ const required = Boolean(rule.required);
152
+ const multiple = rule.multiple !== false;
153
+ const count = counts.get(rt) ?? 0;
154
+ if (required && count === 0) {
155
+ issues.push({
156
+ code: "relation.missing_required",
157
+ severity: "error",
158
+ message: `Missing required relationType "${rt}".`,
159
+ path: "relations",
160
+ });
161
+ }
162
+ if (!multiple && count > 1) {
163
+ issues.push({
164
+ code: "relation.too_many",
165
+ severity: "error",
166
+ message: `relationType "${rt}" allows only one relation, but found ${count}.`,
167
+ path: "relations",
168
+ });
169
+ }
170
+ }
171
+ return { isValid: issues.every((i) => i.severity !== "error"), issues };
172
+ }
173
+ async validateCatalogTypeForContext(context, catalogType) {
174
+ const registry = this.deps.catalogTypes
175
+ ? await this.deps.catalogTypes.resolveForContext({
176
+ ...(context.storeId ? { storeId: context.storeId } : {}),
177
+ ...(context.appId ? { appId: context.appId } : {}),
178
+ })
179
+ : null;
180
+ if (!registry)
181
+ return; // registry not configured or no entry exists yet
182
+ const allowed = new Set((registry.types ?? []).map((t) => String(t)));
183
+ if (!allowed.has(String(catalogType))) {
184
+ throw new CatalogAdapterError({
185
+ reason: "catalog_type_not_allowed",
186
+ message: `catalogType "${String(catalogType)}" is not allowed for scope ${JSON.stringify(registry.scope)}.`,
187
+ });
188
+ }
189
+ }
49
190
  catalogLifecycleDeps() {
50
191
  return {
51
192
  firestoreStore: this.deps.firestoreStore,
@@ -245,7 +386,7 @@ export class Catalox {
245
386
  data: (i.data ?? {}),
246
387
  ...(i.title != null ? { title: i.title } : {}),
247
388
  ...(i.subtitle != null ? { subtitle: i.subtitle } : {}),
248
- ...(i.status != null ? { status: i.status } : {}),
389
+ ...(i.metadata?.status != null ? { status: String(i.metadata.status) } : {}),
249
390
  ...(i.metadata != null ? { metadata: i.metadata } : {}),
250
391
  })),
251
392
  ...(list.total != null ? { total: list.total } : {}),
@@ -269,6 +410,119 @@ export class Catalox {
269
410
  actions: {},
270
411
  };
271
412
  }
413
+ async getDesignObject(context, designId) {
414
+ void context;
415
+ if (!this.deps.designObjects)
416
+ throw new Error("designObjects dependency is not configured");
417
+ return this.deps.designObjects.get(String(designId));
418
+ }
419
+ async listDesignObjects(context, scope) {
420
+ void context;
421
+ if (!this.deps.designObjects)
422
+ throw new Error("designObjects dependency is not configured");
423
+ return this.deps.designObjects.listByScope(scope);
424
+ }
425
+ async getCatalogPresentationBinding(context, catalogId, role, options) {
426
+ const descriptor = await this.getCatalogDescriptor(context, catalogId);
427
+ const profiles = options?.profiles
428
+ ? options.profiles
429
+ : this.deps.presentationProfiles
430
+ ? await this.deps.presentationProfiles.listForCatalog?.({
431
+ catalogId: String(catalogId),
432
+ ...(descriptor?.catalogType ? { catalogType: String(descriptor.catalogType) } : {}),
433
+ })
434
+ : [];
435
+ const bindingBase = resolveCatalogPresentationBinding({
436
+ descriptor,
437
+ profiles,
438
+ role: role,
439
+ ...(options?.mode ? { mode: options.mode } : {}),
440
+ ...(options?.displayContext ? { displayContext: options.displayContext } : {}),
441
+ ...(options?.designScope ? { designScope: options.designScope } : {}),
442
+ ...(options?.designObjects ? { designObjects: options.designObjects } : {}),
443
+ });
444
+ // Optionally fetch snippet metadata when a snippetRef is involved.
445
+ if (options?.includeSnippet && bindingBase.customRenderer?.entry?.snippetRef) {
446
+ const entry = bindingBase.customRenderer.entry;
447
+ const snippetId = entry.snippetRef?.snippetId;
448
+ const catalogScoped = entry.snippetRef?.catalogScoped !== false;
449
+ if (!this.deps.rendererSnippets)
450
+ throw new Error("rendererSnippets dependency is not configured");
451
+ // Support canonical catalog-scoped ids; for anything else, require host-side snippet resolution.
452
+ if (!snippetId) {
453
+ const rec = await this.getCatalogRendererSnippet(context, catalogId, role, options?.mode);
454
+ return { ...bindingBase, customRenderer: { ...bindingBase.customRenderer, snippet: rec } };
455
+ }
456
+ if (catalogScoped) {
457
+ const expected1 = buildRendererSnippetDocId({ catalogId: String(catalogId), role: role, ...(options?.mode ? { mode: options.mode } : {}) });
458
+ if (String(snippetId) !== expected1) {
459
+ throw new Error(`snippetRef.snippetId is not a canonical catalog-scoped id: ${String(snippetId)}`);
460
+ }
461
+ const rec = await this.getCatalogRendererSnippet(context, catalogId, role, options?.mode);
462
+ return { ...bindingBase, customRenderer: { ...bindingBase.customRenderer, snippet: rec } };
463
+ }
464
+ throw new Error("Global (non-catalog-scoped) snippetRef resolution is host-defined.");
465
+ }
466
+ // Optionally include design merge when requested and available.
467
+ if (options?.includeDesignObjects && !options?.designObjects) {
468
+ if (!this.deps.designObjects)
469
+ throw new Error("designObjects dependency is not configured");
470
+ const designObjects = await this.deps.designObjects.listByScope?.({
471
+ ...(options?.designScope?.storeId ? { storeId: options.designScope.storeId } : {}),
472
+ ...(options?.designScope?.appId ? { appId: options.designScope.appId } : {}),
473
+ ...(options?.designScope?.accountId ? { accountId: options.designScope.accountId } : {}),
474
+ ...(options?.designScope?.agentId ? { agentId: options.designScope.agentId } : {}),
475
+ });
476
+ return resolveCatalogPresentationBinding({
477
+ descriptor,
478
+ profiles,
479
+ role: role,
480
+ ...(options?.mode ? { mode: options.mode } : {}),
481
+ ...(options?.displayContext ? { displayContext: options.displayContext } : {}),
482
+ ...(options?.designScope ? { designScope: options.designScope } : {}),
483
+ designObjects,
484
+ // Preserve snippet result if caller already asked for it separately above.
485
+ ...(bindingBase.customRenderer?.snippet !== undefined ? { snippet: bindingBase.customRenderer.snippet } : {}),
486
+ });
487
+ }
488
+ return bindingBase;
489
+ }
490
+ async buildCatalogListPresentationSurface(context, catalogId, options) {
491
+ const map = await this.buildCatalogListRenderMap(context, catalogId, {
492
+ ...(options?.limit != null ? { limit: options.limit } : {}),
493
+ ...(options?.resolveSources != null ? { resolveSources: options.resolveSources } : {}),
494
+ ...(options?.maxSourceOptions != null ? { maxSourceOptions: options.maxSourceOptions } : {}),
495
+ ...(options?.displayContext != null ? { displayContext: options.displayContext } : {}),
496
+ });
497
+ const binding = await this.getCatalogPresentationBinding(context, catalogId, (options?.displayContext ?? "grid") === "grid" ? "grid" : "list", {
498
+ ...(options?.mode ? { mode: options.mode } : {}),
499
+ displayContext: options?.displayContext ?? "grid",
500
+ ...(options?.includeSnippet ? { includeSnippet: true } : {}),
501
+ ...(options?.includeDesignObjects ? { includeDesignObjects: true } : {}),
502
+ ...(options?.designScope ? { designScope: options.designScope } : {}),
503
+ ...(options?.profiles ? { profiles: options.profiles } : {}),
504
+ ...(options?.designObjects ? { designObjects: options.designObjects } : {}),
505
+ });
506
+ return { map, binding };
507
+ }
508
+ async buildCatalogItemPresentationSurface(context, catalogId, itemId, options) {
509
+ const map = await this.buildCatalogItemRenderMap(context, catalogId, itemId, {
510
+ ...(options?.includeReferences != null ? { includeReferences: options.includeReferences } : {}),
511
+ ...(options?.resolveSources != null ? { resolveSources: options.resolveSources } : {}),
512
+ ...(options?.maxSourceOptions != null ? { maxSourceOptions: options.maxSourceOptions } : {}),
513
+ ...(options?.displayContext != null ? { displayContext: options.displayContext } : {}),
514
+ });
515
+ const binding = await this.getCatalogPresentationBinding(context, catalogId, "item", {
516
+ ...(options?.mode ? { mode: options.mode } : {}),
517
+ displayContext: "form",
518
+ ...(options?.includeSnippet ? { includeSnippet: true } : {}),
519
+ ...(options?.includeDesignObjects ? { includeDesignObjects: true } : {}),
520
+ ...(options?.designScope ? { designScope: options.designScope } : {}),
521
+ ...(options?.profiles ? { profiles: options.profiles } : {}),
522
+ ...(options?.designObjects ? { designObjects: options.designObjects } : {}),
523
+ });
524
+ return { map, binding };
525
+ }
272
526
  async buildCatalogItemRenderMap(context, catalogId, itemId, options) {
273
527
  const descriptor = await this.getCatalogDescriptor(context, catalogId);
274
528
  const got = await this.getCatalogItem(context, catalogId, itemId);
@@ -295,6 +549,15 @@ export class Catalox {
295
549
  const refs = options?.includeReferences
296
550
  ? await this.getCatalogItemReferences(context, catalogId, itemId)
297
551
  : [];
552
+ const referenceViews = refs.length
553
+ ? refs.map((r) => ({
554
+ toCatalogId: String(r.toCatalogId),
555
+ toItemId: String(r.toItemId),
556
+ relationType: String(r.relationType),
557
+ ...(r.label != null ? { label: r.label } : {}),
558
+ ...(r.metadata != null ? { metadata: r.metadata } : {}),
559
+ }))
560
+ : [];
298
561
  return {
299
562
  catalogId: String(catalogId),
300
563
  itemId: String(itemId),
@@ -302,23 +565,13 @@ export class Catalox {
302
565
  data: (item.data ?? {}),
303
566
  ...(item.title != null ? { title: item.title } : {}),
304
567
  ...(item.subtitle != null ? { subtitle: item.subtitle } : {}),
305
- ...(item.status != null ? { status: item.status } : {}),
568
+ ...(item.metadata?.status != null ? { status: String(item.metadata.status) } : {}),
306
569
  ...(item.metadata != null ? { metadata: item.metadata } : {}),
307
- ...(item.createdAt != null ? { createdAt: item.createdAt } : {}),
308
- ...(item.updatedAt != null ? { updatedAt: item.updatedAt } : {}),
570
+ ...(item.metadata?.createdAt != null ? { createdAt: String(item.metadata.createdAt) } : {}),
571
+ ...(item.metadata?.lastUpdate != null ? { updatedAt: String(item.metadata.lastUpdate) } : {}),
309
572
  },
310
573
  resolvedSources,
311
- ...(refs.length
312
- ? {
313
- references: refs.map((r) => ({
314
- toCatalogId: String(r.toCatalogId),
315
- toItemId: String(r.toItemId),
316
- relationType: String(r.relationType),
317
- ...(r.label != null ? { label: r.label } : {}),
318
- ...(r.metadata != null ? { metadata: r.metadata } : {}),
319
- })),
320
- }
321
- : {}),
574
+ ...(referenceViews.length ? { relations: referenceViews, references: referenceViews } : {}),
322
575
  ...(descriptor?.presentationSpec ? { presentation: descriptor.presentationSpec } : {}),
323
576
  capabilities: descriptor?.capabilities ?? {
324
577
  canList: true,
@@ -343,12 +596,14 @@ export class Catalox {
343
596
  return Object.keys(out).length ? out : undefined;
344
597
  }
345
598
  stripReservedWriteFields(input) {
346
- const { indexed, scope, ...rest } = input;
599
+ const { indexed, scope, relations, references, ...rest } = input;
347
600
  const parsedScope = scope != null ? parseWriteScopeInput(scope) : undefined;
601
+ const parsedRelations = this.normalizeRelationWrites(relations ?? references);
348
602
  return {
349
603
  data: rest,
350
604
  ...(indexed != null ? { indexed: indexed } : {}),
351
605
  ...(parsedScope != null ? { scope: parsedScope } : {}),
606
+ ...(parsedRelations.length ? { relations: parsedRelations } : {}),
352
607
  };
353
608
  }
354
609
  normalizeListFetchScope(scope) {
@@ -453,8 +708,13 @@ export class Catalox {
453
708
  ...item,
454
709
  ...(title != null ? { title: String(title) } : {}),
455
710
  ...(subtitle != null ? { subtitle: String(subtitle) } : {}),
456
- ...(status != null ? { status: String(status) } : {}),
457
- ...(updatedAt != null && item.updatedAt == null ? { updatedAt: String(updatedAt) } : {}),
711
+ metadata: {
712
+ ...(item.metadata ?? {}),
713
+ ...(item.metadata?.domainIds == null ? { domainIds: [] } : {}),
714
+ ...(item.metadata?.agentIds == null ? { agentIds: [] } : {}),
715
+ ...(status != null ? { status: String(status) } : {}),
716
+ ...(updatedAt != null && item.metadata?.lastUpdate == null ? { lastUpdate: String(updatedAt) } : {}),
717
+ },
458
718
  };
459
719
  }
460
720
  async listAppCatalogs(context, input) {
@@ -470,26 +730,26 @@ export class Catalox {
470
730
  const cat = byId.get(b.catalogId);
471
731
  if (!cat)
472
732
  continue;
473
- if (!input?.includeDisabled && cat.status !== "active")
733
+ if (!input?.includeDisabled && cat.metadata.status !== "active")
474
734
  continue;
475
735
  const descriptor = await this.deps.descriptors.get(cat.catalogId);
476
736
  const description = descriptor?.descriptor.description ?? cat.description;
477
737
  const itemLabel = descriptor?.descriptor.itemLabel ?? cat.itemLabel;
478
738
  const visibility = descriptor?.descriptor.visibility;
479
739
  const descriptorVersion = descriptor?.descriptorVersion;
480
- const metadata = descriptor?.descriptor.metadata ?? cat.metadata;
740
+ const metadata = { ...cat.metadata, ...(descriptor?.descriptor.metadata ?? {}) };
481
741
  out.push({
482
742
  catalogId: cat.catalogId,
483
743
  label: descriptor?.descriptor.label ?? cat.name,
484
744
  ...(description != null ? { description } : {}),
485
745
  ...(itemLabel != null ? { itemLabel } : {}),
746
+ catalogType: cat.catalogType,
486
747
  sourceMode: cat.sourceMode,
487
748
  ...(cat.mappedSourceType != null ? { mappedSourceType: cat.mappedSourceType } : {}),
488
- status: cat.status,
489
749
  ...(visibility != null ? { visibility } : {}),
490
750
  access: b.access,
491
751
  ...(descriptorVersion != null ? { descriptorVersion } : {}),
492
- ...(metadata != null ? { metadata } : {}),
752
+ metadata,
493
753
  });
494
754
  }
495
755
  return out.filter((e) => (input?.includeHidden ? true : e.visibility !== "hidden"));
@@ -536,26 +796,26 @@ export class Catalox {
536
796
  const cat = byId.get(b.catalogId);
537
797
  if (!cat)
538
798
  continue;
539
- if (!input.includeDisabled && cat.status !== "active")
799
+ if (!input.includeDisabled && cat.metadata.status !== "active")
540
800
  continue;
541
801
  const descriptor = await this.deps.descriptors.get(cat.catalogId);
542
802
  const description = descriptor?.descriptor.description ?? cat.description;
543
803
  const itemLabel = descriptor?.descriptor.itemLabel ?? cat.itemLabel;
544
804
  const visibility = descriptor?.descriptor.visibility;
545
805
  const descriptorVersion = descriptor?.descriptorVersion;
546
- const metadata = descriptor?.descriptor.metadata ?? cat.metadata;
806
+ const metadata = { ...cat.metadata, ...(descriptor?.descriptor.metadata ?? {}) };
547
807
  out.push({
548
808
  catalogId: cat.catalogId,
549
809
  label: descriptor?.descriptor.label ?? cat.name,
550
810
  ...(description != null ? { description } : {}),
551
811
  ...(itemLabel != null ? { itemLabel } : {}),
812
+ catalogType: cat.catalogType,
552
813
  sourceMode: cat.sourceMode,
553
814
  ...(cat.mappedSourceType != null ? { mappedSourceType: cat.mappedSourceType } : {}),
554
- status: cat.status,
555
815
  ...(visibility != null ? { visibility } : {}),
556
816
  access: b.access,
557
817
  ...(descriptorVersion != null ? { descriptorVersion } : {}),
558
- ...(metadata != null ? { metadata } : {}),
818
+ metadata,
559
819
  });
560
820
  }
561
821
  return out.filter((e) => (input.includeHidden ? true : e.visibility !== "hidden"));
@@ -679,9 +939,19 @@ export class Catalox {
679
939
  };
680
940
  }
681
941
  const def = await this.deps.definitions.get(catalogId);
682
- if (!def || def.type !== "mapped")
942
+ if (!def)
683
943
  throw new CatalogAdapterError({ catalogId, reason: "missing_definition" });
684
- const mapping = await this.deps.mappings.get(def.mappingId);
944
+ if (def.catalogItems.providerType !== "external") {
945
+ throw new CatalogAdapterError({ catalogId, reason: "missing_definition" });
946
+ }
947
+ const map = def.catalogItems.map;
948
+ const mappingId = map?.mappingId;
949
+ const adapterId = map?.adapterId;
950
+ if (!mappingId)
951
+ throw new CatalogAdapterError({ catalogId, reason: "missing_mapping" });
952
+ if (!adapterId)
953
+ throw new CatalogAdapterError({ catalogId, reason: "missing_adapter" });
954
+ const mapping = await this.deps.mappings.get(mappingId);
685
955
  if (!mapping)
686
956
  throw new CatalogAdapterError({ catalogId, reason: "missing_mapping" });
687
957
  const mappingIssues = validateMappingSpec(mapping.mapping);
@@ -694,10 +964,10 @@ export class Catalox {
694
964
  return rest;
695
965
  })()
696
966
  : undefined;
697
- if (def.adapterType === "mongo") {
967
+ if (def.catalogItems.provider === "mongo") {
698
968
  if (!this.deps.mongoAdapter)
699
969
  throw new CatalogAdapterError({ catalogId, reason: "mongo_adapter_unconfigured" });
700
- const adapterConfig = await this.deps.adapters.get(def.adapterId);
970
+ const adapterConfig = await this.deps.adapters.get(adapterId);
701
971
  if (!adapterConfig)
702
972
  throw new CatalogAdapterError({ catalogId, reason: "missing_adapter" });
703
973
  const result = await this.deps.mongoAdapter.listItems(appCtx, catalogId, adapterConfig, { mapping: mapping.mapping, ...(mapping.options ? { options: mapping.options } : {}) }, mappedListQueryOptions);
@@ -705,10 +975,10 @@ export class Catalox {
705
975
  ? { listOutcome: "ok", items: result.items, issues: result.issues }
706
976
  : { listOutcome: "ok", items: result.items };
707
977
  }
708
- if (def.adapterType === "api") {
978
+ if (def.catalogItems.provider === "api") {
709
979
  if (!this.deps.apiAdapter)
710
980
  throw new CatalogAdapterError({ catalogId, reason: "api_adapter_unconfigured" });
711
- const adapterConfig = await this.deps.adapters.get(def.adapterId);
981
+ const adapterConfig = await this.deps.adapters.get(adapterId);
712
982
  if (!adapterConfig)
713
983
  throw new CatalogAdapterError({ catalogId, reason: "missing_adapter" });
714
984
  const result = await this.deps.apiAdapter.listItems(appCtx, catalogId, adapterConfig, { responseMapping: mapping.mapping, ...(mapping.options ? { options: mapping.options } : {}) }, mappedListQueryOptions);
@@ -767,8 +1037,6 @@ export class Catalox {
767
1037
  sourceType: "firebase",
768
1038
  data: rec.data,
769
1039
  metadata: meta,
770
- createdAt: rec.createdAt,
771
- updatedAt: rec.updatedAt,
772
1040
  };
773
1041
  return { outcome: "found", item: await this.decorateItem(catalogId, base) };
774
1042
  }
@@ -829,8 +1097,12 @@ export class Catalox {
829
1097
  async validateCatalog(_context, _catalogId) {
830
1098
  return { isValid: true, issues: [] };
831
1099
  }
832
- async validateCatalogItem(_context, _catalogId, _itemId) {
833
- return { isValid: true, issues: [] };
1100
+ async validateCatalogItem(context, catalogId, itemId) {
1101
+ const descriptor = await this.getCatalogDescriptor(context, catalogId);
1102
+ if (!descriptor)
1103
+ return { isValid: true, issues: [] };
1104
+ const refs = await this.getCatalogItemReferences(context, catalogId, itemId);
1105
+ return this.validateRelationsAgainstDescriptor(context, { catalogId, itemId, descriptor, refs });
834
1106
  }
835
1107
  async getCatalogItemReferences(context, catalogId, itemId) {
836
1108
  const appId = await this.resolveAppIdForCatalogAccess({ context, catalogId, required: "read" });
@@ -844,6 +1116,59 @@ export class Catalox {
844
1116
  const refs = await this.deps.references.listByCatalog(catalogId);
845
1117
  return refs;
846
1118
  }
1119
+ async upsertCatalogItemRelation(context, input) {
1120
+ const fromCatalogId = input.fromCatalogId;
1121
+ const toCatalogId = input.toCatalogId;
1122
+ const fromAppId = await this.resolveAppIdForCatalogAccess({ context, catalogId: fromCatalogId, required: "write" });
1123
+ await this.deps.authz.requireBindingAccess(context, fromAppId, fromCatalogId, "write");
1124
+ // Ensure target catalog is at least readable by the actor (prevents creating opaque links).
1125
+ const toAppId = await this.resolveAppIdForCatalogAccess({ context, catalogId: toCatalogId, required: "read" });
1126
+ await this.deps.authz.requireBindingAccess(context, toAppId, toCatalogId, "read");
1127
+ const now = new Date().toISOString();
1128
+ const referenceId = this.buildReferenceId({
1129
+ fromCatalogId: String(input.fromCatalogId),
1130
+ fromItemId: String(input.fromItemId),
1131
+ relationType: String(input.relationType),
1132
+ toCatalogId: String(input.toCatalogId),
1133
+ toItemId: String(input.toItemId),
1134
+ });
1135
+ await this.deps.references.upsert({
1136
+ referenceId,
1137
+ fromCatalogId,
1138
+ fromItemId: input.fromItemId,
1139
+ toCatalogId,
1140
+ toItemId: input.toItemId,
1141
+ relationType: String(input.relationType),
1142
+ ...(input.label != null ? { label: input.label } : {}),
1143
+ ...(input.metadata != null ? { metadata: input.metadata } : {}),
1144
+ createdAt: now,
1145
+ updatedAt: now,
1146
+ });
1147
+ return { referenceId };
1148
+ }
1149
+ async deleteCatalogItemRelation(context, input) {
1150
+ const referenceId = "referenceId" in input
1151
+ ? String(input.referenceId)
1152
+ : this.buildReferenceId({
1153
+ fromCatalogId: String(input.fromCatalogId),
1154
+ fromItemId: String(input.fromItemId),
1155
+ relationType: String(input.relationType),
1156
+ toCatalogId: String(input.toCatalogId),
1157
+ toItemId: String(input.toItemId),
1158
+ });
1159
+ // Best-effort access check: if the caller provides endpoints, enforce write on fromCatalog.
1160
+ if (!("referenceId" in input)) {
1161
+ const fromCatalogId = input.fromCatalogId;
1162
+ const appId = await this.resolveAppIdForCatalogAccess({ context, catalogId: fromCatalogId, required: "write" });
1163
+ await this.deps.authz.requireBindingAccess(context, appId, fromCatalogId, "write");
1164
+ }
1165
+ await this.deps.references.delete(referenceId);
1166
+ }
1167
+ async listCatalogItemRelationsToItem(context, catalogId, itemId) {
1168
+ const appId = await this.resolveAppIdForCatalogAccess({ context, catalogId, required: "read" });
1169
+ await this.deps.authz.requireBindingAccess(context, appId, catalogId, "read");
1170
+ return this.deps.references.listToItem(catalogId, itemId);
1171
+ }
847
1172
  // Spec methods below are stubs for now; filled in by later todos.
848
1173
  async getApp(_context, appId) {
849
1174
  const resolved = appId ?? _context.appId;
@@ -854,18 +1179,35 @@ export class Catalox {
854
1179
  async createCatalog(_context, _input) {
855
1180
  const now = new Date().toISOString();
856
1181
  const catalogId = (_input.catalogId ?? randomUUID());
1182
+ await this.validateCatalogTypeForContext(_context, _input.catalogType);
857
1183
  await this.deps.catalogs.upsert({
858
1184
  catalogId,
859
1185
  name: _input.name,
860
1186
  ...(_input.description != null ? { description: _input.description } : {}),
1187
+ catalogType: _input.catalogType,
861
1188
  sourceMode: _input.sourceMode,
862
1189
  ...(_input.sourceMode === "mapped" && _input.mapped?.sourceType
863
1190
  ? { mappedSourceType: _input.mapped.sourceType }
864
1191
  : {}),
865
- status: "active",
866
- ...(_input.metadata != null ? { metadata: _input.metadata } : {}),
867
- createdAt: now,
868
- updatedAt: now,
1192
+ catalogItems: _input.sourceMode === "native"
1193
+ ? {
1194
+ providerType: "internal",
1195
+ ...(_input.native?.itemSchema != null ? { itemSchema: _input.native.itemSchema } : {}),
1196
+ metadata: {},
1197
+ }
1198
+ : {
1199
+ providerType: "external",
1200
+ provider: _input.mapped?.sourceType ?? "api",
1201
+ // map references existing adapter + mapping documents (see createCatalog mapped branch below)
1202
+ map: {},
1203
+ metadata: {},
1204
+ },
1205
+ metadata: {
1206
+ status: "active",
1207
+ createdAt: now,
1208
+ lastUpdate: now,
1209
+ ...(_input.metadata ?? {}),
1210
+ },
869
1211
  });
870
1212
  if (_input.sourceMode === "native") {
871
1213
  if (!_input.native)
@@ -873,12 +1215,12 @@ export class Catalox {
873
1215
  const collectionPath = _input.native.firestoreCollectionPath ?? nativeItemsCollectionId(catalogId);
874
1216
  await this.deps.definitions.upsert({
875
1217
  catalogId,
876
- type: "native",
877
- storage: {
878
- provider: "firestore",
879
- collectionPath,
1218
+ sourceMode: "native",
1219
+ catalogItems: {
1220
+ providerType: "internal",
1221
+ ...(_input.native.itemSchema != null ? { itemSchema: _input.native.itemSchema } : {}),
1222
+ metadata: { firestoreCollectionPath: collectionPath },
880
1223
  },
881
- ...(_input.native.itemSchema != null ? { itemSchema: _input.native.itemSchema } : {}),
882
1224
  createdAt: now,
883
1225
  updatedAt: now,
884
1226
  });
@@ -902,16 +1244,31 @@ export class Catalox {
902
1244
  });
903
1245
  await this.deps.definitions.upsert({
904
1246
  catalogId,
905
- type: "mapped",
906
- adapterType: _input.mapped.sourceType,
907
- adapterId,
908
- mappingId,
909
- ...(_input.mapped.syncMode
910
- ? { sync: { mode: _input.mapped.syncMode, syncStatus: "idle" } }
911
- : {}),
1247
+ sourceMode: "mapped",
1248
+ catalogItems: {
1249
+ providerType: "external",
1250
+ provider: _input.mapped.sourceType,
1251
+ map: { adapterId, mappingId },
1252
+ metadata: {},
1253
+ },
1254
+ ...(_input.mapped.syncMode ? { sync: { mode: _input.mapped.syncMode, syncStatus: "idle" } } : {}),
912
1255
  createdAt: now,
913
1256
  updatedAt: now,
914
1257
  });
1258
+ // Update catalog record to point at the external map references.
1259
+ await this.deps.catalogs.upsert({
1260
+ ...(await this.deps.catalogs.get(catalogId)),
1261
+ catalogItems: {
1262
+ providerType: "external",
1263
+ provider: _input.mapped.sourceType,
1264
+ map: { adapterId, mappingId },
1265
+ metadata: {},
1266
+ },
1267
+ metadata: {
1268
+ ...(await this.deps.catalogs.get(catalogId)).metadata,
1269
+ lastUpdate: now,
1270
+ },
1271
+ });
915
1272
  }
916
1273
  // Seed a minimal descriptor if none exists.
917
1274
  const existingDescriptor = await this.deps.descriptors.get(catalogId);
@@ -959,12 +1316,19 @@ export class Catalox {
959
1316
  if (!existing)
960
1317
  throw new CatalogNotFoundError({ catalogId: _catalogId });
961
1318
  const now = new Date().toISOString();
1319
+ if (_patch.catalogType != null) {
1320
+ await this.validateCatalogTypeForContext(_context, String(_patch.catalogType));
1321
+ }
962
1322
  await this.deps.catalogs.upsert({
963
1323
  ...existing,
964
1324
  ...(_patch.name != null ? { name: _patch.name } : {}),
965
1325
  ...(_patch.description != null ? { description: _patch.description } : {}),
966
- ...(_patch.metadata != null ? { metadata: _patch.metadata } : {}),
967
- updatedAt: now,
1326
+ ...(_patch.catalogType != null ? { catalogType: String(_patch.catalogType) } : {}),
1327
+ metadata: {
1328
+ ...existing.metadata,
1329
+ ...(_patch.metadata ?? {}),
1330
+ lastUpdate: now,
1331
+ },
968
1332
  });
969
1333
  return (await this.deps.catalogs.get(_catalogId));
970
1334
  }
@@ -1264,12 +1628,67 @@ export class Catalox {
1264
1628
  await this.deps.catalogs.upsert({
1265
1629
  catalogId: catalog.catalogId,
1266
1630
  name: catalog.name,
1631
+ catalogType: "generic",
1267
1632
  sourceMode: "native",
1268
- status: catalog.status ?? "active",
1269
- createdAt: now,
1270
- updatedAt: now,
1633
+ catalogItems: { providerType: "internal", metadata: {} },
1634
+ metadata: {
1635
+ status: catalog.status ?? "active",
1636
+ createdAt: now,
1637
+ lastUpdate: now,
1638
+ },
1639
+ });
1640
+ }
1641
+ async resolveAllowedCatalogTypes(context) {
1642
+ if (!this.deps.catalogTypes)
1643
+ return null;
1644
+ return this.deps.catalogTypes.resolveForContext({
1645
+ ...(context.storeId ? { storeId: context.storeId } : {}),
1646
+ ...(context.appId ? { appId: context.appId } : {}),
1647
+ });
1648
+ }
1649
+ async upsertCatalogTypeRegistry(context, scope, input) {
1650
+ if (!context.superAdmin)
1651
+ throw new CatalogAccessDeniedError({ reason: "super_admin_required" });
1652
+ if (!this.deps.catalogTypes)
1653
+ throw new Error("catalogTypes dependency is not configured");
1654
+ await this.deps.catalogTypes.upsert(scope, input);
1655
+ }
1656
+ async resolveAllowedDomains(context) {
1657
+ if (!this.deps.domains)
1658
+ return null;
1659
+ return this.deps.domains.resolveForContext({
1660
+ ...(context.storeId ? { storeId: context.storeId } : {}),
1661
+ ...(context.appId ? { appId: context.appId } : {}),
1271
1662
  });
1272
1663
  }
1664
+ async upsertDomainRegistry(context, scope, input) {
1665
+ if (!context.superAdmin)
1666
+ throw new CatalogAccessDeniedError({ reason: "super_admin_required" });
1667
+ if (!this.deps.domains)
1668
+ throw new Error("domains dependency is not configured");
1669
+ await this.deps.domains.upsert(scope, input);
1670
+ }
1671
+ async resolveAllowedAgents(context) {
1672
+ if (!this.deps.agents)
1673
+ return null;
1674
+ return this.deps.agents.resolveForContext({
1675
+ ...(context.storeId ? { storeId: context.storeId } : {}),
1676
+ ...(context.appId ? { appId: context.appId } : {}),
1677
+ });
1678
+ }
1679
+ async upsertAgentRegistry(context, scope, input) {
1680
+ if (!context.superAdmin)
1681
+ throw new CatalogAccessDeniedError({ reason: "super_admin_required" });
1682
+ if (!this.deps.agents)
1683
+ throw new Error("agents dependency is not configured");
1684
+ await this.deps.agents.upsert(scope, input);
1685
+ }
1686
+ async backfillCatalogModel(context, input = {}) {
1687
+ if (!context.superAdmin)
1688
+ throw new CatalogAccessDeniedError({ reason: "super_admin_required" });
1689
+ const fs = this.deps.firestoreStore.firestore;
1690
+ return backfillCatalogModel(fs, input);
1691
+ }
1273
1692
  async ensureBinding(context, input) {
1274
1693
  // only super-admin apps can provision cross-app bindings.
1275
1694
  if (!context.superAdmin && (!context.appId || input.appId !== context.appId)) {
@@ -1314,12 +1733,43 @@ export class Catalox {
1314
1733
  }
1315
1734
  if (!existing)
1316
1735
  throw new CatalogNotFoundError({ catalogId: _catalogId, itemId: _itemId });
1317
- const { data: patchData, indexed: patchIndexed, scope: patchScope } = this.stripReservedWriteFields(_patch);
1736
+ const { data: patchData, indexed: patchIndexed, scope: patchScope, relations: patchRelations } = this.stripReservedWriteFields(_patch);
1318
1737
  let nextScope = normalizeStoredScope(scopeFromRecordField(existing.scope));
1319
1738
  if (patchScope != null) {
1320
1739
  nextScope = normalizeStoredScope(patchScope);
1321
1740
  assertSuperAdminForNonGlobalScope(_context.superAdmin, nextScope);
1322
1741
  }
1742
+ // Validate relation rules using existing + provided relations (if any).
1743
+ const descriptorRec = await this.deps.descriptors.get(_catalogId);
1744
+ if (descriptorRec?.descriptor?.relationRules?.length) {
1745
+ const existingRefs = await this.deps.references.listByItem(_catalogId, _itemId);
1746
+ const nextRefs = [
1747
+ ...existingRefs,
1748
+ ...(patchRelations ?? []).map((r) => ({
1749
+ fromCatalogId: _catalogId,
1750
+ fromItemId: _itemId,
1751
+ toCatalogId: r.toCatalogId,
1752
+ toItemId: r.toItemId,
1753
+ relationType: r.relationType,
1754
+ ...(r.label != null ? { label: r.label } : {}),
1755
+ ...(r.metadata != null ? { metadata: r.metadata } : {}),
1756
+ })),
1757
+ ];
1758
+ const report = await this.validateRelationsAgainstDescriptor(_context, {
1759
+ catalogId: _catalogId,
1760
+ itemId: _itemId,
1761
+ descriptor: descriptorRec.descriptor,
1762
+ refs: nextRefs,
1763
+ });
1764
+ if (!report.isValid) {
1765
+ throw new CatalogValidationError({
1766
+ reason: "invalid_relations",
1767
+ catalogId: String(_catalogId),
1768
+ itemId: String(_itemId),
1769
+ report,
1770
+ });
1771
+ }
1772
+ }
1323
1773
  const updatedAt = new Date().toISOString();
1324
1774
  const mergedData = { ...(existing.data ?? {}), ...patchData };
1325
1775
  const idx = {
@@ -1334,7 +1784,13 @@ export class Catalox {
1334
1784
  data: mergedData,
1335
1785
  indexed: idx,
1336
1786
  ...(nextScope.kind !== "global" ? { scope: nextScope } : {}),
1337
- updatedAt,
1787
+ metadata: {
1788
+ ...(existing.metadata ?? {}),
1789
+ createdAt: String(existing.metadata?.createdAt ?? updatedAt),
1790
+ lastUpdate: updatedAt,
1791
+ domainIds: Array.isArray(existing.metadata?.domainIds) ? existing.metadata.domainIds : [],
1792
+ agentIds: Array.isArray(existing.metadata?.agentIds) ? existing.metadata.agentIds : [],
1793
+ },
1338
1794
  version: (existing.version ?? 0) + 1,
1339
1795
  ...(this.resolveActorId(_context) ? { updatedBy: this.resolveActorId(_context) } : {}),
1340
1796
  };
@@ -1358,9 +1814,25 @@ export class Catalox {
1358
1814
  sourceMode: "native",
1359
1815
  sourceType: "firebase",
1360
1816
  data: mergedData,
1361
- createdAt: existing.createdAt,
1362
- updatedAt,
1817
+ metadata: {
1818
+ createdAt: String(existing.metadata?.createdAt ?? updatedAt),
1819
+ lastUpdate: updatedAt,
1820
+ domainIds: Array.isArray(existing.metadata?.domainIds) ? existing.metadata.domainIds : [],
1821
+ agentIds: Array.isArray(existing.metadata?.agentIds) ? existing.metadata.agentIds : [],
1822
+ },
1363
1823
  };
1824
+ // Upsert any provided relations after the item is persisted.
1825
+ for (const r of patchRelations ?? []) {
1826
+ await this.upsertCatalogItemRelation(_context, {
1827
+ fromCatalogId: String(_catalogId),
1828
+ fromItemId: String(_itemId),
1829
+ toCatalogId: r.toCatalogId,
1830
+ toItemId: r.toItemId,
1831
+ relationType: r.relationType,
1832
+ ...(r.label != null ? { label: r.label } : {}),
1833
+ ...(r.metadata != null ? { metadata: r.metadata } : {}),
1834
+ });
1835
+ }
1364
1836
  return this.decorateItem(_catalogId, out);
1365
1837
  }
1366
1838
  async deleteNativeCatalogItem(_context, _catalogId, _itemId, _options) {
@@ -1431,7 +1903,7 @@ export class Catalox {
1431
1903
  const descriptor = await this.deps.descriptors.get(catalogId);
1432
1904
  if (!descriptor)
1433
1905
  throw new CatalogAdapterError({ catalogId, reason: "missing_descriptor" });
1434
- const { data, indexed: callerIndexed, scope: inputScope } = this.stripReservedWriteFields(input);
1906
+ const { data, indexed: callerIndexed, scope: inputScope, relations: inputRelations } = this.stripReservedWriteFields(input);
1435
1907
  const storedScope = normalizeStoredScope(inputScope ?? { kind: "global" });
1436
1908
  assertSuperAdminForNonGlobalScope(context.superAdmin, storedScope);
1437
1909
  const derived = this.deriveIndexed(descriptor.descriptor, data);
@@ -1442,6 +1914,36 @@ export class Catalox {
1442
1914
  const now = new Date().toISOString();
1443
1915
  const existing = await this.deps.nativeItems.get(catalogId, storageDocId);
1444
1916
  const actorId = this.resolveActorId(context);
1917
+ // Enforce relation rules on create/upsert when relationRules are declared.
1918
+ if (descriptor.descriptor?.relationRules?.length) {
1919
+ const existingRefs = await this.deps.references.listByItem(catalogId, logicalItemId);
1920
+ const nextRefs = [
1921
+ ...existingRefs,
1922
+ ...(inputRelations ?? []).map((r) => ({
1923
+ fromCatalogId: catalogId,
1924
+ fromItemId: logicalItemId,
1925
+ toCatalogId: r.toCatalogId,
1926
+ toItemId: r.toItemId,
1927
+ relationType: r.relationType,
1928
+ ...(r.label != null ? { label: r.label } : {}),
1929
+ ...(r.metadata != null ? { metadata: r.metadata } : {}),
1930
+ })),
1931
+ ];
1932
+ const report = await this.validateRelationsAgainstDescriptor(context, {
1933
+ catalogId,
1934
+ itemId: logicalItemId,
1935
+ descriptor: descriptor.descriptor,
1936
+ refs: nextRefs,
1937
+ });
1938
+ if (!report.isValid) {
1939
+ throw new CatalogValidationError({
1940
+ reason: "invalid_relations",
1941
+ catalogId: String(catalogId),
1942
+ itemId: String(logicalItemId),
1943
+ report,
1944
+ });
1945
+ }
1946
+ }
1445
1947
  await this.deps.nativeItems.upsert(catalogId, {
1446
1948
  itemId: logicalItemId,
1447
1949
  catalogId,
@@ -1449,9 +1951,13 @@ export class Catalox {
1449
1951
  appScopedOwnerId: appId,
1450
1952
  ...(indexed != null ? { indexed } : {}),
1451
1953
  data,
1954
+ metadata: {
1955
+ createdAt: String(existing?.metadata?.createdAt ?? now),
1956
+ lastUpdate: now,
1957
+ domainIds: Array.isArray(existing?.metadata?.domainIds) ? (existing?.metadata).domainIds : [],
1958
+ agentIds: Array.isArray(existing?.metadata?.agentIds) ? (existing?.metadata).agentIds : [],
1959
+ },
1452
1960
  version: (existing?.version ?? 0) + 1,
1453
- ...(existing?.createdAt ? { createdAt: existing.createdAt } : { createdAt: now }),
1454
- updatedAt: now,
1455
1961
  ...(existing?.createdBy ? { createdBy: existing.createdBy } : actorId ? { createdBy: actorId } : {}),
1456
1962
  ...(actorId ? { updatedBy: actorId } : {}),
1457
1963
  });
@@ -1464,6 +1970,18 @@ export class Catalox {
1464
1970
  before: existing ?? null,
1465
1971
  after: persisted,
1466
1972
  });
1973
+ // Upsert any provided relations after the item is persisted.
1974
+ for (const r of inputRelations ?? []) {
1975
+ await this.upsertCatalogItemRelation(context, {
1976
+ fromCatalogId: String(catalogId),
1977
+ fromItemId: String(logicalItemId),
1978
+ toCatalogId: r.toCatalogId,
1979
+ toItemId: r.toItemId,
1980
+ relationType: r.relationType,
1981
+ ...(r.label != null ? { label: r.label } : {}),
1982
+ ...(r.metadata != null ? { metadata: r.metadata } : {}),
1983
+ });
1984
+ }
1467
1985
  return {
1468
1986
  itemId: logicalItemId,
1469
1987
  catalogId,
@@ -1471,8 +1989,12 @@ export class Catalox {
1471
1989
  sourceMode: "native",
1472
1990
  sourceType: "firebase",
1473
1991
  data,
1474
- createdAt: existing?.createdAt ?? now,
1475
- updatedAt: now,
1992
+ metadata: {
1993
+ createdAt: String(existing?.metadata?.createdAt ?? now),
1994
+ lastUpdate: now,
1995
+ domainIds: Array.isArray(existing?.metadata?.domainIds) ? (existing?.metadata).domainIds : [],
1996
+ agentIds: Array.isArray(existing?.metadata?.agentIds) ? (existing?.metadata).agentIds : [],
1997
+ },
1476
1998
  };
1477
1999
  }
1478
2000
  async batchUpsertNativeCatalogItems(context, catalogId, items) {
@@ -1501,8 +2023,7 @@ export class Catalox {
1501
2023
  appScopedOwnerId: appId,
1502
2024
  ...(indexed != null ? { indexed } : {}),
1503
2025
  data: stripped.data,
1504
- createdAt: now,
1505
- updatedAt: now,
2026
+ metadata: { createdAt: now, lastUpdate: now, domainIds: [], agentIds: [] },
1506
2027
  ...(actorId ? { updatedBy: actorId } : {}),
1507
2028
  };
1508
2029
  });
@@ -1622,13 +2143,13 @@ export class Catalox {
1622
2143
  itemId: it.itemId,
1623
2144
  ...(it.title ? { title: it.title } : {}),
1624
2145
  ...(it.subtitle ? { subtitle: it.subtitle } : {}),
1625
- ...(it.updatedAt ? { updatedAt: it.updatedAt } : {}),
2146
+ ...(it.metadata?.lastUpdate ? { updatedAt: String(it.metadata.lastUpdate) } : {}),
1626
2147
  }));
1627
2148
  catalogStats.push({
1628
2149
  catalogId,
1629
2150
  label: c.label,
1630
2151
  sourceMode: c.sourceMode,
1631
- status: c.status,
2152
+ status: c.metadata.status,
1632
2153
  ...(list.issues ? { mappingIssueCount: list.issues.length } : {}),
1633
2154
  topExamples: examples,
1634
2155
  });
@@ -1705,8 +2226,9 @@ export class Catalox {
1705
2226
  if (!catalog)
1706
2227
  throw new CatalogNotFoundError({ catalogId: _catalogId });
1707
2228
  const def = await this.deps.definitions.get(_catalogId);
1708
- if (!def || def.type !== "mapped")
2229
+ if (!def || def.catalogItems.providerType !== "external") {
1709
2230
  throw new CatalogAdapterError({ catalogId: _catalogId, reason: "not_mapped" });
2231
+ }
1710
2232
  const mode = def.sync?.mode;
1711
2233
  if (mode !== "snapshot")
1712
2234
  return { syncStatus: "idle" };
@@ -1726,7 +2248,7 @@ export class Catalox {
1726
2248
  itemId: item.itemId,
1727
2249
  catalogId: _catalogId,
1728
2250
  sourceFingerprint: this.fingerprint(item.data),
1729
- ...(item.updatedAt ? { sourceUpdatedAt: item.updatedAt } : {}),
2251
+ ...(item.metadata?.lastUpdate ? { sourceUpdatedAt: String(item.metadata.lastUpdate) } : {}),
1730
2252
  data: item.data,
1731
2253
  sync: { lastSyncedAt: now, syncStatus: "success" },
1732
2254
  ...(actorId ? { metadata: { syncedBy: actorId } } : {}),
@@ -1920,8 +2442,7 @@ export class Catalox {
1920
2442
  sourceMode: "native",
1921
2443
  sourceType: "firebase",
1922
2444
  data: liveAfter.data,
1923
- createdAt: liveAfter.createdAt,
1924
- updatedAt: liveAfter.updatedAt,
2445
+ metadata: liveAfter.metadata ?? {},
1925
2446
  });
1926
2447
  }
1927
2448
  async replayCatalogToPointInTime(context, catalogId, input) {