@x12i/catalox 3.0.0 → 3.2.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.
- package/README.md +43 -6
- package/dist/src/catalox/authorization.js +1 -1
- package/dist/src/catalox/authorization.js.map +1 -1
- package/dist/src/catalox/backup-data.d.ts +0 -6
- package/dist/src/catalox/backup-data.d.ts.map +1 -1
- package/dist/src/catalox/backup-data.js +2 -413
- package/dist/src/catalox/backup-data.js.map +1 -1
- package/dist/src/catalox/catalog-lifecycle.d.ts +29 -0
- package/dist/src/catalox/catalog-lifecycle.d.ts.map +1 -0
- package/dist/src/catalox/catalog-lifecycle.js +480 -0
- package/dist/src/catalox/catalog-lifecycle.js.map +1 -0
- package/dist/src/catalox/catalox-bound.d.ts +21 -6
- package/dist/src/catalox/catalox-bound.d.ts.map +1 -1
- package/dist/src/catalox/catalox-bound.js +30 -9
- package/dist/src/catalox/catalox-bound.js.map +1 -1
- package/dist/src/catalox/catalox.d.ts +31 -11
- package/dist/src/catalox/catalox.d.ts.map +1 -1
- package/dist/src/catalox/catalox.js +485 -82
- package/dist/src/catalox/catalox.js.map +1 -1
- package/dist/src/catalox/context.js +2 -2
- package/dist/src/catalox/context.js.map +1 -1
- package/dist/src/catalox/create-catalox.d.ts +6 -0
- package/dist/src/catalox/create-catalox.d.ts.map +1 -1
- package/dist/src/catalox/create-catalox.js +25 -0
- package/dist/src/catalox/create-catalox.js.map +1 -1
- package/dist/src/catalox/index.d.ts +4 -2
- package/dist/src/catalox/index.d.ts.map +1 -1
- package/dist/src/catalox/index.js +4 -2
- package/dist/src/catalox/index.js.map +1 -1
- package/dist/src/catalox/native-catalog-merge.d.ts +12 -0
- package/dist/src/catalox/native-catalog-merge.d.ts.map +1 -0
- package/dist/src/catalox/native-catalog-merge.js +102 -0
- package/dist/src/catalox/native-catalog-merge.js.map +1 -0
- package/dist/src/catalox/native-scope.d.ts +28 -0
- package/dist/src/catalox/native-scope.d.ts.map +1 -0
- package/dist/src/catalox/native-scope.js +184 -0
- package/dist/src/catalox/native-scope.js.map +1 -0
- package/dist/src/catalox/record-history.d.ts +53 -0
- package/dist/src/catalox/record-history.d.ts.map +1 -0
- package/dist/src/catalox/record-history.js +158 -0
- package/dist/src/catalox/record-history.js.map +1 -0
- package/dist/src/catalox/restore-firestore-backup.d.ts +6 -3
- package/dist/src/catalox/restore-firestore-backup.d.ts.map +1 -1
- package/dist/src/catalox/restore-firestore-backup.js +3 -224
- package/dist/src/catalox/restore-firestore-backup.js.map +1 -1
- package/dist/src/cli/index.js +159 -55
- package/dist/src/cli/index.js.map +1 -1
- package/dist/src/contracts/apps.d.ts +2 -0
- package/dist/src/contracts/apps.d.ts.map +1 -1
- package/dist/src/contracts/backup.d.ts +2 -2
- package/dist/src/contracts/backup.d.ts.map +1 -1
- package/dist/src/contracts/catalog-lifecycle.d.ts +70 -0
- package/dist/src/contracts/catalog-lifecycle.d.ts.map +1 -0
- package/dist/src/contracts/catalog-lifecycle.js +2 -0
- package/dist/src/contracts/catalog-lifecycle.js.map +1 -0
- package/dist/src/contracts/catalogs.d.ts +37 -0
- package/dist/src/contracts/catalogs.d.ts.map +1 -1
- package/dist/src/contracts/catalogs.js.map +1 -1
- package/dist/src/contracts/context.d.ts +5 -1
- package/dist/src/contracts/context.d.ts.map +1 -1
- package/dist/src/contracts/descriptors.d.ts +6 -0
- package/dist/src/contracts/descriptors.d.ts.map +1 -1
- package/dist/src/contracts/index.d.ts +6 -3
- package/dist/src/contracts/index.d.ts.map +1 -1
- package/dist/src/contracts/index.js.map +1 -1
- package/dist/src/contracts/items.d.ts +19 -0
- package/dist/src/contracts/items.d.ts.map +1 -1
- package/dist/src/contracts/record-history.d.ts +66 -0
- package/dist/src/contracts/record-history.d.ts.map +1 -0
- package/dist/src/contracts/record-history.js +2 -0
- package/dist/src/contracts/record-history.js.map +1 -0
- package/dist/src/contracts/restore.d.ts +0 -39
- package/dist/src/contracts/restore.d.ts.map +1 -1
- package/dist/src/firebase/adapter-store.d.ts +1 -0
- package/dist/src/firebase/adapter-store.d.ts.map +1 -1
- package/dist/src/firebase/adapter-store.js +3 -0
- package/dist/src/firebase/adapter-store.js.map +1 -1
- package/dist/src/firebase/binding-store.d.ts +2 -0
- package/dist/src/firebase/binding-store.d.ts.map +1 -1
- package/dist/src/firebase/binding-store.js +10 -0
- package/dist/src/firebase/binding-store.js.map +1 -1
- package/dist/src/firebase/catalog-data-index-store.d.ts +1 -0
- package/dist/src/firebase/catalog-data-index-store.d.ts.map +1 -1
- package/dist/src/firebase/catalog-data-index-store.js +3 -0
- package/dist/src/firebase/catalog-data-index-store.js.map +1 -1
- package/dist/src/firebase/catalog-item-history-store.d.ts +21 -0
- package/dist/src/firebase/catalog-item-history-store.d.ts.map +1 -0
- package/dist/src/firebase/catalog-item-history-store.js +61 -0
- package/dist/src/firebase/catalog-item-history-store.js.map +1 -0
- package/dist/src/firebase/catalog-store.d.ts +1 -0
- package/dist/src/firebase/catalog-store.d.ts.map +1 -1
- package/dist/src/firebase/catalog-store.js +3 -0
- package/dist/src/firebase/catalog-store.js.map +1 -1
- package/dist/src/firebase/index.d.ts +1 -0
- package/dist/src/firebase/index.d.ts.map +1 -1
- package/dist/src/firebase/index.js +1 -0
- package/dist/src/firebase/index.js.map +1 -1
- package/dist/src/firebase/mapping-store.d.ts +1 -0
- package/dist/src/firebase/mapping-store.d.ts.map +1 -1
- package/dist/src/firebase/mapping-store.js +3 -0
- package/dist/src/firebase/mapping-store.js.map +1 -1
- package/dist/src/firebase/native-item-store.d.ts +8 -2
- package/dist/src/firebase/native-item-store.d.ts.map +1 -1
- package/dist/src/firebase/native-item-store.js +22 -6
- package/dist/src/firebase/native-item-store.js.map +1 -1
- package/dist/src/firebase/reference-store.d.ts +3 -0
- package/dist/src/firebase/reference-store.d.ts.map +1 -1
- package/dist/src/firebase/reference-store.js +16 -0
- package/dist/src/firebase/reference-store.js.map +1 -1
- package/dist/src/firebase/renderer-snippet-store.d.ts +3 -0
- package/dist/src/firebase/renderer-snippet-store.d.ts.map +1 -1
- package/dist/src/firebase/renderer-snippet-store.js +17 -0
- package/dist/src/firebase/renderer-snippet-store.js.map +1 -1
- package/dist/src/firebase/snapshot-store.d.ts +1 -0
- package/dist/src/firebase/snapshot-store.d.ts.map +1 -1
- package/dist/src/firebase/snapshot-store.js +8 -0
- package/dist/src/firebase/snapshot-store.js.map +1 -1
- package/dist/test/integration/backup-data-gcs.live.test.d.ts +2 -0
- package/dist/test/integration/backup-data-gcs.live.test.d.ts.map +1 -0
- package/dist/test/integration/backup-data-gcs.live.test.js +98 -0
- package/dist/test/integration/backup-data-gcs.live.test.js.map +1 -0
- package/dist/test/integration/firestore.emulator.test.js +25 -4
- package/dist/test/integration/firestore.emulator.test.js.map +1 -1
- package/dist/test/integration/record-history.live.test.d.ts +2 -0
- package/dist/test/integration/record-history.live.test.d.ts.map +1 -0
- package/dist/test/integration/record-history.live.test.js +141 -0
- package/dist/test/integration/record-history.live.test.js.map +1 -0
- package/dist/test/unit/native-catalog-merge.test.d.ts +2 -0
- package/dist/test/unit/native-catalog-merge.test.d.ts.map +1 -0
- package/dist/test/unit/native-catalog-merge.test.js +33 -0
- package/dist/test/unit/native-catalog-merge.test.js.map +1 -0
- package/dist/test/unit/native-scope.test.d.ts +2 -0
- package/dist/test/unit/native-scope.test.d.ts.map +1 -0
- package/dist/test/unit/native-scope.test.js +29 -0
- package/dist/test/unit/native-scope.test.js.map +1 -0
- package/dist/test/unit/record-history-path.test.d.ts +2 -0
- package/dist/test/unit/record-history-path.test.d.ts.map +1 -0
- package/dist/test/unit/record-history-path.test.js +24 -0
- package/dist/test/unit/record-history-path.test.js.map +1 -0
- package/firestore.indexes.json +39 -0
- 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,14 +21,14 @@ 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";
|
|
26
28
|
import { StoreAppBindingStore } from "../firebase/store-app-binding-store.js";
|
|
27
29
|
import { nativeItemsCollectionId } from "../firebase/catalog-data-paths.js";
|
|
28
30
|
import { runBackupData, pruneGcsBackupRuns } from "./backup-data.js";
|
|
29
|
-
import {
|
|
31
|
+
import { runUndoFirestoreRestore } from "./restore-firestore-backup.js";
|
|
30
32
|
import { runRestoreFirestoreBackupFromGcs, runDeleteCataloxGcsBackupRun } from "./restore-firestore-backup-from-gcs.js";
|
|
31
33
|
import { cataloxGcsBackupManifestToFirestoreExportManifest } from "./catalox-gcs-backup-export-manifest.js";
|
|
32
34
|
import { migrateNativeCatalogLayout as runMigrateNativeCatalogLayout, } from "../migrations/migrate-native-catalog-layout.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
|
-
|
|
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
|
|
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
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
}
|
|
383
|
-
|
|
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 } : {}) },
|
|
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 } : {}) },
|
|
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
|
-
|
|
429
|
-
|
|
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
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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.
|
|
636
|
-
throw new CatalogAccessDeniedError({ reason: "
|
|
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.
|
|
660
|
-
throw new CatalogAccessDeniedError({ reason: "
|
|
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;
|
|
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.
|
|
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
|
|
700
|
-
if (!context.
|
|
701
|
-
throw new CatalogAccessDeniedError({ reason: "
|
|
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
|
-
|
|
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
|
|
731
|
-
const
|
|
732
|
-
|
|
733
|
-
...
|
|
734
|
-
|
|
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:
|
|
933
|
+
itemId: nextRec.itemId,
|
|
741
934
|
catalogId: _catalogId,
|
|
742
935
|
appId: _context.appId,
|
|
743
936
|
sourceMode: "native",
|
|
744
937
|
sourceType: "firebase",
|
|
745
|
-
data:
|
|
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
|
-
|
|
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
|
|
762
|
-
|
|
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,
|
|
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((
|
|
797
|
-
const stripped = this.stripReservedWriteFields(
|
|
798
|
-
const
|
|
799
|
-
|
|
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);
|
|
@@ -1046,21 +1336,7 @@ export class Catalox {
|
|
|
1046
1336
|
bindings: this.deps.bindings,
|
|
1047
1337
|
}, input);
|
|
1048
1338
|
}
|
|
1049
|
-
/**
|
|
1050
|
-
* Restore Firestore live collections from Firebase-mode `backup-*` mirrors in the same database.
|
|
1051
|
-
* Always snapshots current live data into `{restoreSessionId}__preRestore-*` first so `undoFirestoreRestore` can revert.
|
|
1052
|
-
*/
|
|
1053
|
-
async restoreFirestoreBackup(_context, input) {
|
|
1054
|
-
return runRestoreFirestoreBackup({
|
|
1055
|
-
firestoreStore: this.deps.firestoreStore,
|
|
1056
|
-
catalogs: this.deps.catalogs,
|
|
1057
|
-
bindings: this.deps.bindings,
|
|
1058
|
-
nativeItems: this.deps.nativeItems,
|
|
1059
|
-
definitions: this.deps.definitions,
|
|
1060
|
-
catalogDataIndex: this.deps.catalogDataIndex,
|
|
1061
|
-
}, input);
|
|
1062
|
-
}
|
|
1063
|
-
/** Revert a prior `restoreFirestoreBackup` using the manifest and `restoreSessionId__preRestore-*` collections. */
|
|
1339
|
+
/** Revert a prior GCS restore using `backup-restoreSessions/{session}` and `{session}__preRestore-*` collections. */
|
|
1064
1340
|
async undoFirestoreRestore(_context, input) {
|
|
1065
1341
|
return runUndoFirestoreRestore({
|
|
1066
1342
|
firestoreStore: this.deps.firestoreStore,
|
|
@@ -1166,6 +1442,133 @@ export class Catalox {
|
|
|
1166
1442
|
firestore: this.deps.firestoreStore.firestore,
|
|
1167
1443
|
});
|
|
1168
1444
|
}
|
|
1445
|
+
async listCatalogItemHistory(context, catalogId, input) {
|
|
1446
|
+
await this.deps.authz.requireBindingAccess(context, context.appId, catalogId, "read");
|
|
1447
|
+
const rh = this.deps.recordHistory;
|
|
1448
|
+
if (!rh)
|
|
1449
|
+
return { events: [] };
|
|
1450
|
+
return rh.store.listByCatalog(catalogId, input);
|
|
1451
|
+
}
|
|
1452
|
+
async getCatalogItemHistoryEvent(context, eventId) {
|
|
1453
|
+
const rh = this.deps.recordHistory;
|
|
1454
|
+
if (!rh)
|
|
1455
|
+
return null;
|
|
1456
|
+
const row = await rh.store.get(eventId);
|
|
1457
|
+
if (!row)
|
|
1458
|
+
return null;
|
|
1459
|
+
await this.deps.authz.requireBindingAccess(context, context.appId, row.catalogId, "read");
|
|
1460
|
+
const payload = await readRecordHistoryEventPayload(rh, row);
|
|
1461
|
+
return { index: row, payload };
|
|
1462
|
+
}
|
|
1463
|
+
async restoreCatalogItemFromHistory(context, eventId, input) {
|
|
1464
|
+
const rh = this.deps.recordHistory;
|
|
1465
|
+
if (!rh)
|
|
1466
|
+
throw new Error("recordHistory is not configured on Catalox");
|
|
1467
|
+
const row = await rh.store.get(eventId);
|
|
1468
|
+
if (!row)
|
|
1469
|
+
throw new Error(`catalogItemHistory event not found: ${eventId}`);
|
|
1470
|
+
await this.deps.authz.requireBindingAccess(context, context.appId, row.catalogId, "write");
|
|
1471
|
+
const payload = await readRecordHistoryEventPayload(rh, row);
|
|
1472
|
+
const rec = input.mode === "before" ? payload.before ?? undefined : payload.after ?? undefined;
|
|
1473
|
+
if (!rec)
|
|
1474
|
+
throw new CatalogNotFoundError({ catalogId: row.catalogId, itemId: row.itemId });
|
|
1475
|
+
const docId = storageDocIdForNativeRecord(rec);
|
|
1476
|
+
const liveBefore = await this.deps.nativeItems.get(row.catalogId, docId);
|
|
1477
|
+
await this.deps.nativeItems.upsert(row.catalogId, rec);
|
|
1478
|
+
const liveAfter = (await this.deps.nativeItems.get(row.catalogId, docId));
|
|
1479
|
+
await this.emitNativeItemHistory(context, {
|
|
1480
|
+
catalogId: row.catalogId,
|
|
1481
|
+
itemId: String(rec.itemId),
|
|
1482
|
+
storageDocId: docId,
|
|
1483
|
+
op: "restore",
|
|
1484
|
+
before: liveBefore ?? null,
|
|
1485
|
+
after: liveAfter,
|
|
1486
|
+
});
|
|
1487
|
+
return this.decorateItem(row.catalogId, {
|
|
1488
|
+
itemId: rec.itemId,
|
|
1489
|
+
catalogId: row.catalogId,
|
|
1490
|
+
appId: context.appId,
|
|
1491
|
+
sourceMode: "native",
|
|
1492
|
+
sourceType: "firebase",
|
|
1493
|
+
data: liveAfter.data,
|
|
1494
|
+
createdAt: liveAfter.createdAt,
|
|
1495
|
+
updatedAt: liveAfter.updatedAt,
|
|
1496
|
+
});
|
|
1497
|
+
}
|
|
1498
|
+
async replayCatalogToPointInTime(context, catalogId, input) {
|
|
1499
|
+
await this.deps.authz.requireBindingAccess(context, context.appId, catalogId, "write");
|
|
1500
|
+
const rh = this.deps.recordHistory;
|
|
1501
|
+
if (!rh)
|
|
1502
|
+
throw new Error("recordHistory is not configured on Catalox");
|
|
1503
|
+
const rows = await rh.store.listByCatalogChronologicalUntil(catalogId, input.asOf);
|
|
1504
|
+
const state = new Map();
|
|
1505
|
+
for (const idx of rows) {
|
|
1506
|
+
const full = await readRecordHistoryEventPayload(rh, idx);
|
|
1507
|
+
const key = String(full.storageDocId ?? full.itemId);
|
|
1508
|
+
if (full.op === "catalog_delete_bulk") {
|
|
1509
|
+
state.clear();
|
|
1510
|
+
continue;
|
|
1511
|
+
}
|
|
1512
|
+
if (full.op === "catalog_rename")
|
|
1513
|
+
continue;
|
|
1514
|
+
if (full.op === "delete") {
|
|
1515
|
+
state.delete(key);
|
|
1516
|
+
continue;
|
|
1517
|
+
}
|
|
1518
|
+
if (full.after)
|
|
1519
|
+
state.set(key, full.after);
|
|
1520
|
+
else
|
|
1521
|
+
state.delete(key);
|
|
1522
|
+
}
|
|
1523
|
+
const live = [];
|
|
1524
|
+
let off = 0;
|
|
1525
|
+
const page = 400;
|
|
1526
|
+
while (true) {
|
|
1527
|
+
const chunk = await this.deps.nativeItems.list(catalogId, { limit: page, offset: off });
|
|
1528
|
+
if (!chunk.length)
|
|
1529
|
+
break;
|
|
1530
|
+
live.push(...chunk);
|
|
1531
|
+
off += chunk.length;
|
|
1532
|
+
if (chunk.length < page)
|
|
1533
|
+
break;
|
|
1534
|
+
}
|
|
1535
|
+
let upserted = 0;
|
|
1536
|
+
let deleted = 0;
|
|
1537
|
+
for (const rec of state.values()) {
|
|
1538
|
+
await this.deps.nativeItems.upsert(catalogId, rec);
|
|
1539
|
+
upserted += 1;
|
|
1540
|
+
}
|
|
1541
|
+
for (const r of live) {
|
|
1542
|
+
const k = storageDocIdForNativeRecord(r);
|
|
1543
|
+
if (!state.has(k)) {
|
|
1544
|
+
await this.deps.nativeItems.delete(catalogId, k);
|
|
1545
|
+
deleted += 1;
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
return { upserted, deleted };
|
|
1549
|
+
}
|
|
1550
|
+
async deleteCatalog(context, catalogId, input) {
|
|
1551
|
+
await this.deps.authz.requireBindingAccess(context, context.appId, catalogId, "admin");
|
|
1552
|
+
return runDeleteCatalog(this.catalogLifecycleDeps(), this.deps.recordHistory, context, catalogId, input);
|
|
1553
|
+
}
|
|
1554
|
+
async restoreDeletedCatalog(context, input) {
|
|
1555
|
+
if (!context.superAdmin) {
|
|
1556
|
+
throw new CatalogAccessDeniedError({ reason: "super_admin_required" });
|
|
1557
|
+
}
|
|
1558
|
+
const rh = this.deps.recordHistory;
|
|
1559
|
+
if (!rh) {
|
|
1560
|
+
return {
|
|
1561
|
+
ok: false,
|
|
1562
|
+
catalogId: "",
|
|
1563
|
+
issues: [{ code: "record_history_required", message: "recordHistory is not configured on Catalox" }],
|
|
1564
|
+
};
|
|
1565
|
+
}
|
|
1566
|
+
return runRestoreDeletedCatalog(this.catalogLifecycleDeps(), rh, input);
|
|
1567
|
+
}
|
|
1568
|
+
async renameCatalog(context, fromCatalogId, toCatalogId, input = {}) {
|
|
1569
|
+
await this.deps.authz.requireBindingAccess(context, context.appId, fromCatalogId, "admin");
|
|
1570
|
+
return runRenameCatalog(this.catalogLifecycleDeps(), this.deps.recordHistory, context, fromCatalogId, toCatalogId, input);
|
|
1571
|
+
}
|
|
1169
1572
|
/**
|
|
1170
1573
|
* Fix {@link CataloxContext} for subsequent calls so embedders omit it on each method (no globals).
|
|
1171
1574
|
*/
|