@x12i/catalox 1.2.0 → 2.0.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.
Files changed (141) hide show
  1. package/README.md +424 -406
  2. package/dist/src/catalox/catalox.d.ts +38 -1
  3. package/dist/src/catalox/catalox.d.ts.map +1 -1
  4. package/dist/src/catalox/catalox.js +468 -5
  5. package/dist/src/catalox/catalox.js.map +1 -1
  6. package/dist/src/catalox/field-source-resolution.d.ts +16 -0
  7. package/dist/src/catalox/field-source-resolution.d.ts.map +1 -0
  8. package/dist/src/catalox/field-source-resolution.js +43 -0
  9. package/dist/src/catalox/field-source-resolution.js.map +1 -0
  10. package/dist/src/catalox/reporting/render-inventory-report.d.ts +3 -0
  11. package/dist/src/catalox/reporting/render-inventory-report.d.ts.map +1 -0
  12. package/dist/src/catalox/reporting/render-inventory-report.js +78 -0
  13. package/dist/src/catalox/reporting/render-inventory-report.js.map +1 -0
  14. package/dist/src/cli/index.d.ts +3 -0
  15. package/dist/src/cli/index.d.ts.map +1 -0
  16. package/dist/src/cli/index.js +267 -0
  17. package/dist/src/cli/index.js.map +1 -0
  18. package/dist/src/contracts/context.d.ts +7 -1
  19. package/dist/src/contracts/context.d.ts.map +1 -1
  20. package/dist/src/contracts/descriptors.d.ts +6 -0
  21. package/dist/src/contracts/descriptors.d.ts.map +1 -1
  22. package/dist/src/contracts/diagram-map.d.ts +24 -0
  23. package/dist/src/contracts/diagram-map.d.ts.map +1 -0
  24. package/dist/src/contracts/diagram-map.js +2 -0
  25. package/dist/src/contracts/diagram-map.js.map +1 -0
  26. package/dist/src/contracts/field-source.d.ts +43 -0
  27. package/dist/src/contracts/field-source.d.ts.map +1 -0
  28. package/dist/src/contracts/field-source.js +2 -0
  29. package/dist/src/contracts/field-source.js.map +1 -0
  30. package/dist/src/contracts/filters.d.ts +59 -0
  31. package/dist/src/contracts/filters.d.ts.map +1 -0
  32. package/dist/src/contracts/filters.js +2 -0
  33. package/dist/src/contracts/filters.js.map +1 -0
  34. package/dist/src/contracts/ids.d.ts +1 -0
  35. package/dist/src/contracts/ids.d.ts.map +1 -1
  36. package/dist/src/contracts/index.d.ts +12 -1
  37. package/dist/src/contracts/index.d.ts.map +1 -1
  38. package/dist/src/contracts/index.js.map +1 -1
  39. package/dist/src/contracts/inventory-export.d.ts +40 -0
  40. package/dist/src/contracts/inventory-export.d.ts.map +1 -0
  41. package/dist/src/contracts/inventory-export.js +2 -0
  42. package/dist/src/contracts/inventory-export.js.map +1 -0
  43. package/dist/src/contracts/inventory-report.d.ts +61 -0
  44. package/dist/src/contracts/inventory-report.d.ts.map +1 -0
  45. package/dist/src/contracts/inventory-report.js +2 -0
  46. package/dist/src/contracts/inventory-report.js.map +1 -0
  47. package/dist/src/contracts/items.d.ts +3 -0
  48. package/dist/src/contracts/items.d.ts.map +1 -1
  49. package/dist/src/contracts/markdown-map.d.ts +42 -0
  50. package/dist/src/contracts/markdown-map.d.ts.map +1 -0
  51. package/dist/src/contracts/markdown-map.js +2 -0
  52. package/dist/src/contracts/markdown-map.js.map +1 -0
  53. package/dist/src/contracts/presentation.d.ts +149 -0
  54. package/dist/src/contracts/presentation.d.ts.map +1 -0
  55. package/dist/src/contracts/presentation.js +2 -0
  56. package/dist/src/contracts/presentation.js.map +1 -0
  57. package/dist/src/contracts/render-map.d.ts +151 -0
  58. package/dist/src/contracts/render-map.d.ts.map +1 -0
  59. package/dist/src/contracts/render-map.js +2 -0
  60. package/dist/src/contracts/render-map.js.map +1 -0
  61. package/dist/src/contracts/renderer-snippets.d.ts +32 -0
  62. package/dist/src/contracts/renderer-snippets.d.ts.map +1 -0
  63. package/dist/src/contracts/renderer-snippets.js +2 -0
  64. package/dist/src/contracts/renderer-snippets.js.map +1 -0
  65. package/dist/src/contracts/snapshots.d.ts +12 -0
  66. package/dist/src/contracts/snapshots.d.ts.map +1 -0
  67. package/dist/src/contracts/snapshots.js +2 -0
  68. package/dist/src/contracts/snapshots.js.map +1 -0
  69. package/dist/src/contracts/stores.d.ts +21 -0
  70. package/dist/src/contracts/stores.d.ts.map +1 -0
  71. package/dist/src/contracts/stores.js +2 -0
  72. package/dist/src/contracts/stores.js.map +1 -0
  73. package/dist/src/diagrams/render-catalog-diagram.d.ts +3 -0
  74. package/dist/src/diagrams/render-catalog-diagram.d.ts.map +1 -0
  75. package/dist/src/diagrams/render-catalog-diagram.js +27 -0
  76. package/dist/src/diagrams/render-catalog-diagram.js.map +1 -0
  77. package/dist/src/diagrams/render-item-diagram.d.ts +3 -0
  78. package/dist/src/diagrams/render-item-diagram.d.ts.map +1 -0
  79. package/dist/src/diagrams/render-item-diagram.js +26 -0
  80. package/dist/src/diagrams/render-item-diagram.js.map +1 -0
  81. package/dist/src/firebase/index.d.ts +2 -0
  82. package/dist/src/firebase/index.d.ts.map +1 -1
  83. package/dist/src/firebase/index.js +2 -0
  84. package/dist/src/firebase/index.js.map +1 -1
  85. package/dist/src/firebase/renderer-snippet-store.d.ts +12 -0
  86. package/dist/src/firebase/renderer-snippet-store.d.ts.map +1 -0
  87. package/dist/src/firebase/renderer-snippet-store.js +21 -0
  88. package/dist/src/firebase/renderer-snippet-store.js.map +1 -0
  89. package/dist/src/firebase/snapshot-store.d.ts +7 -0
  90. package/dist/src/firebase/snapshot-store.d.ts.map +1 -1
  91. package/dist/src/firebase/snapshot-store.js +30 -0
  92. package/dist/src/firebase/snapshot-store.js.map +1 -1
  93. package/dist/src/firebase/store-app-binding-store.d.ts +14 -0
  94. package/dist/src/firebase/store-app-binding-store.d.ts.map +1 -0
  95. package/dist/src/firebase/store-app-binding-store.js +36 -0
  96. package/dist/src/firebase/store-app-binding-store.js.map +1 -0
  97. package/dist/src/index.d.ts +5 -0
  98. package/dist/src/index.d.ts.map +1 -1
  99. package/dist/src/index.js +5 -0
  100. package/dist/src/index.js.map +1 -1
  101. package/dist/src/jsx/index.d.ts +66 -0
  102. package/dist/src/jsx/index.d.ts.map +1 -0
  103. package/dist/src/jsx/index.js +165 -0
  104. package/dist/src/jsx/index.js.map +1 -0
  105. package/dist/src/markdown/render-item-markdown.d.ts +5 -0
  106. package/dist/src/markdown/render-item-markdown.d.ts.map +1 -0
  107. package/dist/src/markdown/render-item-markdown.js +83 -0
  108. package/dist/src/markdown/render-item-markdown.js.map +1 -0
  109. package/dist/src/markdown/render-list-markdown.d.ts +5 -0
  110. package/dist/src/markdown/render-list-markdown.d.ts.map +1 -0
  111. package/dist/src/markdown/render-list-markdown.js +90 -0
  112. package/dist/src/markdown/render-list-markdown.js.map +1 -0
  113. package/dist/src/validation/index.d.ts +3 -1
  114. package/dist/src/validation/index.d.ts.map +1 -1
  115. package/dist/src/validation/index.js +2 -2
  116. package/dist/src/validation/index.js.map +1 -1
  117. package/dist/src/validation/ui-spec-schema.d.ts +20 -0
  118. package/dist/src/validation/ui-spec-schema.d.ts.map +1 -0
  119. package/dist/src/validation/ui-spec-schema.js +605 -0
  120. package/dist/src/validation/ui-spec-schema.js.map +1 -0
  121. package/dist/src/validation/ui-spec-validate.d.ts +20 -0
  122. package/dist/src/validation/ui-spec-validate.d.ts.map +1 -0
  123. package/dist/src/validation/ui-spec-validate.js +73 -0
  124. package/dist/src/validation/ui-spec-validate.js.map +1 -0
  125. package/dist/test/unit/field-source-resolution.test.d.ts +2 -0
  126. package/dist/test/unit/field-source-resolution.test.d.ts.map +1 -0
  127. package/dist/test/unit/field-source-resolution.test.js +45 -0
  128. package/dist/test/unit/field-source-resolution.test.js.map +1 -0
  129. package/dist/test/unit/jsx-snippets.test.d.ts +2 -0
  130. package/dist/test/unit/jsx-snippets.test.d.ts.map +1 -0
  131. package/dist/test/unit/jsx-snippets.test.js +35 -0
  132. package/dist/test/unit/jsx-snippets.test.js.map +1 -0
  133. package/dist/test/unit/markdown-and-diagrams.test.d.ts +2 -0
  134. package/dist/test/unit/markdown-and-diagrams.test.d.ts.map +1 -0
  135. package/dist/test/unit/markdown-and-diagrams.test.js +62 -0
  136. package/dist/test/unit/markdown-and-diagrams.test.js.map +1 -0
  137. package/dist/test/unit/ui-spec-validation.test.d.ts +2 -0
  138. package/dist/test/unit/ui-spec-validation.test.d.ts.map +1 -0
  139. package/dist/test/unit/ui-spec-validation.test.js +90 -0
  140. package/dist/test/unit/ui-spec-validation.test.js.map +1 -0
  141. package/package.json +16 -2
@@ -6,7 +6,9 @@ import { MongoCatalogAdapter } from "../adapters/mongo/mongo-adapter.js";
6
6
  import { AuthorizationService } from "./authorization.js";
7
7
  import { resolveCatalogItemId } from "./identity.js";
8
8
  import { parseJson, toJson } from "./json-io.js";
9
- import { randomUUID } from "node:crypto";
9
+ import { createHash, randomUUID } from "node:crypto";
10
+ import { renderInventoryReportMarkdown } from "./reporting/render-inventory-report.js";
11
+ import { optionsFromCatalogItems, optionsFromStaticSource, resolveFilterBy } from "./field-source-resolution.js";
10
12
  import { AppStore } from "../firebase/app-store.js";
11
13
  import { AdapterStore } from "../firebase/adapter-store.js";
12
14
  import { BindingStore } from "../firebase/binding-store.js";
@@ -16,12 +18,49 @@ import { DescriptorStore } from "../firebase/descriptor-store.js";
16
18
  import { MappingStore } from "../firebase/mapping-store.js";
17
19
  import { NativeItemStore } from "../firebase/native-item-store.js";
18
20
  import { ReferenceStore } from "../firebase/reference-store.js";
21
+ import { RendererSnippetStore } from "../firebase/renderer-snippet-store.js";
19
22
  import { SnapshotStore } from "../firebase/snapshot-store.js";
23
+ import { StoreAppBindingStore } from "../firebase/store-app-binding-store.js";
20
24
  export class Catalox {
21
25
  deps;
22
26
  constructor(deps) {
23
27
  this.deps = deps;
24
28
  }
29
+ resolveActorId(context) {
30
+ return context.userId ?? context.actor?.id;
31
+ }
32
+ stableStringify(value) {
33
+ const seen = new WeakSet();
34
+ const walk = (v) => {
35
+ if (v == null || typeof v !== "object")
36
+ return v;
37
+ if (seen.has(v))
38
+ return "[Circular]";
39
+ seen.add(v);
40
+ if (Array.isArray(v))
41
+ return v.map(walk);
42
+ const out = {};
43
+ for (const k of Object.keys(v).sort())
44
+ out[k] = walk(v[k]);
45
+ return out;
46
+ };
47
+ return JSON.stringify(walk(value));
48
+ }
49
+ fingerprint(value) {
50
+ return createHash("sha256").update(this.stableStringify(value)).digest("hex");
51
+ }
52
+ async resolveEffectiveAppIds(context, input) {
53
+ const storeId = input?.storeId ?? context.storeId;
54
+ if (input?.appIds?.length)
55
+ return { ...(storeId ? { storeId } : {}), appIds: input.appIds };
56
+ if (storeId && this.deps.storeAppBindings) {
57
+ const bindings = await this.deps.storeAppBindings.listAppsByStore(storeId);
58
+ const active = bindings.filter((b) => b.status === "active").map((b) => b.appId);
59
+ if (active.length)
60
+ return { storeId, appIds: active };
61
+ }
62
+ return { ...(storeId ? { storeId } : {}), appIds: [context.appId] };
63
+ }
25
64
  readPath(obj, path) {
26
65
  if (!path)
27
66
  return undefined;
@@ -34,6 +73,171 @@ export class Catalox {
34
73
  }
35
74
  return cur;
36
75
  }
76
+ gatherDescriptorSources(descriptor) {
77
+ const out = {};
78
+ if (!descriptor)
79
+ return out;
80
+ const filterSpec = descriptor.filterSpec;
81
+ for (const f of filterSpec?.filters ?? []) {
82
+ if (f?.fieldPath && f.source)
83
+ out[String(f.fieldPath)] = f.source;
84
+ }
85
+ const presentationFields = descriptor.presentationSpec?.fields;
86
+ for (const fp of presentationFields ?? []) {
87
+ if (fp?.fieldPath && fp.source)
88
+ out[String(fp.fieldPath)] = fp.source;
89
+ }
90
+ return out;
91
+ }
92
+ async resolveFieldSourceOptions(params) {
93
+ const maxOptions = params.maxOptions ?? 200;
94
+ const source = params.source;
95
+ if (source.type === "static") {
96
+ return optionsFromStaticSource(source);
97
+ }
98
+ if (source.type === "field-distinct") {
99
+ // Distinct values for this field in the current catalog.
100
+ const list = await this.listCatalogItems(params.context, params.currentCatalogId, { limit: 500 });
101
+ const values = new Map();
102
+ for (const it of list.items) {
103
+ const v = this.readPath(it.data, params.fieldPath);
104
+ if (v == null)
105
+ continue;
106
+ const value = typeof v === "string" || typeof v === "number" ? v : String(v);
107
+ if (!values.has(value))
108
+ values.set(value, { value, label: String(value) });
109
+ }
110
+ return [...values.values()].slice(0, maxOptions);
111
+ }
112
+ if (source.type === "catalog") {
113
+ // Enforce authz: safest default is to require binding (unless god-mode).
114
+ await this.deps.authz.requireBindingAccess(params.context, params.context.appId, source.catalogId, "read");
115
+ const filterEq = resolveFilterBy(source.filterBy, params.currentItemData, (o, p) => this.readPath(o, p));
116
+ const sort = source.sortBy
117
+ ? { [source.sortBy.field]: source.sortBy.direction === "asc" ? 1 : -1 }
118
+ : undefined;
119
+ const list = await this.listCatalogItems(params.context, source.catalogId, {
120
+ limit: maxOptions,
121
+ ...(Object.keys(filterEq).length ? { filter: filterEq } : {}),
122
+ ...(sort ? { sort } : {}),
123
+ });
124
+ return optionsFromCatalogItems({
125
+ items: list.items.map((i) => ({ data: i.data })),
126
+ valueField: source.valueField,
127
+ labelField: source.labelField,
128
+ readPath: (o, p) => this.readPath(o, p),
129
+ });
130
+ }
131
+ return [];
132
+ }
133
+ async buildCatalogListRenderMap(context, catalogId, options) {
134
+ const descriptor = await this.getCatalogDescriptor(context, catalogId);
135
+ const list = await this.listCatalogItems(context, catalogId, { limit: options?.limit ?? 50 });
136
+ const sources = this.gatherDescriptorSources(descriptor);
137
+ const resolvedSources = {};
138
+ if (options?.resolveSources !== false) {
139
+ for (const [fieldPath, source] of Object.entries(sources)) {
140
+ // In list context we do not have current item data; $. paths in filterBy will be ignored.
141
+ resolvedSources[fieldPath] = await this.resolveFieldSourceOptions({
142
+ context,
143
+ currentCatalogId: catalogId,
144
+ fieldPath,
145
+ source,
146
+ ...(options?.maxSourceOptions != null ? { maxOptions: options.maxSourceOptions } : {}),
147
+ });
148
+ }
149
+ }
150
+ return {
151
+ catalogId: String(catalogId),
152
+ label: descriptor?.label ?? String(catalogId),
153
+ ...(descriptor?.itemLabel != null ? { itemLabel: descriptor.itemLabel } : {}),
154
+ items: list.items.map((i) => ({
155
+ itemId: String(i.itemId),
156
+ data: (i.data ?? {}),
157
+ ...(i.title != null ? { title: i.title } : {}),
158
+ ...(i.subtitle != null ? { subtitle: i.subtitle } : {}),
159
+ ...(i.status != null ? { status: i.status } : {}),
160
+ ...(i.metadata != null ? { metadata: i.metadata } : {}),
161
+ })),
162
+ ...(list.total != null ? { total: list.total } : {}),
163
+ resolvedSources,
164
+ filters: {
165
+ active: {},
166
+ ...(descriptor?.filterSpec ? { spec: descriptor.filterSpec } : {}),
167
+ },
168
+ sort: null,
169
+ pagination: { page: 1, pageSize: options?.limit ?? 50, hasMore: Boolean(list.nextCursor) },
170
+ selection: [],
171
+ ...(descriptor?.presentationSpec ? { presentation: descriptor.presentationSpec } : {}),
172
+ capabilities: descriptor?.capabilities ?? {
173
+ canList: true,
174
+ canGet: true,
175
+ canCreate: false,
176
+ canEdit: false,
177
+ canDelete: false,
178
+ },
179
+ context: { mode: "view", displayContext: options?.displayContext ?? "grid" },
180
+ actions: {},
181
+ };
182
+ }
183
+ async buildCatalogItemRenderMap(context, catalogId, itemId, options) {
184
+ const descriptor = await this.getCatalogDescriptor(context, catalogId);
185
+ const item = await this.getCatalogItem(context, catalogId, itemId);
186
+ if (!item)
187
+ throw new CatalogNotFoundError({ catalogId, itemId });
188
+ const sources = this.gatherDescriptorSources(descriptor);
189
+ const resolvedSources = {};
190
+ if (options?.resolveSources !== false) {
191
+ for (const [fieldPath, source] of Object.entries(sources)) {
192
+ resolvedSources[fieldPath] = await this.resolveFieldSourceOptions({
193
+ context,
194
+ currentCatalogId: catalogId,
195
+ fieldPath,
196
+ source,
197
+ currentItemData: (item.data ?? {}),
198
+ ...(options?.maxSourceOptions != null ? { maxOptions: options.maxSourceOptions } : {}),
199
+ });
200
+ }
201
+ }
202
+ const refs = options?.includeReferences
203
+ ? await this.getCatalogItemReferences(context, catalogId, itemId)
204
+ : [];
205
+ return {
206
+ catalogId: String(catalogId),
207
+ itemId: String(itemId),
208
+ item: {
209
+ data: (item.data ?? {}),
210
+ ...(item.title != null ? { title: item.title } : {}),
211
+ ...(item.subtitle != null ? { subtitle: item.subtitle } : {}),
212
+ ...(item.status != null ? { status: item.status } : {}),
213
+ ...(item.metadata != null ? { metadata: item.metadata } : {}),
214
+ ...(item.createdAt != null ? { createdAt: item.createdAt } : {}),
215
+ ...(item.updatedAt != null ? { updatedAt: item.updatedAt } : {}),
216
+ },
217
+ resolvedSources,
218
+ ...(refs.length
219
+ ? {
220
+ references: refs.map((r) => ({
221
+ toCatalogId: String(r.toCatalogId),
222
+ toItemId: String(r.toItemId),
223
+ relationType: String(r.relationType),
224
+ ...(r.label != null ? { label: r.label } : {}),
225
+ ...(r.metadata != null ? { metadata: r.metadata } : {}),
226
+ })),
227
+ }
228
+ : {}),
229
+ ...(descriptor?.presentationSpec ? { presentation: descriptor.presentationSpec } : {}),
230
+ capabilities: descriptor?.capabilities ?? {
231
+ canList: true,
232
+ canGet: true,
233
+ canCreate: false,
234
+ canEdit: false,
235
+ canDelete: false,
236
+ },
237
+ context: { mode: "view", displayContext: options?.displayContext ?? "form" },
238
+ actions: {},
239
+ };
240
+ }
37
241
  deriveIndexed(descriptor, data) {
38
242
  const out = {};
39
243
  for (const f of descriptor.queryableFields ?? []) {
@@ -106,6 +310,13 @@ export class Catalox {
106
310
  const rec = await this.deps.descriptors.get(catalogId);
107
311
  return rec?.descriptor ?? null;
108
312
  }
313
+ async getCatalogRendererSnippet(context, catalogId, surface) {
314
+ await this.deps.authz.requireBindingAccess(context, context.appId, catalogId, "read");
315
+ if (!this.deps.rendererSnippets) {
316
+ throw new Error("rendererSnippets dependency is not configured");
317
+ }
318
+ return this.deps.rendererSnippets.get(catalogId, surface);
319
+ }
109
320
  async getAppCatalogBootstrap(context, appId) {
110
321
  const resolved = appId ?? context.appId;
111
322
  const entries = await this.listAppCatalogs(context, { appId: resolved });
@@ -381,6 +592,57 @@ export class Catalox {
381
592
  updatedAt: new Date().toISOString(),
382
593
  });
383
594
  }
595
+ async bindAppToStore(context, input) {
596
+ if (!this.deps.storeAppBindings)
597
+ throw new Error("storeAppBindings dependency is not configured");
598
+ if (!context.isGodMode && input.appId !== context.appId) {
599
+ throw new CatalogAccessDeniedError({ reason: "not_god_mode" });
600
+ }
601
+ const existing = await this.deps.storeAppBindings.findByStoreApp(input.storeId, input.appId);
602
+ if (existing)
603
+ return existing;
604
+ const now = new Date().toISOString();
605
+ const actorId = this.resolveActorId(context);
606
+ const record = {
607
+ bindingId: `${String(input.storeId)}:${String(input.appId)}`,
608
+ storeId: input.storeId,
609
+ appId: input.appId,
610
+ status: "active",
611
+ ...(input.metadata != null ? { metadata: input.metadata } : {}),
612
+ createdAt: now,
613
+ updatedAt: now,
614
+ ...(actorId ? { createdBy: actorId, updatedBy: actorId } : {}),
615
+ };
616
+ await this.deps.storeAppBindings.upsert(record);
617
+ return record;
618
+ }
619
+ async unbindAppFromStore(context, storeId, appId) {
620
+ if (!this.deps.storeAppBindings)
621
+ throw new Error("storeAppBindings dependency is not configured");
622
+ if (!context.isGodMode && appId !== context.appId) {
623
+ throw new CatalogAccessDeniedError({ reason: "not_god_mode" });
624
+ }
625
+ const existing = await this.deps.storeAppBindings.findByStoreApp(storeId, appId);
626
+ if (!existing)
627
+ return;
628
+ const actorId = this.resolveActorId(context);
629
+ await this.deps.storeAppBindings.upsert({
630
+ ...existing,
631
+ status: "disabled",
632
+ updatedAt: new Date().toISOString(),
633
+ ...(actorId ? { updatedBy: actorId } : {}),
634
+ });
635
+ }
636
+ async listAppsForStore(context, storeId) {
637
+ if (!this.deps.storeAppBindings)
638
+ throw new Error("storeAppBindings dependency is not configured");
639
+ // listing is allowed for any caller; enforce god-mode only if you want to hide cross-app membership.
640
+ const records = await this.deps.storeAppBindings.listAppsByStore(storeId);
641
+ if (context.isGodMode)
642
+ return records;
643
+ // non-god: only reveal memberships that include the caller's own appId
644
+ return records.filter((r) => r.appId === context.appId);
645
+ }
384
646
  async ensureCatalog(context, catalog) {
385
647
  await this.deps.authz.requireBindingAccess(context, context.appId, catalog.catalogId, "admin");
386
648
  const existing = await this.deps.catalogs.get(catalog.catalogId);
@@ -429,10 +691,13 @@ export class Catalox {
429
691
  throw new CatalogNotFoundError({ catalogId: _catalogId, itemId: _itemId });
430
692
  const updatedAt = new Date().toISOString();
431
693
  const merged = { ...(existing.data ?? {}), ..._patch };
694
+ const actorId = this.resolveActorId(_context);
432
695
  await this.deps.nativeItems.upsert(_catalogId, {
433
696
  ...existing,
434
697
  data: merged,
435
698
  updatedAt,
699
+ ...(actorId ? { updatedBy: actorId } : {}),
700
+ version: (existing.version ?? 0) + 1,
436
701
  });
437
702
  const out = {
438
703
  itemId: _itemId,
@@ -459,14 +724,19 @@ export class Catalox {
459
724
  const indexed = callerIndexed ?? this.deriveIndexed(descriptor.descriptor, data);
460
725
  const itemId = resolveCatalogItemId({ identity: descriptor.descriptor.identity, data });
461
726
  const now = new Date().toISOString();
727
+ const existing = await this.deps.nativeItems.get(catalogId, itemId);
728
+ const actorId = this.resolveActorId(context);
462
729
  await this.deps.nativeItems.upsert(catalogId, {
463
730
  itemId,
464
731
  catalogId,
465
732
  appScopedOwnerId: context.appId,
466
733
  ...(indexed != null ? { indexed } : {}),
467
734
  data,
468
- createdAt: now,
735
+ version: (existing?.version ?? 0) + 1,
736
+ ...(existing?.createdAt ? { createdAt: existing.createdAt } : { createdAt: now }),
469
737
  updatedAt: now,
738
+ ...(existing?.createdBy ? { createdBy: existing.createdBy } : actorId ? { createdBy: actorId } : {}),
739
+ ...(actorId ? { updatedBy: actorId } : {}),
470
740
  });
471
741
  return {
472
742
  itemId,
@@ -475,7 +745,7 @@ export class Catalox {
475
745
  sourceMode: "native",
476
746
  sourceType: "firebase",
477
747
  data,
478
- createdAt: now,
748
+ createdAt: existing?.createdAt ?? now,
479
749
  updatedAt: now,
480
750
  };
481
751
  }
@@ -485,6 +755,7 @@ export class Catalox {
485
755
  if (!descriptor)
486
756
  throw new CatalogAdapterError({ catalogId, reason: "missing_descriptor" });
487
757
  const now = new Date().toISOString();
758
+ const actorId = this.resolveActorId(context);
488
759
  const records = items.map((input) => {
489
760
  const stripped = this.stripReservedWriteFields(input);
490
761
  const indexed = stripped.indexed ?? this.deriveIndexed(descriptor.descriptor, stripped.data);
@@ -497,6 +768,7 @@ export class Catalox {
497
768
  data: stripped.data,
498
769
  createdAt: now,
499
770
  updatedAt: now,
771
+ ...(actorId ? { updatedBy: actorId } : {}),
500
772
  };
501
773
  });
502
774
  await this.deps.nativeItems.batchUpsert(catalogId, records);
@@ -507,6 +779,174 @@ export class Catalox {
507
779
  exportCatalogItemsToJson(value, pretty = true) {
508
780
  return toJson(value, pretty);
509
781
  }
782
+ async exportInventory(context, input = {}) {
783
+ const now = new Date().toISOString();
784
+ const resolved = await this.resolveEffectiveAppIds(context, input);
785
+ const out = {
786
+ generatedAt: now,
787
+ input,
788
+ resolved: {
789
+ ...(resolved.storeId != null ? { storeId: resolved.storeId } : {}),
790
+ appIds: resolved.appIds,
791
+ ...(input.catalogIds?.length ? { catalogIds: input.catalogIds } : {}),
792
+ },
793
+ apps: [],
794
+ };
795
+ for (const appId of resolved.appIds) {
796
+ const appCtx = { ...context, appId };
797
+ const catalogs = await this.listAppCatalogs(appCtx, {
798
+ appId,
799
+ ...(input.includeDisabledCatalogs != null ? { includeDisabled: input.includeDisabledCatalogs } : {}),
800
+ ...(input.includeHiddenCatalogs != null ? { includeHidden: input.includeHiddenCatalogs } : {}),
801
+ });
802
+ const filteredCatalogs = catalogs.filter((c) => {
803
+ if (input.catalogIds?.length && !input.catalogIds.includes(c.catalogId))
804
+ return false;
805
+ if (input.sourceModes?.length && !input.sourceModes.includes(c.sourceMode))
806
+ return false;
807
+ return true;
808
+ });
809
+ const exportedCatalogs = [];
810
+ for (const c of filteredCatalogs) {
811
+ if (!input.includeItems) {
812
+ exportedCatalogs.push({ catalog: c });
813
+ continue;
814
+ }
815
+ const list = await this.listCatalogItems(appCtx, c.catalogId, {
816
+ limit: input.limitPerCatalog ?? 50,
817
+ });
818
+ const items = input.includeItemData === false
819
+ ? list.items.map((it) => {
820
+ const { data: _data, ...rest } = it;
821
+ return rest;
822
+ })
823
+ : list.items;
824
+ exportedCatalogs.push({
825
+ catalog: c,
826
+ items,
827
+ ...(list.issues ? { issues: list.issues } : {}),
828
+ });
829
+ }
830
+ out.apps.push({
831
+ appId,
832
+ catalogs: exportedCatalogs,
833
+ });
834
+ }
835
+ return out;
836
+ }
837
+ async exportInventoryToJson(context, input = {}, pretty = true) {
838
+ const data = await this.exportInventory(context, input);
839
+ return toJson(data, pretty);
840
+ }
841
+ async generateInventoryReport(context, input = {}) {
842
+ const now = new Date().toISOString();
843
+ const top = input.top ?? 10;
844
+ const resolved = await this.resolveEffectiveAppIds(context, input);
845
+ const catalogsById = new Map();
846
+ for (const appId of resolved.appIds) {
847
+ const appCtx = { ...context, appId };
848
+ const catalogs = await this.listAppCatalogs(appCtx, {
849
+ appId,
850
+ ...(input.includeDisabledCatalogs != null ? { includeDisabled: input.includeDisabledCatalogs } : {}),
851
+ ...(input.includeHiddenCatalogs != null ? { includeHidden: input.includeHiddenCatalogs } : {}),
852
+ });
853
+ for (const c of catalogs) {
854
+ if (input.catalogIds?.length && !input.catalogIds.includes(c.catalogId))
855
+ continue;
856
+ catalogsById.set(c.catalogId, c);
857
+ }
858
+ }
859
+ const catalogIds = [...catalogsById.keys()].sort();
860
+ const catalogStats = [];
861
+ for (const catalogId of catalogIds) {
862
+ const c = catalogsById.get(catalogId);
863
+ // pick the first appId that has the binding for fetching examples
864
+ const appId = resolved.appIds[0];
865
+ const appCtx = { ...context, appId };
866
+ const list = await this.listCatalogItems(appCtx, catalogId, { limit: top });
867
+ const examples = list.items.slice(0, top).map((it) => ({
868
+ catalogId,
869
+ itemId: it.itemId,
870
+ ...(it.title ? { title: it.title } : {}),
871
+ ...(it.subtitle ? { subtitle: it.subtitle } : {}),
872
+ ...(it.updatedAt ? { updatedAt: it.updatedAt } : {}),
873
+ }));
874
+ catalogStats.push({
875
+ catalogId,
876
+ label: c.label,
877
+ sourceMode: c.sourceMode,
878
+ status: c.status,
879
+ ...(list.issues ? { mappingIssueCount: list.issues.length } : {}),
880
+ topExamples: examples,
881
+ });
882
+ }
883
+ const data = {
884
+ generatedAt: now,
885
+ input,
886
+ resolved: {
887
+ ...(resolved.storeId != null ? { storeId: resolved.storeId } : {}),
888
+ appIds: resolved.appIds,
889
+ ...(input.catalogIds?.length ? { catalogIds: input.catalogIds } : {}),
890
+ },
891
+ summary: {
892
+ catalogCount: catalogStats.length,
893
+ nativeCatalogCount: catalogStats.filter((c) => c.sourceMode === "native").length,
894
+ mappedCatalogCount: catalogStats.filter((c) => c.sourceMode === "mapped").length,
895
+ totalExamples: catalogStats.reduce((n, c) => n + (c.topExamples?.length ?? 0), 0),
896
+ },
897
+ catalogs: catalogStats,
898
+ };
899
+ if (input.includeDiff) {
900
+ const diffCatalogs = [];
901
+ for (const catalogId of catalogIds) {
902
+ const runs = await this.deps.snapshots.listRuns(catalogId, 2);
903
+ const latest = runs[0];
904
+ const prev = runs[1];
905
+ if (!latest || !prev) {
906
+ diffCatalogs.push({ catalogId, added: 0, removed: 0, changed: 0 });
907
+ continue;
908
+ }
909
+ const [newItems, oldItems] = await Promise.all([
910
+ this.deps.snapshots.listRunItems(catalogId, latest.runId),
911
+ this.deps.snapshots.listRunItems(catalogId, prev.runId),
912
+ ]);
913
+ const oldById = new Map(oldItems.map((r) => [r.itemId, r.sourceFingerprint ?? this.fingerprint(r.data)]));
914
+ const newById = new Map(newItems.map((r) => [r.itemId, r.sourceFingerprint ?? this.fingerprint(r.data)]));
915
+ const added = [];
916
+ const removed = [];
917
+ const changed = [];
918
+ for (const id of newById.keys())
919
+ if (!oldById.has(id))
920
+ added.push(id);
921
+ for (const id of oldById.keys())
922
+ if (!newById.has(id))
923
+ removed.push(id);
924
+ for (const [id, fp] of newById) {
925
+ const oldFp = oldById.get(id);
926
+ if (oldFp && oldFp !== fp)
927
+ changed.push(id);
928
+ }
929
+ const ex = (ids) => ids.slice(0, top).map((itemId) => ({ catalogId, itemId }));
930
+ diffCatalogs.push({
931
+ catalogId,
932
+ added: added.length,
933
+ removed: removed.length,
934
+ changed: changed.length,
935
+ examples: {
936
+ ...(added.length ? { added: ex(added) } : {}),
937
+ ...(removed.length ? { removed: ex(removed) } : {}),
938
+ ...(changed.length ? { changed: ex(changed) } : {}),
939
+ },
940
+ });
941
+ }
942
+ data.diff = {
943
+ baseline: { kind: "previous_snapshot_run" },
944
+ catalogs: diffCatalogs,
945
+ };
946
+ }
947
+ const markdown = renderInventoryReportMarkdown(data);
948
+ return { markdown, data };
949
+ }
510
950
  async syncMappedCatalog(_context, _catalogId) {
511
951
  const catalog = await this.deps.catalogs.get(_catalogId);
512
952
  if (!catalog)
@@ -518,15 +958,38 @@ export class Catalox {
518
958
  if (mode !== "snapshot")
519
959
  return { syncStatus: "idle" };
520
960
  const now = new Date().toISOString();
961
+ const runId = randomUUID();
962
+ const actorId = this.resolveActorId(_context);
963
+ await this.deps.snapshots.upsertRun(_catalogId, {
964
+ runId,
965
+ catalogId: _catalogId,
966
+ startedAt: now,
967
+ status: "running",
968
+ ...(actorId ? { triggeredBy: actorId } : {}),
969
+ });
521
970
  const list = await this.listCatalogItems(_context, _catalogId, { limit: 500 });
522
971
  for (const item of list.items) {
523
- await this.deps.snapshots.upsert(_catalogId, {
972
+ const record = {
524
973
  itemId: item.itemId,
525
974
  catalogId: _catalogId,
975
+ sourceFingerprint: this.fingerprint(item.data),
976
+ ...(item.updatedAt ? { sourceUpdatedAt: item.updatedAt } : {}),
526
977
  data: item.data,
527
978
  sync: { lastSyncedAt: now, syncStatus: "success" },
528
- });
979
+ ...(actorId ? { metadata: { syncedBy: actorId } } : {}),
980
+ };
981
+ await this.deps.snapshots.upsert(_catalogId, record);
982
+ await this.deps.snapshots.upsertRunItem(_catalogId, runId, record);
529
983
  }
984
+ await this.deps.snapshots.upsertRun(_catalogId, {
985
+ runId,
986
+ catalogId: _catalogId,
987
+ startedAt: now,
988
+ finishedAt: new Date().toISOString(),
989
+ status: "success",
990
+ itemCount: list.items.length,
991
+ ...(actorId ? { triggeredBy: actorId } : {}),
992
+ });
530
993
  await this.deps.definitions.upsert({
531
994
  ...def,
532
995
  sync: {