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