@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,322 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { applyJsonPointerOverlay, buildDriftInvalidationPredicate, createInvalidateOnDrift, isStale, JsonPointerError, mergeOverlaysIntoContent, parseJsonPointer, pickBestCachedLocale, } from "./content-service.js";
|
|
3
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
4
|
+
// isStale
|
|
5
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
6
|
+
describe("isStale", () => {
|
|
7
|
+
it("returns false when fresh_until is null/undefined", () => {
|
|
8
|
+
expect(isStale({ fresh_until: null })).toBe(false);
|
|
9
|
+
expect(isStale({ fresh_until: undefined })).toBe(false);
|
|
10
|
+
});
|
|
11
|
+
it("returns true when fresh_until is in the past", () => {
|
|
12
|
+
const past = new Date(Date.now() - 60_000);
|
|
13
|
+
expect(isStale({ fresh_until: past })).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
it("returns false when fresh_until is in the future", () => {
|
|
16
|
+
const future = new Date(Date.now() + 60_000);
|
|
17
|
+
expect(isStale({ fresh_until: future })).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
it("treats fresh_until == now as stale (boundary)", () => {
|
|
20
|
+
const now = new Date();
|
|
21
|
+
expect(isStale({ fresh_until: now }, now)).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
25
|
+
// pickBestCachedLocale
|
|
26
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
27
|
+
describe("pickBestCachedLocale", () => {
|
|
28
|
+
it("returns null when there are no candidates", () => {
|
|
29
|
+
expect(pickBestCachedLocale([], ["en-GB"])).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
it("returns an exact match when present", () => {
|
|
32
|
+
const result = pickBestCachedLocale([
|
|
33
|
+
{ locale: "en-GB", payload: 1 },
|
|
34
|
+
{ locale: "ro-RO", payload: 2 },
|
|
35
|
+
], ["ro-RO", "en-GB"]);
|
|
36
|
+
expect(result?.served_locale).toBe("ro-RO");
|
|
37
|
+
expect(result?.match_kind).toBe("exact");
|
|
38
|
+
expect(result?.candidate.payload).toBe(2);
|
|
39
|
+
});
|
|
40
|
+
it("falls back to language match when region differs", () => {
|
|
41
|
+
const result = pickBestCachedLocale([{ locale: "fr-FR", payload: 1 }], ["fr-CA"]);
|
|
42
|
+
expect(result?.served_locale).toBe("fr-FR");
|
|
43
|
+
expect(result?.match_kind).toBe("language_match");
|
|
44
|
+
});
|
|
45
|
+
it("prefers exact match later in chain over earlier language match", () => {
|
|
46
|
+
// Asked for fr-CA first (lang match available as fr-FR), then en-GB
|
|
47
|
+
// (exact match available). The earlier preference still wins because
|
|
48
|
+
// its language-match rank (0) beats the later exact rank (1).
|
|
49
|
+
const result = pickBestCachedLocale([
|
|
50
|
+
{ locale: "fr-FR", payload: 1 },
|
|
51
|
+
{ locale: "en-GB", payload: 2 },
|
|
52
|
+
], ["fr-CA", "en-GB"]);
|
|
53
|
+
expect(result?.served_locale).toBe("fr-FR");
|
|
54
|
+
expect(result?.match_kind).toBe("language_match");
|
|
55
|
+
});
|
|
56
|
+
it("when nothing matches the chain, falls back to fallback_chain mark", () => {
|
|
57
|
+
const result = pickBestCachedLocale([{ locale: "de-DE", payload: 1 }], ["ro-RO", "en-GB"]);
|
|
58
|
+
expect(result?.match_kind).toBe("fallback_chain");
|
|
59
|
+
expect(result?.served_locale).toBe("de-DE");
|
|
60
|
+
});
|
|
61
|
+
it("returns 'any' when the preference chain is empty", () => {
|
|
62
|
+
const result = pickBestCachedLocale([{ locale: "ro-RO", payload: 1 }], []);
|
|
63
|
+
expect(result?.match_kind).toBe("any");
|
|
64
|
+
expect(result?.served_locale).toBe("ro-RO");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
68
|
+
// JSON pointer
|
|
69
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
70
|
+
describe("parseJsonPointer", () => {
|
|
71
|
+
it("parses '' as the root pointer", () => {
|
|
72
|
+
expect(parseJsonPointer("")).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
it("parses '/foo' as a single segment", () => {
|
|
75
|
+
expect(parseJsonPointer("/foo")).toEqual(["foo"]);
|
|
76
|
+
});
|
|
77
|
+
it("parses nested pointers", () => {
|
|
78
|
+
expect(parseJsonPointer("/days/3/description")).toEqual(["days", "3", "description"]);
|
|
79
|
+
});
|
|
80
|
+
it("unescapes ~1 to / and ~0 to ~ (in that order)", () => {
|
|
81
|
+
expect(parseJsonPointer("/foo~1bar")).toEqual(["foo/bar"]);
|
|
82
|
+
expect(parseJsonPointer("/foo~0bar")).toEqual(["foo~bar"]);
|
|
83
|
+
// Edge: per RFC 6901, ~01 should unescape to ~1 (not /).
|
|
84
|
+
expect(parseJsonPointer("/foo~01")).toEqual(["foo~1"]);
|
|
85
|
+
});
|
|
86
|
+
it("throws on a non-empty pointer that doesn't start with '/'", () => {
|
|
87
|
+
expect(() => parseJsonPointer("foo")).toThrow(JsonPointerError);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
describe("applyJsonPointerOverlay", () => {
|
|
91
|
+
it("sets a top-level field on an object", () => {
|
|
92
|
+
const target = { name: "Original" };
|
|
93
|
+
const result = applyJsonPointerOverlay(target, "/name", "Updated");
|
|
94
|
+
expect(result.name).toBe("Updated");
|
|
95
|
+
});
|
|
96
|
+
it("descends into nested objects", () => {
|
|
97
|
+
const target = { hero: { caption: "old" } };
|
|
98
|
+
applyJsonPointerOverlay(target, "/hero/caption", "new");
|
|
99
|
+
expect(target.hero.caption).toBe("new");
|
|
100
|
+
});
|
|
101
|
+
it("descends into arrays via numeric segments", () => {
|
|
102
|
+
const target = { days: [{ description: "Day 1" }, { description: "Day 2" }] };
|
|
103
|
+
applyJsonPointerOverlay(target, "/days/1/description", "Day 2 (overridden)");
|
|
104
|
+
expect(target.days[1]?.description).toBe("Day 2 (overridden)");
|
|
105
|
+
});
|
|
106
|
+
it("replaces array elements at the leaf", () => {
|
|
107
|
+
const target = { tags: ["a", "b", "c"] };
|
|
108
|
+
applyJsonPointerOverlay(target, "/tags/0", "z");
|
|
109
|
+
expect(target.tags[0]).toBe("z");
|
|
110
|
+
});
|
|
111
|
+
it("throws on missing intermediate keys", () => {
|
|
112
|
+
const target = { hero: {} };
|
|
113
|
+
expect(() => applyJsonPointerOverlay(target, "/hero/caption/foo", "x")).toThrow(JsonPointerError);
|
|
114
|
+
});
|
|
115
|
+
it("throws on out-of-range array indices", () => {
|
|
116
|
+
const target = { tags: ["a"] };
|
|
117
|
+
expect(() => applyJsonPointerOverlay(target, "/tags/5", "z")).toThrow(JsonPointerError);
|
|
118
|
+
});
|
|
119
|
+
it("rejects RFC 6901 '-' append-segment (we don't support extending)", () => {
|
|
120
|
+
const target = { tags: ["a"] };
|
|
121
|
+
expect(() => applyJsonPointerOverlay(target, "/tags/-", "b")).toThrow(JsonPointerError);
|
|
122
|
+
});
|
|
123
|
+
it("returns the value when pointer is empty (whole-document replace)", () => {
|
|
124
|
+
const result = applyJsonPointerOverlay({ foo: 1 }, "", { bar: 2 });
|
|
125
|
+
expect(result).toEqual({ bar: 2 });
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
129
|
+
// Overlay merge
|
|
130
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
131
|
+
describe("mergeOverlaysIntoContent", () => {
|
|
132
|
+
it("returns a deep-cloned payload — does not mutate the input", () => {
|
|
133
|
+
const payload = { name: "Original", days: [{ description: "Day 1" }] };
|
|
134
|
+
const overlays = [{ field_path: "/name", value: "Override" }];
|
|
135
|
+
const merged = mergeOverlaysIntoContent(payload, overlays);
|
|
136
|
+
expect(merged.name).toBe("Override");
|
|
137
|
+
expect(payload.name).toBe("Original"); // original untouched
|
|
138
|
+
});
|
|
139
|
+
it("applies multiple overlays in order", () => {
|
|
140
|
+
const payload = { a: 1, b: 2 };
|
|
141
|
+
const overlays = [
|
|
142
|
+
{ field_path: "/a", value: 100 },
|
|
143
|
+
{ field_path: "/b", value: 200 },
|
|
144
|
+
];
|
|
145
|
+
const merged = mergeOverlaysIntoContent(payload, overlays);
|
|
146
|
+
expect(merged.a).toBe(100);
|
|
147
|
+
expect(merged.b).toBe(200);
|
|
148
|
+
});
|
|
149
|
+
it("skips overlays that fail to apply, calling onOverlayError once each", () => {
|
|
150
|
+
const payload = { name: "Original" };
|
|
151
|
+
const overlays = [
|
|
152
|
+
{ field_path: "/name", value: "Override" },
|
|
153
|
+
{ field_path: "/missing/deep", value: "X" }, // fails: no /missing
|
|
154
|
+
{ field_path: "/name", value: "Final" },
|
|
155
|
+
];
|
|
156
|
+
const errors = [];
|
|
157
|
+
const merged = mergeOverlaysIntoContent(payload, overlays, {
|
|
158
|
+
onOverlayError: (e) => errors.push(e),
|
|
159
|
+
});
|
|
160
|
+
expect(merged.name).toBe("Final");
|
|
161
|
+
expect(errors).toHaveLength(1);
|
|
162
|
+
expect(errors[0]?.overlay.field_path).toBe("/missing/deep");
|
|
163
|
+
});
|
|
164
|
+
it("rolls back overlays whose result fails the validator", () => {
|
|
165
|
+
const payload = { count: 1 };
|
|
166
|
+
const overlays = [{ field_path: "/count", value: "not-a-number" }];
|
|
167
|
+
const errors = [];
|
|
168
|
+
const merged = mergeOverlaysIntoContent(payload, overlays, {
|
|
169
|
+
validate(p) {
|
|
170
|
+
const ok = typeof p.count === "number";
|
|
171
|
+
return { valid: ok, reason: ok ? undefined : "count must be number" };
|
|
172
|
+
},
|
|
173
|
+
onOverlayError: (e) => errors.push(e),
|
|
174
|
+
});
|
|
175
|
+
expect(merged.count).toBe(1); // rolled back
|
|
176
|
+
expect(errors).toHaveLength(1);
|
|
177
|
+
expect(errors[0]?.reason).toContain("count must be number");
|
|
178
|
+
});
|
|
179
|
+
it("validator that always passes lets every overlay through", () => {
|
|
180
|
+
const payload = { a: 1 };
|
|
181
|
+
const overlays = [{ field_path: "/a", value: 2 }];
|
|
182
|
+
const merged = mergeOverlaysIntoContent(payload, overlays, {
|
|
183
|
+
validate: () => ({ valid: true }),
|
|
184
|
+
});
|
|
185
|
+
expect(merged.a).toBe(2);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
189
|
+
// Drift invalidation predicate
|
|
190
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
191
|
+
describe("buildDriftInvalidationPredicate", () => {
|
|
192
|
+
it("matches one entity across all locales / markets when neither is set", () => {
|
|
193
|
+
const event = {
|
|
194
|
+
id: "cnde_1",
|
|
195
|
+
entity_module: "products",
|
|
196
|
+
entity_id: "prod_abc",
|
|
197
|
+
kind: "content_changed",
|
|
198
|
+
detected_at: new Date(),
|
|
199
|
+
};
|
|
200
|
+
const pred = buildDriftInvalidationPredicate(event);
|
|
201
|
+
expect(pred.entity_module).toBe("products");
|
|
202
|
+
expect(pred.entity_id).toBe("prod_abc");
|
|
203
|
+
expect(pred.locale).toBeNull();
|
|
204
|
+
expect(pred.market).toBeNull();
|
|
205
|
+
});
|
|
206
|
+
it("narrows to one locale + market when set", () => {
|
|
207
|
+
const event = {
|
|
208
|
+
id: "cnde_2",
|
|
209
|
+
entity_module: "products",
|
|
210
|
+
entity_id: "prod_abc",
|
|
211
|
+
locale: "ro-RO",
|
|
212
|
+
market: "RO",
|
|
213
|
+
kind: "content_changed",
|
|
214
|
+
detected_at: new Date(),
|
|
215
|
+
};
|
|
216
|
+
const pred = buildDriftInvalidationPredicate(event);
|
|
217
|
+
expect(pred.locale).toBe("ro-RO");
|
|
218
|
+
expect(pred.market).toBe("RO");
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
222
|
+
// Locale chain — language tag matching is case-insensitive
|
|
223
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
224
|
+
describe("pickBestCachedLocale — case insensitivity", () => {
|
|
225
|
+
it("compares language tags case-insensitively", () => {
|
|
226
|
+
const result = pickBestCachedLocale([{ locale: "FR-fr", payload: 1 }], ["fr-CA"]);
|
|
227
|
+
expect(result?.match_kind).toBe("language_match");
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
// vi unused but imported for parity with other test files; reference it
|
|
231
|
+
// to avoid lint complaints under noUnusedLocals.
|
|
232
|
+
void vi;
|
|
233
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
234
|
+
// createInvalidateOnDrift — runner factory
|
|
235
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
236
|
+
describe("createInvalidateOnDrift", () => {
|
|
237
|
+
function makeStubDb() {
|
|
238
|
+
const calls = [];
|
|
239
|
+
const fakeColumn = { name: "stub" };
|
|
240
|
+
const fakeTable = {
|
|
241
|
+
entity_id: fakeColumn,
|
|
242
|
+
locale: fakeColumn,
|
|
243
|
+
market: fakeColumn,
|
|
244
|
+
fresh_until: fakeColumn,
|
|
245
|
+
};
|
|
246
|
+
let rowsToReturn = 1;
|
|
247
|
+
const db = {
|
|
248
|
+
update(t) {
|
|
249
|
+
calls.push({ phase: "update", arg: t });
|
|
250
|
+
return {
|
|
251
|
+
set(values) {
|
|
252
|
+
calls.push({ phase: "set", arg: values });
|
|
253
|
+
return {
|
|
254
|
+
where(cond) {
|
|
255
|
+
calls.push({ phase: "where", arg: cond });
|
|
256
|
+
return {
|
|
257
|
+
async returning(_cols) {
|
|
258
|
+
calls.push({ phase: "returning", arg: _cols });
|
|
259
|
+
return Array.from({ length: rowsToReturn }, (_, i) => ({
|
|
260
|
+
entity_id: `row_${i}`,
|
|
261
|
+
}));
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
},
|
|
269
|
+
__setRows(n) {
|
|
270
|
+
rowsToReturn = n;
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
return { db: db, calls, table: fakeTable, setRows: (n) => db.__setRows(n) };
|
|
274
|
+
}
|
|
275
|
+
function makeEvent(overrides = {}) {
|
|
276
|
+
return {
|
|
277
|
+
id: "cnde_x",
|
|
278
|
+
entity_module: "products",
|
|
279
|
+
entity_id: "prod_abc",
|
|
280
|
+
kind: "content_changed",
|
|
281
|
+
detected_at: new Date(),
|
|
282
|
+
...overrides,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
it("returns invalidated: 0 and skips the UPDATE for events targeting other modules", async () => {
|
|
286
|
+
const { db, calls, table } = makeStubDb();
|
|
287
|
+
const invalidate = createInvalidateOnDrift(table, { entityModule: "products" });
|
|
288
|
+
const result = await invalidate(db, makeEvent({ entity_module: "cruises" }));
|
|
289
|
+
expect(result.invalidated).toBe(0);
|
|
290
|
+
expect(calls).toHaveLength(0);
|
|
291
|
+
});
|
|
292
|
+
it("dispatches the UPDATE when the event matches the registered entity_module", async () => {
|
|
293
|
+
const { db, calls, table, setRows } = makeStubDb();
|
|
294
|
+
setRows(3);
|
|
295
|
+
const invalidate = createInvalidateOnDrift(table, { entityModule: "products" });
|
|
296
|
+
const result = await invalidate(db, makeEvent());
|
|
297
|
+
expect(result.invalidated).toBe(3);
|
|
298
|
+
// Drizzle chain: update → set → where → returning
|
|
299
|
+
expect(calls.map((c) => c.phase)).toEqual(["update", "set", "where", "returning"]);
|
|
300
|
+
});
|
|
301
|
+
it("returns invalidated: 0 when no rows match (returning() returns empty)", async () => {
|
|
302
|
+
const { db, table, setRows } = makeStubDb();
|
|
303
|
+
setRows(0);
|
|
304
|
+
const invalidate = createInvalidateOnDrift(table, { entityModule: "products" });
|
|
305
|
+
const result = await invalidate(db, makeEvent());
|
|
306
|
+
expect(result.invalidated).toBe(0);
|
|
307
|
+
});
|
|
308
|
+
it("scopes to one locale + market when the event sets them", async () => {
|
|
309
|
+
const { db, table } = makeStubDb();
|
|
310
|
+
const invalidate = createInvalidateOnDrift(table, { entityModule: "products" });
|
|
311
|
+
// Just ensures no throw — the actual SQL is opaque but the
|
|
312
|
+
// function consumed all event fields. Coverage is at the
|
|
313
|
+
// integration test level.
|
|
314
|
+
await invalidate(db, makeEvent({ locale: "ro-RO", market: "RO" }));
|
|
315
|
+
});
|
|
316
|
+
it("matches all locales / markets when event fields are unset (full-entity invalidation)", async () => {
|
|
317
|
+
const { db, table } = makeStubDb();
|
|
318
|
+
const invalidate = createInvalidateOnDrift(table, { entityModule: "products" });
|
|
319
|
+
// No throw — the WHERE clause omits the locale/market predicates.
|
|
320
|
+
await invalidate(db, makeEvent({ locale: undefined, market: undefined }));
|
|
321
|
+
});
|
|
322
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IndexerService — higher-level wrapper around an `IndexerAdapter` that
|
|
3
|
+
* handles the cross-slice orchestration verticals actually need:
|
|
4
|
+
*
|
|
5
|
+
* - Targeted reindex of one entity across all configured slices.
|
|
6
|
+
* - Targeted reindex narrowed to a single `(locale, audience, market)`
|
|
7
|
+
* when an editorial overlay change has narrow scope.
|
|
8
|
+
* - Deletion across all slices for the same entity.
|
|
9
|
+
* - `ensureCollections` at deployment startup.
|
|
10
|
+
* - Search delegation to the underlying adapter.
|
|
11
|
+
*
|
|
12
|
+
* Templates configure the service with their actual slice set (which
|
|
13
|
+
* `(locale, audience, market)` combinations the deployment serves) and the
|
|
14
|
+
* IndexerAdapter implementation (Typesense by default).
|
|
15
|
+
*
|
|
16
|
+
* See `docs/architecture/catalog-architecture.md` §5.4 for the design.
|
|
17
|
+
*/
|
|
18
|
+
import type { FieldPolicyRegistry } from "../contract.js";
|
|
19
|
+
import type { IndexerAdapter, IndexerDocument, IndexerSlice, SearchRequest, SearchResults } from "../indexer/contract.js";
|
|
20
|
+
/**
|
|
21
|
+
* Options for constructing an IndexerService.
|
|
22
|
+
*/
|
|
23
|
+
export interface IndexerServiceOptions {
|
|
24
|
+
/** The underlying IndexerAdapter implementation. */
|
|
25
|
+
adapter: IndexerAdapter;
|
|
26
|
+
/**
|
|
27
|
+
* The `(vertical, locale, audience, market)` slices this deployment
|
|
28
|
+
* materializes. Default deployments have ~2 slices per vertical per
|
|
29
|
+
* locale; scale-stage deployments have more.
|
|
30
|
+
*/
|
|
31
|
+
slices: IndexerSlice[];
|
|
32
|
+
/**
|
|
33
|
+
* Per-vertical field-policy registries, keyed by `entity_module`.
|
|
34
|
+
* `ensureCollections` and document-emission paths consult the relevant
|
|
35
|
+
* registry to know what to index and how.
|
|
36
|
+
*/
|
|
37
|
+
registries: ReadonlyMap<string, FieldPolicyRegistry>;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Builder function the service calls per slice when reindexing — produces
|
|
41
|
+
* the `IndexerDocument` for a particular entity in a particular slice.
|
|
42
|
+
*
|
|
43
|
+
* Verticals supply this via their `DocumentEmitter` (see
|
|
44
|
+
* `../indexer/contract.ts`); the service is engine-agnostic.
|
|
45
|
+
*/
|
|
46
|
+
export type DocumentBuilder = (entityId: string, slice: IndexerSlice) => Promise<IndexerDocument | null>;
|
|
47
|
+
/**
|
|
48
|
+
* The IndexerService surface.
|
|
49
|
+
*/
|
|
50
|
+
export interface IndexerService {
|
|
51
|
+
/**
|
|
52
|
+
* Ensure every configured slice has its engine-side schema set up.
|
|
53
|
+
* Called at deployment startup or after a field-policy registry change.
|
|
54
|
+
*/
|
|
55
|
+
ensureCollections(): Promise<void>;
|
|
56
|
+
/**
|
|
57
|
+
* Reindex one entity across **all** slices configured for its vertical.
|
|
58
|
+
* Used when a source projection update affects all variant combinations
|
|
59
|
+
* (e.g. a managed-class field changes).
|
|
60
|
+
*/
|
|
61
|
+
reindexEntity(entityModule: string, entityId: string, builder: DocumentBuilder): Promise<void>;
|
|
62
|
+
/**
|
|
63
|
+
* Reindex one entity for **one specific slice only**. Used when an
|
|
64
|
+
* editorial overlay change has narrow scope (one locale × audience ×
|
|
65
|
+
* market) and rebuilding all slices would be wasteful.
|
|
66
|
+
*/
|
|
67
|
+
reindexEntityForSlice(slice: IndexerSlice, entityId: string, builder: DocumentBuilder): Promise<void>;
|
|
68
|
+
/**
|
|
69
|
+
* Delete one entity from every slice configured for its vertical. Used by
|
|
70
|
+
* the source-disconnect cleanup pipeline (§5.10.3) and for hard entity
|
|
71
|
+
* deletions.
|
|
72
|
+
*/
|
|
73
|
+
deleteEntity(entityModule: string, entityId: string): Promise<void>;
|
|
74
|
+
/**
|
|
75
|
+
* Search delegation — pass-through to the underlying adapter.
|
|
76
|
+
*/
|
|
77
|
+
search(slice: IndexerSlice, request: SearchRequest): Promise<SearchResults>;
|
|
78
|
+
/**
|
|
79
|
+
* Returns the slices configured for a given vertical. Useful for callers
|
|
80
|
+
* orchestrating bulk operations themselves.
|
|
81
|
+
*/
|
|
82
|
+
slicesForVertical(entityModule: string): IndexerSlice[];
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Create the service from its options. Pure construction — no IO until
|
|
86
|
+
* methods are called.
|
|
87
|
+
*/
|
|
88
|
+
export declare function createIndexerService(options: IndexerServiceOptions): IndexerService;
|
|
89
|
+
/**
|
|
90
|
+
* Build an `IndexerDocument` from a field-keyed projection, filtered by:
|
|
91
|
+
* - Visibility — for storefront slices (`customer` / `partner` / `supplier`),
|
|
92
|
+
* only fields whose policy `visibility[]` includes the slice audience.
|
|
93
|
+
* For admin slices (the `staff-admin` sentinel), all fields visible to
|
|
94
|
+
* any audience are included so the admin search document carries the
|
|
95
|
+
* cross-audience denormalized text described in architecture §5.4.4.
|
|
96
|
+
* - Queryability — `blob-only` fields are skipped (stored on the entity row
|
|
97
|
+
* but not indexed). `indexed-column` and `first-class-table` fields land
|
|
98
|
+
* in the document.
|
|
99
|
+
*
|
|
100
|
+
* Field paths ending in `[]` (lists) are renamed to drop the suffix so the
|
|
101
|
+
* indexer schema field names match (`gallery[]` → `gallery`).
|
|
102
|
+
*
|
|
103
|
+
* Pure logic — no IO. Sync emitters use this directly; async builders wrap
|
|
104
|
+
* it with row-fetching.
|
|
105
|
+
*
|
|
106
|
+
* Used by every vertical's `DocumentEmitter` implementation.
|
|
107
|
+
*/
|
|
108
|
+
export declare function buildIndexerDocument(registry: FieldPolicyRegistry, projection: ReadonlyMap<string, unknown>, slice: IndexerSlice, entityId: string): IndexerDocument;
|
|
109
|
+
//# sourceMappingURL=indexer-service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"indexer-service.d.ts","sourceRoot":"","sources":["../../src/services/indexer-service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAe,mBAAmB,EAAE,MAAM,gBAAgB,CAAA;AACtE,OAAO,KAAK,EACV,cAAc,EACd,eAAe,EACf,YAAY,EACZ,aAAa,EACb,aAAa,EACd,MAAM,wBAAwB,CAAA;AAE/B;;GAEG;AACH,MAAM,WAAW,qBAAqB;IACpC,oDAAoD;IACpD,OAAO,EAAE,cAAc,CAAA;IACvB;;;;OAIG;IACH,MAAM,EAAE,YAAY,EAAE,CAAA;IACtB;;;;OAIG;IACH,UAAU,EAAE,WAAW,CAAC,MAAM,EAAE,mBAAmB,CAAC,CAAA;CACrD;AAED;;;;;;GAMG;AACH,MAAM,MAAM,eAAe,GAAG,CAC5B,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,YAAY,KAChB,OAAO,CAAC,eAAe,GAAG,IAAI,CAAC,CAAA;AAEpC;;GAEG;AACH,MAAM,WAAW,cAAc;IAC7B;;;OAGG;IACH,iBAAiB,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IAElC;;;;OAIG;IACH,aAAa,CAAC,YAAY,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAE9F;;;;OAIG;IACH,qBAAqB,CACnB,KAAK,EAAE,YAAY,EACnB,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,eAAe,GACvB,OAAO,CAAC,IAAI,CAAC,CAAA;IAEhB;;;;OAIG;IACH,YAAY,CAAC,YAAY,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAEnE;;OAEG;IACH,MAAM,CAAC,KAAK,EAAE,YAAY,EAAE,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,aAAa,CAAC,CAAA;IAE3E;;;OAGG;IACH,iBAAiB,CAAC,YAAY,EAAE,MAAM,GAAG,YAAY,EAAE,CAAA;CACxD;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,qBAAqB,GAAG,cAAc,CA0DnF;AAMD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,mBAAmB,EAC7B,UAAU,EAAE,WAAW,CAAC,MAAM,EAAE,OAAO,CAAC,EACxC,KAAK,EAAE,YAAY,EACnB,QAAQ,EAAE,MAAM,GACf,eAAe,CAgBjB"}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IndexerService — higher-level wrapper around an `IndexerAdapter` that
|
|
3
|
+
* handles the cross-slice orchestration verticals actually need:
|
|
4
|
+
*
|
|
5
|
+
* - Targeted reindex of one entity across all configured slices.
|
|
6
|
+
* - Targeted reindex narrowed to a single `(locale, audience, market)`
|
|
7
|
+
* when an editorial overlay change has narrow scope.
|
|
8
|
+
* - Deletion across all slices for the same entity.
|
|
9
|
+
* - `ensureCollections` at deployment startup.
|
|
10
|
+
* - Search delegation to the underlying adapter.
|
|
11
|
+
*
|
|
12
|
+
* Templates configure the service with their actual slice set (which
|
|
13
|
+
* `(locale, audience, market)` combinations the deployment serves) and the
|
|
14
|
+
* IndexerAdapter implementation (Typesense by default).
|
|
15
|
+
*
|
|
16
|
+
* See `docs/architecture/catalog-architecture.md` §5.4 for the design.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Create the service from its options. Pure construction — no IO until
|
|
20
|
+
* methods are called.
|
|
21
|
+
*/
|
|
22
|
+
export function createIndexerService(options) {
|
|
23
|
+
const { adapter, slices, registries } = options;
|
|
24
|
+
const slicesForVertical = (entityModule) => slices.filter((slice) => slice.vertical === entityModule);
|
|
25
|
+
const requireRegistry = (entityModule) => {
|
|
26
|
+
const registry = registries.get(entityModule);
|
|
27
|
+
if (!registry) {
|
|
28
|
+
throw new Error(`IndexerService: no field-policy registry registered for vertical "${entityModule}"`);
|
|
29
|
+
}
|
|
30
|
+
return registry;
|
|
31
|
+
};
|
|
32
|
+
return {
|
|
33
|
+
async ensureCollections() {
|
|
34
|
+
for (const slice of slices) {
|
|
35
|
+
const registry = requireRegistry(slice.vertical);
|
|
36
|
+
await adapter.ensureCollection(slice, registry);
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
async reindexEntity(entityModule, entityId, builder) {
|
|
40
|
+
const verticalSlices = slicesForVertical(entityModule);
|
|
41
|
+
for (const slice of verticalSlices) {
|
|
42
|
+
const document = await builder(entityId, slice);
|
|
43
|
+
if (!document) {
|
|
44
|
+
await adapter.delete(slice, [entityId]);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
await adapter.upsert(slice, [document]);
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
async reindexEntityForSlice(slice, entityId, builder) {
|
|
51
|
+
const document = await builder(entityId, slice);
|
|
52
|
+
if (!document) {
|
|
53
|
+
await adapter.delete(slice, [entityId]);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
await adapter.upsert(slice, [document]);
|
|
57
|
+
},
|
|
58
|
+
async deleteEntity(entityModule, entityId) {
|
|
59
|
+
const verticalSlices = slicesForVertical(entityModule);
|
|
60
|
+
for (const slice of verticalSlices) {
|
|
61
|
+
await adapter.delete(slice, [entityId]);
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
async search(slice, request) {
|
|
65
|
+
return adapter.search(slice, request);
|
|
66
|
+
},
|
|
67
|
+
slicesForVertical,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
71
|
+
// Document construction
|
|
72
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
73
|
+
/**
|
|
74
|
+
* Build an `IndexerDocument` from a field-keyed projection, filtered by:
|
|
75
|
+
* - Visibility — for storefront slices (`customer` / `partner` / `supplier`),
|
|
76
|
+
* only fields whose policy `visibility[]` includes the slice audience.
|
|
77
|
+
* For admin slices (the `staff-admin` sentinel), all fields visible to
|
|
78
|
+
* any audience are included so the admin search document carries the
|
|
79
|
+
* cross-audience denormalized text described in architecture §5.4.4.
|
|
80
|
+
* - Queryability — `blob-only` fields are skipped (stored on the entity row
|
|
81
|
+
* but not indexed). `indexed-column` and `first-class-table` fields land
|
|
82
|
+
* in the document.
|
|
83
|
+
*
|
|
84
|
+
* Field paths ending in `[]` (lists) are renamed to drop the suffix so the
|
|
85
|
+
* indexer schema field names match (`gallery[]` → `gallery`).
|
|
86
|
+
*
|
|
87
|
+
* Pure logic — no IO. Sync emitters use this directly; async builders wrap
|
|
88
|
+
* it with row-fetching.
|
|
89
|
+
*
|
|
90
|
+
* Used by every vertical's `DocumentEmitter` implementation.
|
|
91
|
+
*/
|
|
92
|
+
export function buildIndexerDocument(registry, projection, slice, entityId) {
|
|
93
|
+
const fields = {};
|
|
94
|
+
for (const [path, value] of projection) {
|
|
95
|
+
let policy = registry.resolve(path);
|
|
96
|
+
let policyPath = path;
|
|
97
|
+
if (!policy && Array.isArray(value)) {
|
|
98
|
+
const listPath = `${path}[]`;
|
|
99
|
+
policy = registry.resolve(listPath);
|
|
100
|
+
policyPath = listPath;
|
|
101
|
+
}
|
|
102
|
+
if (!policy)
|
|
103
|
+
continue;
|
|
104
|
+
if (!shouldIndexInDocument(policy, slice.audience))
|
|
105
|
+
continue;
|
|
106
|
+
const fieldName = policyPath.endsWith("[]") ? policyPath.slice(0, -2) : policyPath;
|
|
107
|
+
fields[fieldName] = value;
|
|
108
|
+
}
|
|
109
|
+
return { id: entityId, fields };
|
|
110
|
+
}
|
|
111
|
+
function shouldIndexInDocument(policy, audience) {
|
|
112
|
+
// Skip blob-only fields — they're stored on the entity row, not indexed.
|
|
113
|
+
if (policy.query === "blob-only")
|
|
114
|
+
return false;
|
|
115
|
+
// Admin slices carry cross-audience text (§5.4.4) — include any field
|
|
116
|
+
// visible to any audience. The admin search document is queryable only
|
|
117
|
+
// by staff actors so cross-audience denormalization is safe here.
|
|
118
|
+
if (audience === "staff-admin") {
|
|
119
|
+
return policy.visibility.length > 0;
|
|
120
|
+
}
|
|
121
|
+
// Storefront slices: only fields visible to that specific audience.
|
|
122
|
+
return policy.visibility.includes(audience);
|
|
123
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"indexer-service.test.d.ts","sourceRoot":"","sources":["../../src/services/indexer-service.test.ts"],"names":[],"mappings":""}
|