@x12i/catalox 3.0.0 → 3.1.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 (125) hide show
  1. package/README.md +38 -5
  2. package/dist/src/catalox/authorization.js +1 -1
  3. package/dist/src/catalox/authorization.js.map +1 -1
  4. package/dist/src/catalox/catalog-lifecycle.d.ts +29 -0
  5. package/dist/src/catalox/catalog-lifecycle.d.ts.map +1 -0
  6. package/dist/src/catalox/catalog-lifecycle.js +480 -0
  7. package/dist/src/catalox/catalog-lifecycle.js.map +1 -0
  8. package/dist/src/catalox/catalox-bound.d.ts +20 -4
  9. package/dist/src/catalox/catalox-bound.d.ts.map +1 -1
  10. package/dist/src/catalox/catalox-bound.js +30 -6
  11. package/dist/src/catalox/catalox-bound.js.map +1 -1
  12. package/dist/src/catalox/catalox.d.ts +29 -4
  13. package/dist/src/catalox/catalox.d.ts.map +1 -1
  14. package/dist/src/catalox/catalox.js +483 -66
  15. package/dist/src/catalox/catalox.js.map +1 -1
  16. package/dist/src/catalox/context.js +2 -2
  17. package/dist/src/catalox/context.js.map +1 -1
  18. package/dist/src/catalox/create-catalox.d.ts +6 -0
  19. package/dist/src/catalox/create-catalox.d.ts.map +1 -1
  20. package/dist/src/catalox/create-catalox.js +25 -0
  21. package/dist/src/catalox/create-catalox.js.map +1 -1
  22. package/dist/src/catalox/index.d.ts +2 -0
  23. package/dist/src/catalox/index.d.ts.map +1 -1
  24. package/dist/src/catalox/index.js +2 -0
  25. package/dist/src/catalox/index.js.map +1 -1
  26. package/dist/src/catalox/native-catalog-merge.d.ts +12 -0
  27. package/dist/src/catalox/native-catalog-merge.d.ts.map +1 -0
  28. package/dist/src/catalox/native-catalog-merge.js +102 -0
  29. package/dist/src/catalox/native-catalog-merge.js.map +1 -0
  30. package/dist/src/catalox/native-scope.d.ts +28 -0
  31. package/dist/src/catalox/native-scope.d.ts.map +1 -0
  32. package/dist/src/catalox/native-scope.js +184 -0
  33. package/dist/src/catalox/native-scope.js.map +1 -0
  34. package/dist/src/catalox/record-history.d.ts +53 -0
  35. package/dist/src/catalox/record-history.d.ts.map +1 -0
  36. package/dist/src/catalox/record-history.js +158 -0
  37. package/dist/src/catalox/record-history.js.map +1 -0
  38. package/dist/src/cli/index.js +133 -1
  39. package/dist/src/cli/index.js.map +1 -1
  40. package/dist/src/contracts/apps.d.ts +2 -0
  41. package/dist/src/contracts/apps.d.ts.map +1 -1
  42. package/dist/src/contracts/catalog-lifecycle.d.ts +70 -0
  43. package/dist/src/contracts/catalog-lifecycle.d.ts.map +1 -0
  44. package/dist/src/contracts/catalog-lifecycle.js +2 -0
  45. package/dist/src/contracts/catalog-lifecycle.js.map +1 -0
  46. package/dist/src/contracts/catalogs.d.ts +37 -0
  47. package/dist/src/contracts/catalogs.d.ts.map +1 -1
  48. package/dist/src/contracts/catalogs.js.map +1 -1
  49. package/dist/src/contracts/context.d.ts +5 -1
  50. package/dist/src/contracts/context.d.ts.map +1 -1
  51. package/dist/src/contracts/descriptors.d.ts +6 -0
  52. package/dist/src/contracts/descriptors.d.ts.map +1 -1
  53. package/dist/src/contracts/index.d.ts +5 -2
  54. package/dist/src/contracts/index.d.ts.map +1 -1
  55. package/dist/src/contracts/index.js.map +1 -1
  56. package/dist/src/contracts/items.d.ts +19 -0
  57. package/dist/src/contracts/items.d.ts.map +1 -1
  58. package/dist/src/contracts/record-history.d.ts +66 -0
  59. package/dist/src/contracts/record-history.d.ts.map +1 -0
  60. package/dist/src/contracts/record-history.js +2 -0
  61. package/dist/src/contracts/record-history.js.map +1 -0
  62. package/dist/src/firebase/adapter-store.d.ts +1 -0
  63. package/dist/src/firebase/adapter-store.d.ts.map +1 -1
  64. package/dist/src/firebase/adapter-store.js +3 -0
  65. package/dist/src/firebase/adapter-store.js.map +1 -1
  66. package/dist/src/firebase/binding-store.d.ts +2 -0
  67. package/dist/src/firebase/binding-store.d.ts.map +1 -1
  68. package/dist/src/firebase/binding-store.js +10 -0
  69. package/dist/src/firebase/binding-store.js.map +1 -1
  70. package/dist/src/firebase/catalog-data-index-store.d.ts +1 -0
  71. package/dist/src/firebase/catalog-data-index-store.d.ts.map +1 -1
  72. package/dist/src/firebase/catalog-data-index-store.js +3 -0
  73. package/dist/src/firebase/catalog-data-index-store.js.map +1 -1
  74. package/dist/src/firebase/catalog-item-history-store.d.ts +21 -0
  75. package/dist/src/firebase/catalog-item-history-store.d.ts.map +1 -0
  76. package/dist/src/firebase/catalog-item-history-store.js +61 -0
  77. package/dist/src/firebase/catalog-item-history-store.js.map +1 -0
  78. package/dist/src/firebase/catalog-store.d.ts +1 -0
  79. package/dist/src/firebase/catalog-store.d.ts.map +1 -1
  80. package/dist/src/firebase/catalog-store.js +3 -0
  81. package/dist/src/firebase/catalog-store.js.map +1 -1
  82. package/dist/src/firebase/index.d.ts +1 -0
  83. package/dist/src/firebase/index.d.ts.map +1 -1
  84. package/dist/src/firebase/index.js +1 -0
  85. package/dist/src/firebase/index.js.map +1 -1
  86. package/dist/src/firebase/mapping-store.d.ts +1 -0
  87. package/dist/src/firebase/mapping-store.d.ts.map +1 -1
  88. package/dist/src/firebase/mapping-store.js +3 -0
  89. package/dist/src/firebase/mapping-store.js.map +1 -1
  90. package/dist/src/firebase/native-item-store.d.ts +8 -2
  91. package/dist/src/firebase/native-item-store.d.ts.map +1 -1
  92. package/dist/src/firebase/native-item-store.js +22 -6
  93. package/dist/src/firebase/native-item-store.js.map +1 -1
  94. package/dist/src/firebase/reference-store.d.ts +3 -0
  95. package/dist/src/firebase/reference-store.d.ts.map +1 -1
  96. package/dist/src/firebase/reference-store.js +16 -0
  97. package/dist/src/firebase/reference-store.js.map +1 -1
  98. package/dist/src/firebase/renderer-snippet-store.d.ts +3 -0
  99. package/dist/src/firebase/renderer-snippet-store.d.ts.map +1 -1
  100. package/dist/src/firebase/renderer-snippet-store.js +17 -0
  101. package/dist/src/firebase/renderer-snippet-store.js.map +1 -1
  102. package/dist/src/firebase/snapshot-store.d.ts +1 -0
  103. package/dist/src/firebase/snapshot-store.d.ts.map +1 -1
  104. package/dist/src/firebase/snapshot-store.js +8 -0
  105. package/dist/src/firebase/snapshot-store.js.map +1 -1
  106. package/dist/test/integration/firestore.emulator.test.js +7 -1
  107. package/dist/test/integration/firestore.emulator.test.js.map +1 -1
  108. package/dist/test/integration/record-history.live.test.d.ts +2 -0
  109. package/dist/test/integration/record-history.live.test.d.ts.map +1 -0
  110. package/dist/test/integration/record-history.live.test.js +126 -0
  111. package/dist/test/integration/record-history.live.test.js.map +1 -0
  112. package/dist/test/unit/native-catalog-merge.test.d.ts +2 -0
  113. package/dist/test/unit/native-catalog-merge.test.d.ts.map +1 -0
  114. package/dist/test/unit/native-catalog-merge.test.js +33 -0
  115. package/dist/test/unit/native-catalog-merge.test.js.map +1 -0
  116. package/dist/test/unit/native-scope.test.d.ts +2 -0
  117. package/dist/test/unit/native-scope.test.d.ts.map +1 -0
  118. package/dist/test/unit/native-scope.test.js +29 -0
  119. package/dist/test/unit/native-scope.test.js.map +1 -0
  120. package/dist/test/unit/record-history-path.test.d.ts +2 -0
  121. package/dist/test/unit/record-history-path.test.d.ts.map +1 -0
  122. package/dist/test/unit/record-history-path.test.js +24 -0
  123. package/dist/test/unit/record-history-path.test.js.map +1 -0
  124. package/firestore.indexes.json +39 -0
  125. package/package.json +3 -2
@@ -6,6 +6,8 @@ import { ApiCatalogAdapter } from "../adapters/api/api-adapter.js";
6
6
  import { MongoCatalogAdapter } from "../adapters/mongo/mongo-adapter.js";
7
7
  import { AuthorizationService } from "./authorization.js";
8
8
  import { resolveCatalogItemId } from "./identity.js";
9
+ import { assertSuperAdminForNonGlobalScope, encodeNativeItemStorageDocId, indexedScopeFields, isGlobalPhysicalRow, normalizeStoredScope, parseWriteScopeInput, scopeFromRecordField, } from "./native-scope.js";
10
+ import { filterPhysicalForTenantFetch, mergeNativePhysicalRows, pickWinningPhysicalRow, } from "./native-catalog-merge.js";
9
11
  import { parseJson, toJson } from "./json-io.js";
10
12
  import { createHash, randomUUID } from "node:crypto";
11
13
  import { renderInventoryReportMarkdown } from "./reporting/render-inventory-report.js";
@@ -19,7 +21,7 @@ import { DefinitionStore } from "../firebase/definition-store.js";
19
21
  import { DescriptorStore } from "../firebase/descriptor-store.js";
20
22
  import { FirestoreStore } from "../firebase/firestore-store.js";
21
23
  import { MappingStore } from "../firebase/mapping-store.js";
22
- import { NativeItemStore } from "../firebase/native-item-store.js";
24
+ import { NativeItemStore, storageDocIdForNativeRecord } from "../firebase/native-item-store.js";
23
25
  import { ReferenceStore } from "../firebase/reference-store.js";
24
26
  import { RendererSnippetStore } from "../firebase/renderer-snippet-store.js";
25
27
  import { SnapshotStore } from "../firebase/snapshot-store.js";
@@ -34,11 +36,44 @@ import { reportNativeCatalogLayoutDiagnostics as runReportNativeCatalogLayoutDia
34
36
  import { exportAllFirestoreCollectionsToGcs, exportFirestoreCollectionToGcs, restoreAllFirestoreCollectionsFromGcsManifest, restoreFirestoreCollectionFromGcs, } from "./firestore-gcs-transfer.js";
35
37
  import { compareAllFirestoreCollectionsWithGcsManifest, compareFirestoreCollectionWithGcsNdjson, } from "./firestore-gcs-compare.js";
36
38
  import { CataloxBound } from "./catalox-bound.js";
39
+ import { emitRecordHistoryEvent, readRecordHistoryEventPayload, } from "./record-history.js";
40
+ import { runDeleteCatalog, runRenameCatalog, runRestoreDeletedCatalog, } from "./catalog-lifecycle.js";
37
41
  export class Catalox {
38
42
  deps;
39
43
  constructor(deps) {
40
44
  this.deps = deps;
41
45
  }
46
+ catalogLifecycleDeps() {
47
+ return {
48
+ firestoreStore: this.deps.firestoreStore,
49
+ catalogs: this.deps.catalogs,
50
+ bindings: this.deps.bindings,
51
+ definitions: this.deps.definitions,
52
+ descriptors: this.deps.descriptors,
53
+ mappings: this.deps.mappings,
54
+ adapters: this.deps.adapters,
55
+ references: this.deps.references,
56
+ nativeItems: this.deps.nativeItems,
57
+ snapshots: this.deps.snapshots,
58
+ catalogDataIndex: this.deps.catalogDataIndex,
59
+ ...(this.deps.rendererSnippets ? { rendererSnippets: this.deps.rendererSnippets } : {}),
60
+ };
61
+ }
62
+ async emitNativeItemHistory(context, params) {
63
+ const rh = this.deps.recordHistory;
64
+ if (!rh)
65
+ return;
66
+ const res = await emitRecordHistoryEvent(rh, context, {
67
+ catalogId: params.catalogId,
68
+ itemId: params.itemId,
69
+ ...(params.storageDocId != null ? { storageDocId: params.storageDocId } : {}),
70
+ op: params.op,
71
+ ...(params.before !== undefined ? { before: params.before } : {}),
72
+ ...(params.after !== undefined ? { after: params.after } : {}),
73
+ });
74
+ if (!res.ok && rh.failClosed)
75
+ throw new Error(`recordHistory: ${res.error}`);
76
+ }
42
77
  resolveActorId(context) {
43
78
  return context.userId ?? context.actor?.id;
44
79
  }
@@ -267,8 +302,83 @@ export class Catalox {
267
302
  return Object.keys(out).length ? out : undefined;
268
303
  }
269
304
  stripReservedWriteFields(input) {
270
- const { indexed, ...rest } = input;
271
- return { data: rest, ...(indexed != null ? { indexed: indexed } : {}) };
305
+ const { indexed, scope, ...rest } = input;
306
+ const parsedScope = scope != null ? parseWriteScopeInput(scope) : undefined;
307
+ return {
308
+ data: rest,
309
+ ...(indexed != null ? { indexed: indexed } : {}),
310
+ ...(parsedScope != null ? { scope: parsedScope } : {}),
311
+ };
312
+ }
313
+ normalizeListFetchScope(scope) {
314
+ if (!scope)
315
+ return undefined;
316
+ if (!scope.accountId && !scope.agentId && !scope.userId && scope.superAdmin !== true)
317
+ return undefined;
318
+ return scope;
319
+ }
320
+ buildAppliedScope(scope, effectiveSuperList) {
321
+ return {
322
+ ...(scope?.accountId != null ? { accountId: scope.accountId } : {}),
323
+ ...(scope?.agentId != null ? { agentId: scope.agentId } : {}),
324
+ ...(scope?.userId != null ? { userId: scope.userId } : {}),
325
+ superAdmin: effectiveSuperList,
326
+ };
327
+ }
328
+ async fetchGlobalPhysicalRows(catalogId, listOpts) {
329
+ const userFe = { ...(listOpts.filterEq ?? {}) };
330
+ const withGlobal = { ...userFe, scopeLayer: "global" };
331
+ let recs = await this.deps.nativeItems.list(catalogId, { ...listOpts, filterEq: withGlobal });
332
+ if (recs.length === 0 && Object.keys(userFe).length === 0) {
333
+ const broad = await this.deps.nativeItems.list(catalogId, { ...listOpts, filterEq: {} });
334
+ recs = broad.filter((r) => isGlobalPhysicalRow(r));
335
+ }
336
+ return recs;
337
+ }
338
+ async fetchTenantPhysicalRows(catalogId, fetch, listOpts) {
339
+ const userFe = { ...(listOpts.filterEq ?? {}) };
340
+ const cap = { ...listOpts, limit: 500, offset: 0 };
341
+ const seen = new Set();
342
+ const out = [];
343
+ const take = (rows) => {
344
+ for (const r of rows) {
345
+ const id = storageDocIdForNativeRecord(r);
346
+ if (seen.has(id))
347
+ continue;
348
+ seen.add(id);
349
+ out.push(r);
350
+ }
351
+ };
352
+ take(await this.deps.nativeItems.list(catalogId, { ...cap, filterEq: { ...userFe, scopeLayer: "global" } }));
353
+ if (fetch.accountId) {
354
+ take(await this.deps.nativeItems.list(catalogId, {
355
+ ...cap,
356
+ filterEq: { ...userFe, scopeLayer: "account", scopeAccountId: fetch.accountId },
357
+ }));
358
+ if (fetch.agentId) {
359
+ take(await this.deps.nativeItems.list(catalogId, {
360
+ ...cap,
361
+ filterEq: {
362
+ ...userFe,
363
+ scopeLayer: "agent",
364
+ scopeAccountId: fetch.accountId,
365
+ scopeAgentId: fetch.agentId,
366
+ },
367
+ }));
368
+ }
369
+ if (fetch.userId) {
370
+ take(await this.deps.nativeItems.list(catalogId, {
371
+ ...cap,
372
+ filterEq: {
373
+ ...userFe,
374
+ scopeLayer: "user",
375
+ scopeAccountId: fetch.accountId,
376
+ scopeUserId: fetch.userId,
377
+ },
378
+ }));
379
+ }
380
+ }
381
+ return filterPhysicalForTenantFetch(out, fetch);
272
382
  }
273
383
  async decorateItem(catalogId, item) {
274
384
  const descriptor = await this.deps.descriptors.get(catalogId);
@@ -361,26 +471,38 @@ export class Catalox {
361
471
  };
362
472
  })();
363
473
  if (catalog.sourceMode === "native") {
474
+ const descRec = await this.deps.descriptors.get(catalogId);
475
+ if (!descRec)
476
+ throw new CatalogAdapterError({ catalogId, reason: "missing_descriptor" });
364
477
  const filterEq = (listQueryOptions?.filter ?? {});
365
- const listOpts = {
478
+ const baseListOpts = {
366
479
  ...(listQueryOptions?.limit != null ? { limit: listQueryOptions.limit } : {}),
367
480
  ...(listQueryOptions?.offset != null ? { offset: listQueryOptions.offset } : {}),
368
481
  ...(Object.keys(filterEq).length ? { filterEq } : {}),
369
482
  ...(listQueryOptions?.sort ? { sort: listQueryOptions.sort } : {}),
370
483
  };
371
- const records = await this.deps.nativeItems.list(catalogId, listOpts);
372
- const items = records.map((r) => ({
373
- itemId: r.itemId,
374
- catalogId: r.catalogId,
375
- appId: context.appId,
376
- sourceMode: "native",
377
- sourceType: "firebase",
378
- data: r.data,
379
- ...(r.metadata != null ? { metadata: r.metadata } : {}),
380
- createdAt: r.createdAt,
381
- updatedAt: r.updatedAt,
382
- }));
383
- return { listOutcome: "ok", items };
484
+ const rawScope = this.normalizeListFetchScope(listQueryOptions?.scope);
485
+ const wantsSuperList = rawScope?.superAdmin === true && context.superAdmin === true;
486
+ let physical = [];
487
+ if (wantsSuperList) {
488
+ physical = await this.deps.nativeItems.list(catalogId, baseListOpts);
489
+ }
490
+ else if (!rawScope?.accountId && !rawScope?.agentId && !rawScope?.userId) {
491
+ physical = await this.fetchGlobalPhysicalRows(catalogId, baseListOpts);
492
+ }
493
+ else {
494
+ physical = await this.fetchTenantPhysicalRows(catalogId, rawScope, baseListOpts);
495
+ }
496
+ const merged = mergeNativePhysicalRows(physical, descRec.descriptor.identity, String(context.appId), wantsSuperList);
497
+ const off = listQueryOptions?.offset ?? 0;
498
+ const lim = listQueryOptions?.limit ?? 100;
499
+ const sliced = merged.slice(off, off + lim);
500
+ const decorated = await Promise.all(sliced.map((it) => this.decorateItem(catalogId, it)));
501
+ return {
502
+ listOutcome: "ok",
503
+ items: decorated,
504
+ appliedScope: this.buildAppliedScope(rawScope, wantsSuperList),
505
+ };
384
506
  }
385
507
  const def = await this.deps.definitions.get(catalogId);
386
508
  if (!def || def.type !== "mapped")
@@ -392,13 +514,19 @@ export class Catalox {
392
514
  if (mappingIssues.some((i) => i.severity === "error")) {
393
515
  return { listOutcome: "mapping_blocked", items: [], issues: mappingIssues };
394
516
  }
517
+ const mappedListQueryOptions = listQueryOptions != null
518
+ ? (() => {
519
+ const { scope: _omitScope, ...rest } = listQueryOptions;
520
+ return rest;
521
+ })()
522
+ : undefined;
395
523
  if (def.adapterType === "mongo") {
396
524
  if (!this.deps.mongoAdapter)
397
525
  throw new CatalogAdapterError({ catalogId, reason: "mongo_adapter_unconfigured" });
398
526
  const adapterConfig = await this.deps.adapters.get(def.adapterId);
399
527
  if (!adapterConfig)
400
528
  throw new CatalogAdapterError({ catalogId, reason: "missing_adapter" });
401
- const result = await this.deps.mongoAdapter.listItems(context, catalogId, adapterConfig, { mapping: mapping.mapping, ...(mapping.options ? { options: mapping.options } : {}) }, listQueryOptions);
529
+ const result = await this.deps.mongoAdapter.listItems(context, catalogId, adapterConfig, { mapping: mapping.mapping, ...(mapping.options ? { options: mapping.options } : {}) }, mappedListQueryOptions);
402
530
  return result.issues?.length
403
531
  ? { listOutcome: "ok", items: result.items, issues: result.issues }
404
532
  : { listOutcome: "ok", items: result.items };
@@ -409,7 +537,7 @@ export class Catalox {
409
537
  const adapterConfig = await this.deps.adapters.get(def.adapterId);
410
538
  if (!adapterConfig)
411
539
  throw new CatalogAdapterError({ catalogId, reason: "missing_adapter" });
412
- const result = await this.deps.apiAdapter.listItems(context, catalogId, adapterConfig, { responseMapping: mapping.mapping, ...(mapping.options ? { options: mapping.options } : {}) }, listQueryOptions);
540
+ const result = await this.deps.apiAdapter.listItems(context, catalogId, adapterConfig, { responseMapping: mapping.mapping, ...(mapping.options ? { options: mapping.options } : {}) }, mappedListQueryOptions);
413
541
  return {
414
542
  listOutcome: "ok",
415
543
  items: result.items,
@@ -419,27 +547,53 @@ export class Catalox {
419
547
  }
420
548
  throw new CatalogAdapterError({ catalogId, reason: "unknown_adapter_type" });
421
549
  }
422
- async getCatalogItem(context, catalogId, itemId) {
550
+ async getCatalogItem(context, catalogId, itemId, options) {
423
551
  await this.deps.authz.requireBindingAccess(context, context.appId, catalogId, "read");
424
552
  const catalog = await this.deps.catalogs.get(catalogId);
425
553
  if (!catalog)
426
554
  throw new CatalogNotFoundError({ catalogId });
427
555
  if (catalog.sourceMode === "native") {
428
- const rec = await this.deps.nativeItems.get(catalogId, itemId);
429
- if (!rec)
556
+ if (options?.storageDocId) {
557
+ const rec = await this.deps.nativeItems.get(catalogId, options.storageDocId);
558
+ if (!rec)
559
+ return { outcome: "not_found" };
560
+ const meta = {
561
+ ...(rec.metadata && typeof rec.metadata === "object" ? rec.metadata : {}),
562
+ scope: rec.scope ?? { kind: "global" },
563
+ };
564
+ const base = {
565
+ itemId: rec.itemId,
566
+ catalogId: rec.catalogId,
567
+ appId: context.appId,
568
+ sourceMode: "native",
569
+ sourceType: "firebase",
570
+ data: rec.data,
571
+ metadata: meta,
572
+ createdAt: rec.createdAt,
573
+ updatedAt: rec.updatedAt,
574
+ };
575
+ return { outcome: "found", item: await this.decorateItem(catalogId, base) };
576
+ }
577
+ const descRec = await this.deps.descriptors.get(catalogId);
578
+ if (!descRec)
579
+ throw new CatalogAdapterError({ catalogId, reason: "missing_descriptor" });
580
+ let rows = await this.deps.nativeItems.findByLogicalItemId(catalogId, itemId);
581
+ if (!rows.length) {
582
+ const legacy = await this.deps.nativeItems.get(catalogId, itemId);
583
+ if (legacy)
584
+ rows = [legacy];
585
+ }
586
+ if (!rows.length)
430
587
  return { outcome: "not_found" };
431
- const base = {
432
- itemId: rec.itemId,
433
- catalogId: rec.catalogId,
434
- appId: context.appId,
435
- sourceMode: "native",
436
- sourceType: "firebase",
437
- data: rec.data,
438
- ...(rec.metadata != null ? { metadata: rec.metadata } : {}),
439
- createdAt: rec.createdAt,
440
- updatedAt: rec.updatedAt,
441
- };
442
- return { outcome: "found", item: await this.decorateItem(catalogId, base) };
588
+ const scope = options?.scope;
589
+ const filtered = scope?.accountId || scope?.agentId || scope?.userId
590
+ ? filterPhysicalForTenantFetch(rows, scope)
591
+ : rows.filter((r) => isGlobalPhysicalRow(r));
592
+ const merged = mergeNativePhysicalRows(filtered, descRec.descriptor.identity, String(context.appId), false);
593
+ const hit = merged.find((m) => m.itemId === itemId) ?? merged[0];
594
+ if (!hit)
595
+ return { outcome: "not_found" };
596
+ return { outcome: "found", item: await this.decorateItem(catalogId, hit) };
443
597
  }
444
598
  // For mapped catalogs, use list path with filter if adapter supports.
445
599
  const result = await this.listCatalogItems(context, catalogId, {
@@ -632,8 +786,8 @@ export class Catalox {
632
786
  async bindAppToStore(context, input) {
633
787
  if (!this.deps.storeAppBindings)
634
788
  throw new Error("storeAppBindings dependency is not configured");
635
- if (!context.isGodMode && input.appId !== context.appId) {
636
- throw new CatalogAccessDeniedError({ reason: "not_god_mode" });
789
+ if (!context.superAdmin && input.appId !== context.appId) {
790
+ throw new CatalogAccessDeniedError({ reason: "not_super_admin" });
637
791
  }
638
792
  const existing = await this.deps.storeAppBindings.findByStoreApp(input.storeId, input.appId);
639
793
  if (existing)
@@ -656,8 +810,8 @@ export class Catalox {
656
810
  async unbindAppFromStore(context, storeId, appId) {
657
811
  if (!this.deps.storeAppBindings)
658
812
  throw new Error("storeAppBindings dependency is not configured");
659
- if (!context.isGodMode && appId !== context.appId) {
660
- throw new CatalogAccessDeniedError({ reason: "not_god_mode" });
813
+ if (!context.superAdmin && appId !== context.appId) {
814
+ throw new CatalogAccessDeniedError({ reason: "not_super_admin" });
661
815
  }
662
816
  const existing = await this.deps.storeAppBindings.findByStoreApp(storeId, appId);
663
817
  if (!existing)
@@ -673,9 +827,9 @@ export class Catalox {
673
827
  async listAppsForStore(context, storeId) {
674
828
  if (!this.deps.storeAppBindings)
675
829
  throw new Error("storeAppBindings dependency is not configured");
676
- // listing is allowed for any caller; enforce god-mode only if you want to hide cross-app membership.
830
+ // listing is allowed for any caller; super-admin sees all store memberships.
677
831
  const records = await this.deps.storeAppBindings.listAppsByStore(storeId);
678
- if (context.isGodMode)
832
+ if (context.superAdmin)
679
833
  return records;
680
834
  // non-god: only reveal memberships that include the caller's own appId
681
835
  return records.filter((r) => r.appId === context.appId);
@@ -696,9 +850,9 @@ export class Catalox {
696
850
  });
697
851
  }
698
852
  async ensureBinding(context, input) {
699
- // only god-mode apps can provision cross-app bindings.
700
- if (!context.isGodMode && input.appId !== context.appId) {
701
- throw new CatalogAccessDeniedError({ reason: "not_god_mode" });
853
+ // only super-admin apps can provision cross-app bindings.
854
+ if (!context.superAdmin && input.appId !== context.appId) {
855
+ throw new CatalogAccessDeniedError({ reason: "not_super_admin" });
702
856
  }
703
857
  const existing = await this.deps.bindings.findByAppCatalog(input.appId, input.catalogId);
704
858
  if (existing)
@@ -721,51 +875,152 @@ export class Catalox {
721
875
  async createNativeCatalogItem(_context, _catalogId, _input) {
722
876
  return this.upsertNativeCatalogItem(_context, _catalogId, _input);
723
877
  }
724
- async updateNativeCatalogItem(_context, _catalogId, _itemId, _patch) {
878
+ async updateNativeCatalogItem(_context, _catalogId, _itemId, _patch, _options) {
725
879
  await this.deps.authz.requireBindingAccess(_context, _context.appId, _catalogId, "write");
726
- const existing = await this.deps.nativeItems.get(_catalogId, _itemId);
880
+ let existing = null;
881
+ if (_options?.storageDocId) {
882
+ existing = await this.deps.nativeItems.get(_catalogId, _options.storageDocId);
883
+ }
884
+ else {
885
+ let rows = await this.deps.nativeItems.findByLogicalItemId(_catalogId, _itemId);
886
+ if (!rows.length) {
887
+ const legacy = await this.deps.nativeItems.get(_catalogId, _itemId);
888
+ if (legacy)
889
+ rows = [legacy];
890
+ }
891
+ existing = pickWinningPhysicalRow(rows, _options?.scope);
892
+ }
727
893
  if (!existing)
728
894
  throw new CatalogNotFoundError({ catalogId: _catalogId, itemId: _itemId });
895
+ const { data: patchData, indexed: patchIndexed, scope: patchScope } = this.stripReservedWriteFields(_patch);
896
+ let nextScope = normalizeStoredScope(scopeFromRecordField(existing.scope));
897
+ if (patchScope != null) {
898
+ nextScope = normalizeStoredScope(patchScope);
899
+ assertSuperAdminForNonGlobalScope(_context.superAdmin, nextScope);
900
+ }
729
901
  const updatedAt = new Date().toISOString();
730
- const merged = { ...(existing.data ?? {}), ..._patch };
731
- const actorId = this.resolveActorId(_context);
732
- await this.deps.nativeItems.upsert(_catalogId, {
733
- ...existing,
734
- data: merged,
902
+ const mergedData = { ...(existing.data ?? {}), ...patchData };
903
+ const idx = {
904
+ ...(existing.indexed ?? {}),
905
+ ...(patchIndexed ?? {}),
906
+ ...indexedScopeFields(nextScope),
907
+ };
908
+ const oldDocId = storageDocIdForNativeRecord(existing);
909
+ const { scope: _dropScope, ...existingRest } = existing;
910
+ const nextRec = {
911
+ ...existingRest,
912
+ data: mergedData,
913
+ indexed: idx,
914
+ ...(nextScope.kind !== "global" ? { scope: nextScope } : {}),
735
915
  updatedAt,
736
- ...(actorId ? { updatedBy: actorId } : {}),
737
916
  version: (existing.version ?? 0) + 1,
917
+ ...(this.resolveActorId(_context) ? { updatedBy: this.resolveActorId(_context) } : {}),
918
+ };
919
+ const newDocId = storageDocIdForNativeRecord(nextRec);
920
+ await this.deps.nativeItems.upsert(_catalogId, nextRec);
921
+ if (newDocId !== oldDocId)
922
+ await this.deps.nativeItems.delete(_catalogId, oldDocId);
923
+ const persisted = (await this.deps.nativeItems.get(_catalogId, newDocId)) ?? nextRec;
924
+ await this.emitNativeItemHistory(_context, {
925
+ catalogId: _catalogId,
926
+ itemId: String(persisted.itemId),
927
+ storageDocId: newDocId,
928
+ op: "update",
929
+ before: existing,
930
+ after: persisted,
738
931
  });
739
932
  const out = {
740
- itemId: _itemId,
933
+ itemId: nextRec.itemId,
741
934
  catalogId: _catalogId,
742
935
  appId: _context.appId,
743
936
  sourceMode: "native",
744
937
  sourceType: "firebase",
745
- data: merged,
938
+ data: mergedData,
746
939
  createdAt: existing.createdAt,
747
940
  updatedAt,
748
941
  };
749
942
  return this.decorateItem(_catalogId, out);
750
943
  }
751
- async deleteNativeCatalogItem(_context, _catalogId, _itemId) {
944
+ async deleteNativeCatalogItem(_context, _catalogId, _itemId, _options) {
752
945
  await this.deps.authz.requireBindingAccess(_context, _context.appId, _catalogId, "write");
753
- await this.deps.nativeItems.delete(_catalogId, _itemId);
946
+ let docId = _options?.storageDocId;
947
+ if (!docId) {
948
+ let rows = await this.deps.nativeItems.findByLogicalItemId(_catalogId, _itemId);
949
+ if (!rows.length) {
950
+ const legacy = await this.deps.nativeItems.get(_catalogId, _itemId);
951
+ if (legacy)
952
+ rows = [legacy];
953
+ }
954
+ const winner = pickWinningPhysicalRow(rows, _options?.scope);
955
+ if (!winner)
956
+ throw new CatalogNotFoundError({ catalogId: _catalogId, itemId: _itemId });
957
+ docId = storageDocIdForNativeRecord(winner);
958
+ }
959
+ const beforeDel = await this.deps.nativeItems.get(_catalogId, docId);
960
+ await this.deps.nativeItems.delete(_catalogId, docId);
961
+ if (beforeDel) {
962
+ await this.emitNativeItemHistory(_context, {
963
+ catalogId: _catalogId,
964
+ itemId: String(beforeDel.itemId),
965
+ storageDocId: docId,
966
+ op: "delete",
967
+ before: beforeDel,
968
+ after: null,
969
+ });
970
+ }
971
+ }
972
+ async moveNativeCatalogItemScope(context, catalogId, itemId, input) {
973
+ if (!context.superAdmin) {
974
+ throw new CatalogAccessDeniedError({ reason: "super_admin_required" });
975
+ }
976
+ await this.deps.authz.requireBindingAccess(context, context.appId, catalogId, "write");
977
+ const fromDoc = input.fromStorageDocId ??
978
+ encodeNativeItemStorageDocId(String(itemId), normalizeStoredScope(input.fromScope ?? { kind: "global" }));
979
+ const rec = await this.deps.nativeItems.get(catalogId, fromDoc);
980
+ if (!rec)
981
+ throw new CatalogNotFoundError({ catalogId, itemId });
982
+ const toScope = normalizeStoredScope(input.toScope);
983
+ const { scope: _dropScope, ...recRest } = rec;
984
+ const nextRec = {
985
+ ...recRest,
986
+ ...(toScope.kind !== "global" ? { scope: toScope } : {}),
987
+ indexed: { ...(rec.indexed ?? {}), ...indexedScopeFields(toScope) },
988
+ updatedAt: new Date().toISOString(),
989
+ };
990
+ const toDoc = storageDocIdForNativeRecord(nextRec);
991
+ await this.deps.nativeItems.upsert(catalogId, nextRec);
992
+ if (toDoc !== fromDoc)
993
+ await this.deps.nativeItems.delete(catalogId, fromDoc);
994
+ const persisted = (await this.deps.nativeItems.get(catalogId, toDoc)) ?? nextRec;
995
+ await this.emitNativeItemHistory(context, {
996
+ catalogId,
997
+ itemId: String(persisted.itemId),
998
+ storageDocId: toDoc,
999
+ op: "update",
1000
+ before: rec,
1001
+ after: persisted,
1002
+ });
754
1003
  }
755
1004
  async upsertNativeCatalogItem(context, catalogId, input) {
756
1005
  await this.deps.authz.requireBindingAccess(context, context.appId, catalogId, "write");
757
1006
  const descriptor = await this.deps.descriptors.get(catalogId);
758
1007
  if (!descriptor)
759
1008
  throw new CatalogAdapterError({ catalogId, reason: "missing_descriptor" });
760
- const { data, indexed: callerIndexed } = this.stripReservedWriteFields(input);
761
- const indexed = callerIndexed ?? this.deriveIndexed(descriptor.descriptor, data);
762
- const itemId = resolveCatalogItemId({ identity: descriptor.descriptor.identity, data });
1009
+ const { data, indexed: callerIndexed, scope: inputScope } = this.stripReservedWriteFields(input);
1010
+ const storedScope = normalizeStoredScope(inputScope ?? { kind: "global" });
1011
+ assertSuperAdminForNonGlobalScope(context.superAdmin, storedScope);
1012
+ const derived = this.deriveIndexed(descriptor.descriptor, data);
1013
+ const scopeIdx = indexedScopeFields(storedScope);
1014
+ const indexed = derived != null ? { ...derived, ...scopeIdx } : (Object.keys(scopeIdx).length ? scopeIdx : undefined);
1015
+ const logicalItemId = resolveCatalogItemId({ identity: descriptor.descriptor.identity, data });
1016
+ const storageDocId = encodeNativeItemStorageDocId(String(logicalItemId), storedScope);
763
1017
  const now = new Date().toISOString();
764
- const existing = await this.deps.nativeItems.get(catalogId, itemId);
1018
+ const existing = await this.deps.nativeItems.get(catalogId, storageDocId);
765
1019
  const actorId = this.resolveActorId(context);
766
1020
  await this.deps.nativeItems.upsert(catalogId, {
767
- itemId,
1021
+ itemId: logicalItemId,
768
1022
  catalogId,
1023
+ ...(storedScope.kind !== "global" ? { scope: storedScope } : {}),
769
1024
  appScopedOwnerId: context.appId,
770
1025
  ...(indexed != null ? { indexed } : {}),
771
1026
  data,
@@ -775,8 +1030,17 @@ export class Catalox {
775
1030
  ...(existing?.createdBy ? { createdBy: existing.createdBy } : actorId ? { createdBy: actorId } : {}),
776
1031
  ...(actorId ? { updatedBy: actorId } : {}),
777
1032
  });
1033
+ const persisted = (await this.deps.nativeItems.get(catalogId, storageDocId));
1034
+ await this.emitNativeItemHistory(context, {
1035
+ catalogId,
1036
+ itemId: String(logicalItemId),
1037
+ storageDocId,
1038
+ op: "update",
1039
+ before: existing ?? null,
1040
+ after: persisted,
1041
+ });
778
1042
  return {
779
- itemId,
1043
+ itemId: logicalItemId,
780
1044
  catalogId,
781
1045
  appId: context.appId,
782
1046
  sourceMode: "native",
@@ -793,13 +1057,21 @@ export class Catalox {
793
1057
  throw new CatalogAdapterError({ catalogId, reason: "missing_descriptor" });
794
1058
  const now = new Date().toISOString();
795
1059
  const actorId = this.resolveActorId(context);
796
- const records = items.map((input) => {
797
- const stripped = this.stripReservedWriteFields(input);
798
- const indexed = stripped.indexed ?? this.deriveIndexed(descriptor.descriptor, stripped.data);
799
- const itemId = resolveCatalogItemId({ identity: descriptor.descriptor.identity, data: stripped.data });
1060
+ const records = items.map((row) => {
1061
+ const stripped = this.stripReservedWriteFields(row);
1062
+ const storedScope = normalizeStoredScope(stripped.scope ?? { kind: "global" });
1063
+ assertSuperAdminForNonGlobalScope(context.superAdmin, storedScope);
1064
+ const derived = this.deriveIndexed(descriptor.descriptor, stripped.data);
1065
+ const scopeIdx = indexedScopeFields(storedScope);
1066
+ const indexed = derived != null ? { ...derived, ...scopeIdx } : (Object.keys(scopeIdx).length ? scopeIdx : undefined);
1067
+ const logicalItemId = resolveCatalogItemId({
1068
+ identity: descriptor.descriptor.identity,
1069
+ data: stripped.data,
1070
+ });
800
1071
  return {
801
- itemId,
1072
+ itemId: logicalItemId,
802
1073
  catalogId,
1074
+ ...(storedScope.kind !== "global" ? { scope: storedScope } : {}),
803
1075
  appScopedOwnerId: context.appId,
804
1076
  ...(indexed != null ? { indexed } : {}),
805
1077
  data: stripped.data,
@@ -808,7 +1080,25 @@ export class Catalox {
808
1080
  ...(actorId ? { updatedBy: actorId } : {}),
809
1081
  };
810
1082
  });
1083
+ const beforeRows = [];
1084
+ for (const r of records) {
1085
+ const docId = storageDocIdForNativeRecord(r);
1086
+ const ex = await this.deps.nativeItems.get(catalogId, docId);
1087
+ beforeRows.push({ docId, rec: ex });
1088
+ }
811
1089
  await this.deps.nativeItems.batchUpsert(catalogId, records);
1090
+ for (let i = 0; i < records.length; i++) {
1091
+ const { docId, rec: before } = beforeRows[i];
1092
+ const after = (await this.deps.nativeItems.get(catalogId, docId));
1093
+ await this.emitNativeItemHistory(context, {
1094
+ catalogId,
1095
+ itemId: String(after.itemId),
1096
+ storageDocId: docId,
1097
+ op: "update",
1098
+ before,
1099
+ after,
1100
+ });
1101
+ }
812
1102
  }
813
1103
  importCatalogItemsFromJson(json) {
814
1104
  return parseJson(json);
@@ -1166,6 +1456,133 @@ export class Catalox {
1166
1456
  firestore: this.deps.firestoreStore.firestore,
1167
1457
  });
1168
1458
  }
1459
+ async listCatalogItemHistory(context, catalogId, input) {
1460
+ await this.deps.authz.requireBindingAccess(context, context.appId, catalogId, "read");
1461
+ const rh = this.deps.recordHistory;
1462
+ if (!rh)
1463
+ return { events: [] };
1464
+ return rh.store.listByCatalog(catalogId, input);
1465
+ }
1466
+ async getCatalogItemHistoryEvent(context, eventId) {
1467
+ const rh = this.deps.recordHistory;
1468
+ if (!rh)
1469
+ return null;
1470
+ const row = await rh.store.get(eventId);
1471
+ if (!row)
1472
+ return null;
1473
+ await this.deps.authz.requireBindingAccess(context, context.appId, row.catalogId, "read");
1474
+ const payload = await readRecordHistoryEventPayload(rh, row);
1475
+ return { index: row, payload };
1476
+ }
1477
+ async restoreCatalogItemFromHistory(context, eventId, input) {
1478
+ const rh = this.deps.recordHistory;
1479
+ if (!rh)
1480
+ throw new Error("recordHistory is not configured on Catalox");
1481
+ const row = await rh.store.get(eventId);
1482
+ if (!row)
1483
+ throw new Error(`catalogItemHistory event not found: ${eventId}`);
1484
+ await this.deps.authz.requireBindingAccess(context, context.appId, row.catalogId, "write");
1485
+ const payload = await readRecordHistoryEventPayload(rh, row);
1486
+ const rec = input.mode === "before" ? payload.before ?? undefined : payload.after ?? undefined;
1487
+ if (!rec)
1488
+ throw new CatalogNotFoundError({ catalogId: row.catalogId, itemId: row.itemId });
1489
+ const docId = storageDocIdForNativeRecord(rec);
1490
+ const liveBefore = await this.deps.nativeItems.get(row.catalogId, docId);
1491
+ await this.deps.nativeItems.upsert(row.catalogId, rec);
1492
+ const liveAfter = (await this.deps.nativeItems.get(row.catalogId, docId));
1493
+ await this.emitNativeItemHistory(context, {
1494
+ catalogId: row.catalogId,
1495
+ itemId: String(rec.itemId),
1496
+ storageDocId: docId,
1497
+ op: "restore",
1498
+ before: liveBefore ?? null,
1499
+ after: liveAfter,
1500
+ });
1501
+ return this.decorateItem(row.catalogId, {
1502
+ itemId: rec.itemId,
1503
+ catalogId: row.catalogId,
1504
+ appId: context.appId,
1505
+ sourceMode: "native",
1506
+ sourceType: "firebase",
1507
+ data: liveAfter.data,
1508
+ createdAt: liveAfter.createdAt,
1509
+ updatedAt: liveAfter.updatedAt,
1510
+ });
1511
+ }
1512
+ async replayCatalogToPointInTime(context, catalogId, input) {
1513
+ await this.deps.authz.requireBindingAccess(context, context.appId, catalogId, "write");
1514
+ const rh = this.deps.recordHistory;
1515
+ if (!rh)
1516
+ throw new Error("recordHistory is not configured on Catalox");
1517
+ const rows = await rh.store.listByCatalogChronologicalUntil(catalogId, input.asOf);
1518
+ const state = new Map();
1519
+ for (const idx of rows) {
1520
+ const full = await readRecordHistoryEventPayload(rh, idx);
1521
+ const key = String(full.storageDocId ?? full.itemId);
1522
+ if (full.op === "catalog_delete_bulk") {
1523
+ state.clear();
1524
+ continue;
1525
+ }
1526
+ if (full.op === "catalog_rename")
1527
+ continue;
1528
+ if (full.op === "delete") {
1529
+ state.delete(key);
1530
+ continue;
1531
+ }
1532
+ if (full.after)
1533
+ state.set(key, full.after);
1534
+ else
1535
+ state.delete(key);
1536
+ }
1537
+ const live = [];
1538
+ let off = 0;
1539
+ const page = 400;
1540
+ while (true) {
1541
+ const chunk = await this.deps.nativeItems.list(catalogId, { limit: page, offset: off });
1542
+ if (!chunk.length)
1543
+ break;
1544
+ live.push(...chunk);
1545
+ off += chunk.length;
1546
+ if (chunk.length < page)
1547
+ break;
1548
+ }
1549
+ let upserted = 0;
1550
+ let deleted = 0;
1551
+ for (const rec of state.values()) {
1552
+ await this.deps.nativeItems.upsert(catalogId, rec);
1553
+ upserted += 1;
1554
+ }
1555
+ for (const r of live) {
1556
+ const k = storageDocIdForNativeRecord(r);
1557
+ if (!state.has(k)) {
1558
+ await this.deps.nativeItems.delete(catalogId, k);
1559
+ deleted += 1;
1560
+ }
1561
+ }
1562
+ return { upserted, deleted };
1563
+ }
1564
+ async deleteCatalog(context, catalogId, input) {
1565
+ await this.deps.authz.requireBindingAccess(context, context.appId, catalogId, "admin");
1566
+ return runDeleteCatalog(this.catalogLifecycleDeps(), this.deps.recordHistory, context, catalogId, input);
1567
+ }
1568
+ async restoreDeletedCatalog(context, input) {
1569
+ if (!context.superAdmin) {
1570
+ throw new CatalogAccessDeniedError({ reason: "super_admin_required" });
1571
+ }
1572
+ const rh = this.deps.recordHistory;
1573
+ if (!rh) {
1574
+ return {
1575
+ ok: false,
1576
+ catalogId: "",
1577
+ issues: [{ code: "record_history_required", message: "recordHistory is not configured on Catalox" }],
1578
+ };
1579
+ }
1580
+ return runRestoreDeletedCatalog(this.catalogLifecycleDeps(), rh, input);
1581
+ }
1582
+ async renameCatalog(context, fromCatalogId, toCatalogId, input = {}) {
1583
+ await this.deps.authz.requireBindingAccess(context, context.appId, fromCatalogId, "admin");
1584
+ return runRenameCatalog(this.catalogLifecycleDeps(), this.deps.recordHistory, context, fromCatalogId, toCatalogId, input);
1585
+ }
1169
1586
  /**
1170
1587
  * Fix {@link CataloxContext} for subsequent calls so embedders omit it on each method (no globals).
1171
1588
  */