@x12i/catalox 4.2.1 → 5.1.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 (56) hide show
  1. package/README.md +61 -0
  2. package/dist/src/catalox/catalox.d.ts +7 -5
  3. package/dist/src/catalox/catalox.d.ts.map +1 -1
  4. package/dist/src/catalox/catalox.js +151 -155
  5. package/dist/src/catalox/catalox.js.map +1 -1
  6. package/dist/src/catalox/item-scope-match.d.ts +39 -0
  7. package/dist/src/catalox/item-scope-match.d.ts.map +1 -0
  8. package/dist/src/catalox/item-scope-match.js +114 -0
  9. package/dist/src/catalox/item-scope-match.js.map +1 -0
  10. package/dist/src/catalox/item-scope.d.ts +36 -0
  11. package/dist/src/catalox/item-scope.d.ts.map +1 -0
  12. package/dist/src/catalox/item-scope.js +137 -0
  13. package/dist/src/catalox/item-scope.js.map +1 -0
  14. package/dist/src/catalox/native-catalog-merge.d.ts +7 -9
  15. package/dist/src/catalox/native-catalog-merge.d.ts.map +1 -1
  16. package/dist/src/catalox/native-catalog-merge.js +23 -106
  17. package/dist/src/catalox/native-catalog-merge.js.map +1 -1
  18. package/dist/src/catalox/scope-legacy-convert.d.ts +19 -0
  19. package/dist/src/catalox/scope-legacy-convert.d.ts.map +1 -0
  20. package/dist/src/catalox/scope-legacy-convert.js +177 -0
  21. package/dist/src/catalox/scope-legacy-convert.js.map +1 -0
  22. package/dist/src/catalox/smart-properties.d.ts +33 -0
  23. package/dist/src/catalox/smart-properties.d.ts.map +1 -0
  24. package/dist/src/catalox/smart-properties.js +245 -0
  25. package/dist/src/catalox/smart-properties.js.map +1 -0
  26. package/dist/src/cli/index.js +96 -3
  27. package/dist/src/cli/index.js.map +1 -1
  28. package/dist/src/contracts/catalogs.d.ts +21 -25
  29. package/dist/src/contracts/catalogs.d.ts.map +1 -1
  30. package/dist/src/contracts/catalogs.js.map +1 -1
  31. package/dist/src/contracts/context.d.ts +9 -4
  32. package/dist/src/contracts/context.d.ts.map +1 -1
  33. package/dist/src/contracts/descriptors.d.ts +14 -0
  34. package/dist/src/contracts/descriptors.d.ts.map +1 -1
  35. package/dist/src/contracts/index.d.ts +2 -2
  36. package/dist/src/contracts/index.d.ts.map +1 -1
  37. package/dist/src/contracts/items.d.ts +8 -51
  38. package/dist/src/contracts/items.d.ts.map +1 -1
  39. package/dist/src/firebase/native-item-store.d.ts +2 -1
  40. package/dist/src/firebase/native-item-store.d.ts.map +1 -1
  41. package/dist/src/firebase/native-item-store.js +2 -5
  42. package/dist/src/firebase/native-item-store.js.map +1 -1
  43. package/dist/src/migrations/index.d.ts +1 -0
  44. package/dist/src/migrations/index.d.ts.map +1 -1
  45. package/dist/src/migrations/index.js +1 -0
  46. package/dist/src/migrations/index.js.map +1 -1
  47. package/dist/src/migrations/migrate-native-item-scope.d.ts +36 -0
  48. package/dist/src/migrations/migrate-native-item-scope.d.ts.map +1 -0
  49. package/dist/src/migrations/migrate-native-item-scope.js +204 -0
  50. package/dist/src/migrations/migrate-native-item-scope.js.map +1 -0
  51. package/docs/cli-items.md +1 -1
  52. package/package.json +1 -1
  53. package/dist/src/catalox/native-scope.d.ts +0 -29
  54. package/dist/src/catalox/native-scope.d.ts.map +0 -1
  55. package/dist/src/catalox/native-scope.js +0 -324
  56. package/dist/src/catalox/native-scope.js.map +0 -1
@@ -8,8 +8,10 @@ import { ApiCatalogAdapter } from "../adapters/api/api-adapter.js";
8
8
  import { MongoCatalogAdapter } from "../adapters/mongo/mongo-adapter.js";
9
9
  import { AuthorizationService } from "./authorization.js";
10
10
  import { resolveCatalogItemId } from "./identity.js";
11
- import { assertSuperAdminForNonGlobalScope, encodeNativeItemStorageDocId, indexedScopeFields, isGlobalPhysicalRow, normalizeStoredScope, parseWriteScopeInput, scopeFromRecordField, } from "./native-scope.js";
12
- import { filterPhysicalForTenantFetch, mergeNativePhysicalRows, pickWinningPhysicalRow, } from "./native-catalog-merge.js";
11
+ import { assertWriteScopeAllowed, indexedScopeFields, isGenericScope, metadataFromScope, normalizeItemScope, parseWriteScopeInput, scopeFromRecordField, } from "./item-scope.js";
12
+ import { isUnrestrictedScopeContext, mergeEffectiveScope, resolveCatalogScopeFetchMode, shouldIncludeItemInList, tokenScopeFromContext, } from "./item-scope-match.js";
13
+ import { mergeNativePhysicalRows, pickWinningPhysicalRow, toUnifiedNativeItem } from "./native-catalog-merge.js";
14
+ import { deriveIndexedFromSmartProperties, resolveSmartPropertiesOnWrite, validateSmartPropertiesForItem, validateSmartPropertyRules, } from "./smart-properties.js";
13
15
  import { parseJson, toJson } from "./json-io.js";
14
16
  import { buildCataloxExportV1, filterCatalogEntries, listAllCatalogItemsForExport, stripItemData, } from "./catalog-snapshot-export.js";
15
17
  import { createHash, randomUUID } from "node:crypto";
@@ -43,6 +45,7 @@ import { runUndoFirestoreRestore } from "./restore-firestore-backup.js";
43
45
  import { runRestoreFirestoreBackupFromGcs, runDeleteCataloxGcsBackupRun } from "./restore-firestore-backup-from-gcs.js";
44
46
  import { cataloxGcsBackupManifestToFirestoreExportManifest } from "./catalox-gcs-backup-export-manifest.js";
45
47
  import { migrateNativeCatalogLayout as runMigrateNativeCatalogLayout, } from "../migrations/migrate-native-catalog-layout.js";
48
+ import { migrateNativeItemScope as runMigrateNativeItemScope, } from "../migrations/migrate-native-item-scope.js";
46
49
  import { reportNativeCatalogLayoutDiagnostics as runReportNativeCatalogLayoutDiagnostics, } from "./native-catalog-layout-diagnostics.js";
47
50
  import { exportAllFirestoreCollectionsToGcs, exportFirestoreCollectionToGcs, restoreAllFirestoreCollectionsFromGcsManifest, restoreFirestoreCollectionFromGcs, } from "./firestore-gcs-transfer.js";
48
51
  import { compareAllFirestoreCollectionsWithGcsManifest, compareFirestoreCollectionWithGcsNdjson, } from "./firestore-gcs-compare.js";
@@ -638,8 +641,20 @@ export class Catalox {
638
641
  if (v !== undefined)
639
642
  out[f.key] = v;
640
643
  }
644
+ const smartIdx = deriveIndexedFromSmartProperties(descriptor, data);
645
+ if (smartIdx)
646
+ Object.assign(out, smartIdx);
641
647
  return Object.keys(out).length ? out : undefined;
642
648
  }
649
+ smartPropertyDeps() {
650
+ return {
651
+ catalogs: this.deps.catalogs,
652
+ descriptors: this.deps.descriptors,
653
+ nativeItems: this.deps.nativeItems,
654
+ authz: this.deps.authz,
655
+ resolveAppIdForCatalog: (input) => this.resolveAppIdForCatalogAccess(input),
656
+ };
657
+ }
643
658
  stripReservedWriteFields(input) {
644
659
  const { indexed, scope, relations, references, ...rest } = input;
645
660
  const parsedScope = scope != null ? parseWriteScopeInput(scope) : undefined;
@@ -651,93 +666,44 @@ export class Catalox {
651
666
  ...(parsedRelations.length ? { relations: parsedRelations } : {}),
652
667
  };
653
668
  }
654
- normalizeListFetchScope(scope) {
655
- if (!scope)
656
- return undefined;
657
- const hasCoords = Boolean(scope.accountId) ||
658
- Boolean(scope.groupId) ||
659
- Boolean(scope.userId) ||
660
- Boolean(scope.channelId) ||
661
- Boolean(scope.visitorId) ||
662
- Boolean(scope.domainId) ||
663
- Boolean(scope.agentId);
664
- if (!hasCoords && scope.superAdmin !== true)
665
- return undefined;
666
- return scope;
667
- }
668
- buildAppliedScope(scope, effectiveSuperList) {
669
- return {
670
- ...(scope?.accountId != null ? { accountId: scope.accountId } : {}),
671
- ...(scope?.groupId != null ? { groupId: scope.groupId } : {}),
672
- ...(scope?.agentId != null ? { agentId: scope.agentId } : {}),
673
- ...(scope?.domainId != null ? { domainId: scope.domainId } : {}),
674
- ...(scope?.userId != null ? { userId: scope.userId } : {}),
675
- ...(scope?.channelId != null ? { channelId: scope.channelId } : {}),
676
- ...(scope?.visitorId != null ? { visitorId: scope.visitorId } : {}),
677
- superAdmin: effectiveSuperList,
678
- };
679
- }
680
- async fetchGlobalPhysicalRows(catalogId, listOpts) {
681
- const userFe = { ...(listOpts.filterEq ?? {}) };
682
- const withGlobal = { ...userFe, scopeLayer: "global" };
683
- let recs = await this.deps.nativeItems.list(catalogId, { ...listOpts, filterEq: withGlobal });
684
- if (recs.length === 0 && Object.keys(userFe).length === 0) {
685
- const broad = await this.deps.nativeItems.list(catalogId, { ...listOpts, filterEq: {} });
686
- recs = broad.filter((r) => isGlobalPhysicalRow(r));
669
+ filterNativeCatalogRows(context, records, fetchOpts) {
670
+ const wantsSuperList = fetchOpts?.superAdmin === true && context.superAdmin === true;
671
+ if (wantsSuperList) {
672
+ return {
673
+ rows: records,
674
+ applied: {
675
+ effectiveScope: {},
676
+ includeGeneric: true,
677
+ genericOnly: false,
678
+ scopedOnly: false,
679
+ scopedAccessAllowed: true,
680
+ superAdmin: true,
681
+ },
682
+ };
687
683
  }
688
- return recs;
689
- }
690
- async fetchScopedPhysicalRows(catalogId, fetch, listOpts) {
691
- const userFe = { ...(listOpts.filterEq ?? {}) };
692
- const cap = { ...listOpts, limit: 500, offset: 0 };
693
- const seen = new Set();
694
- const out = [];
695
- const take = (rows) => {
696
- for (const r of rows) {
697
- const id = storageDocIdForNativeRecord(r);
698
- if (seen.has(id))
699
- continue;
700
- seen.add(id);
701
- out.push(r);
702
- }
684
+ const fetchMode = resolveCatalogScopeFetchMode(fetchOpts);
685
+ const unrestricted = isUnrestrictedScopeContext(context);
686
+ const token = tokenScopeFromContext(context);
687
+ const { effectiveScope, scopedAccessAllowed } = mergeEffectiveScope(token, fetchOpts?.scope, {
688
+ unrestricted,
689
+ });
690
+ const filtered = records.filter((r) => shouldIncludeItemInList({
691
+ itemScope: scopeFromRecordField(r.scope),
692
+ effectiveScope,
693
+ scopedAccessAllowed: unrestricted || scopedAccessAllowed,
694
+ fetchMode,
695
+ }));
696
+ return {
697
+ rows: filtered,
698
+ applied: {
699
+ effectiveScope,
700
+ includeGeneric: fetchMode.includeGeneric,
701
+ genericOnly: fetchMode.genericOnly,
702
+ scopedOnly: fetchMode.scopedOnly,
703
+ scopedAccessAllowed: unrestricted || scopedAccessAllowed,
704
+ superAdmin: false,
705
+ },
703
706
  };
704
- take(await this.deps.nativeItems.list(catalogId, { ...cap, filterEq: { ...userFe, scopeLayer: "global" } }));
705
- // Legacy v1 scopes (still queried for back-compat)
706
- if (fetch.accountId) {
707
- take(await this.deps.nativeItems.list(catalogId, { ...cap, filterEq: { ...userFe, scopeLayer: "account", scopeAccountId: fetch.accountId } }));
708
- if (fetch.agentId) {
709
- take(await this.deps.nativeItems.list(catalogId, { ...cap, filterEq: { ...userFe, scopeLayer: "agent", scopeAccountId: fetch.accountId, scopeAgentId: fetch.agentId } }));
710
- }
711
- if (fetch.userId) {
712
- take(await this.deps.nativeItems.list(catalogId, { ...cap, filterEq: { ...userFe, scopeLayer: "user", scopeAccountId: fetch.accountId, scopeUserId: fetch.userId } }));
713
- }
714
- }
715
- // v2 scoped rows: best-effort prefilter by available indexed coordinates.
716
- const scopedQueries = [];
717
- if (fetch.accountId)
718
- scopedQueries.push({ scopeLayer: "scoped", scopeAccountId: fetch.accountId });
719
- if (fetch.userId)
720
- scopedQueries.push({ scopeLayer: "scoped", scopeUserId: fetch.userId });
721
- if (fetch.channelId)
722
- scopedQueries.push({ scopeLayer: "scoped", scopeChannelId: fetch.channelId });
723
- if (fetch.visitorId)
724
- scopedQueries.push({ scopeLayer: "scoped", scopeVisitorId: fetch.visitorId });
725
- if (fetch.domainId)
726
- scopedQueries.push({ scopeLayer: "scoped", scopeDomainId: fetch.domainId });
727
- if (fetch.agentId)
728
- scopedQueries.push({ scopeLayer: "scoped", scopeAgentId: fetch.agentId });
729
- if (fetch.groupId)
730
- scopedQueries.push({ scopeLayer: "scoped", scopeGroupId: fetch.groupId });
731
- if (scopedQueries.length) {
732
- for (const q of scopedQueries) {
733
- take(await this.deps.nativeItems.list(catalogId, { ...cap, filterEq: { ...userFe, ...q } }));
734
- }
735
- }
736
- else {
737
- // Fallback: if caller provided coordinates but none mapped to a queryable indexed field, list scoped broadly.
738
- take(await this.deps.nativeItems.list(catalogId, { ...cap, filterEq: { ...userFe, scopeLayer: "scoped" } }));
739
- }
740
- return filterPhysicalForTenantFetch(out, fetch);
741
707
  }
742
708
  async decorateItem(catalogId, item) {
743
709
  const descriptor = await this.deps.descriptors.get(catalogId);
@@ -978,19 +944,16 @@ export class Catalox {
978
944
  ...(Object.keys(filterEq).length ? { filterEq } : {}),
979
945
  ...(listQueryOptions?.sort ? { sort: listQueryOptions.sort } : {}),
980
946
  };
981
- const rawScope = this.normalizeListFetchScope(listQueryOptions?.scope);
982
- const wantsSuperList = rawScope?.superAdmin === true && context.superAdmin === true;
983
- let physical = [];
984
- if (wantsSuperList) {
985
- physical = await this.deps.nativeItems.list(catalogId, baseListOpts);
986
- }
987
- else if (!rawScope || (!rawScope.accountId && !rawScope.groupId && !rawScope.userId && !rawScope.channelId && !rawScope.visitorId && !rawScope.domainId && !rawScope.agentId)) {
988
- physical = await this.fetchGlobalPhysicalRows(catalogId, baseListOpts);
989
- }
990
- else {
991
- physical = await this.fetchScopedPhysicalRows(catalogId, rawScope, baseListOpts);
992
- }
993
- const merged = mergeNativePhysicalRows(physical, descRec.descriptor.identity, String(appId), wantsSuperList);
947
+ const fetchOpts = listQueryOptions?.scope;
948
+ const includeScopeInResponse = fetchOpts?.includeScopeInResponse !== false;
949
+ const poolLimit = Math.min(5000, Math.max((listQueryOptions?.limit ?? 100) + (listQueryOptions?.offset ?? 0), 500));
950
+ const physical = await this.deps.nativeItems.list(catalogId, {
951
+ ...baseListOpts,
952
+ limit: poolLimit,
953
+ offset: 0,
954
+ });
955
+ const { rows: filtered, applied } = this.filterNativeCatalogRows(context, physical, fetchOpts);
956
+ const merged = mergeNativePhysicalRows(filtered, descRec.descriptor.identity, String(appId), includeScopeInResponse);
994
957
  const off = listQueryOptions?.offset ?? 0;
995
958
  const lim = listQueryOptions?.limit ?? 100;
996
959
  const sliced = merged.slice(off, off + lim);
@@ -998,7 +961,7 @@ export class Catalox {
998
961
  return {
999
962
  listOutcome: "ok",
1000
963
  items: decorated,
1001
- appliedScope: this.buildAppliedScope(rawScope, wantsSuperList),
964
+ appliedScope: applied,
1002
965
  };
1003
966
  }
1004
967
  const def = await this.deps.definitions.get(catalogId);
@@ -1088,24 +1051,13 @@ export class Catalox {
1088
1051
  const rec = await this.deps.nativeItems.get(catalogId, options.storageDocId);
1089
1052
  if (!rec)
1090
1053
  return { outcome: "not_found" };
1091
- const meta = {
1092
- ...(rec.metadata && typeof rec.metadata === "object" ? rec.metadata : {}),
1093
- scope: rec.scope ?? { kind: "global" },
1094
- };
1095
- const base = {
1096
- itemId: rec.itemId,
1097
- catalogId: rec.catalogId,
1098
- appId,
1099
- sourceMode: "native",
1100
- sourceType: "firebase",
1101
- data: rec.data,
1102
- metadata: meta,
1103
- };
1054
+ const { rows } = this.filterNativeCatalogRows(context, [rec], options?.scope);
1055
+ if (!rows.length)
1056
+ return { outcome: "not_found" };
1057
+ const includeScope = options?.scope?.includeScopeInResponse !== false;
1058
+ const base = toUnifiedNativeItem(rows[0], String(appId), includeScope);
1104
1059
  return { outcome: "found", item: await this.decorateItem(catalogId, base) };
1105
1060
  }
1106
- const descRec = await this.deps.descriptors.get(catalogId);
1107
- if (!descRec)
1108
- throw new CatalogAdapterError({ catalogId, reason: "missing_descriptor" });
1109
1061
  let rows = await this.deps.nativeItems.findByLogicalItemId(catalogId, itemId);
1110
1062
  if (!rows.length) {
1111
1063
  const legacy = await this.deps.nativeItems.get(catalogId, itemId);
@@ -1114,15 +1066,12 @@ export class Catalox {
1114
1066
  }
1115
1067
  if (!rows.length)
1116
1068
  return { outcome: "not_found" };
1117
- const scope = options?.scope;
1118
- const filtered = scope?.accountId || scope?.groupId || scope?.userId || scope?.channelId || scope?.visitorId || scope?.domainId || scope?.agentId
1119
- ? filterPhysicalForTenantFetch(rows, scope)
1120
- : rows.filter((r) => isGlobalPhysicalRow(r));
1121
- const merged = mergeNativePhysicalRows(filtered, descRec.descriptor.identity, String(appId), false);
1122
- const hit = merged.find((m) => m.itemId === itemId) ?? merged[0];
1123
- if (!hit)
1069
+ const { rows: filtered } = this.filterNativeCatalogRows(context, rows, options?.scope);
1070
+ if (!filtered.length)
1124
1071
  return { outcome: "not_found" };
1125
- return { outcome: "found", item: await this.decorateItem(catalogId, hit) };
1072
+ const includeScope = options?.scope?.includeScopeInResponse !== false;
1073
+ const base = toUnifiedNativeItem(filtered[0], String(appId), includeScope);
1074
+ return { outcome: "found", item: await this.decorateItem(catalogId, base) };
1126
1075
  }
1127
1076
  // For mapped catalogs, use list path with filter if adapter supports.
1128
1077
  const result = await this.listCatalogItems(context, catalogId, {
@@ -1241,7 +1190,7 @@ export class Catalox {
1241
1190
  }
1242
1191
  return { isValid: !issues.some((i) => i.severity === "error"), issues };
1243
1192
  }
1244
- async validateCatalogItem(context, catalogId, itemId) {
1193
+ async validateCatalogItem(context, catalogId, itemId, options) {
1245
1194
  const issues = [];
1246
1195
  const descriptor = await this.getCatalogDescriptor(context, catalogId);
1247
1196
  if (!descriptor) {
@@ -1271,6 +1220,10 @@ export class Catalox {
1271
1220
  const typeRegistry = await this.resolveCatalogTypeRegistry(context);
1272
1221
  const payloadReport = this.validateNativeItemData(got.item.data, catalog, typeRegistry);
1273
1222
  issues.push(...payloadReport.issues);
1223
+ if (descriptor.smartProperties?.length) {
1224
+ const spReport = await validateSmartPropertiesForItem(this.smartPropertyDeps(), context, catalogId, descriptor, got.item.data, { autoCreate: options?.noAutoCreate ? false : true });
1225
+ issues.push(...spReport.issues);
1226
+ }
1274
1227
  }
1275
1228
  }
1276
1229
  return {
@@ -1743,15 +1696,15 @@ export class Catalox {
1743
1696
  if (legacy)
1744
1697
  rows = [legacy];
1745
1698
  }
1746
- existing = pickWinningPhysicalRow(rows, _options?.scope);
1699
+ existing = pickWinningPhysicalRow(rows);
1747
1700
  }
1748
1701
  if (!existing)
1749
1702
  throw new CatalogNotFoundError({ catalogId: _catalogId, itemId: _itemId });
1750
1703
  const { data: patchData, indexed: patchIndexed, scope: patchScope, relations: patchRelations } = this.stripReservedWriteFields(_patch);
1751
- let nextScope = normalizeStoredScope(scopeFromRecordField(existing.scope));
1704
+ let nextScope = scopeFromRecordField(existing.scope);
1752
1705
  if (patchScope != null) {
1753
- nextScope = normalizeStoredScope(patchScope);
1754
- assertSuperAdminForNonGlobalScope(_context.superAdmin, nextScope);
1706
+ nextScope = normalizeItemScope(patchScope);
1707
+ assertWriteScopeAllowed(_context, nextScope);
1755
1708
  }
1756
1709
  // Validate relation rules using existing + provided relations (if any).
1757
1710
  const descriptorRec = await this.deps.descriptors.get(_catalogId);
@@ -1786,24 +1739,40 @@ export class Catalox {
1786
1739
  }
1787
1740
  const updatedAt = new Date().toISOString();
1788
1741
  const mergedData = { ...(existing.data ?? {}), ...patchData };
1742
+ const desc = descriptorRec?.descriptor;
1743
+ if (desc?.smartProperties?.length) {
1744
+ const rulesOnly = validateSmartPropertyRules(desc, mergedData);
1745
+ if (!rulesOnly.isValid) {
1746
+ throw new CatalogValidationError({
1747
+ reason: "smart_properties",
1748
+ catalogId: String(_catalogId),
1749
+ itemId: String(_itemId),
1750
+ report: rulesOnly,
1751
+ });
1752
+ }
1753
+ await resolveSmartPropertiesOnWrite(this.smartPropertyDeps(), _context, _catalogId, desc, mergedData, nextScope);
1754
+ }
1755
+ const derived = desc ? this.deriveIndexed(desc, mergedData) : undefined;
1789
1756
  const idx = {
1790
1757
  ...(existing.indexed ?? {}),
1791
1758
  ...(patchIndexed ?? {}),
1759
+ ...(derived ?? {}),
1792
1760
  ...indexedScopeFields(nextScope),
1793
1761
  };
1794
1762
  const oldDocId = storageDocIdForNativeRecord(existing);
1795
1763
  const { scope: _dropScope, ...existingRest } = existing;
1764
+ const scopeMeta = metadataFromScope(nextScope);
1796
1765
  const nextRec = {
1797
1766
  ...existingRest,
1798
1767
  data: mergedData,
1799
1768
  indexed: idx,
1800
- ...(nextScope.kind !== "global" ? { scope: nextScope } : {}),
1769
+ scope: nextScope,
1801
1770
  metadata: {
1802
1771
  ...(existing.metadata ?? {}),
1803
1772
  createdAt: String(existing.metadata?.createdAt ?? updatedAt),
1804
1773
  lastUpdate: updatedAt,
1805
- domainIds: Array.isArray(existing.metadata?.domainIds) ? existing.metadata.domainIds : [],
1806
- agentIds: Array.isArray(existing.metadata?.agentIds) ? existing.metadata.agentIds : [],
1774
+ domainIds: scopeMeta.domainIds,
1775
+ agentIds: scopeMeta.agentIds,
1807
1776
  },
1808
1777
  version: (existing.version ?? 0) + 1,
1809
1778
  ...(this.resolveActorId(_context) ? { updatedBy: this.resolveActorId(_context) } : {}),
@@ -1828,11 +1797,12 @@ export class Catalox {
1828
1797
  sourceMode: "native",
1829
1798
  sourceType: "firebase",
1830
1799
  data: mergedData,
1800
+ scope: nextScope,
1831
1801
  metadata: {
1832
1802
  createdAt: String(existing.metadata?.createdAt ?? updatedAt),
1833
1803
  lastUpdate: updatedAt,
1834
- domainIds: Array.isArray(existing.metadata?.domainIds) ? existing.metadata.domainIds : [],
1835
- agentIds: Array.isArray(existing.metadata?.agentIds) ? existing.metadata.agentIds : [],
1804
+ domainIds: scopeMeta.domainIds,
1805
+ agentIds: scopeMeta.agentIds,
1836
1806
  },
1837
1807
  };
1838
1808
  // Upsert any provided relations after the item is persisted.
@@ -1860,7 +1830,7 @@ export class Catalox {
1860
1830
  if (legacy)
1861
1831
  rows = [legacy];
1862
1832
  }
1863
- const winner = pickWinningPhysicalRow(rows, _options?.scope);
1833
+ const winner = pickWinningPhysicalRow(rows);
1864
1834
  if (!winner)
1865
1835
  throw new CatalogNotFoundError({ catalogId: _catalogId, itemId: _itemId });
1866
1836
  docId = storageDocIdForNativeRecord(winner);
@@ -1884,18 +1854,23 @@ export class Catalox {
1884
1854
  }
1885
1855
  const appId = await this.resolveAppIdForCatalogAccess({ context, catalogId, required: "write" });
1886
1856
  await this.deps.authz.requireBindingAccess(context, appId, catalogId, "write");
1887
- const fromDoc = input.fromStorageDocId ??
1888
- encodeNativeItemStorageDocId(String(itemId), normalizeStoredScope(input.fromScope ?? { kind: "global" }));
1857
+ const fromDoc = input.fromStorageDocId ?? storageDocIdForNativeRecord({ itemId: String(itemId) });
1889
1858
  const rec = await this.deps.nativeItems.get(catalogId, fromDoc);
1890
1859
  if (!rec)
1891
1860
  throw new CatalogNotFoundError({ catalogId, itemId });
1892
- const toScope = normalizeStoredScope(input.toScope);
1893
- const { scope: _dropScope, ...recRest } = rec;
1861
+ const toScope = normalizeItemScope(input.toScope);
1862
+ assertWriteScopeAllowed(context, toScope);
1863
+ const scopeMeta = metadataFromScope(toScope);
1894
1864
  const nextRec = {
1895
- ...recRest,
1896
- ...(toScope.kind !== "global" ? { scope: toScope } : {}),
1865
+ ...rec,
1866
+ scope: toScope,
1897
1867
  indexed: { ...(rec.indexed ?? {}), ...indexedScopeFields(toScope) },
1898
- updatedAt: new Date().toISOString(),
1868
+ metadata: {
1869
+ ...(rec.metadata ?? {}),
1870
+ domainIds: scopeMeta.domainIds,
1871
+ agentIds: scopeMeta.agentIds,
1872
+ lastUpdate: new Date().toISOString(),
1873
+ },
1899
1874
  };
1900
1875
  const toDoc = storageDocIdForNativeRecord(nextRec);
1901
1876
  await this.deps.nativeItems.upsert(catalogId, nextRec);
@@ -1918,15 +1893,26 @@ export class Catalox {
1918
1893
  if (!descriptor)
1919
1894
  throw new CatalogAdapterError({ catalogId, reason: "missing_descriptor" });
1920
1895
  const { data, indexed: callerIndexed, scope: inputScope, relations: inputRelations } = this.stripReservedWriteFields(input);
1921
- const storedScope = normalizeStoredScope(inputScope ?? { kind: "global" });
1922
- assertSuperAdminForNonGlobalScope(context.superAdmin, storedScope);
1896
+ const storedScope = normalizeItemScope(inputScope ?? {});
1897
+ assertWriteScopeAllowed(context, storedScope);
1923
1898
  const logicalItemIdEarly = resolveCatalogItemId({ identity: descriptor.descriptor.identity, data });
1924
1899
  await this.assertNativeItemPayloadValid(context, catalogId, data, String(logicalItemIdEarly));
1900
+ const rulesOnly = validateSmartPropertyRules(descriptor.descriptor, data);
1901
+ if (!rulesOnly.isValid) {
1902
+ throw new CatalogValidationError({
1903
+ reason: "smart_properties",
1904
+ catalogId: String(catalogId),
1905
+ itemId: String(logicalItemIdEarly),
1906
+ report: rulesOnly,
1907
+ });
1908
+ }
1909
+ await resolveSmartPropertiesOnWrite(this.smartPropertyDeps(), context, catalogId, descriptor.descriptor, data, storedScope);
1925
1910
  const derived = this.deriveIndexed(descriptor.descriptor, data);
1926
1911
  const scopeIdx = indexedScopeFields(storedScope);
1927
1912
  const indexed = derived != null ? { ...derived, ...scopeIdx } : (Object.keys(scopeIdx).length ? scopeIdx : undefined);
1928
1913
  const logicalItemId = resolveCatalogItemId({ identity: descriptor.descriptor.identity, data });
1929
- const storageDocId = encodeNativeItemStorageDocId(String(logicalItemId), storedScope);
1914
+ const storageDocId = storageDocIdForNativeRecord({ itemId: String(logicalItemId) });
1915
+ const scopeMeta = metadataFromScope(storedScope);
1930
1916
  const now = new Date().toISOString();
1931
1917
  const existing = await this.deps.nativeItems.get(catalogId, storageDocId);
1932
1918
  const actorId = this.resolveActorId(context);
@@ -1963,15 +1949,15 @@ export class Catalox {
1963
1949
  await this.deps.nativeItems.upsert(catalogId, {
1964
1950
  itemId: logicalItemId,
1965
1951
  catalogId,
1966
- ...(storedScope.kind !== "global" ? { scope: storedScope } : {}),
1952
+ scope: storedScope,
1967
1953
  appScopedOwnerId: appId,
1968
1954
  ...(indexed != null ? { indexed } : {}),
1969
1955
  data,
1970
1956
  metadata: {
1971
1957
  createdAt: String(existing?.metadata?.createdAt ?? now),
1972
1958
  lastUpdate: now,
1973
- domainIds: Array.isArray(existing?.metadata?.domainIds) ? (existing?.metadata).domainIds : [],
1974
- agentIds: Array.isArray(existing?.metadata?.agentIds) ? (existing?.metadata).agentIds : [],
1959
+ domainIds: scopeMeta.domainIds,
1960
+ agentIds: scopeMeta.agentIds,
1975
1961
  },
1976
1962
  version: (existing?.version ?? 0) + 1,
1977
1963
  ...(existing?.createdBy ? { createdBy: existing.createdBy } : actorId ? { createdBy: actorId } : {}),
@@ -2005,11 +1991,12 @@ export class Catalox {
2005
1991
  sourceMode: "native",
2006
1992
  sourceType: "firebase",
2007
1993
  data,
1994
+ scope: storedScope,
2008
1995
  metadata: {
2009
1996
  createdAt: String(existing?.metadata?.createdAt ?? now),
2010
1997
  lastUpdate: now,
2011
- domainIds: Array.isArray(existing?.metadata?.domainIds) ? (existing?.metadata).domainIds : [],
2012
- agentIds: Array.isArray(existing?.metadata?.agentIds) ? (existing?.metadata).agentIds : [],
1998
+ domainIds: scopeMeta.domainIds,
1999
+ agentIds: scopeMeta.agentIds,
2013
2000
  },
2014
2001
  };
2015
2002
  }
@@ -2025,8 +2012,9 @@ export class Catalox {
2025
2012
  const typeRegistry = catalog ? await this.resolveCatalogTypeRegistry(context) : null;
2026
2013
  const records = items.map((row) => {
2027
2014
  const stripped = this.stripReservedWriteFields(row);
2028
- const storedScope = normalizeStoredScope(stripped.scope ?? { kind: "global" });
2029
- assertSuperAdminForNonGlobalScope(context.superAdmin, storedScope);
2015
+ const storedScope = normalizeItemScope(stripped.scope ?? {});
2016
+ assertWriteScopeAllowed(context, storedScope);
2017
+ const scopeMeta = metadataFromScope(storedScope);
2030
2018
  if (catalog) {
2031
2019
  const report = this.validateNativeItemData(stripped.data, catalog, typeRegistry);
2032
2020
  if (!report.isValid) {
@@ -2049,11 +2037,11 @@ export class Catalox {
2049
2037
  return {
2050
2038
  itemId: logicalItemId,
2051
2039
  catalogId,
2052
- ...(storedScope.kind !== "global" ? { scope: storedScope } : {}),
2040
+ scope: storedScope,
2053
2041
  appScopedOwnerId: appId,
2054
2042
  ...(indexed != null ? { indexed } : {}),
2055
2043
  data: stripped.data,
2056
- metadata: { createdAt: now, lastUpdate: now, domainIds: [], agentIds: [] },
2044
+ metadata: { createdAt: now, lastUpdate: now, domainIds: scopeMeta.domainIds, agentIds: scopeMeta.agentIds },
2057
2045
  ...(actorId ? { updatedBy: actorId } : {}),
2058
2046
  };
2059
2047
  });
@@ -2428,6 +2416,14 @@ export class Catalox {
2428
2416
  definitions: this.deps.definitions,
2429
2417
  }, input);
2430
2418
  }
2419
+ async migrateNativeItemScope(_context, input) {
2420
+ return runMigrateNativeItemScope({
2421
+ firestoreStore: this.deps.firestoreStore,
2422
+ catalogs: this.deps.catalogs,
2423
+ bindings: this.deps.bindings,
2424
+ nativeItems: this.deps.nativeItems,
2425
+ }, input);
2426
+ }
2431
2427
  /**
2432
2428
  * Export one Firestore collection (slash path) to a GCS object as NDJSON (`{ id, data }` per line).
2433
2429
  * Auth: uses Admin Firestore only; bucket IAM must allow the runtime credential.