@x12i/catalox 4.1.2 → 4.2.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 (58) hide show
  1. package/README.md +7 -0
  2. package/dist/src/catalox/catalog-snapshot-export.d.ts +46 -0
  3. package/dist/src/catalox/catalog-snapshot-export.d.ts.map +1 -0
  4. package/dist/src/catalox/catalog-snapshot-export.js +103 -0
  5. package/dist/src/catalox/catalog-snapshot-export.js.map +1 -0
  6. package/dist/src/catalox/catalox-bound.d.ts +3 -1
  7. package/dist/src/catalox/catalox-bound.d.ts.map +1 -1
  8. package/dist/src/catalox/catalox-bound.js +6 -0
  9. package/dist/src/catalox/catalox-bound.js.map +1 -1
  10. package/dist/src/catalox/catalox.d.ts +23 -6
  11. package/dist/src/catalox/catalox.d.ts.map +1 -1
  12. package/dist/src/catalox/catalox.js +232 -5
  13. package/dist/src/catalox/catalox.js.map +1 -1
  14. package/dist/src/catalox/create-catalox.d.ts.map +1 -1
  15. package/dist/src/catalox/create-catalox.js +3 -0
  16. package/dist/src/catalox/create-catalox.js.map +1 -1
  17. package/dist/src/catalox/seed-preset.d.ts +15 -0
  18. package/dist/src/catalox/seed-preset.d.ts.map +1 -1
  19. package/dist/src/catalox/seed-preset.js +35 -0
  20. package/dist/src/catalox/seed-preset.js.map +1 -1
  21. package/dist/src/cli/index.js +116 -6
  22. package/dist/src/cli/index.js.map +1 -1
  23. package/dist/src/contracts/catalog-snapshot-export.d.ts +55 -0
  24. package/dist/src/contracts/catalog-snapshot-export.d.ts.map +1 -0
  25. package/dist/src/contracts/catalog-snapshot-export.js +3 -0
  26. package/dist/src/contracts/catalog-snapshot-export.js.map +1 -0
  27. package/dist/src/contracts/catalog-types.d.ts +13 -1
  28. package/dist/src/contracts/catalog-types.d.ts.map +1 -1
  29. package/dist/src/contracts/index.d.ts +2 -0
  30. package/dist/src/contracts/index.d.ts.map +1 -1
  31. package/dist/src/contracts/index.js +1 -0
  32. package/dist/src/contracts/index.js.map +1 -1
  33. package/dist/src/contracts/inputs.d.ts +2 -0
  34. package/dist/src/contracts/inputs.d.ts.map +1 -1
  35. package/dist/src/contracts/validation.d.ts +2 -1
  36. package/dist/src/contracts/validation.d.ts.map +1 -1
  37. package/dist/src/embedder.d.ts +3 -1
  38. package/dist/src/embedder.d.ts.map +1 -1
  39. package/dist/src/embedder.js +3 -1
  40. package/dist/src/embedder.js.map +1 -1
  41. package/dist/src/validation/index.d.ts +2 -0
  42. package/dist/src/validation/index.d.ts.map +1 -1
  43. package/dist/src/validation/index.js +2 -0
  44. package/dist/src/validation/index.js.map +1 -1
  45. package/dist/src/validation/payload-schema-registry.d.ts +20 -0
  46. package/dist/src/validation/payload-schema-registry.d.ts.map +1 -0
  47. package/dist/src/validation/payload-schema-registry.js +76 -0
  48. package/dist/src/validation/payload-schema-registry.js.map +1 -0
  49. package/dist/src/validation/payload-schema.d.ts +30 -0
  50. package/dist/src/validation/payload-schema.d.ts.map +1 -0
  51. package/dist/src/validation/payload-schema.js +160 -0
  52. package/dist/src/validation/payload-schema.js.map +1 -0
  53. package/docs/cli-items.md +94 -93
  54. package/docs/cli-snapshot-export.md +65 -0
  55. package/package.json +6 -2
  56. package/presets/native-map-v1.json +14 -2
  57. package/schemas/native-map-item/v1.json +10 -0
  58. package/schemas/registry.json +3 -0
@@ -1,6 +1,8 @@
1
1
  import { compactCatalogFilter } from "../contracts/catalogs.js";
2
2
  import { CatalogAccessDeniedError, CatalogAdapterError, CatalogBindingError, CatalogNotFoundError, CatalogValidationError, } from "../contracts/errors.js";
3
3
  import { validateMappingSpec } from "../mapping/validate-mapping.js";
4
+ import { resolvePayloadSchema, validateNativeItemPayload, } from "../validation/payload-schema.js";
5
+ import { createBundledPayloadSchemaRegistry } from "../validation/payload-schema-registry.js";
4
6
  import { executeMapping } from "../mapping/execute-mapping.js";
5
7
  import { ApiCatalogAdapter } from "../adapters/api/api-adapter.js";
6
8
  import { MongoCatalogAdapter } from "../adapters/mongo/mongo-adapter.js";
@@ -9,6 +11,7 @@ import { resolveCatalogItemId } from "./identity.js";
9
11
  import { assertSuperAdminForNonGlobalScope, encodeNativeItemStorageDocId, indexedScopeFields, isGlobalPhysicalRow, normalizeStoredScope, parseWriteScopeInput, scopeFromRecordField, } from "./native-scope.js";
10
12
  import { filterPhysicalForTenantFetch, mergeNativePhysicalRows, pickWinningPhysicalRow, } from "./native-catalog-merge.js";
11
13
  import { parseJson, toJson } from "./json-io.js";
14
+ import { buildCataloxExportV1, filterCatalogEntries, listAllCatalogItemsForExport, stripItemData, } from "./catalog-snapshot-export.js";
12
15
  import { createHash, randomUUID } from "node:crypto";
13
16
  import { renderInventoryReportMarkdown } from "./reporting/render-inventory-report.js";
14
17
  import { optionsFromCatalogItems, optionsFromStaticSource, resolveFilterBy } from "./field-source-resolution.js";
@@ -48,8 +51,51 @@ import { emitRecordHistoryEvent, readRecordHistoryEventPayload, } from "./record
48
51
  import { runDeleteCatalog, runRenameCatalog, runRestoreDeletedCatalog, } from "./catalog-lifecycle.js";
49
52
  export class Catalox {
50
53
  deps;
54
+ payloadSchemaRegistry;
51
55
  constructor(deps) {
52
56
  this.deps = deps;
57
+ this.payloadSchemaRegistry = deps.payloadSchemas ?? createBundledPayloadSchemaRegistry();
58
+ }
59
+ getPayloadSchemaRegistry() {
60
+ return this.payloadSchemaRegistry;
61
+ }
62
+ async resolveCatalogTypeRegistry(context) {
63
+ if (!this.deps.catalogTypes)
64
+ return null;
65
+ return this.deps.catalogTypes.resolveForContext({
66
+ ...(context.storeId ? { storeId: context.storeId } : {}),
67
+ ...(context.appId ? { appId: context.appId } : {}),
68
+ });
69
+ }
70
+ resolvePayloadSchemaForCatalog(catalog, catalogTypeRegistry) {
71
+ return resolvePayloadSchema({
72
+ catalog,
73
+ catalogTypeRegistry: catalogTypeRegistry ?? null,
74
+ registry: this.getPayloadSchemaRegistry(),
75
+ });
76
+ }
77
+ validateNativeItemData(data, catalog, catalogTypeRegistry) {
78
+ const resolved = this.resolvePayloadSchemaForCatalog(catalog, catalogTypeRegistry);
79
+ if (!resolved)
80
+ return { isValid: true, issues: [] };
81
+ return validateNativeItemPayload(data, resolved, this.getPayloadSchemaRegistry());
82
+ }
83
+ async assertNativeItemPayloadValid(context, catalogId, data, logicalItemId) {
84
+ if (!this.deps.catalogs)
85
+ return;
86
+ const catalog = await this.deps.catalogs.get(catalogId);
87
+ if (!catalog)
88
+ return;
89
+ const typeRegistry = await this.resolveCatalogTypeRegistry(context);
90
+ const report = this.validateNativeItemData(data, catalog, typeRegistry);
91
+ if (report.isValid)
92
+ return;
93
+ throw new CatalogValidationError({
94
+ reason: "invalid_payload",
95
+ catalogId: String(catalogId),
96
+ ...(logicalItemId != null ? { itemId: logicalItemId } : {}),
97
+ report,
98
+ });
53
99
  }
54
100
  encodeRefSegment(value) {
55
101
  // Keep IDs stable and URL-safe for use as Firestore doc ids.
@@ -1111,15 +1157,126 @@ export class Catalox {
1111
1157
  throw e;
1112
1158
  }
1113
1159
  }
1114
- async validateCatalog(_context, _catalogId) {
1115
- return { isValid: true, issues: [] };
1160
+ async validateCatalog(context, catalogId) {
1161
+ const issues = [];
1162
+ const catalog = await this.deps.catalogs.get(catalogId);
1163
+ if (!catalog) {
1164
+ issues.push({
1165
+ code: "catalog.not_found",
1166
+ severity: "error",
1167
+ message: `Catalog "${String(catalogId)}" was not found.`,
1168
+ });
1169
+ return { isValid: false, issues };
1170
+ }
1171
+ const descriptor = await this.deps.descriptors.get(catalogId);
1172
+ if (!descriptor) {
1173
+ issues.push({
1174
+ code: "catalog.missing_descriptor",
1175
+ severity: "error",
1176
+ message: `Descriptor missing for catalog "${String(catalogId)}".`,
1177
+ });
1178
+ }
1179
+ const definition = await this.deps.definitions.get(catalogId);
1180
+ if (catalog.sourceMode === "native") {
1181
+ if (!definition || definition.sourceMode !== "native") {
1182
+ issues.push({
1183
+ code: "catalog.missing_native_config",
1184
+ severity: "error",
1185
+ message: `Native definition missing for catalog "${String(catalogId)}".`,
1186
+ });
1187
+ }
1188
+ }
1189
+ else if (catalog.sourceMode === "mapped") {
1190
+ if (!definition || definition.sourceMode !== "mapped") {
1191
+ issues.push({
1192
+ code: "catalog.missing_mapped_config",
1193
+ severity: "error",
1194
+ message: `Mapped definition missing for catalog "${String(catalogId)}".`,
1195
+ });
1196
+ }
1197
+ else {
1198
+ const map = definition.catalogItems?.map;
1199
+ const mappingId = map?.mappingId != null ? String(map.mappingId) : "";
1200
+ if (!mappingId) {
1201
+ issues.push({
1202
+ code: "catalog.mapping_invalid",
1203
+ severity: "error",
1204
+ message: `Mapped catalog "${String(catalogId)}" has no mappingId.`,
1205
+ });
1206
+ }
1207
+ else {
1208
+ const mapping = await this.deps.mappings.get(mappingId);
1209
+ if (!mapping) {
1210
+ issues.push({
1211
+ code: "catalog.mapping_invalid",
1212
+ severity: "error",
1213
+ message: `Mapping "${mappingId}" not found for catalog "${String(catalogId)}".`,
1214
+ });
1215
+ }
1216
+ else {
1217
+ const mappingIssues = validateMappingSpec(mapping.mapping);
1218
+ for (const mi of mappingIssues) {
1219
+ issues.push({
1220
+ code: "catalog.mapping_invalid",
1221
+ severity: "error",
1222
+ message: mi.message ?? String(mi.code ?? "mapping_invalid"),
1223
+ });
1224
+ }
1225
+ }
1226
+ }
1227
+ }
1228
+ }
1229
+ if (this.deps.catalogTypes) {
1230
+ const registry = await this.resolveCatalogTypeRegistry(context);
1231
+ if (registry?.types?.length) {
1232
+ const allowed = new Set(registry.types.map((t) => String(t)));
1233
+ if (!allowed.has(String(catalog.catalogType))) {
1234
+ issues.push({
1235
+ code: "catalog.type_not_allowed",
1236
+ severity: "error",
1237
+ message: `catalogType "${String(catalog.catalogType)}" is not in the scoped registry.`,
1238
+ });
1239
+ }
1240
+ }
1241
+ }
1242
+ return { isValid: !issues.some((i) => i.severity === "error"), issues };
1116
1243
  }
1117
1244
  async validateCatalogItem(context, catalogId, itemId) {
1245
+ const issues = [];
1118
1246
  const descriptor = await this.getCatalogDescriptor(context, catalogId);
1119
- if (!descriptor)
1120
- return { isValid: true, issues: [] };
1247
+ if (!descriptor) {
1248
+ return {
1249
+ isValid: false,
1250
+ issues: [
1251
+ {
1252
+ code: "catalog.missing_descriptor",
1253
+ severity: "error",
1254
+ message: `Descriptor missing for catalog "${String(catalogId)}".`,
1255
+ },
1256
+ ],
1257
+ };
1258
+ }
1121
1259
  const refs = await this.getCatalogItemReferences(context, catalogId, itemId);
1122
- return this.validateRelationsAgainstDescriptor(context, { catalogId, itemId, descriptor, refs });
1260
+ const relationReport = await this.validateRelationsAgainstDescriptor(context, {
1261
+ catalogId,
1262
+ itemId,
1263
+ descriptor,
1264
+ refs,
1265
+ });
1266
+ issues.push(...relationReport.issues);
1267
+ const catalog = await this.deps.catalogs.get(catalogId);
1268
+ if (catalog?.sourceMode === "native") {
1269
+ const got = await this.getCatalogItem(context, catalogId, itemId);
1270
+ if (got.outcome === "found" && got.item?.data != null) {
1271
+ const typeRegistry = await this.resolveCatalogTypeRegistry(context);
1272
+ const payloadReport = this.validateNativeItemData(got.item.data, catalog, typeRegistry);
1273
+ issues.push(...payloadReport.issues);
1274
+ }
1275
+ }
1276
+ return {
1277
+ isValid: !issues.some((i) => i.severity === "error"),
1278
+ issues,
1279
+ };
1123
1280
  }
1124
1281
  async getCatalogItemReferences(context, catalogId, itemId) {
1125
1282
  const appId = await this.resolveAppIdForCatalogAccess({ context, catalogId, required: "read" });
@@ -1202,6 +1359,7 @@ export class Catalox {
1202
1359
  name: _input.name,
1203
1360
  ...(_input.description != null ? { description: _input.description } : {}),
1204
1361
  catalogType: _input.catalogType,
1362
+ ...(_input.schemaVersion != null ? { schemaVersion: String(_input.schemaVersion) } : {}),
1205
1363
  sourceMode: _input.sourceMode,
1206
1364
  ...(_input.sourceMode === "mapped" && _input.mapped?.sourceType
1207
1365
  ? { mappedSourceType: _input.mapped.sourceType }
@@ -1762,6 +1920,8 @@ export class Catalox {
1762
1920
  const { data, indexed: callerIndexed, scope: inputScope, relations: inputRelations } = this.stripReservedWriteFields(input);
1763
1921
  const storedScope = normalizeStoredScope(inputScope ?? { kind: "global" });
1764
1922
  assertSuperAdminForNonGlobalScope(context.superAdmin, storedScope);
1923
+ const logicalItemIdEarly = resolveCatalogItemId({ identity: descriptor.descriptor.identity, data });
1924
+ await this.assertNativeItemPayloadValid(context, catalogId, data, String(logicalItemIdEarly));
1765
1925
  const derived = this.deriveIndexed(descriptor.descriptor, data);
1766
1926
  const scopeIdx = indexedScopeFields(storedScope);
1767
1927
  const indexed = derived != null ? { ...derived, ...scopeIdx } : (Object.keys(scopeIdx).length ? scopeIdx : undefined);
@@ -1861,10 +2021,24 @@ export class Catalox {
1861
2021
  throw new CatalogAdapterError({ catalogId, reason: "missing_descriptor" });
1862
2022
  const now = new Date().toISOString();
1863
2023
  const actorId = this.resolveActorId(context);
2024
+ const catalog = await this.deps.catalogs.get(catalogId);
2025
+ const typeRegistry = catalog ? await this.resolveCatalogTypeRegistry(context) : null;
1864
2026
  const records = items.map((row) => {
1865
2027
  const stripped = this.stripReservedWriteFields(row);
1866
2028
  const storedScope = normalizeStoredScope(stripped.scope ?? { kind: "global" });
1867
2029
  assertSuperAdminForNonGlobalScope(context.superAdmin, storedScope);
2030
+ if (catalog) {
2031
+ const report = this.validateNativeItemData(stripped.data, catalog, typeRegistry);
2032
+ if (!report.isValid) {
2033
+ const logicalItemId = resolveCatalogItemId({ identity: descriptor.descriptor.identity, data: stripped.data });
2034
+ throw new CatalogValidationError({
2035
+ reason: "invalid_payload",
2036
+ catalogId: String(catalogId),
2037
+ itemId: String(logicalItemId),
2038
+ report,
2039
+ });
2040
+ }
2041
+ }
1868
2042
  const derived = this.deriveIndexed(descriptor.descriptor, stripped.data);
1869
2043
  const scopeIdx = indexedScopeFields(storedScope);
1870
2044
  const indexed = derived != null ? { ...derived, ...scopeIdx } : (Object.keys(scopeIdx).length ? scopeIdx : undefined);
@@ -1968,6 +2142,59 @@ export class Catalox {
1968
2142
  const data = await this.exportInventory(context, input);
1969
2143
  return toJson(data, pretty);
1970
2144
  }
2145
+ /**
2146
+ * Versioned full-catalog snapshot (`catalox-export/v1`) for backup and drift detection.
2147
+ * Paginates all items per catalog; catalogs and items are sorted deterministically.
2148
+ */
2149
+ async exportCatalogSnapshot(context, input = {}) {
2150
+ const appId = context.appId;
2151
+ if (!appId)
2152
+ throw new Error("exportCatalogSnapshot: missing context.appId");
2153
+ const appCtx = {
2154
+ ...context,
2155
+ appId,
2156
+ ...(input.storeId != null ? { storeId: input.storeId } : {}),
2157
+ };
2158
+ const catalogs = await this.listAppCatalogs(appCtx, {
2159
+ appId,
2160
+ ...(input.includeDisabledCatalogs != null ? { includeDisabled: input.includeDisabledCatalogs } : {}),
2161
+ ...(input.includeHiddenCatalogs != null ? { includeHidden: input.includeHiddenCatalogs } : {}),
2162
+ });
2163
+ const filtered = filterCatalogEntries(catalogs, input);
2164
+ const pageSize = input.pageSize ?? 200;
2165
+ const superList = input.includeAllNativeScopes !== false && Boolean(context.superAdmin);
2166
+ const includeItemData = input.includeItemData !== false;
2167
+ const exported = [];
2168
+ for (const c of filtered.sort((a, b) => String(a.catalogId).localeCompare(String(b.catalogId), "en"))) {
2169
+ const { items, truncated, listOutcome, issues } = await listAllCatalogItemsForExport(c.catalogId, (catalogId, opts) => this.listCatalogItems(appCtx, catalogId, {
2170
+ limit: opts.limit,
2171
+ ...(opts.offset != null ? { offset: opts.offset } : {}),
2172
+ ...(opts.cursor ? { cursor: opts.cursor } : {}),
2173
+ ...(opts.scope ? { scope: opts.scope } : {}),
2174
+ }), {
2175
+ pageSize,
2176
+ ...(input.maxItemsPerCatalog != null ? { maxItems: input.maxItemsPerCatalog } : {}),
2177
+ ...(superList && c.sourceMode === "native" ? { superList: true } : {}),
2178
+ });
2179
+ const payloadItems = includeItemData ? items : stripItemData(items);
2180
+ const catalogRecord = await this.deps.catalogs.get(c.catalogId);
2181
+ exported.push({
2182
+ catalogId: c.catalogId,
2183
+ catalog: c,
2184
+ ...(catalogRecord?.schemaVersion != null ? { schemaVersion: String(catalogRecord.schemaVersion) } : {}),
2185
+ itemCount: payloadItems.length,
2186
+ ...(truncated ? { truncated: true } : {}),
2187
+ listOutcome,
2188
+ ...(issues?.length ? { issues } : {}),
2189
+ items: payloadItems,
2190
+ });
2191
+ }
2192
+ return buildCataloxExportV1({ context: appCtx, input, catalogs: exported });
2193
+ }
2194
+ async exportCatalogSnapshotToJson(context, input = {}, pretty = true) {
2195
+ const data = await this.exportCatalogSnapshot(context, input);
2196
+ return toJson(data, pretty);
2197
+ }
1971
2198
  async generateInventoryReport(context, input = {}) {
1972
2199
  const now = new Date().toISOString();
1973
2200
  const top = input.top ?? 10;