@voyant-travel/catalog 0.117.2
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/LICENSE +201 -0
- package/README.md +190 -0
- package/dist/adapter/booking-forwarding.d.ts +2 -0
- package/dist/adapter/booking-forwarding.d.ts.map +1 -0
- package/dist/adapter/booking-forwarding.js +1 -0
- package/dist/adapter/channel-push-contracts.d.ts +2 -0
- package/dist/adapter/channel-push-contracts.d.ts.map +1 -0
- package/dist/adapter/channel-push-contracts.js +1 -0
- package/dist/adapter/contract.d.ts +2 -0
- package/dist/adapter/contract.d.ts.map +1 -0
- package/dist/adapter/contract.js +1 -0
- package/dist/adapter/contract.test.d.ts +2 -0
- package/dist/adapter/contract.test.d.ts.map +1 -0
- package/dist/adapter/contract.test.js +390 -0
- package/dist/adapter/provider-contracts.d.ts +2 -0
- package/dist/adapter/provider-contracts.d.ts.map +1 -0
- package/dist/adapter/provider-contracts.js +1 -0
- package/dist/adapter/provider-contracts.test.d.ts +2 -0
- package/dist/adapter/provider-contracts.test.d.ts.map +1 -0
- package/dist/adapter/provider-contracts.test.js +206 -0
- package/dist/adapter/schemas.d.ts +2 -0
- package/dist/adapter/schemas.d.ts.map +1 -0
- package/dist/adapter/schemas.js +1 -0
- package/dist/adapter/schemas.test.d.ts +2 -0
- package/dist/adapter/schemas.test.d.ts.map +1 -0
- package/dist/adapter/schemas.test.js +344 -0
- package/dist/booking-engine/book.d.ts +124 -0
- package/dist/booking-engine/book.d.ts.map +1 -0
- package/dist/booking-engine/book.js +311 -0
- package/dist/booking-engine/cancel.d.ts +40 -0
- package/dist/booking-engine/cancel.d.ts.map +1 -0
- package/dist/booking-engine/cancel.js +56 -0
- package/dist/booking-engine/checkout-finalize.d.ts +146 -0
- package/dist/booking-engine/checkout-finalize.d.ts.map +1 -0
- package/dist/booking-engine/checkout-finalize.js +132 -0
- package/dist/booking-engine/contracts.d.ts +9 -0
- package/dist/booking-engine/contracts.d.ts.map +1 -0
- package/dist/booking-engine/contracts.js +8 -0
- package/dist/booking-engine/contracts.test.d.ts +2 -0
- package/dist/booking-engine/contracts.test.d.ts.map +1 -0
- package/dist/booking-engine/contracts.test.js +116 -0
- package/dist/booking-engine/draft-shape.d.ts +10 -0
- package/dist/booking-engine/draft-shape.d.ts.map +1 -0
- package/dist/booking-engine/draft-shape.js +9 -0
- package/dist/booking-engine/draft-shape.test.d.ts +2 -0
- package/dist/booking-engine/draft-shape.test.d.ts.map +1 -0
- package/dist/booking-engine/draft-shape.test.js +74 -0
- package/dist/booking-engine/drafts-schema.d.ts +302 -0
- package/dist/booking-engine/drafts-schema.d.ts.map +1 -0
- package/dist/booking-engine/drafts-schema.js +53 -0
- package/dist/booking-engine/drafts-service.d.ts +41 -0
- package/dist/booking-engine/drafts-service.d.ts.map +1 -0
- package/dist/booking-engine/drafts-service.js +108 -0
- package/dist/booking-engine/errors.d.ts +81 -0
- package/dist/booking-engine/errors.d.ts.map +1 -0
- package/dist/booking-engine/errors.js +113 -0
- package/dist/booking-engine/index.d.ts +36 -0
- package/dist/booking-engine/index.d.ts.map +1 -0
- package/dist/booking-engine/index.js +34 -0
- package/dist/booking-engine/orders.d.ts +41 -0
- package/dist/booking-engine/orders.d.ts.map +1 -0
- package/dist/booking-engine/orders.js +49 -0
- package/dist/booking-engine/owned-handler.d.ts +166 -0
- package/dist/booking-engine/owned-handler.d.ts.map +1 -0
- package/dist/booking-engine/owned-handler.js +50 -0
- package/dist/booking-engine/owned-handler.test.d.ts +2 -0
- package/dist/booking-engine/owned-handler.test.d.ts.map +1 -0
- package/dist/booking-engine/owned-handler.test.js +63 -0
- package/dist/booking-engine/promotions-contract.d.ts +8 -0
- package/dist/booking-engine/promotions-contract.d.ts.map +1 -0
- package/dist/booking-engine/promotions-contract.js +7 -0
- package/dist/booking-engine/quote-enricher.test.d.ts +12 -0
- package/dist/booking-engine/quote-enricher.test.d.ts.map +1 -0
- package/dist/booking-engine/quote-enricher.test.js +138 -0
- package/dist/booking-engine/quote.d.ts +163 -0
- package/dist/booking-engine/quote.d.ts.map +1 -0
- package/dist/booking-engine/quote.js +259 -0
- package/dist/booking-engine/registry.d.ts +85 -0
- package/dist/booking-engine/registry.d.ts.map +1 -0
- package/dist/booking-engine/registry.js +118 -0
- package/dist/booking-engine/registry.test.d.ts +2 -0
- package/dist/booking-engine/registry.test.d.ts.map +1 -0
- package/dist/booking-engine/registry.test.js +132 -0
- package/dist/booking-engine/routes-contracts.d.ts +169 -0
- package/dist/booking-engine/routes-contracts.d.ts.map +1 -0
- package/dist/booking-engine/routes-contracts.js +63 -0
- package/dist/booking-engine/routes.d.ts +7 -0
- package/dist/booking-engine/routes.d.ts.map +1 -0
- package/dist/booking-engine/routes.js +443 -0
- package/dist/booking-engine/routes.test.d.ts +2 -0
- package/dist/booking-engine/routes.test.d.ts.map +1 -0
- package/dist/booking-engine/routes.test.js +304 -0
- package/dist/booking-engine/schema.d.ts +455 -0
- package/dist/booking-engine/schema.d.ts.map +1 -0
- package/dist/booking-engine/schema.js +75 -0
- package/dist/booking-engine/snapshot-content.d.ts +120 -0
- package/dist/booking-engine/snapshot-content.d.ts.map +1 -0
- package/dist/booking-engine/snapshot-content.js +110 -0
- package/dist/booking-engine/snapshot-content.test.d.ts +2 -0
- package/dist/booking-engine/snapshot-content.test.d.ts.map +1 -0
- package/dist/booking-engine/snapshot-content.test.js +213 -0
- package/dist/booking-engine/sync.d.ts +136 -0
- package/dist/booking-engine/sync.d.ts.map +1 -0
- package/dist/booking-engine/sync.js +177 -0
- package/dist/booking-engine/sync.test.d.ts +2 -0
- package/dist/booking-engine/sync.test.d.ts.map +1 -0
- package/dist/booking-engine/sync.test.js +377 -0
- package/dist/contract.d.ts +2 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +1 -0
- package/dist/contract.test.d.ts +2 -0
- package/dist/contract.test.d.ts.map +1 -0
- package/dist/contract.test.js +107 -0
- package/dist/drift/events.d.ts +2 -0
- package/dist/drift/events.d.ts.map +1 -0
- package/dist/drift/events.js +1 -0
- package/dist/drift/events.test.d.ts +2 -0
- package/dist/drift/events.test.d.ts.map +1 -0
- package/dist/drift/events.test.js +100 -0
- package/dist/embeddings/contract.d.ts +85 -0
- package/dist/embeddings/contract.d.ts.map +1 -0
- package/dist/embeddings/contract.js +42 -0
- package/dist/embeddings/contract.test.d.ts +2 -0
- package/dist/embeddings/contract.test.d.ts.map +1 -0
- package/dist/embeddings/contract.test.js +30 -0
- package/dist/embeddings/gemini.d.ts +110 -0
- package/dist/embeddings/gemini.d.ts.map +1 -0
- package/dist/embeddings/gemini.js +118 -0
- package/dist/embeddings/gemini.test.d.ts +2 -0
- package/dist/embeddings/gemini.test.d.ts.map +1 -0
- package/dist/embeddings/gemini.test.js +132 -0
- package/dist/embeddings/model-registry.d.ts +62 -0
- package/dist/embeddings/model-registry.d.ts.map +1 -0
- package/dist/embeddings/model-registry.js +78 -0
- package/dist/embeddings/model-registry.test.d.ts +2 -0
- package/dist/embeddings/model-registry.test.d.ts.map +1 -0
- package/dist/embeddings/model-registry.test.js +81 -0
- package/dist/embeddings/openai.d.ts +81 -0
- package/dist/embeddings/openai.d.ts.map +1 -0
- package/dist/embeddings/openai.js +123 -0
- package/dist/embeddings/openai.test.d.ts +2 -0
- package/dist/embeddings/openai.test.d.ts.map +1 -0
- package/dist/embeddings/openai.test.js +164 -0
- package/dist/events/taxonomy.d.ts +158 -0
- package/dist/events/taxonomy.d.ts.map +1 -0
- package/dist/events/taxonomy.js +99 -0
- package/dist/events/taxonomy.test.d.ts +2 -0
- package/dist/events/taxonomy.test.d.ts.map +1 -0
- package/dist/events/taxonomy.test.js +48 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +39 -0
- package/dist/indexer/contract.d.ts +203 -0
- package/dist/indexer/contract.d.ts.map +1 -0
- package/dist/indexer/contract.js +16 -0
- package/dist/indexer/typesense-search-query.d.ts +31 -0
- package/dist/indexer/typesense-search-query.d.ts.map +1 -0
- package/dist/indexer/typesense-search-query.js +185 -0
- package/dist/indexer/typesense.d.ts +105 -0
- package/dist/indexer/typesense.d.ts.map +1 -0
- package/dist/indexer/typesense.js +394 -0
- package/dist/indexer/typesense.test.d.ts +2 -0
- package/dist/indexer/typesense.test.d.ts.map +1 -0
- package/dist/indexer/typesense.test.js +253 -0
- package/dist/overlay/resolver.d.ts +101 -0
- package/dist/overlay/resolver.d.ts.map +1 -0
- package/dist/overlay/resolver.js +167 -0
- package/dist/overlay/resolver.test.d.ts +2 -0
- package/dist/overlay/resolver.test.d.ts.map +1 -0
- package/dist/overlay/resolver.test.js +179 -0
- package/dist/overlay/schema.d.ts +266 -0
- package/dist/overlay/schema.d.ts.map +1 -0
- package/dist/overlay/schema.js +71 -0
- package/dist/provenance.d.ts +2 -0
- package/dist/provenance.d.ts.map +1 -0
- package/dist/provenance.js +1 -0
- package/dist/schema-sourced-entries.d.ts +344 -0
- package/dist/schema-sourced-entries.d.ts.map +1 -0
- package/dist/schema-sourced-entries.js +75 -0
- package/dist/schema.d.ts +21 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +20 -0
- package/dist/search/federate.d.ts +58 -0
- package/dist/search/federate.d.ts.map +1 -0
- package/dist/search/federate.js +103 -0
- package/dist/search/federate.test.d.ts +2 -0
- package/dist/search/federate.test.d.ts.map +1 -0
- package/dist/search/federate.test.js +146 -0
- package/dist/search/rerank.d.ts +77 -0
- package/dist/search/rerank.d.ts.map +1 -0
- package/dist/search/rerank.js +68 -0
- package/dist/search/rerank.test.d.ts +2 -0
- package/dist/search/rerank.test.d.ts.map +1 -0
- package/dist/search/rerank.test.js +60 -0
- package/dist/search/routes.d.ts +144 -0
- package/dist/search/routes.d.ts.map +1 -0
- package/dist/search/routes.js +288 -0
- package/dist/search/routes.test.d.ts +2 -0
- package/dist/search/routes.test.d.ts.map +1 -0
- package/dist/search/routes.test.js +322 -0
- package/dist/search/semantic.d.ts +63 -0
- package/dist/search/semantic.d.ts.map +1 -0
- package/dist/search/semantic.js +75 -0
- package/dist/search/semantic.test.d.ts +2 -0
- package/dist/search/semantic.test.d.ts.map +1 -0
- package/dist/search/semantic.test.js +143 -0
- package/dist/services/build-indexer-document.test.d.ts +2 -0
- package/dist/services/build-indexer-document.test.d.ts.map +1 -0
- package/dist/services/build-indexer-document.test.js +102 -0
- package/dist/services/content-service.d.ts +125 -0
- package/dist/services/content-service.d.ts.map +1 -0
- package/dist/services/content-service.js +139 -0
- package/dist/services/content-service.test.d.ts +2 -0
- package/dist/services/content-service.test.d.ts.map +1 -0
- package/dist/services/content-service.test.js +322 -0
- package/dist/services/indexer-service.d.ts +109 -0
- package/dist/services/indexer-service.d.ts.map +1 -0
- package/dist/services/indexer-service.js +123 -0
- package/dist/services/indexer-service.test.d.ts +2 -0
- package/dist/services/indexer-service.test.d.ts.map +1 -0
- package/dist/services/indexer-service.test.js +176 -0
- package/dist/services/overlay-service.d.ts +108 -0
- package/dist/services/overlay-service.d.ts.map +1 -0
- package/dist/services/overlay-service.js +211 -0
- package/dist/services/overlay-service.test.d.ts +2 -0
- package/dist/services/overlay-service.test.d.ts.map +1 -0
- package/dist/services/overlay-service.test.js +79 -0
- package/dist/services/snapshot-builder.test.d.ts +2 -0
- package/dist/services/snapshot-builder.test.d.ts.map +1 -0
- package/dist/services/snapshot-builder.test.js +93 -0
- package/dist/services/snapshot-service.d.ts +78 -0
- package/dist/services/snapshot-service.d.ts.map +1 -0
- package/dist/services/snapshot-service.js +165 -0
- package/dist/services/sourced-entry-service.d.ts +142 -0
- package/dist/services/sourced-entry-service.d.ts.map +1 -0
- package/dist/services/sourced-entry-service.js +203 -0
- package/dist/services/sourced-entry-service.test.d.ts +10 -0
- package/dist/services/sourced-entry-service.test.d.ts.map +1 -0
- package/dist/services/sourced-entry-service.test.js +66 -0
- package/dist/snapshot/schema.d.ts +362 -0
- package/dist/snapshot/schema.d.ts.map +1 -0
- package/dist/snapshot/schema.js +102 -0
- package/package.json +210 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snapshot content capture per sourced-content §5.1.
|
|
3
|
+
*
|
|
4
|
+
* The booking engine calls a `SnapshotContentCapturer` at commit time
|
|
5
|
+
* to refresh content from the adapter and embed the result in the
|
|
6
|
+
* snapshot's `frozen_payload.content_capture` envelope. The
|
|
7
|
+
* capturer's contract is "refresh-with-fallback":
|
|
8
|
+
*
|
|
9
|
+
* 1. Try to fetch fresh content from the adapter (and write through
|
|
10
|
+
* to the per-vertical content cache so the next read sees the
|
|
11
|
+
* fresh row).
|
|
12
|
+
* 2. On adapter error (network blip, rate limit, transient outage),
|
|
13
|
+
* fall back to the cache — including stale rows.
|
|
14
|
+
* 3. If neither is available, throw `SnapshotContentUnavailableError`.
|
|
15
|
+
* The booking engine propagates this and aborts the commit. We
|
|
16
|
+
* deliberately do NOT snapshot from the indexed projection —
|
|
17
|
+
* refunds and audit need real "what was sold" content, not a
|
|
18
|
+
* stub.
|
|
19
|
+
*
|
|
20
|
+
* The orchestrator stays in the booking-engine sub-package because the
|
|
21
|
+
* envelope shape, error types, and integration point all live with
|
|
22
|
+
* `bookEntity`. Per-vertical content services (products, cruises,
|
|
23
|
+
* etc.) implement `ContentSnapshotAdapter` and the template wires them
|
|
24
|
+
* into a single capturer via `composeSnapshotContentCapturer`.
|
|
25
|
+
*
|
|
26
|
+
* See `docs/architecture/catalog-sourced-content.md` §5.1.
|
|
27
|
+
*/
|
|
28
|
+
import { SnapshotContentUnavailableError } from "./errors.js";
|
|
29
|
+
/**
|
|
30
|
+
* Compose a `SnapshotContentCapturer` from a registry of per-vertical
|
|
31
|
+
* `ContentSnapshotAdapter`s and an adapter registry. Use when the
|
|
32
|
+
* template wants the §5.1 refresh-with-fallback behavior wired
|
|
33
|
+
* uniformly across verticals.
|
|
34
|
+
*
|
|
35
|
+
* Returns null when:
|
|
36
|
+
* - The entity_module isn't in the per-vertical adapter map
|
|
37
|
+
* (assumed owned or out-of-scope; engine skips the envelope).
|
|
38
|
+
* - The source adapter for `source_kind` doesn't implement
|
|
39
|
+
* `getContent` AND no cache row exists. (When `getContent` is
|
|
40
|
+
* missing but a cache row exists, we serve the cache row — the
|
|
41
|
+
* vertical's synthesizer is for read paths, not write paths.)
|
|
42
|
+
*
|
|
43
|
+
* Throws `SnapshotContentUnavailableError` when neither fresh nor
|
|
44
|
+
* cached content can be produced.
|
|
45
|
+
*/
|
|
46
|
+
export function composeSnapshotContentCapturer(options) {
|
|
47
|
+
return async function captureSnapshotContent(input) {
|
|
48
|
+
const verticalAdapter = options.contentAdapters.get(input.entity_module);
|
|
49
|
+
if (!verticalAdapter) {
|
|
50
|
+
// No content service for this vertical → owned or out-of-scope.
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
const sourceAdapter = input.source_connection_id
|
|
54
|
+
? (options.registry.resolveByConnection(input.source_connection_id) ??
|
|
55
|
+
options.registry.byKind(input.source_kind)[0]?.adapter)
|
|
56
|
+
: options.registry.byKind(input.source_kind)[0]?.adapter;
|
|
57
|
+
if (!sourceAdapter) {
|
|
58
|
+
// No source adapter registered. Try cache fallback first; if
|
|
59
|
+
// even that fails, signal unavailable so the engine can abort.
|
|
60
|
+
return readCachedOrThrow(verticalAdapter, input, "no source adapter registered");
|
|
61
|
+
}
|
|
62
|
+
const request = {
|
|
63
|
+
entity_module: input.entity_module,
|
|
64
|
+
entity_id: input.entity_id,
|
|
65
|
+
locale: input.locale,
|
|
66
|
+
market: input.market,
|
|
67
|
+
currency: input.currency,
|
|
68
|
+
};
|
|
69
|
+
if (sourceAdapter.getContent) {
|
|
70
|
+
try {
|
|
71
|
+
const fresh = await verticalAdapter.refresh(input.db, sourceAdapter, input.adapterContext, request);
|
|
72
|
+
return {
|
|
73
|
+
source: "fresh",
|
|
74
|
+
fetched_at: new Date(),
|
|
75
|
+
content_etag: fresh.etag,
|
|
76
|
+
content_schema_version: fresh.content_schema_version,
|
|
77
|
+
content: fresh.content,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
82
|
+
return readCachedOrThrow(verticalAdapter, input, reason);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// Adapter is thin (no getContent). Cache-or-throw — synthesizer
|
|
86
|
+
// is read-time only, not snapshot-time.
|
|
87
|
+
return readCachedOrThrow(verticalAdapter, input, "adapter does not implement getContent");
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
async function readCachedOrThrow(verticalAdapter, input, fallbackReason) {
|
|
91
|
+
const request = {
|
|
92
|
+
entity_module: input.entity_module,
|
|
93
|
+
entity_id: input.entity_id,
|
|
94
|
+
locale: input.locale,
|
|
95
|
+
market: input.market,
|
|
96
|
+
currency: input.currency,
|
|
97
|
+
};
|
|
98
|
+
const cached = await verticalAdapter.readCached(input.db, request);
|
|
99
|
+
if (!cached) {
|
|
100
|
+
throw new SnapshotContentUnavailableError(input.entity_module, input.entity_id, fallbackReason);
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
source: "cache_fallback",
|
|
104
|
+
fetched_at: cached.fetched_at,
|
|
105
|
+
fallback_reason: fallbackReason,
|
|
106
|
+
content_etag: cached.etag ?? undefined,
|
|
107
|
+
content_schema_version: cached.content_schema_version,
|
|
108
|
+
content: cached.payload,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"snapshot-content.test.d.ts","sourceRoot":"","sources":["../../src/booking-engine/snapshot-content.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { SnapshotContentUnavailableError } from "./errors.js";
|
|
3
|
+
import { createSourceAdapterRegistry } from "./registry.js";
|
|
4
|
+
import { composeSnapshotContentCapturer, } from "./snapshot-content.js";
|
|
5
|
+
function fakeDb() {
|
|
6
|
+
return {};
|
|
7
|
+
}
|
|
8
|
+
function makeRichAdapter(getContent) {
|
|
9
|
+
return {
|
|
10
|
+
kind: "rich",
|
|
11
|
+
capabilities: {
|
|
12
|
+
verticals: ["products"],
|
|
13
|
+
supportsLiveResolution: false,
|
|
14
|
+
supportsDriftDetection: false,
|
|
15
|
+
supportsBookingForwarding: false,
|
|
16
|
+
postBookOperations: [],
|
|
17
|
+
supportsContentFetch: true,
|
|
18
|
+
},
|
|
19
|
+
connect: async () => undefined,
|
|
20
|
+
pause: async () => undefined,
|
|
21
|
+
disconnect: async () => undefined,
|
|
22
|
+
getState: async () => "active",
|
|
23
|
+
discover: async () => ({ projections: [], next_cursor: undefined }),
|
|
24
|
+
getContent,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function makeThinAdapter() {
|
|
28
|
+
return {
|
|
29
|
+
kind: "thin",
|
|
30
|
+
capabilities: {
|
|
31
|
+
verticals: ["products"],
|
|
32
|
+
supportsLiveResolution: false,
|
|
33
|
+
supportsDriftDetection: false,
|
|
34
|
+
supportsBookingForwarding: false,
|
|
35
|
+
postBookOperations: [],
|
|
36
|
+
supportsContentFetch: false,
|
|
37
|
+
},
|
|
38
|
+
connect: async () => undefined,
|
|
39
|
+
pause: async () => undefined,
|
|
40
|
+
disconnect: async () => undefined,
|
|
41
|
+
getState: async () => "active",
|
|
42
|
+
discover: async () => ({ projections: [], next_cursor: undefined }),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const baseInput = {
|
|
46
|
+
db: fakeDb(),
|
|
47
|
+
entity_module: "products",
|
|
48
|
+
entity_id: "prod_abc",
|
|
49
|
+
source_kind: "rich",
|
|
50
|
+
source_connection_id: "conn_1",
|
|
51
|
+
source_ref: "REF-1",
|
|
52
|
+
locale: "en-GB",
|
|
53
|
+
market: "GB",
|
|
54
|
+
currency: "GBP",
|
|
55
|
+
adapterContext: { connection_id: "conn_1" },
|
|
56
|
+
};
|
|
57
|
+
describe("composeSnapshotContentCapturer — refresh-with-fallback per §5.1", () => {
|
|
58
|
+
it("returns null when the entity_module isn't in the per-vertical map (owned / out-of-scope)", async () => {
|
|
59
|
+
const registry = createSourceAdapterRegistry();
|
|
60
|
+
const capturer = composeSnapshotContentCapturer({
|
|
61
|
+
registry,
|
|
62
|
+
contentAdapters: new Map(),
|
|
63
|
+
});
|
|
64
|
+
const result = await capturer({ ...baseInput, entity_module: "owned-vertical" });
|
|
65
|
+
expect(result).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
it("returns 'fresh' when the adapter call succeeds — writes through to cache", async () => {
|
|
68
|
+
const fresh = {
|
|
69
|
+
entity_module: "products",
|
|
70
|
+
entity_id: "prod_abc",
|
|
71
|
+
source_ref: "REF-1",
|
|
72
|
+
returned_locale: "en-GB",
|
|
73
|
+
content: { product: { id: "prod_abc", name: "Sample" } },
|
|
74
|
+
content_schema_version: "products/v1",
|
|
75
|
+
etag: 'W/"abc"',
|
|
76
|
+
};
|
|
77
|
+
const refresh = vi.fn(async () => fresh);
|
|
78
|
+
const verticalAdapter = {
|
|
79
|
+
refresh,
|
|
80
|
+
readCached: async () => null,
|
|
81
|
+
};
|
|
82
|
+
const registry = createSourceAdapterRegistry();
|
|
83
|
+
registry.register(makeRichAdapter(async () => fresh));
|
|
84
|
+
const capturer = composeSnapshotContentCapturer({
|
|
85
|
+
registry,
|
|
86
|
+
contentAdapters: new Map([["products", verticalAdapter]]),
|
|
87
|
+
});
|
|
88
|
+
const result = await capturer(baseInput);
|
|
89
|
+
expect(result).not.toBeNull();
|
|
90
|
+
expect(result?.source).toBe("fresh");
|
|
91
|
+
expect(result?.content_schema_version).toBe("products/v1");
|
|
92
|
+
expect(result?.content_etag).toBe('W/"abc"');
|
|
93
|
+
expect(result?.content).toEqual({ product: { id: "prod_abc", name: "Sample" } });
|
|
94
|
+
expect(refresh).toHaveBeenCalledTimes(1);
|
|
95
|
+
});
|
|
96
|
+
it("falls back to cache when adapter.getContent throws", async () => {
|
|
97
|
+
const cachedFetchedAt = new Date("2026-04-01T12:00:00Z");
|
|
98
|
+
const verticalAdapter = {
|
|
99
|
+
refresh: vi.fn(async () => {
|
|
100
|
+
throw new Error("upstream rate-limit");
|
|
101
|
+
}),
|
|
102
|
+
readCached: vi.fn(async () => ({
|
|
103
|
+
payload: { product: { id: "prod_abc", name: "Sample (cached)" } },
|
|
104
|
+
content_schema_version: "products/v1",
|
|
105
|
+
fetched_at: cachedFetchedAt,
|
|
106
|
+
etag: 'W/"old"',
|
|
107
|
+
})),
|
|
108
|
+
};
|
|
109
|
+
const registry = createSourceAdapterRegistry();
|
|
110
|
+
registry.register(makeRichAdapter(async () => {
|
|
111
|
+
throw new Error("upstream rate-limit");
|
|
112
|
+
}));
|
|
113
|
+
const capturer = composeSnapshotContentCapturer({
|
|
114
|
+
registry,
|
|
115
|
+
contentAdapters: new Map([["products", verticalAdapter]]),
|
|
116
|
+
});
|
|
117
|
+
const result = await capturer(baseInput);
|
|
118
|
+
expect(result?.source).toBe("cache_fallback");
|
|
119
|
+
expect(result?.fallback_reason).toBe("upstream rate-limit");
|
|
120
|
+
expect(result?.fetched_at).toEqual(cachedFetchedAt);
|
|
121
|
+
expect(result?.content).toEqual({ product: { id: "prod_abc", name: "Sample (cached)" } });
|
|
122
|
+
});
|
|
123
|
+
it("throws SnapshotContentUnavailableError when neither fresh nor cached produces content", async () => {
|
|
124
|
+
const verticalAdapter = {
|
|
125
|
+
refresh: async () => {
|
|
126
|
+
throw new Error("upstream down");
|
|
127
|
+
},
|
|
128
|
+
readCached: async () => null,
|
|
129
|
+
};
|
|
130
|
+
const registry = createSourceAdapterRegistry();
|
|
131
|
+
registry.register(makeRichAdapter(async () => {
|
|
132
|
+
throw new Error("upstream down");
|
|
133
|
+
}));
|
|
134
|
+
const capturer = composeSnapshotContentCapturer({
|
|
135
|
+
registry,
|
|
136
|
+
contentAdapters: new Map([["products", verticalAdapter]]),
|
|
137
|
+
});
|
|
138
|
+
await expect(capturer(baseInput)).rejects.toBeInstanceOf(SnapshotContentUnavailableError);
|
|
139
|
+
});
|
|
140
|
+
it("returns cache-fallback when adapter is thin and a cache row exists", async () => {
|
|
141
|
+
const cachedFetchedAt = new Date("2026-03-01T00:00:00Z");
|
|
142
|
+
const verticalAdapter = {
|
|
143
|
+
refresh: async () => {
|
|
144
|
+
throw new Error("should not be called for thin adapter");
|
|
145
|
+
},
|
|
146
|
+
readCached: async () => ({
|
|
147
|
+
payload: { product: { id: "prod_abc", name: "Sample" } },
|
|
148
|
+
content_schema_version: "products/v1",
|
|
149
|
+
fetched_at: cachedFetchedAt,
|
|
150
|
+
etag: null,
|
|
151
|
+
}),
|
|
152
|
+
};
|
|
153
|
+
const registry = createSourceAdapterRegistry();
|
|
154
|
+
registry.register(makeThinAdapter());
|
|
155
|
+
const capturer = composeSnapshotContentCapturer({
|
|
156
|
+
registry,
|
|
157
|
+
contentAdapters: new Map([["products", verticalAdapter]]),
|
|
158
|
+
});
|
|
159
|
+
const result = await capturer({ ...baseInput, source_kind: "thin" });
|
|
160
|
+
expect(result?.source).toBe("cache_fallback");
|
|
161
|
+
expect(result?.fallback_reason).toContain("does not implement getContent");
|
|
162
|
+
});
|
|
163
|
+
it("throws when adapter is thin and no cache row exists", async () => {
|
|
164
|
+
const verticalAdapter = {
|
|
165
|
+
refresh: async () => {
|
|
166
|
+
throw new Error("never");
|
|
167
|
+
},
|
|
168
|
+
readCached: async () => null,
|
|
169
|
+
};
|
|
170
|
+
const registry = createSourceAdapterRegistry();
|
|
171
|
+
registry.register(makeThinAdapter());
|
|
172
|
+
const capturer = composeSnapshotContentCapturer({
|
|
173
|
+
registry,
|
|
174
|
+
contentAdapters: new Map([["products", verticalAdapter]]),
|
|
175
|
+
});
|
|
176
|
+
await expect(capturer({ ...baseInput, source_kind: "thin" })).rejects.toBeInstanceOf(SnapshotContentUnavailableError);
|
|
177
|
+
});
|
|
178
|
+
it("threads the request scope (locale/market/currency) into refresh + readCached", async () => {
|
|
179
|
+
const seenRequests = [];
|
|
180
|
+
const verticalAdapter = {
|
|
181
|
+
refresh: async (_db, _adapter, _ctx, request) => {
|
|
182
|
+
seenRequests.push(request);
|
|
183
|
+
return {
|
|
184
|
+
entity_module: request.entity_module,
|
|
185
|
+
entity_id: request.entity_id,
|
|
186
|
+
source_ref: "REF-1",
|
|
187
|
+
returned_locale: request.locale,
|
|
188
|
+
content: {},
|
|
189
|
+
content_schema_version: "products/v1",
|
|
190
|
+
};
|
|
191
|
+
},
|
|
192
|
+
readCached: async () => null,
|
|
193
|
+
};
|
|
194
|
+
const registry = createSourceAdapterRegistry();
|
|
195
|
+
registry.register(makeRichAdapter(async () => ({
|
|
196
|
+
entity_module: "products",
|
|
197
|
+
entity_id: "prod_abc",
|
|
198
|
+
source_ref: "REF-1",
|
|
199
|
+
returned_locale: "ro-RO",
|
|
200
|
+
content: {},
|
|
201
|
+
content_schema_version: "products/v1",
|
|
202
|
+
})));
|
|
203
|
+
const capturer = composeSnapshotContentCapturer({
|
|
204
|
+
registry,
|
|
205
|
+
contentAdapters: new Map([["products", verticalAdapter]]),
|
|
206
|
+
});
|
|
207
|
+
await capturer({ ...baseInput, locale: "ro-RO", market: "RO", currency: "RON" });
|
|
208
|
+
expect(seenRequests).toHaveLength(1);
|
|
209
|
+
expect(seenRequests[0]?.locale).toBe("ro-RO");
|
|
210
|
+
expect(seenRequests[0]?.market).toBe("RO");
|
|
211
|
+
expect(seenRequests[0]?.currency).toBe("RON");
|
|
212
|
+
});
|
|
213
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source discovery sync — pulls projections from every registered
|
|
3
|
+
* `SourceAdapter` and pushes them into the deployment's indexer so
|
|
4
|
+
* sourced inventory shows up in the catalog UI alongside owned rows.
|
|
5
|
+
*
|
|
6
|
+
* Mirrors the bulk reindex flow (`scripts/reindex.ts` in operator
|
|
7
|
+
* templates) for owned rows, except the data comes from
|
|
8
|
+
* `adapter.discover()` instead of a Drizzle table scan.
|
|
9
|
+
*
|
|
10
|
+
* The orchestrator is pure: it takes the registry, the indexer service,
|
|
11
|
+
* and the per-vertical field-policy registries, and returns a summary
|
|
12
|
+
* of what was synced. Templates wire env / Typesense / embeddings; this
|
|
13
|
+
* function only touches what was passed in.
|
|
14
|
+
*
|
|
15
|
+
* Usage pattern (in a template script):
|
|
16
|
+
*
|
|
17
|
+
* const summary = await syncSources({
|
|
18
|
+
* registry,
|
|
19
|
+
* indexerService,
|
|
20
|
+
* fieldPolicyRegistries,
|
|
21
|
+
* })
|
|
22
|
+
*
|
|
23
|
+
* Drift events, scheduled re-runs, and concurrency limits are deferred
|
|
24
|
+
* to the catalog plane's normal drift pipeline (foundation §5.5);
|
|
25
|
+
* `syncSources` is a one-shot bulk pass driven by a CLI or cron job.
|
|
26
|
+
*/
|
|
27
|
+
import type { AnyDrizzleDb } from "@voyant-travel/db";
|
|
28
|
+
import type { SourceAdapter, SourceAdapterContext } from "../adapter/contract.js";
|
|
29
|
+
import type { FieldPolicyRegistry } from "../contract.js";
|
|
30
|
+
import type { DocumentBuilder, IndexerService } from "../services/indexer-service.js";
|
|
31
|
+
import type { SourceAdapterRegistry } from "./registry.js";
|
|
32
|
+
export interface SyncSourcesOptions {
|
|
33
|
+
/** Booking-engine registry — every registered adapter's `discover` is fanned out. */
|
|
34
|
+
registry: SourceAdapterRegistry;
|
|
35
|
+
/** Indexer the projections land in. Caller passes the same instance the live route uses. */
|
|
36
|
+
indexerService: IndexerService;
|
|
37
|
+
/**
|
|
38
|
+
* Per-vertical field-policy registries keyed by `entity_module`.
|
|
39
|
+
* Same shape the indexer service is built with; passed separately so
|
|
40
|
+
* the sync can build documents from projections without re-reading
|
|
41
|
+
* the registry from the indexer service.
|
|
42
|
+
*/
|
|
43
|
+
fieldPolicyRegistries: ReadonlyMap<string, FieldPolicyRegistry>;
|
|
44
|
+
/**
|
|
45
|
+
* Drizzle DB handle. When set, sync upserts a row into
|
|
46
|
+
* `catalog_sourced_entries` for every sourced projection (the durable
|
|
47
|
+
* provenance + projection-capture store, sourced-content §2.5). The
|
|
48
|
+
* upsert is idempotent on `(entity_module, entity_id)` and runs before
|
|
49
|
+
* the indexer write. Owned projections are skipped — they live in the
|
|
50
|
+
* vertical's owned schema, not the sourced-entry store.
|
|
51
|
+
*
|
|
52
|
+
* Optional only because pure-indexer test setups (no DB) want to use
|
|
53
|
+
* `syncSources` for indexer-shape coverage. Production wiring always
|
|
54
|
+
* passes a DB.
|
|
55
|
+
*/
|
|
56
|
+
db?: AnyDrizzleDb;
|
|
57
|
+
/**
|
|
58
|
+
* Optional adapter context override. Most adapters need at minimum
|
|
59
|
+
* a `connection_id`; the demo plugin doesn't care so the default
|
|
60
|
+
* `{ connection_id: adapter.kind }` is sufficient. Templates with
|
|
61
|
+
* real connections pass their connection id (or build a per-adapter
|
|
62
|
+
* map keyed by `kind`).
|
|
63
|
+
*/
|
|
64
|
+
buildAdapterContext?: (adapter: SourceAdapter) => SourceAdapterContext;
|
|
65
|
+
/**
|
|
66
|
+
* Optional wrapper around the per-projection `DocumentBuilder` — used
|
|
67
|
+
* to attach embeddings via `withEmbedding` (the operator starter's
|
|
68
|
+
* helper) without coupling this orchestrator to any embedding
|
|
69
|
+
* provider.
|
|
70
|
+
*/
|
|
71
|
+
wrapBuilder?: (builder: DocumentBuilder) => DocumentBuilder;
|
|
72
|
+
/** Per-page log hook — called every page so callers can show progress. */
|
|
73
|
+
onProgress?: (event: SyncProgressEvent) => void;
|
|
74
|
+
/**
|
|
75
|
+
* Optional vertical allow-list. Scheduled vertical refreshes use this to run
|
|
76
|
+
* only the adapter projections relevant to the current job.
|
|
77
|
+
*/
|
|
78
|
+
verticals?: ReadonlyArray<string>;
|
|
79
|
+
/**
|
|
80
|
+
* When true, rows from the same source/connection/vertical that were not
|
|
81
|
+
* emitted by a successful discovery pass are marked withdrawn and deleted
|
|
82
|
+
* from catalog search slices. Failed adapter passes never prune.
|
|
83
|
+
*/
|
|
84
|
+
pruneMissing?: boolean;
|
|
85
|
+
}
|
|
86
|
+
export interface SyncProgressEvent {
|
|
87
|
+
adapter: string;
|
|
88
|
+
page: number;
|
|
89
|
+
pageSize: number;
|
|
90
|
+
totalSoFar: number;
|
|
91
|
+
}
|
|
92
|
+
export interface SyncAdapterSummary {
|
|
93
|
+
adapter: string;
|
|
94
|
+
pages: number;
|
|
95
|
+
projectionsSynced: number;
|
|
96
|
+
/**
|
|
97
|
+
* Verticals the adapter touched, derived from `entity_module` on
|
|
98
|
+
* each projection. Useful for the CLI to print per-vertical counts.
|
|
99
|
+
*/
|
|
100
|
+
verticalsTouched: string[];
|
|
101
|
+
/**
|
|
102
|
+
* Projections skipped because no field-policy registry was registered
|
|
103
|
+
* for their `entity_module`. Common when an adapter declares more
|
|
104
|
+
* verticals than the deployment indexes.
|
|
105
|
+
*/
|
|
106
|
+
skippedNoRegistry: number;
|
|
107
|
+
/**
|
|
108
|
+
* Projections that landed in `catalog_sourced_entries` (sourced rows
|
|
109
|
+
* with a DB handle in scope). Owned projections are not counted —
|
|
110
|
+
* they're not part of the sourced-entry store.
|
|
111
|
+
*/
|
|
112
|
+
sourcedEntriesUpserted: number;
|
|
113
|
+
/**
|
|
114
|
+
* Owned projections passed through unchanged. Owned-flagged
|
|
115
|
+
* projections via `discover()` are an unusual case (most adapters
|
|
116
|
+
* emit only sourced rows) but the orchestrator still routes them to
|
|
117
|
+
* the indexer.
|
|
118
|
+
*/
|
|
119
|
+
ownedProjections: number;
|
|
120
|
+
/**
|
|
121
|
+
* Previously active sourced rows that were not emitted by a successful
|
|
122
|
+
* discovery pass, marked withdrawn and removed from search slices.
|
|
123
|
+
*/
|
|
124
|
+
withdrawnProjections: number;
|
|
125
|
+
}
|
|
126
|
+
export interface SyncSourcesSummary {
|
|
127
|
+
adapters: SyncAdapterSummary[];
|
|
128
|
+
totalProjections: number;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Run a one-shot discovery sync against every registered adapter.
|
|
132
|
+
* Throws if an adapter doesn't support `discover` (a contract violation —
|
|
133
|
+
* adapters wishing to participate in the sync must implement it).
|
|
134
|
+
*/
|
|
135
|
+
export declare function syncSources(options: SyncSourcesOptions): Promise<SyncSourcesSummary>;
|
|
136
|
+
//# sourceMappingURL=sync.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sync.d.ts","sourceRoot":"","sources":["../../src/booking-engine/sync.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAErD,OAAO,KAAK,EAAE,aAAa,EAAE,oBAAoB,EAAE,MAAM,wBAAwB,CAAA;AACjF,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAA;AAGzD,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,gCAAgC,CAAA;AAOrF,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,eAAe,CAAA;AAE1D,MAAM,WAAW,kBAAkB;IACjC,qFAAqF;IACrF,QAAQ,EAAE,qBAAqB,CAAA;IAC/B,4FAA4F;IAC5F,cAAc,EAAE,cAAc,CAAA;IAC9B;;;;;OAKG;IACH,qBAAqB,EAAE,WAAW,CAAC,MAAM,EAAE,mBAAmB,CAAC,CAAA;IAC/D;;;;;;;;;;;OAWG;IACH,EAAE,CAAC,EAAE,YAAY,CAAA;IACjB;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,CAAC,OAAO,EAAE,aAAa,KAAK,oBAAoB,CAAA;IACtE;;;;;OAKG;IACH,WAAW,CAAC,EAAE,CAAC,OAAO,EAAE,eAAe,KAAK,eAAe,CAAA;IAC3D,0EAA0E;IAC1E,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,CAAA;IAC/C;;;OAGG;IACH,SAAS,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAA;IACjC;;;;OAIG;IACH,YAAY,CAAC,EAAE,OAAO,CAAA;CACvB;AAED,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,MAAM,CAAA;IACf,IAAI,EAAE,MAAM,CAAA;IACZ,QAAQ,EAAE,MAAM,CAAA;IAChB,UAAU,EAAE,MAAM,CAAA;CACnB;AAED,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,MAAM,CAAA;IACf,KAAK,EAAE,MAAM,CAAA;IACb,iBAAiB,EAAE,MAAM,CAAA;IACzB;;;OAGG;IACH,gBAAgB,EAAE,MAAM,EAAE,CAAA;IAC1B;;;;OAIG;IACH,iBAAiB,EAAE,MAAM,CAAA;IACzB;;;;OAIG;IACH,sBAAsB,EAAE,MAAM,CAAA;IAC9B;;;;;OAKG;IACH,gBAAgB,EAAE,MAAM,CAAA;IACxB;;;OAGG;IACH,oBAAoB,EAAE,MAAM,CAAA;CAC7B;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,EAAE,kBAAkB,EAAE,CAAA;IAC9B,gBAAgB,EAAE,MAAM,CAAA;CACzB;AAED;;;;GAIG;AACH,wBAAsB,WAAW,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,kBAAkB,CAAC,CA6H1F"}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source discovery sync — pulls projections from every registered
|
|
3
|
+
* `SourceAdapter` and pushes them into the deployment's indexer so
|
|
4
|
+
* sourced inventory shows up in the catalog UI alongside owned rows.
|
|
5
|
+
*
|
|
6
|
+
* Mirrors the bulk reindex flow (`scripts/reindex.ts` in operator
|
|
7
|
+
* templates) for owned rows, except the data comes from
|
|
8
|
+
* `adapter.discover()` instead of a Drizzle table scan.
|
|
9
|
+
*
|
|
10
|
+
* The orchestrator is pure: it takes the registry, the indexer service,
|
|
11
|
+
* and the per-vertical field-policy registries, and returns a summary
|
|
12
|
+
* of what was synced. Templates wire env / Typesense / embeddings; this
|
|
13
|
+
* function only touches what was passed in.
|
|
14
|
+
*
|
|
15
|
+
* Usage pattern (in a template script):
|
|
16
|
+
*
|
|
17
|
+
* const summary = await syncSources({
|
|
18
|
+
* registry,
|
|
19
|
+
* indexerService,
|
|
20
|
+
* fieldPolicyRegistries,
|
|
21
|
+
* })
|
|
22
|
+
*
|
|
23
|
+
* Drift events, scheduled re-runs, and concurrency limits are deferred
|
|
24
|
+
* to the catalog plane's normal drift pipeline (foundation §5.5);
|
|
25
|
+
* `syncSources` is a one-shot bulk pass driven by a CLI or cron job.
|
|
26
|
+
*/
|
|
27
|
+
import { isOwned } from "../provenance.js";
|
|
28
|
+
import { buildIndexerDocument } from "../services/indexer-service.js";
|
|
29
|
+
import { markMissingSourcedEntriesWithdrawn, upsertSourcedEntry, } from "../services/sourced-entry-service.js";
|
|
30
|
+
/**
|
|
31
|
+
* Run a one-shot discovery sync against every registered adapter.
|
|
32
|
+
* Throws if an adapter doesn't support `discover` (a contract violation —
|
|
33
|
+
* adapters wishing to participate in the sync must implement it).
|
|
34
|
+
*/
|
|
35
|
+
export async function syncSources(options) {
|
|
36
|
+
const verticalFilter = options.verticals ? new Set(options.verticals) : undefined;
|
|
37
|
+
// Iterate every registered (connection_id, adapter) pair — multiple
|
|
38
|
+
// connections of the same kind each get their own discovery pass.
|
|
39
|
+
// Skip adapters that don't implement `discover` (outbound-only
|
|
40
|
+
// channel-push adapters).
|
|
41
|
+
const entries = options.registry
|
|
42
|
+
.connections()
|
|
43
|
+
.map((connectionId) => ({
|
|
44
|
+
connectionId,
|
|
45
|
+
adapter: options.registry.resolveByConnectionOrThrow(connectionId),
|
|
46
|
+
}))
|
|
47
|
+
.filter((e) => typeof e.adapter.discover === "function")
|
|
48
|
+
.filter((e) => !verticalFilter ||
|
|
49
|
+
e.adapter.capabilities.verticals.some((vertical) => verticalFilter.has(vertical)));
|
|
50
|
+
const adapterSummaries = [];
|
|
51
|
+
let totalProjections = 0;
|
|
52
|
+
for (const { connectionId, adapter } of entries) {
|
|
53
|
+
const adapterCtx = options.buildAdapterContext?.(adapter) ?? {
|
|
54
|
+
connection_id: connectionId,
|
|
55
|
+
};
|
|
56
|
+
const summary = {
|
|
57
|
+
adapter: adapter.kind,
|
|
58
|
+
pages: 0,
|
|
59
|
+
projectionsSynced: 0,
|
|
60
|
+
verticalsTouched: [],
|
|
61
|
+
skippedNoRegistry: 0,
|
|
62
|
+
sourcedEntriesUpserted: 0,
|
|
63
|
+
ownedProjections: 0,
|
|
64
|
+
withdrawnProjections: 0,
|
|
65
|
+
};
|
|
66
|
+
const verticals = new Set();
|
|
67
|
+
const seenBySource = new Map();
|
|
68
|
+
let cursor;
|
|
69
|
+
do {
|
|
70
|
+
// `discover` is optional in the contract; the filter above
|
|
71
|
+
// ensures we only iterate adapters that implement it.
|
|
72
|
+
const page = await adapter.discover?.(adapterCtx, cursor);
|
|
73
|
+
if (!page)
|
|
74
|
+
break;
|
|
75
|
+
summary.pages += 1;
|
|
76
|
+
options.onProgress?.({
|
|
77
|
+
adapter: adapter.kind,
|
|
78
|
+
page: summary.pages,
|
|
79
|
+
pageSize: page.projections.length,
|
|
80
|
+
totalSoFar: summary.projectionsSynced + page.projections.length,
|
|
81
|
+
});
|
|
82
|
+
for (const projection of page.projections) {
|
|
83
|
+
if (verticalFilter && !verticalFilter.has(projection.entity_module)) {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const registry = options.fieldPolicyRegistries.get(projection.entity_module);
|
|
87
|
+
if (!registry) {
|
|
88
|
+
summary.skippedNoRegistry += 1;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
verticals.add(projection.entity_module);
|
|
92
|
+
// Durable sourced-entry capture (sourced-content §2.5.2).
|
|
93
|
+
// Owned projections skip — they live in the vertical's owned
|
|
94
|
+
// schema. Sourced projections upsert before the indexer write so
|
|
95
|
+
// the sourced-entry store is the canonical local copy of what
|
|
96
|
+
// discover() said for downstream synthesizer reads.
|
|
97
|
+
if (isOwned(projection.provenance)) {
|
|
98
|
+
summary.ownedProjections += 1;
|
|
99
|
+
}
|
|
100
|
+
else if (options.db) {
|
|
101
|
+
await upsertSourcedEntry(options.db, { projection });
|
|
102
|
+
summary.sourcedEntriesUpserted += 1;
|
|
103
|
+
if (options.pruneMissing) {
|
|
104
|
+
trackSeenSourcedProjection(seenBySource, {
|
|
105
|
+
entityModule: projection.entity_module,
|
|
106
|
+
entityId: projection.entity_id,
|
|
107
|
+
sourceKind: projection.provenance.source_kind,
|
|
108
|
+
sourceConnectionId: projection.provenance.source_connection_id,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const projectionMap = toProjectionMap(projection.fields);
|
|
113
|
+
const baseBuilder = async (_entityId, slice) => buildIndexerDocument(registry, projectionMap, slice, projection.entity_id);
|
|
114
|
+
const builder = options.wrapBuilder ? options.wrapBuilder(baseBuilder) : baseBuilder;
|
|
115
|
+
await options.indexerService.reindexEntity(projection.entity_module, projection.entity_id, builder);
|
|
116
|
+
summary.projectionsSynced += 1;
|
|
117
|
+
totalProjections += 1;
|
|
118
|
+
}
|
|
119
|
+
cursor = page.next_cursor;
|
|
120
|
+
} while (cursor);
|
|
121
|
+
if (options.pruneMissing && options.db) {
|
|
122
|
+
ensureAdapterVerticalPruneScopes(seenBySource, adapter, connectionId, verticalFilter);
|
|
123
|
+
for (const seen of seenBySource.values()) {
|
|
124
|
+
const withdrawn = await markMissingSourcedEntriesWithdrawn(options.db, {
|
|
125
|
+
entityModule: seen.entityModule,
|
|
126
|
+
sourceKind: seen.sourceKind,
|
|
127
|
+
sourceConnectionId: seen.sourceConnectionId,
|
|
128
|
+
seenEntityIds: seen.entityIds,
|
|
129
|
+
});
|
|
130
|
+
for (const row of withdrawn) {
|
|
131
|
+
await options.indexerService.deleteEntity(row.entity_module, row.entity_id);
|
|
132
|
+
}
|
|
133
|
+
summary.withdrawnProjections += withdrawn.length;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
summary.verticalsTouched = [...verticals];
|
|
137
|
+
adapterSummaries.push(summary);
|
|
138
|
+
}
|
|
139
|
+
return { adapters: adapterSummaries, totalProjections };
|
|
140
|
+
}
|
|
141
|
+
function toProjectionMap(fields) {
|
|
142
|
+
return new Map(Object.entries(fields));
|
|
143
|
+
}
|
|
144
|
+
function trackSeenSourcedProjection(seenBySource, input) {
|
|
145
|
+
const key = sourceScopeKey(input);
|
|
146
|
+
let seen = seenBySource.get(key);
|
|
147
|
+
if (!seen) {
|
|
148
|
+
seen = {
|
|
149
|
+
entityModule: input.entityModule,
|
|
150
|
+
sourceKind: input.sourceKind,
|
|
151
|
+
sourceConnectionId: input.sourceConnectionId,
|
|
152
|
+
entityIds: new Set(),
|
|
153
|
+
};
|
|
154
|
+
seenBySource.set(key, seen);
|
|
155
|
+
}
|
|
156
|
+
seen.entityIds.add(input.entityId);
|
|
157
|
+
}
|
|
158
|
+
function ensureAdapterVerticalPruneScopes(seenBySource, adapter, connectionId, verticalFilter) {
|
|
159
|
+
const verticals = adapter.capabilities.verticals.filter((vertical) => !verticalFilter || verticalFilter.has(vertical));
|
|
160
|
+
for (const entityModule of verticals) {
|
|
161
|
+
const input = {
|
|
162
|
+
entityModule,
|
|
163
|
+
sourceKind: adapter.kind,
|
|
164
|
+
sourceConnectionId: connectionId,
|
|
165
|
+
};
|
|
166
|
+
const key = sourceScopeKey(input);
|
|
167
|
+
if (seenBySource.has(key))
|
|
168
|
+
continue;
|
|
169
|
+
seenBySource.set(key, {
|
|
170
|
+
...input,
|
|
171
|
+
entityIds: new Set(),
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function sourceScopeKey(input) {
|
|
176
|
+
return `${input.entityModule}\u0000${input.sourceKind}\u0000${input.sourceConnectionId ?? ""}`;
|
|
177
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sync.test.d.ts","sourceRoot":"","sources":["../../src/booking-engine/sync.test.ts"],"names":[],"mappings":""}
|