@x12i/catalox 2.3.0 → 2.4.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 (75) hide show
  1. package/README.md +27 -6
  2. package/dist/src/catalox/backup-data.d.ts +40 -0
  3. package/dist/src/catalox/backup-data.d.ts.map +1 -0
  4. package/dist/src/catalox/backup-data.js +522 -0
  5. package/dist/src/catalox/backup-data.js.map +1 -0
  6. package/dist/src/catalox/catalox.d.ts +25 -0
  7. package/dist/src/catalox/catalox.d.ts.map +1 -1
  8. package/dist/src/catalox/catalox.js +63 -1
  9. package/dist/src/catalox/catalox.js.map +1 -1
  10. package/dist/src/catalox/index.d.ts +3 -0
  11. package/dist/src/catalox/index.d.ts.map +1 -1
  12. package/dist/src/catalox/index.js +3 -0
  13. package/dist/src/catalox/index.js.map +1 -1
  14. package/dist/src/catalox/native-catalog-layout-diagnostics.d.ts +29 -0
  15. package/dist/src/catalox/native-catalog-layout-diagnostics.d.ts.map +1 -0
  16. package/dist/src/catalox/native-catalog-layout-diagnostics.js +55 -0
  17. package/dist/src/catalox/native-catalog-layout-diagnostics.js.map +1 -0
  18. package/dist/src/catalox/restore-firestore-backup.d.ts +13 -0
  19. package/dist/src/catalox/restore-firestore-backup.d.ts.map +1 -0
  20. package/dist/src/catalox/restore-firestore-backup.js +326 -0
  21. package/dist/src/catalox/restore-firestore-backup.js.map +1 -0
  22. package/dist/src/cli/index.js +143 -0
  23. package/dist/src/cli/index.js.map +1 -1
  24. package/dist/src/contracts/backup.d.ts +36 -0
  25. package/dist/src/contracts/backup.d.ts.map +1 -0
  26. package/dist/src/contracts/backup.js +2 -0
  27. package/dist/src/contracts/backup.js.map +1 -0
  28. package/dist/src/contracts/catalog-data-index.d.ts +13 -0
  29. package/dist/src/contracts/catalog-data-index.d.ts.map +1 -0
  30. package/dist/src/contracts/catalog-data-index.js +2 -0
  31. package/dist/src/contracts/catalog-data-index.js.map +1 -0
  32. package/dist/src/contracts/catalogs.d.ts +2 -1
  33. package/dist/src/contracts/catalogs.d.ts.map +1 -1
  34. package/dist/src/contracts/index.d.ts +3 -0
  35. package/dist/src/contracts/index.d.ts.map +1 -1
  36. package/dist/src/contracts/index.js.map +1 -1
  37. package/dist/src/contracts/restore.d.ts +44 -0
  38. package/dist/src/contracts/restore.d.ts.map +1 -0
  39. package/dist/src/contracts/restore.js +2 -0
  40. package/dist/src/contracts/restore.js.map +1 -0
  41. package/dist/src/firebase/catalog-data-index-store.d.ts +14 -0
  42. package/dist/src/firebase/catalog-data-index-store.d.ts.map +1 -0
  43. package/dist/src/firebase/catalog-data-index-store.js +33 -0
  44. package/dist/src/firebase/catalog-data-index-store.js.map +1 -0
  45. package/dist/src/firebase/catalog-data-paths.d.ts +14 -0
  46. package/dist/src/firebase/catalog-data-paths.d.ts.map +1 -0
  47. package/dist/src/firebase/catalog-data-paths.js +31 -0
  48. package/dist/src/firebase/catalog-data-paths.js.map +1 -0
  49. package/dist/src/firebase/index.d.ts +2 -0
  50. package/dist/src/firebase/index.d.ts.map +1 -1
  51. package/dist/src/firebase/index.js +2 -0
  52. package/dist/src/firebase/index.js.map +1 -1
  53. package/dist/src/firebase/native-item-store.d.ts +5 -1
  54. package/dist/src/firebase/native-item-store.d.ts.map +1 -1
  55. package/dist/src/firebase/native-item-store.js +33 -15
  56. package/dist/src/firebase/native-item-store.js.map +1 -1
  57. package/dist/src/migrations/index.d.ts +1 -0
  58. package/dist/src/migrations/index.d.ts.map +1 -1
  59. package/dist/src/migrations/index.js +1 -0
  60. package/dist/src/migrations/index.js.map +1 -1
  61. package/dist/src/migrations/migrate-native-catalog-layout.d.ts +51 -0
  62. package/dist/src/migrations/migrate-native-catalog-layout.d.ts.map +1 -0
  63. package/dist/src/migrations/migrate-native-catalog-layout.js +224 -0
  64. package/dist/src/migrations/migrate-native-catalog-layout.js.map +1 -0
  65. package/dist/test/integration/firestore.emulator.test.js +10 -2
  66. package/dist/test/integration/firestore.emulator.test.js.map +1 -1
  67. package/dist/test/unit/backup-timestamp.test.d.ts +2 -0
  68. package/dist/test/unit/backup-timestamp.test.d.ts.map +1 -0
  69. package/dist/test/unit/backup-timestamp.test.js +11 -0
  70. package/dist/test/unit/backup-timestamp.test.js.map +1 -0
  71. package/dist/test/unit/resolve-native-items-layout.test.d.ts +2 -0
  72. package/dist/test/unit/resolve-native-items-layout.test.d.ts.map +1 -0
  73. package/dist/test/unit/resolve-native-items-layout.test.js +50 -0
  74. package/dist/test/unit/resolve-native-items-layout.test.js.map +1 -0
  75. package/package.json +2 -1
package/README.md CHANGED
@@ -82,13 +82,23 @@ The `catalox` binary loads `.env` via `dotenv`. Use **`CATALOX_*`** for **Catalo
82
82
  - **`CATALOX_USER_ID`** — Optional user id / actor for authz-sensitive CLI paths.
83
83
  - **`CATALOX_MONGO_URI`** — If set, enables the Mongo catalog adapter in the CLI (see `createCatalox()` in `src/cli/index.ts`).
84
84
 
85
+ **Running commands from this repository:** the shell command `catalox` is only on your `PATH` if the package is installed globally (`npm i -g @x12i/catalox`) or linked (`npm link` from this repo). Otherwise, after `npm run build`, use:
86
+
87
+ ```bash
88
+ npm run cli -- firestore report-native-layout --app "narrix"
89
+ # same as: node dist/src/cli/index.js firestore report-native-layout --app "narrix"
90
+ ```
91
+
85
92
  ## Firestore data model (logical collections)
86
93
 
94
+ For a **full layout** (subcollections, document id conventions, snapshot runs, and query notes), see [`docs/firestore-data-model.md`](docs/firestore-data-model.md).
95
+
87
96
  Metadata:
88
97
 
89
98
  - `apps/{appId}`
90
99
  - `catalogs/{catalogId}`
91
100
  - `catalogBindings/{bindingId}` (many-to-many app↔catalog)
101
+ - `storeAppBindings/{bindingId}` (store↔app, multi-app export/report)
92
102
  - `catalogDefinitions/{catalogId}` (native vs mapped specifics)
93
103
  - `catalogAdapters/{adapterId}` (mongo/api adapter definitions)
94
104
  - `catalogMappings/{mappingId}` (field mapping specs)
@@ -98,16 +108,24 @@ Metadata:
98
108
 
99
109
  Data:
100
110
 
101
- - `catalogData/{catalogId}/items/{itemId}` (native items)
111
+ - `catalogData-{catalogId}-items/{itemId}` (native item rows; top-level collection per catalog)
112
+ - `catalogData/{catalogId}` (index and metadata for that catalog’s native storage; not item payloads)
102
113
  - `catalogSnapshots/{catalogId}/items/{itemId}` (mapped snapshot mode)
103
114
 
115
+ Backups (optional operator feature):
116
+
117
+ - `backup-*` and `{timestamp}__backup-*` (Firebase mode, same database)
118
+ - Mongo database `catalox-backups` (Mongo mode)
119
+
120
+ See [`docs/backup.md`](docs/backup.md) for `backupData` / CLI backup, [`docs/restore-firestore-backup.md`](docs/restore-firestore-backup.md) for restoring Firebase mirrors to live data with undo, [`docs/migration-native-catalog-data.md`](docs/migration-native-catalog-data.md) for moving legacy native rows into `catalogData-{catalogId}-items`, and [`docs/firestore-native-layout-vs-backup.md`](docs/firestore-native-layout-vs-backup.md) for how live paths differ from `backup-*` mirrors and what `backupData` reports in `nativeItemSourceLayoutByCatalogId`. If the console still shows **`catalogData/{catalogId}/items`**, run **`catalox firestore report-native-layout`** then **`catalox firestore migrate-native-catalog-data`** (backup + copy to flat; optional `--delete-legacy` after you trust backups).
121
+
104
122
  ## Core usage (generic from `appId`)
105
123
 
106
124
  ### Create stores and `Catalox`
107
125
 
108
126
  ```ts
109
127
  import { FirestoreStore } from "@x12i/catalox";
110
- import { AppStore, CatalogStore, BindingStore, DefinitionStore, MappingStore, DescriptorStore, ReferenceStore, NativeItemStore, SnapshotStore, AdapterStore } from "@x12i/catalox";
128
+ import { AppStore, CatalogStore, BindingStore, DefinitionStore, MappingStore, DescriptorStore, ReferenceStore, NativeItemStore, CatalogDataIndexStore, SnapshotStore, AdapterStore } from "@x12i/catalox";
111
129
  import { AuthorizationService, Catalox } from "@x12i/catalox";
112
130
 
113
131
  // firebase-admin initialization is up to the consumer
@@ -116,18 +134,21 @@ import { getFirestore } from "firebase-admin/firestore";
116
134
  const firestore = getFirestore();
117
135
  const store = new FirestoreStore({ firestore });
118
136
 
137
+ const bindings = new BindingStore(store);
119
138
  const deps = {
120
139
  apps: new AppStore(store),
121
140
  catalogs: new CatalogStore(store),
122
- bindings: new BindingStore(store),
141
+ bindings,
123
142
  definitions: new DefinitionStore(store),
124
143
  mappings: new MappingStore(store),
125
144
  descriptors: new DescriptorStore(store),
126
145
  references: new ReferenceStore(store),
127
146
  nativeItems: new NativeItemStore(store),
147
+ catalogDataIndex: new CatalogDataIndexStore(store),
128
148
  snapshots: new SnapshotStore(store),
129
149
  adapters: new AdapterStore(store),
130
- authz: new AuthorizationService(new BindingStore(store)),
150
+ firestoreStore: store,
151
+ authz: new AuthorizationService(bindings),
131
152
  };
132
153
 
133
154
  const catalox = new Catalox(deps);
@@ -320,7 +341,7 @@ await catalox.createCatalog(ctx, {
320
341
  catalogId: "signals",
321
342
  name: "Signals",
322
343
  sourceMode: "native",
323
- native: { type: "native", firestoreCollectionPath: "catalogData/signals/items" },
344
+ native: { type: "native" },
324
345
  });
325
346
  ```
326
347
 
@@ -464,7 +485,7 @@ Integration tests are **live-only** (no mocks, no emulators). They require:
464
485
  ### Live test safety (read before running)
465
486
 
466
487
  - **Never run against production credentials/projects.** Use a dedicated Firebase project for tests.
467
- - **Touched collections**: `apps`, `catalogs`, `catalogBindings`, `catalogDefinitions`, `catalogDescriptors`, and `catalogData/{catalogId}/items/...`.
488
+ - **Touched collections**: `apps`, `catalogs`, `catalogBindings`, `catalogDefinitions`, `catalogDescriptors`, `catalogData/{catalogId}`, and `catalogData-{catalogId}-items/...`. The **`catalox firestore restore-backup`** / **`undo-restore-backup`** commands additionally write `backup-restoreSessions` and `{restoreSessionId}__preRestore-*` sidecar collections (see [`docs/restore-firestore-backup.md`](docs/restore-firestore-backup.md)).
468
489
  - **Cleanup**: tests do best-effort deletes of the docs they created, but do not guarantee full teardown.
469
490
 
470
491
  ## Boundaries (important)
@@ -0,0 +1,40 @@
1
+ import { type Firestore } from "firebase-admin/firestore";
2
+ import { type Db } from "mongodb";
3
+ import type { CatalogId } from "../contracts/ids.js";
4
+ import type { BackupDataInput, BackupDataResult } from "../contracts/backup.js";
5
+ import type { CatalogRecord } from "../contracts/catalogs.js";
6
+ import { CatalogStore } from "../firebase/catalog-store.js";
7
+ import { BindingStore } from "../firebase/binding-store.js";
8
+ import { FirestoreStore } from "../firebase/firestore-store.js";
9
+ export declare const BACKUP_METADATA_SOURCE_NAMES: readonly ["apps", "catalogs", "catalogBindings", "storeAppBindings", "catalogDefinitions", "catalogDescriptors", "catalogMappings", "catalogAdapters", "catalogReferences", "catalogData"];
10
+ export type BackupDataDeps = {
11
+ firestoreStore: FirestoreStore;
12
+ catalogs: CatalogStore;
13
+ bindings: BindingStore;
14
+ };
15
+ export declare function formatBackupTimestamp(versionOverride?: string, d?: Date): string;
16
+ /** Latest Firebase backup mirror collection id for a logical source (e.g. `apps`, `catalogData--{id}`). */
17
+ export declare function firebaseLatestName(logical: string): string;
18
+ /** Versioned Firebase backup collection id. */
19
+ export declare function firebaseVersionedName(ts: string, logical: string): string;
20
+ export declare function firebaseNativeLogical(catalogId: string): string;
21
+ export declare function firebaseSnapshotLogical(catalogId: string): string;
22
+ export declare function firebaseSnapshotRunLogical(catalogId: string, runId: string): string;
23
+ export declare function paginatedCopyFirestoreToFirestore(fs: Firestore, sourceCollectionPath: string, destCollectionPath: string, counts: Record<string, number>, countKey: string): Promise<void>;
24
+ export declare function deleteAllDocsInFirestoreCollection(fs: Firestore, collectionPath: string): Promise<void>;
25
+ export declare function discoverNativeCatalogIds(fs: Firestore, catalogs: CatalogRecord[], opts: {
26
+ bindings: BindingStore;
27
+ catalogIds?: CatalogId[];
28
+ appId?: string;
29
+ }): Promise<CatalogId[]>;
30
+ /** Delete Mongo `backupRuns` documents with createdAt older than cutoff ISO string. */
31
+ export declare function pruneMongoBackupRuns(db: Db, createdBeforeIso: string): Promise<number>;
32
+ /**
33
+ * Delete documents in oldest Firestore versioned backup collections matching `{ts}__backup-*`
34
+ * until at most `keepLast` such collections remain.
35
+ */
36
+ export declare function pruneFirebaseVersionedBackups(firestore: Firestore, keepLast: number): Promise<{
37
+ deleted: string[];
38
+ }>;
39
+ export declare function runBackupData(deps: BackupDataDeps, input: BackupDataInput): Promise<BackupDataResult>;
40
+ //# sourceMappingURL=backup-data.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"backup-data.d.ts","sourceRoot":"","sources":["../../../src/catalox/backup-data.ts"],"names":[],"mappings":"AAAA,OAAO,EAAa,KAAK,SAAS,EAAE,MAAM,0BAA0B,CAAC;AACrE,OAAO,EAAe,KAAK,EAAE,EAAE,MAAM,SAAS,CAAC;AAE/C,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,KAAK,EAAE,eAAe,EAAmB,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AACjG,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAE9D,OAAO,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AAC5D,OAAO,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AAC5D,OAAO,EAAE,cAAc,EAAE,MAAM,gCAAgC,CAAC;AAWhE,eAAO,MAAM,4BAA4B,4LAY/B,CAAC;AAEX,MAAM,MAAM,cAAc,GAAG;IAC3B,cAAc,EAAE,cAAc,CAAC;IAC/B,QAAQ,EAAE,YAAY,CAAC;IACvB,QAAQ,EAAE,YAAY,CAAC;CACxB,CAAC;AAEF,wBAAgB,qBAAqB,CAAC,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC,OAAa,GAAG,MAAM,CAMtF;AAMD,2GAA2G;AAC3G,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAE1D;AAED,+CAA+C;AAC/C,wBAAgB,qBAAqB,CAAC,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAEzE;AA0BD,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAE/D;AAED,wBAAgB,uBAAuB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAEjE;AAED,wBAAgB,0BAA0B,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAEnF;AAED,wBAAsB,iCAAiC,CACrD,EAAE,EAAE,SAAS,EACb,oBAAoB,EAAE,MAAM,EAC5B,kBAAkB,EAAE,MAAM,EAC1B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC9B,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,IAAI,CAAC,CAmBf;AAED,wBAAsB,kCAAkC,CAAC,EAAE,EAAE,SAAS,EAAE,cAAc,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAS7G;AAED,wBAAsB,wBAAwB,CAC5C,EAAE,EAAE,SAAS,EACb,QAAQ,EAAE,aAAa,EAAE,EACzB,IAAI,EAAE;IAAE,QAAQ,EAAE,YAAY,CAAC;IAAC,UAAU,CAAC,EAAE,SAAS,EAAE,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,GACzE,OAAO,CAAC,SAAS,EAAE,CAAC,CAwBtB;AA0PD,uFAAuF;AACvF,wBAAsB,oBAAoB,CAAC,EAAE,EAAE,EAAE,EAAE,gBAAgB,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAK5F;AAED;;;GAGG;AACH,wBAAsB,6BAA6B,CACjD,SAAS,EAAE,SAAS,EACpB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC;IAAE,OAAO,EAAE,MAAM,EAAE,CAAA;CAAE,CAAC,CAchC;AAED,wBAAsB,aAAa,CACjC,IAAI,EAAE,cAAc,EACpB,KAAK,EAAE,eAAe,GACrB,OAAO,CAAC,gBAAgB,CAAC,CA+K3B"}
@@ -0,0 +1,522 @@
1
+ import { FieldPath } from "firebase-admin/firestore";
2
+ import { MongoClient } from "mongodb";
3
+ import { CatalogStore } from "../firebase/catalog-store.js";
4
+ import { BindingStore } from "../firebase/binding-store.js";
5
+ import { FirestoreStore } from "../firebase/firestore-store.js";
6
+ import { flatNativeItemsCollectionRef, legacyNativeItemsCollectionRef, nativeItemsCollectionId, resolveNativeItemsLayout, } from "../firebase/catalog-data-paths.js";
7
+ const MONGO_BACKUP_DB = "catalox-backups";
8
+ export const BACKUP_METADATA_SOURCE_NAMES = [
9
+ "apps",
10
+ "catalogs",
11
+ "catalogBindings",
12
+ "storeAppBindings",
13
+ "catalogDefinitions",
14
+ "catalogDescriptors",
15
+ "catalogMappings",
16
+ "catalogAdapters",
17
+ "catalogReferences",
18
+ /** Parent docs only (`catalogData/{catalogId}`); subcollections are not copied. */
19
+ "catalogData",
20
+ ];
21
+ export function formatBackupTimestamp(versionOverride, d = new Date()) {
22
+ if (versionOverride?.trim()) {
23
+ return versionOverride.trim().replace(/[/\\]/g, "_");
24
+ }
25
+ const iso = d.toISOString().replace(/\.\d{3}Z$/, "Z");
26
+ return iso.replace(/[-:]/g, "_").replace(/\./g, "_");
27
+ }
28
+ function firebaseTmpName(logical) {
29
+ return `tmp-backup-${logical}`;
30
+ }
31
+ /** Latest Firebase backup mirror collection id for a logical source (e.g. `apps`, `catalogData--{id}`). */
32
+ export function firebaseLatestName(logical) {
33
+ return `backup-${logical}`;
34
+ }
35
+ /** Versioned Firebase backup collection id. */
36
+ export function firebaseVersionedName(ts, logical) {
37
+ return `${ts}__backup-${logical}`;
38
+ }
39
+ function mongoTmpName(logical) {
40
+ return `__tmp_backup_${logical}`;
41
+ }
42
+ function mongoVersionedName(ts, logical) {
43
+ return `${ts}__${logical}`;
44
+ }
45
+ function mongoNativeItemsCollectionName(catalogId) {
46
+ return `catalogData__${sanitizeMongoCollectionPart(catalogId)}`;
47
+ }
48
+ function mongoSnapshotCollectionName(catalogId) {
49
+ return `catalogSnapshots__${sanitizeMongoCollectionPart(catalogId)}`;
50
+ }
51
+ function mongoSnapshotRunCollectionName(catalogId, runId) {
52
+ return `catalogSnapshotsRun__${sanitizeMongoCollectionPart(catalogId)}__${sanitizeMongoCollectionPart(runId)}`;
53
+ }
54
+ function sanitizeMongoCollectionPart(s) {
55
+ return String(s).replace(/[^a-zA-Z0-9_-]/g, "_");
56
+ }
57
+ export function firebaseNativeLogical(catalogId) {
58
+ return `catalogData--${catalogId}`;
59
+ }
60
+ export function firebaseSnapshotLogical(catalogId) {
61
+ return `catalogSnapshots--${catalogId}`;
62
+ }
63
+ export function firebaseSnapshotRunLogical(catalogId, runId) {
64
+ return `catalogSnapshotsRun--${catalogId}--${runId}`;
65
+ }
66
+ export async function paginatedCopyFirestoreToFirestore(fs, sourceCollectionPath, destCollectionPath, counts, countKey) {
67
+ const src = fs.collection(sourceCollectionPath);
68
+ const dst = fs.collection(destCollectionPath);
69
+ let last;
70
+ let total = 0;
71
+ while (true) {
72
+ let q = src.orderBy(FieldPath.documentId()).limit(400);
73
+ if (last)
74
+ q = q.startAfter(last);
75
+ const snap = await q.get();
76
+ if (snap.empty)
77
+ break;
78
+ const batch = fs.batch();
79
+ for (const d of snap.docs) {
80
+ batch.set(dst.doc(d.id), d.data(), { merge: false });
81
+ total += 1;
82
+ }
83
+ await batch.commit();
84
+ last = snap.docs[snap.docs.length - 1];
85
+ }
86
+ counts[countKey] = (counts[countKey] ?? 0) + total;
87
+ }
88
+ export async function deleteAllDocsInFirestoreCollection(fs, collectionPath) {
89
+ const col = fs.collection(collectionPath);
90
+ while (true) {
91
+ const snap = await col.limit(450).get();
92
+ if (snap.empty)
93
+ break;
94
+ const batch = fs.batch();
95
+ for (const d of snap.docs)
96
+ batch.delete(d.ref);
97
+ await batch.commit();
98
+ }
99
+ }
100
+ export async function discoverNativeCatalogIds(fs, catalogs, opts) {
101
+ const ids = new Set();
102
+ for (const c of catalogs) {
103
+ if (c.sourceMode === "native")
104
+ ids.add(String(c.catalogId));
105
+ }
106
+ const parentRefs = await fs.collection("catalogData").listDocuments();
107
+ for (const pref of parentRefs) {
108
+ const cid = pref.id;
109
+ const leg = await pref.collection("items").limit(1).get();
110
+ const flat = await fs.collection(nativeItemsCollectionId(cid)).limit(1).get();
111
+ if (!leg.empty || !flat.empty)
112
+ ids.add(cid);
113
+ }
114
+ let list = [...ids];
115
+ if (opts.appId) {
116
+ const binds = await opts.bindings.listByApp(opts.appId);
117
+ const allowed = new Set(binds.map((b) => String(b.catalogId)));
118
+ list = list.filter((id) => allowed.has(id));
119
+ }
120
+ if (opts.catalogIds?.length) {
121
+ const allow = new Set(opts.catalogIds.map(String));
122
+ list = list.filter((id) => allow.has(id));
123
+ }
124
+ return list;
125
+ }
126
+ async function copyNativeCatalogToFirestoreBackup(fs, catalogId, destCollectionPath, counts, layout) {
127
+ const src = layout === "legacy"
128
+ ? legacyNativeItemsCollectionRef(fs, catalogId)
129
+ : flatNativeItemsCollectionRef(fs, catalogId);
130
+ await paginatedCopyFirestoreToFirestore(fs, src.path, destCollectionPath, counts, destCollectionPath);
131
+ }
132
+ async function mongoReplacePage(col, docs) {
133
+ const bulk = docs.map((d) => {
134
+ const payload = { ...d.data() };
135
+ delete payload._id;
136
+ return {
137
+ replaceOne: {
138
+ filter: { _id: d.id },
139
+ replacement: { _id: d.id, ...payload },
140
+ upsert: true,
141
+ },
142
+ };
143
+ });
144
+ if (bulk.length)
145
+ await col.bulkWrite(bulk);
146
+ }
147
+ async function paginatedCopyFirestoreToMongo(fs, sourceCollectionPath, db, destCollectionName, counts, countKey) {
148
+ const src = fs.collection(sourceCollectionPath);
149
+ const col = db.collection(destCollectionName);
150
+ let last;
151
+ let total = 0;
152
+ while (true) {
153
+ let q = src.orderBy(FieldPath.documentId()).limit(200);
154
+ if (last)
155
+ q = q.startAfter(last);
156
+ const snap = await q.get();
157
+ if (snap.empty)
158
+ break;
159
+ await mongoReplacePage(col, snap.docs);
160
+ total += snap.docs.length;
161
+ last = snap.docs[snap.docs.length - 1];
162
+ }
163
+ counts[countKey] = (counts[countKey] ?? 0) + total;
164
+ }
165
+ async function promoteMongoLatest(db, tmpName, latestName) {
166
+ const n = await db.collection(tmpName).countDocuments();
167
+ if (n === 0) {
168
+ await db.collection(latestName).drop().catch(() => undefined);
169
+ return;
170
+ }
171
+ await db.collection(latestName).drop().catch(() => undefined);
172
+ await db.collection(tmpName).rename(latestName, { dropTarget: true });
173
+ }
174
+ async function backupMetadataMongo(fs, db, ts, counts, collectionsWritten, versionedWritten, issues) {
175
+ for (const name of BACKUP_METADATA_SOURCE_NAMES) {
176
+ const tmp = mongoTmpName(name);
177
+ const versioned = mongoVersionedName(ts, name);
178
+ try {
179
+ await db.collection(tmp).drop().catch(() => undefined);
180
+ await paginatedCopyFirestoreToMongo(fs, name, db, tmp, counts, tmp);
181
+ await db.collection(versioned).drop().catch(() => undefined);
182
+ await paginatedCopyFirestoreToMongo(fs, name, db, versioned, counts, versioned);
183
+ await promoteMongoLatest(db, tmp, name);
184
+ collectionsWritten.push(name);
185
+ versionedWritten.push(versioned);
186
+ }
187
+ catch (e) {
188
+ issues.push({
189
+ code: "mongo_metadata_copy_failed",
190
+ message: e instanceof Error ? e.message : String(e),
191
+ detail: { name },
192
+ });
193
+ throw e;
194
+ }
195
+ }
196
+ }
197
+ async function backupMetadataFirebase(fs, ts, counts, collectionsWritten, versionedWritten, issues) {
198
+ for (const name of BACKUP_METADATA_SOURCE_NAMES) {
199
+ const tmp = firebaseTmpName(name);
200
+ const latest = firebaseLatestName(name);
201
+ const ver = firebaseVersionedName(ts, name);
202
+ try {
203
+ await deleteAllDocsInFirestoreCollection(fs, tmp);
204
+ await paginatedCopyFirestoreToFirestore(fs, name, tmp, counts, tmp);
205
+ await paginatedCopyFirestoreToFirestore(fs, name, ver, counts, ver);
206
+ await deleteAllDocsInFirestoreCollection(fs, latest);
207
+ await paginatedCopyFirestoreToFirestore(fs, tmp, latest, counts, latest);
208
+ await deleteAllDocsInFirestoreCollection(fs, tmp);
209
+ collectionsWritten.push(latest);
210
+ versionedWritten.push(ver);
211
+ }
212
+ catch (e) {
213
+ issues.push({
214
+ code: "firebase_metadata_copy_failed",
215
+ message: e instanceof Error ? e.message : String(e),
216
+ detail: { name },
217
+ });
218
+ throw e;
219
+ }
220
+ }
221
+ }
222
+ async function backupSnapshotsFirebase(fs, ts, counts, collectionsWritten, versionedWritten, issues) {
223
+ const roots = await fs.collection("catalogSnapshots").listDocuments();
224
+ for (const pref of roots) {
225
+ const catalogId = pref.id;
226
+ const logical = firebaseSnapshotLogical(catalogId);
227
+ const itemsPath = pref.collection("items").path;
228
+ const tmp = firebaseTmpName(logical);
229
+ const latest = firebaseLatestName(logical);
230
+ const ver = firebaseVersionedName(ts, logical);
231
+ try {
232
+ await deleteAllDocsInFirestoreCollection(fs, tmp);
233
+ await paginatedCopyFirestoreToFirestore(fs, itemsPath, tmp, counts, tmp);
234
+ await paginatedCopyFirestoreToFirestore(fs, itemsPath, ver, counts, ver);
235
+ await deleteAllDocsInFirestoreCollection(fs, latest);
236
+ await paginatedCopyFirestoreToFirestore(fs, tmp, latest, counts, latest);
237
+ await deleteAllDocsInFirestoreCollection(fs, tmp);
238
+ collectionsWritten.push(latest);
239
+ versionedWritten.push(ver);
240
+ const runs = await pref.collection("runs").listDocuments();
241
+ for (const runRef of runs) {
242
+ const runId = runRef.id;
243
+ const runLogical = firebaseSnapshotRunLogical(catalogId, runId);
244
+ const runItemsPath = runRef.collection("items").path;
245
+ const rtmp = firebaseTmpName(runLogical);
246
+ const rlatest = firebaseLatestName(runLogical);
247
+ const rver = firebaseVersionedName(ts, runLogical);
248
+ await deleteAllDocsInFirestoreCollection(fs, rtmp);
249
+ await paginatedCopyFirestoreToFirestore(fs, runItemsPath, rtmp, counts, rtmp);
250
+ await paginatedCopyFirestoreToFirestore(fs, runItemsPath, rver, counts, rver);
251
+ await deleteAllDocsInFirestoreCollection(fs, rlatest);
252
+ await paginatedCopyFirestoreToFirestore(fs, rtmp, rlatest, counts, rlatest);
253
+ await deleteAllDocsInFirestoreCollection(fs, rtmp);
254
+ collectionsWritten.push(rlatest);
255
+ versionedWritten.push(rver);
256
+ }
257
+ }
258
+ catch (e) {
259
+ issues.push({
260
+ code: "firebase_snapshot_copy_failed",
261
+ message: e instanceof Error ? e.message : String(e),
262
+ detail: { catalogId },
263
+ });
264
+ throw e;
265
+ }
266
+ }
267
+ }
268
+ async function backupSnapshotsMongo(fs, db, ts, counts, collectionsWritten, versionedWritten, issues) {
269
+ const roots = await fs.collection("catalogSnapshots").listDocuments();
270
+ for (const pref of roots) {
271
+ const catalogId = pref.id;
272
+ const itemsPath = pref.collection("items").path;
273
+ const latestName = mongoSnapshotCollectionName(catalogId);
274
+ const versionedName = mongoVersionedName(ts, latestName);
275
+ const tmpName = mongoTmpName(latestName);
276
+ try {
277
+ await db.collection(tmpName).drop().catch(() => undefined);
278
+ await paginatedCopyFirestoreToMongo(fs, itemsPath, db, tmpName, counts, tmpName);
279
+ await db.collection(versionedName).drop().catch(() => undefined);
280
+ await paginatedCopyFirestoreToMongo(fs, itemsPath, db, versionedName, counts, versionedName);
281
+ await promoteMongoLatest(db, tmpName, latestName);
282
+ collectionsWritten.push(latestName);
283
+ versionedWritten.push(versionedName);
284
+ const runs = await pref.collection("runs").listDocuments();
285
+ for (const runRef of runs) {
286
+ const runId = runRef.id;
287
+ const runItemsPath = runRef.collection("items").path;
288
+ const runLatest = mongoSnapshotRunCollectionName(catalogId, runId);
289
+ const runVer = mongoVersionedName(ts, runLatest);
290
+ const runTmp = mongoTmpName(runLatest);
291
+ await db.collection(runTmp).drop().catch(() => undefined);
292
+ await paginatedCopyFirestoreToMongo(fs, runItemsPath, db, runTmp, counts, runTmp);
293
+ await db.collection(runVer).drop().catch(() => undefined);
294
+ await paginatedCopyFirestoreToMongo(fs, runItemsPath, db, runVer, counts, runVer);
295
+ await promoteMongoLatest(db, runTmp, runLatest);
296
+ collectionsWritten.push(runLatest);
297
+ versionedWritten.push(runVer);
298
+ }
299
+ }
300
+ catch (e) {
301
+ issues.push({
302
+ code: "mongo_snapshot_copy_failed",
303
+ message: e instanceof Error ? e.message : String(e),
304
+ detail: { catalogId },
305
+ });
306
+ throw e;
307
+ }
308
+ }
309
+ }
310
+ async function writeBackupManifestFirebase(fs, payload) {
311
+ await fs.collection("backup-backupRuns").add({
312
+ ...payload,
313
+ createdAt: new Date().toISOString(),
314
+ });
315
+ }
316
+ async function writeBackupManifestMongo(db, payload) {
317
+ await db.collection("backupRuns").insertOne({
318
+ ...payload,
319
+ createdAt: new Date().toISOString(),
320
+ });
321
+ }
322
+ /** Delete Mongo `backupRuns` documents with createdAt older than cutoff ISO string. */
323
+ export async function pruneMongoBackupRuns(db, createdBeforeIso) {
324
+ const res = await db.collection("backupRuns").deleteMany({
325
+ createdAt: { $lt: createdBeforeIso },
326
+ });
327
+ return res.deletedCount ?? 0;
328
+ }
329
+ /**
330
+ * Delete documents in oldest Firestore versioned backup collections matching `{ts}__backup-*`
331
+ * until at most `keepLast` such collections remain.
332
+ */
333
+ export async function pruneFirebaseVersionedBackups(firestore, keepLast) {
334
+ const cols = await firestore.listCollections();
335
+ const versioned = cols
336
+ .map((c) => c.id)
337
+ .filter((id) => /^\d{4}_\d{2}_\d{2}T.*__backup-/.test(id))
338
+ .sort();
339
+ if (versioned.length <= keepLast)
340
+ return { deleted: [] };
341
+ const toRemove = versioned.slice(0, versioned.length - keepLast);
342
+ const deleted = [];
343
+ for (const id of toRemove) {
344
+ await deleteAllDocsInFirestoreCollection(firestore, id);
345
+ deleted.push(id);
346
+ }
347
+ return { deleted };
348
+ }
349
+ export async function runBackupData(deps, input) {
350
+ const issues = [];
351
+ const collectionsWritten = [];
352
+ const versionedCollectionsWritten = [];
353
+ const counts = {};
354
+ const nativeItemSourceLayoutByCatalogId = {};
355
+ const fs = deps.firestoreStore.firestore;
356
+ const ts = formatBackupTimestamp(input.versionTimestamp);
357
+ const includeSnapshots = Boolean(input.includeSnapshots);
358
+ const allCatalogs = await deps.catalogs.listAll();
359
+ const nativeCatalogIds = await discoverNativeCatalogIds(fs, allCatalogs, {
360
+ bindings: deps.bindings,
361
+ ...(input.catalogIds != null && input.catalogIds.length ? { catalogIds: input.catalogIds } : {}),
362
+ ...(input.appId != null && input.appId !== "" ? { appId: input.appId } : {}),
363
+ });
364
+ if (input.mode === "mongo") {
365
+ if (!input.mongoBackupUri?.trim()) {
366
+ return {
367
+ ok: false,
368
+ mode: "mongo",
369
+ databaseName: MONGO_BACKUP_DB,
370
+ backupTimestamp: new Date().toISOString(),
371
+ ...(input.backupLabel != null ? { backupLabel: input.backupLabel } : {}),
372
+ includeSnapshots,
373
+ collectionsWritten: [],
374
+ versionedCollectionsWritten: [],
375
+ counts: {},
376
+ issues: [{ code: "missing_mongo_uri", message: "mongoBackupUri is required for mongo mode" }],
377
+ nativeItemSourceLayoutByCatalogId: {},
378
+ };
379
+ }
380
+ const client = new MongoClient(input.mongoBackupUri);
381
+ try {
382
+ await client.connect();
383
+ const db = client.db(MONGO_BACKUP_DB);
384
+ await backupMetadataMongo(fs, db, ts, counts, collectionsWritten, versionedCollectionsWritten, issues);
385
+ for (const catalogId of nativeCatalogIds) {
386
+ const layout = await resolveNativeItemsLayout(fs, catalogId);
387
+ nativeItemSourceLayoutByCatalogId[String(catalogId)] = layout;
388
+ const logical = mongoNativeItemsCollectionName(String(catalogId));
389
+ const tmp = mongoTmpName(logical);
390
+ const versioned = mongoVersionedName(ts, logical);
391
+ await db.collection(tmp).drop().catch(() => undefined);
392
+ await copyNativeCatalogToMongo(fs, db, catalogId, tmp, counts, layout);
393
+ await db.collection(versioned).drop().catch(() => undefined);
394
+ await copyNativeCatalogToMongo(fs, db, catalogId, versioned, counts, layout);
395
+ await promoteMongoLatest(db, tmp, logical);
396
+ collectionsWritten.push(logical);
397
+ versionedCollectionsWritten.push(versioned);
398
+ }
399
+ if (includeSnapshots) {
400
+ await backupSnapshotsMongo(fs, db, ts, counts, collectionsWritten, versionedCollectionsWritten, issues);
401
+ }
402
+ await writeBackupManifestMongo(db, {
403
+ backupTimestamp: new Date().toISOString(),
404
+ mode: "mongo",
405
+ backupLabel: input.backupLabel,
406
+ includeSnapshots,
407
+ collectionsWritten,
408
+ versionedCollectionsWritten,
409
+ counts,
410
+ issues,
411
+ nativeItemSourceLayoutByCatalogId,
412
+ });
413
+ return {
414
+ ok: true,
415
+ mode: "mongo",
416
+ databaseName: MONGO_BACKUP_DB,
417
+ backupTimestamp: new Date().toISOString(),
418
+ ...(input.backupLabel != null ? { backupLabel: input.backupLabel } : {}),
419
+ includeSnapshots,
420
+ collectionsWritten,
421
+ versionedCollectionsWritten,
422
+ counts,
423
+ issues,
424
+ nativeItemSourceLayoutByCatalogId,
425
+ };
426
+ }
427
+ catch (e) {
428
+ issues.push({
429
+ code: "mongo_backup_failed",
430
+ message: e instanceof Error ? e.message : String(e),
431
+ });
432
+ return {
433
+ ok: false,
434
+ mode: "mongo",
435
+ databaseName: MONGO_BACKUP_DB,
436
+ backupTimestamp: new Date().toISOString(),
437
+ ...(input.backupLabel != null ? { backupLabel: input.backupLabel } : {}),
438
+ includeSnapshots,
439
+ collectionsWritten,
440
+ versionedCollectionsWritten,
441
+ counts,
442
+ issues,
443
+ nativeItemSourceLayoutByCatalogId,
444
+ };
445
+ }
446
+ finally {
447
+ await client.close().catch(() => undefined);
448
+ }
449
+ }
450
+ try {
451
+ await backupMetadataFirebase(fs, ts, counts, collectionsWritten, versionedCollectionsWritten, issues);
452
+ for (const catalogId of nativeCatalogIds) {
453
+ const layout = await resolveNativeItemsLayout(fs, catalogId);
454
+ nativeItemSourceLayoutByCatalogId[String(catalogId)] = layout;
455
+ const logical = firebaseNativeLogical(String(catalogId));
456
+ const tmp = firebaseTmpName(logical);
457
+ const latest = firebaseLatestName(logical);
458
+ const ver = firebaseVersionedName(ts, logical);
459
+ await deleteAllDocsInFirestoreCollection(fs, tmp);
460
+ await copyNativeCatalogToFirestoreBackup(fs, catalogId, tmp, counts, layout);
461
+ await copyNativeCatalogToFirestoreBackup(fs, catalogId, ver, counts, layout);
462
+ await deleteAllDocsInFirestoreCollection(fs, latest);
463
+ await paginatedCopyFirestoreToFirestore(fs, tmp, latest, counts, latest);
464
+ await deleteAllDocsInFirestoreCollection(fs, tmp);
465
+ collectionsWritten.push(latest);
466
+ versionedCollectionsWritten.push(ver);
467
+ }
468
+ if (includeSnapshots) {
469
+ await backupSnapshotsFirebase(fs, ts, counts, collectionsWritten, versionedCollectionsWritten, issues);
470
+ }
471
+ await writeBackupManifestFirebase(fs, {
472
+ backupTimestamp: new Date().toISOString(),
473
+ mode: "firebase",
474
+ backupLabel: input.backupLabel,
475
+ includeSnapshots,
476
+ collectionsWritten,
477
+ versionedCollectionsWritten,
478
+ counts,
479
+ issues,
480
+ nativeItemSourceLayoutByCatalogId,
481
+ });
482
+ return {
483
+ ok: true,
484
+ mode: "firebase",
485
+ databaseName: "(same-firestore-target)",
486
+ backupTimestamp: new Date().toISOString(),
487
+ ...(input.backupLabel != null ? { backupLabel: input.backupLabel } : {}),
488
+ includeSnapshots,
489
+ collectionsWritten,
490
+ versionedCollectionsWritten,
491
+ counts,
492
+ issues,
493
+ nativeItemSourceLayoutByCatalogId,
494
+ };
495
+ }
496
+ catch (e) {
497
+ issues.push({
498
+ code: "firebase_backup_failed",
499
+ message: e instanceof Error ? e.message : String(e),
500
+ });
501
+ return {
502
+ ok: false,
503
+ mode: "firebase",
504
+ databaseName: "(same-firestore-target)",
505
+ backupTimestamp: new Date().toISOString(),
506
+ ...(input.backupLabel != null ? { backupLabel: input.backupLabel } : {}),
507
+ includeSnapshots,
508
+ collectionsWritten,
509
+ versionedCollectionsWritten,
510
+ counts,
511
+ issues,
512
+ nativeItemSourceLayoutByCatalogId,
513
+ };
514
+ }
515
+ }
516
+ async function copyNativeCatalogToMongo(fs, db, catalogId, destCollection, counts, layout) {
517
+ const src = layout === "legacy"
518
+ ? legacyNativeItemsCollectionRef(fs, catalogId)
519
+ : flatNativeItemsCollectionRef(fs, catalogId);
520
+ await paginatedCopyFirestoreToMongo(fs, src.path, db, destCollection, counts, destCollection);
521
+ }
522
+ //# sourceMappingURL=backup-data.js.map