@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,176 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createFieldPolicyRegistry, defineFieldPolicy } from "../contract.js";
|
|
3
|
+
import { createIndexerService } from "./indexer-service.js";
|
|
4
|
+
function createStubAdapter() {
|
|
5
|
+
const calls = [];
|
|
6
|
+
const capabilities = {
|
|
7
|
+
supportsKeywordSearch: true,
|
|
8
|
+
supportsHybridSearch: false,
|
|
9
|
+
supportsVectorFields: false,
|
|
10
|
+
vectorDimensions: null,
|
|
11
|
+
maxVectorsPerDocument: null,
|
|
12
|
+
supportsCrossAudienceFederation: false,
|
|
13
|
+
supportsAdminDenormalization: false,
|
|
14
|
+
};
|
|
15
|
+
return {
|
|
16
|
+
capabilities,
|
|
17
|
+
calls,
|
|
18
|
+
async ensureCollection(slice) {
|
|
19
|
+
calls.push({ op: "ensureCollection", slice });
|
|
20
|
+
},
|
|
21
|
+
async upsert(slice, documents) {
|
|
22
|
+
calls.push({ op: "upsert", slice, documents });
|
|
23
|
+
},
|
|
24
|
+
async delete(slice, ids) {
|
|
25
|
+
calls.push({ op: "delete", slice, ids });
|
|
26
|
+
},
|
|
27
|
+
async search(slice, _request) {
|
|
28
|
+
calls.push({ op: "search", slice });
|
|
29
|
+
return { hits: [], total: 0 };
|
|
30
|
+
},
|
|
31
|
+
async bulkReindex(slice, _stream, _options) {
|
|
32
|
+
calls.push({ op: "bulkReindex", slice });
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
const merchandisable = {
|
|
37
|
+
class: "merchandisable",
|
|
38
|
+
merge: "replace",
|
|
39
|
+
editRole: "marketing",
|
|
40
|
+
overrideFriction: "none",
|
|
41
|
+
snapshot: "on-book",
|
|
42
|
+
};
|
|
43
|
+
const productsRegistry = createFieldPolicyRegistry(defineFieldPolicy([{ path: "title", ...merchandisable, visibility: ["staff", "customer"] }]));
|
|
44
|
+
const cruisesRegistry = createFieldPolicyRegistry(defineFieldPolicy([{ path: "name", ...merchandisable, visibility: ["staff", "customer"] }]));
|
|
45
|
+
const productSlices = [
|
|
46
|
+
{ vertical: "products", locale: "en-GB", audience: "staff-admin", market: "default" },
|
|
47
|
+
{ vertical: "products", locale: "en-GB", audience: "customer", market: "default" },
|
|
48
|
+
];
|
|
49
|
+
const cruiseSlices = [
|
|
50
|
+
{ vertical: "cruises", locale: "en-GB", audience: "customer", market: "default" },
|
|
51
|
+
];
|
|
52
|
+
describe("IndexerService", () => {
|
|
53
|
+
it("ensures collections across every configured slice", async () => {
|
|
54
|
+
const adapter = createStubAdapter();
|
|
55
|
+
const service = createIndexerService({
|
|
56
|
+
adapter,
|
|
57
|
+
slices: [...productSlices, ...cruiseSlices],
|
|
58
|
+
registries: new Map([
|
|
59
|
+
["products", productsRegistry],
|
|
60
|
+
["cruises", cruisesRegistry],
|
|
61
|
+
]),
|
|
62
|
+
});
|
|
63
|
+
await service.ensureCollections();
|
|
64
|
+
const ensureCalls = adapter.calls.filter((c) => c.op === "ensureCollection");
|
|
65
|
+
expect(ensureCalls).toHaveLength(3);
|
|
66
|
+
});
|
|
67
|
+
it("reindexes one entity across only its vertical's slices", async () => {
|
|
68
|
+
const adapter = createStubAdapter();
|
|
69
|
+
const service = createIndexerService({
|
|
70
|
+
adapter,
|
|
71
|
+
slices: [...productSlices, ...cruiseSlices],
|
|
72
|
+
registries: new Map([
|
|
73
|
+
["products", productsRegistry],
|
|
74
|
+
["cruises", cruisesRegistry],
|
|
75
|
+
]),
|
|
76
|
+
});
|
|
77
|
+
await service.reindexEntity("products", "prod_xyz", async (entityId, slice) => ({
|
|
78
|
+
id: entityId,
|
|
79
|
+
fields: { title: `${slice.audience} title` },
|
|
80
|
+
}));
|
|
81
|
+
const upsertCalls = adapter.calls.filter((c) => c.op === "upsert");
|
|
82
|
+
expect(upsertCalls).toHaveLength(2);
|
|
83
|
+
expect(upsertCalls.every((c) => c.slice.vertical === "products")).toBe(true);
|
|
84
|
+
// Each slice received its own document with audience-specific fields.
|
|
85
|
+
expect(upsertCalls[0]?.documents?.[0]?.fields.title).toBe("staff-admin title");
|
|
86
|
+
expect(upsertCalls[1]?.documents?.[0]?.fields.title).toBe("customer title");
|
|
87
|
+
});
|
|
88
|
+
it("reindexEntityForSlice writes to exactly one slice", async () => {
|
|
89
|
+
const adapter = createStubAdapter();
|
|
90
|
+
const service = createIndexerService({
|
|
91
|
+
adapter,
|
|
92
|
+
slices: [...productSlices, ...cruiseSlices],
|
|
93
|
+
registries: new Map([
|
|
94
|
+
["products", productsRegistry],
|
|
95
|
+
["cruises", cruisesRegistry],
|
|
96
|
+
]),
|
|
97
|
+
});
|
|
98
|
+
const customerSlice = productSlices[1];
|
|
99
|
+
await service.reindexEntityForSlice(customerSlice, "prod_xyz", async () => ({
|
|
100
|
+
id: "prod_xyz",
|
|
101
|
+
fields: { title: "customer title" },
|
|
102
|
+
}));
|
|
103
|
+
const upsertCalls = adapter.calls.filter((c) => c.op === "upsert");
|
|
104
|
+
expect(upsertCalls).toHaveLength(1);
|
|
105
|
+
expect(upsertCalls[0]?.slice).toEqual(customerSlice);
|
|
106
|
+
});
|
|
107
|
+
it("deletes one entity from every slice configured for its vertical", async () => {
|
|
108
|
+
const adapter = createStubAdapter();
|
|
109
|
+
const service = createIndexerService({
|
|
110
|
+
adapter,
|
|
111
|
+
slices: [...productSlices, ...cruiseSlices],
|
|
112
|
+
registries: new Map([
|
|
113
|
+
["products", productsRegistry],
|
|
114
|
+
["cruises", cruisesRegistry],
|
|
115
|
+
]),
|
|
116
|
+
});
|
|
117
|
+
await service.deleteEntity("products", "prod_xyz");
|
|
118
|
+
const deleteCalls = adapter.calls.filter((c) => c.op === "delete");
|
|
119
|
+
expect(deleteCalls).toHaveLength(2);
|
|
120
|
+
expect(deleteCalls.every((c) => c.ids?.includes("prod_xyz"))).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
it("deletes stale slice documents when the document builder returns null", async () => {
|
|
123
|
+
const adapter = createStubAdapter();
|
|
124
|
+
const service = createIndexerService({
|
|
125
|
+
adapter,
|
|
126
|
+
slices: productSlices,
|
|
127
|
+
registries: new Map([["products", productsRegistry]]),
|
|
128
|
+
});
|
|
129
|
+
await service.reindexEntity("products", "prod_xyz", async (_entityId, slice) => slice.audience === "customer" ? null : { id: "prod_xyz", fields: {} });
|
|
130
|
+
const upsertCalls = adapter.calls.filter((c) => c.op === "upsert");
|
|
131
|
+
expect(upsertCalls).toHaveLength(1);
|
|
132
|
+
expect(upsertCalls[0]?.slice.audience).toBe("staff-admin");
|
|
133
|
+
const deleteCalls = adapter.calls.filter((c) => c.op === "delete");
|
|
134
|
+
expect(deleteCalls).toHaveLength(1);
|
|
135
|
+
expect(deleteCalls[0]?.slice.audience).toBe("customer");
|
|
136
|
+
expect(deleteCalls[0]?.ids).toEqual(["prod_xyz"]);
|
|
137
|
+
});
|
|
138
|
+
it("deletes stale single-slice documents when the slice builder returns null", async () => {
|
|
139
|
+
const adapter = createStubAdapter();
|
|
140
|
+
const service = createIndexerService({
|
|
141
|
+
adapter,
|
|
142
|
+
slices: productSlices,
|
|
143
|
+
registries: new Map([["products", productsRegistry]]),
|
|
144
|
+
});
|
|
145
|
+
const customerSlice = productSlices[1];
|
|
146
|
+
await service.reindexEntityForSlice(customerSlice, "prod_xyz", async () => null);
|
|
147
|
+
expect(adapter.calls.filter((c) => c.op === "upsert")).toHaveLength(0);
|
|
148
|
+
const deleteCalls = adapter.calls.filter((c) => c.op === "delete");
|
|
149
|
+
expect(deleteCalls).toHaveLength(1);
|
|
150
|
+
expect(deleteCalls[0]?.slice).toEqual(customerSlice);
|
|
151
|
+
expect(deleteCalls[0]?.ids).toEqual(["prod_xyz"]);
|
|
152
|
+
});
|
|
153
|
+
it("throws when reindexing for a vertical without a registered registry", async () => {
|
|
154
|
+
const adapter = createStubAdapter();
|
|
155
|
+
const service = createIndexerService({
|
|
156
|
+
adapter,
|
|
157
|
+
slices: [{ vertical: "phantom", locale: "en-GB", audience: "customer", market: "default" }],
|
|
158
|
+
registries: new Map(),
|
|
159
|
+
});
|
|
160
|
+
await expect(service.ensureCollections()).rejects.toThrow(/no field-policy registry/);
|
|
161
|
+
});
|
|
162
|
+
it("slicesForVertical filters down correctly", () => {
|
|
163
|
+
const adapter = createStubAdapter();
|
|
164
|
+
const service = createIndexerService({
|
|
165
|
+
adapter,
|
|
166
|
+
slices: [...productSlices, ...cruiseSlices],
|
|
167
|
+
registries: new Map([
|
|
168
|
+
["products", productsRegistry],
|
|
169
|
+
["cruises", cruisesRegistry],
|
|
170
|
+
]),
|
|
171
|
+
});
|
|
172
|
+
expect(service.slicesForVertical("products")).toHaveLength(2);
|
|
173
|
+
expect(service.slicesForVertical("cruises")).toHaveLength(1);
|
|
174
|
+
expect(service.slicesForVertical("phantom")).toHaveLength(0);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OverlayService — drizzle-bound entry point for the catalog overlay store.
|
|
3
|
+
*
|
|
4
|
+
* Wraps the pure `resolveOverlay` logic (in `../overlay/resolver.ts`) with
|
|
5
|
+
* the DB queries verticals actually need: fetch active overlays for an
|
|
6
|
+
* entity, write a new overlay row, soft-delete an overlay, list by origin,
|
|
7
|
+
* and the all-in-one `resolveEntityView` helper that fetches + resolves in
|
|
8
|
+
* one call.
|
|
9
|
+
*
|
|
10
|
+
* Functions take an `AnyDrizzleDb` as their first parameter to match the
|
|
11
|
+
* existing voyant convention (see `packages/products/src/service.ts`). Pure
|
|
12
|
+
* resolver logic stays separate and remains unit-testable without a DB.
|
|
13
|
+
*
|
|
14
|
+
* See `docs/architecture/catalog-architecture.md` §5.2 for the design.
|
|
15
|
+
*/
|
|
16
|
+
import type { AnyDrizzleDb } from "@voyant-travel/db";
|
|
17
|
+
import type { FieldPolicyRegistry, Visibility } from "../contract.js";
|
|
18
|
+
import { type OverlayLookup, type ResolvedView, type ResolverOverlay, type ResolverScope } from "../overlay/resolver.js";
|
|
19
|
+
import { OVERLAY_DEFAULT_SCOPE, type OverlayOrigin, type SelectCatalogOverlay } from "../overlay/schema.js";
|
|
20
|
+
/**
|
|
21
|
+
* Fetch all active (not soft-deleted) overlays for an entity in one query.
|
|
22
|
+
* The resolver expects this exact shape; downstream callers pass the result
|
|
23
|
+
* straight into `resolveOverlay`.
|
|
24
|
+
*/
|
|
25
|
+
export declare function fetchOverlaysForEntity(db: AnyDrizzleDb, entityModule: string, entityId: string): Promise<ResolverOverlay[]>;
|
|
26
|
+
/**
|
|
27
|
+
* Batched form of `fetchOverlaysForEntity`: fetch all active overlays for
|
|
28
|
+
* many entities of one module in a single query, grouped by entity id.
|
|
29
|
+
*
|
|
30
|
+
* Every requested id is present in the returned map (entities without
|
|
31
|
+
* overlays map to an empty array), so callers can index without null
|
|
32
|
+
* checks. Pass the per-entity array straight into
|
|
33
|
+
* `resolveEntityViewWithOverlays` — the result is identical to calling
|
|
34
|
+
* `resolveEntityView` once per entity, minus the N-1 round trips.
|
|
35
|
+
*/
|
|
36
|
+
export declare function fetchOverlaysForEntities(db: AnyDrizzleDb, entityModule: string, entityIds: ReadonlyArray<string>): Promise<Map<string, ResolverOverlay[]>>;
|
|
37
|
+
/**
|
|
38
|
+
* Resolve an entity's view in one call: fetch overlays, run the resolver,
|
|
39
|
+
* return the merged view filtered by the actor's visibility.
|
|
40
|
+
*
|
|
41
|
+
* The caller supplies the source projection (typically gathered from the
|
|
42
|
+
* vertical's own service layer) and the requesting scope. Verticals call
|
|
43
|
+
* this in their `getEntity` / `listEntities` paths.
|
|
44
|
+
*/
|
|
45
|
+
export declare function resolveEntityView(db: AnyDrizzleDb, registry: FieldPolicyRegistry, entityModule: string, entityId: string, sourceProjection: ReadonlyMap<string, unknown>, scope: ResolverScope): Promise<ResolvedView>;
|
|
46
|
+
/**
|
|
47
|
+
* Lower-level helper for callers that have already fetched overlays (e.g.
|
|
48
|
+
* batch read-paths that pre-fetch overlays for many entities in one query).
|
|
49
|
+
*/
|
|
50
|
+
export declare function resolveEntityViewWithOverlays(registry: FieldPolicyRegistry, sourceProjection: ReadonlyMap<string, unknown>, overlays: OverlayLookup, scope: ResolverScope): ResolvedView;
|
|
51
|
+
/**
|
|
52
|
+
* List overlay rows by their origin discriminator. Used by revert / re-sync
|
|
53
|
+
* workflows ("show me everything Sanity wrote in the last week", "revert
|
|
54
|
+
* all AI-generated overlays on this entity").
|
|
55
|
+
*/
|
|
56
|
+
export interface OverlayOriginFilter {
|
|
57
|
+
kind: OverlayOrigin["kind"];
|
|
58
|
+
/** Kind-specific narrowing — e.g. `provider: "sanity"` for `kind: "cms"`. */
|
|
59
|
+
match?: Partial<OverlayOrigin>;
|
|
60
|
+
}
|
|
61
|
+
export declare function listOverlaysByOrigin(db: AnyDrizzleDb, filter: OverlayOriginFilter, options?: {
|
|
62
|
+
includeDeleted?: boolean;
|
|
63
|
+
limit?: number;
|
|
64
|
+
}): Promise<SelectCatalogOverlay[]>;
|
|
65
|
+
/**
|
|
66
|
+
* Input for writing a single overlay row. Variant axes default to
|
|
67
|
+
* `OVERLAY_DEFAULT_SCOPE`.
|
|
68
|
+
*/
|
|
69
|
+
export interface WriteOverlayInput {
|
|
70
|
+
entity_module: string;
|
|
71
|
+
entity_id: string;
|
|
72
|
+
field_path: string;
|
|
73
|
+
locale?: string;
|
|
74
|
+
audience?: Visibility | typeof OVERLAY_DEFAULT_SCOPE;
|
|
75
|
+
market?: string;
|
|
76
|
+
value: unknown;
|
|
77
|
+
origin: OverlayOrigin;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Write or replace an overlay row for the given variant tuple.
|
|
81
|
+
*
|
|
82
|
+
* If a row already exists for `(entity_module, entity_id, field_path,
|
|
83
|
+
* locale, audience, market)` and is not soft-deleted, this updates its
|
|
84
|
+
* value, origin, and `updated_at`. The partial unique index on the active
|
|
85
|
+
* rows guarantees idempotency.
|
|
86
|
+
*
|
|
87
|
+
* Last-write-wins is the default conflict-resolution policy (see
|
|
88
|
+
* architecture §5.2.3); per-field canonical-writer config is deferred.
|
|
89
|
+
*
|
|
90
|
+
* Note: this function does not validate that the field policy permits an
|
|
91
|
+
* overlay write (e.g. that `merge` is not `source-only` or that the actor's
|
|
92
|
+
* `editRole` matches). Callers are responsible for policy validation; the
|
|
93
|
+
* service-layer caller typically wraps this with a check against the
|
|
94
|
+
* vertical's `FieldPolicyRegistry`.
|
|
95
|
+
*/
|
|
96
|
+
export declare function writeOverlay(db: AnyDrizzleDb, input: WriteOverlayInput): Promise<SelectCatalogOverlay>;
|
|
97
|
+
/**
|
|
98
|
+
* Soft-delete an overlay row by setting `deleted_at`. The row is preserved
|
|
99
|
+
* for retention / restore but no longer participates in resolver merges.
|
|
100
|
+
*/
|
|
101
|
+
export declare function softDeleteOverlay(db: AnyDrizzleDb, id: string): Promise<void>;
|
|
102
|
+
/**
|
|
103
|
+
* Restore a soft-deleted overlay by clearing `deleted_at`. Used by source
|
|
104
|
+
* reconnection (§5.10.5) when an entity comes back within the retention
|
|
105
|
+
* window.
|
|
106
|
+
*/
|
|
107
|
+
export declare function restoreOverlay(db: AnyDrizzleDb, id: string): Promise<void>;
|
|
108
|
+
//# sourceMappingURL=overlay-service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"overlay-service.d.ts","sourceRoot":"","sources":["../../src/services/overlay-service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAIrD,OAAO,KAAK,EAAE,mBAAmB,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAA;AACrE,OAAO,EACL,KAAK,aAAa,EAClB,KAAK,YAAY,EACjB,KAAK,eAAe,EACpB,KAAK,aAAa,EAEnB,MAAM,wBAAwB,CAAA;AAC/B,OAAO,EAEL,qBAAqB,EACrB,KAAK,aAAa,EAClB,KAAK,oBAAoB,EAC1B,MAAM,sBAAsB,CAAA;AAM7B;;;;GAIG;AACH,wBAAsB,sBAAsB,CAC1C,EAAE,EAAE,YAAY,EAChB,YAAY,EAAE,MAAM,EACpB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,eAAe,EAAE,CAAC,CAyB5B;AAED;;;;;;;;;GASG;AACH,wBAAsB,wBAAwB,CAC5C,EAAE,EAAE,YAAY,EAChB,YAAY,EAAE,MAAM,EACpB,SAAS,EAAE,aAAa,CAAC,MAAM,CAAC,GAC/B,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,eAAe,EAAE,CAAC,CAAC,CAmCzC;AAED;;;;;;;GAOG;AACH,wBAAsB,iBAAiB,CACrC,EAAE,EAAE,YAAY,EAChB,QAAQ,EAAE,mBAAmB,EAC7B,YAAY,EAAE,MAAM,EACpB,QAAQ,EAAE,MAAM,EAChB,gBAAgB,EAAE,WAAW,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9C,KAAK,EAAE,aAAa,GACnB,OAAO,CAAC,YAAY,CAAC,CAGvB;AAED;;;GAGG;AACH,wBAAgB,6BAA6B,CAC3C,QAAQ,EAAE,mBAAmB,EAC7B,gBAAgB,EAAE,WAAW,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9C,QAAQ,EAAE,aAAa,EACvB,KAAK,EAAE,aAAa,GACnB,YAAY,CAEd;AAED;;;;GAIG;AACH,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,aAAa,CAAC,MAAM,CAAC,CAAA;IAC3B,6EAA6E;IAC7E,KAAK,CAAC,EAAE,OAAO,CAAC,aAAa,CAAC,CAAA;CAC/B;AAED,wBAAsB,oBAAoB,CACxC,EAAE,EAAE,YAAY,EAChB,MAAM,EAAE,mBAAmB,EAC3B,OAAO,GAAE;IAAE,cAAc,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAO,GACzD,OAAO,CAAC,oBAAoB,EAAE,CAAC,CA2BjC;AAMD;;;GAGG;AACH,MAAM,WAAW,iBAAiB;IAChC,aAAa,EAAE,MAAM,CAAA;IACrB,SAAS,EAAE,MAAM,CAAA;IACjB,UAAU,EAAE,MAAM,CAAA;IAClB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,QAAQ,CAAC,EAAE,UAAU,GAAG,OAAO,qBAAqB,CAAA;IACpD,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,KAAK,EAAE,OAAO,CAAA;IACd,MAAM,EAAE,aAAa,CAAA;CACtB;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,YAAY,CAChC,EAAE,EAAE,YAAY,EAChB,KAAK,EAAE,iBAAiB,GACvB,OAAO,CAAC,oBAAoB,CAAC,CA0C/B;AAED;;;GAGG;AACH,wBAAsB,iBAAiB,CAAC,EAAE,EAAE,YAAY,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAKnF;AAED;;;;GAIG;AACH,wBAAsB,cAAc,CAAC,EAAE,EAAE,YAAY,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAKhF"}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OverlayService — drizzle-bound entry point for the catalog overlay store.
|
|
3
|
+
*
|
|
4
|
+
* Wraps the pure `resolveOverlay` logic (in `../overlay/resolver.ts`) with
|
|
5
|
+
* the DB queries verticals actually need: fetch active overlays for an
|
|
6
|
+
* entity, write a new overlay row, soft-delete an overlay, list by origin,
|
|
7
|
+
* and the all-in-one `resolveEntityView` helper that fetches + resolves in
|
|
8
|
+
* one call.
|
|
9
|
+
*
|
|
10
|
+
* Functions take an `AnyDrizzleDb` as their first parameter to match the
|
|
11
|
+
* existing voyant convention (see `packages/products/src/service.ts`). Pure
|
|
12
|
+
* resolver logic stays separate and remains unit-testable without a DB.
|
|
13
|
+
*
|
|
14
|
+
* See `docs/architecture/catalog-architecture.md` §5.2 for the design.
|
|
15
|
+
*/
|
|
16
|
+
import { newId } from "@voyant-travel/db/lib/typeid";
|
|
17
|
+
import { and, eq, inArray, isNull, sql } from "drizzle-orm";
|
|
18
|
+
import { resolveOverlay, } from "../overlay/resolver.js";
|
|
19
|
+
import { catalogOverlayTable, OVERLAY_DEFAULT_SCOPE, } from "../overlay/schema.js";
|
|
20
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
21
|
+
// Reads
|
|
22
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
23
|
+
/**
|
|
24
|
+
* Fetch all active (not soft-deleted) overlays for an entity in one query.
|
|
25
|
+
* The resolver expects this exact shape; downstream callers pass the result
|
|
26
|
+
* straight into `resolveOverlay`.
|
|
27
|
+
*/
|
|
28
|
+
export async function fetchOverlaysForEntity(db, entityModule, entityId) {
|
|
29
|
+
const rows = await db
|
|
30
|
+
.select({
|
|
31
|
+
field_path: catalogOverlayTable.field_path,
|
|
32
|
+
locale: catalogOverlayTable.locale,
|
|
33
|
+
audience: catalogOverlayTable.audience,
|
|
34
|
+
market: catalogOverlayTable.market,
|
|
35
|
+
value: catalogOverlayTable.value,
|
|
36
|
+
})
|
|
37
|
+
.from(catalogOverlayTable)
|
|
38
|
+
.where(and(eq(catalogOverlayTable.entity_module, entityModule), eq(catalogOverlayTable.entity_id, entityId), isNull(catalogOverlayTable.deleted_at)));
|
|
39
|
+
return rows.map((row) => ({
|
|
40
|
+
field_path: row.field_path,
|
|
41
|
+
locale: row.locale,
|
|
42
|
+
audience: row.audience,
|
|
43
|
+
market: row.market,
|
|
44
|
+
value: row.value,
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Batched form of `fetchOverlaysForEntity`: fetch all active overlays for
|
|
49
|
+
* many entities of one module in a single query, grouped by entity id.
|
|
50
|
+
*
|
|
51
|
+
* Every requested id is present in the returned map (entities without
|
|
52
|
+
* overlays map to an empty array), so callers can index without null
|
|
53
|
+
* checks. Pass the per-entity array straight into
|
|
54
|
+
* `resolveEntityViewWithOverlays` — the result is identical to calling
|
|
55
|
+
* `resolveEntityView` once per entity, minus the N-1 round trips.
|
|
56
|
+
*/
|
|
57
|
+
export async function fetchOverlaysForEntities(db, entityModule, entityIds) {
|
|
58
|
+
const byEntity = new Map();
|
|
59
|
+
for (const id of entityIds) {
|
|
60
|
+
if (!byEntity.has(id))
|
|
61
|
+
byEntity.set(id, []);
|
|
62
|
+
}
|
|
63
|
+
if (byEntity.size === 0)
|
|
64
|
+
return byEntity;
|
|
65
|
+
const rows = await db
|
|
66
|
+
.select({
|
|
67
|
+
entity_id: catalogOverlayTable.entity_id,
|
|
68
|
+
field_path: catalogOverlayTable.field_path,
|
|
69
|
+
locale: catalogOverlayTable.locale,
|
|
70
|
+
audience: catalogOverlayTable.audience,
|
|
71
|
+
market: catalogOverlayTable.market,
|
|
72
|
+
value: catalogOverlayTable.value,
|
|
73
|
+
})
|
|
74
|
+
.from(catalogOverlayTable)
|
|
75
|
+
.where(and(eq(catalogOverlayTable.entity_module, entityModule), inArray(catalogOverlayTable.entity_id, [...byEntity.keys()]), isNull(catalogOverlayTable.deleted_at)));
|
|
76
|
+
for (const row of rows) {
|
|
77
|
+
byEntity.get(row.entity_id)?.push({
|
|
78
|
+
field_path: row.field_path,
|
|
79
|
+
locale: row.locale,
|
|
80
|
+
audience: row.audience,
|
|
81
|
+
market: row.market,
|
|
82
|
+
value: row.value,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return byEntity;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Resolve an entity's view in one call: fetch overlays, run the resolver,
|
|
89
|
+
* return the merged view filtered by the actor's visibility.
|
|
90
|
+
*
|
|
91
|
+
* The caller supplies the source projection (typically gathered from the
|
|
92
|
+
* vertical's own service layer) and the requesting scope. Verticals call
|
|
93
|
+
* this in their `getEntity` / `listEntities` paths.
|
|
94
|
+
*/
|
|
95
|
+
export async function resolveEntityView(db, registry, entityModule, entityId, sourceProjection, scope) {
|
|
96
|
+
const overlays = await fetchOverlaysForEntity(db, entityModule, entityId);
|
|
97
|
+
return resolveOverlay(registry, sourceProjection, overlays, scope);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Lower-level helper for callers that have already fetched overlays (e.g.
|
|
101
|
+
* batch read-paths that pre-fetch overlays for many entities in one query).
|
|
102
|
+
*/
|
|
103
|
+
export function resolveEntityViewWithOverlays(registry, sourceProjection, overlays, scope) {
|
|
104
|
+
return resolveOverlay(registry, sourceProjection, overlays, scope);
|
|
105
|
+
}
|
|
106
|
+
export async function listOverlaysByOrigin(db, filter, options = {}) {
|
|
107
|
+
// agent-quality: raw-sql reviewed -- JSONB origin discriminator uses parameterized values; Drizzle has no helper for the `->>` JSON key predicate here.
|
|
108
|
+
const conditions = [sql `${catalogOverlayTable.origin}->>'kind' = ${filter.kind}`];
|
|
109
|
+
if (filter.match) {
|
|
110
|
+
for (const [key, value] of Object.entries(filter.match)) {
|
|
111
|
+
if (key === "kind")
|
|
112
|
+
continue;
|
|
113
|
+
// agent-quality: raw-sql reviewed -- Origin match keys come from the typed overlay-origin filter and values stay parameterized.
|
|
114
|
+
conditions.push(sql `${catalogOverlayTable.origin}->>${key} = ${String(value)}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (!options.includeDeleted) {
|
|
118
|
+
// agent-quality: raw-sql reviewed -- Literal deleted_at null predicate is scoped to the overlay table.
|
|
119
|
+
conditions.push(sql `${catalogOverlayTable.deleted_at} IS NULL`);
|
|
120
|
+
}
|
|
121
|
+
if (options.limit != null) {
|
|
122
|
+
return db
|
|
123
|
+
.select()
|
|
124
|
+
.from(catalogOverlayTable)
|
|
125
|
+
.where(and(...conditions))
|
|
126
|
+
.limit(options.limit);
|
|
127
|
+
}
|
|
128
|
+
return db
|
|
129
|
+
.select()
|
|
130
|
+
.from(catalogOverlayTable)
|
|
131
|
+
.where(and(...conditions));
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Write or replace an overlay row for the given variant tuple.
|
|
135
|
+
*
|
|
136
|
+
* If a row already exists for `(entity_module, entity_id, field_path,
|
|
137
|
+
* locale, audience, market)` and is not soft-deleted, this updates its
|
|
138
|
+
* value, origin, and `updated_at`. The partial unique index on the active
|
|
139
|
+
* rows guarantees idempotency.
|
|
140
|
+
*
|
|
141
|
+
* Last-write-wins is the default conflict-resolution policy (see
|
|
142
|
+
* architecture §5.2.3); per-field canonical-writer config is deferred.
|
|
143
|
+
*
|
|
144
|
+
* Note: this function does not validate that the field policy permits an
|
|
145
|
+
* overlay write (e.g. that `merge` is not `source-only` or that the actor's
|
|
146
|
+
* `editRole` matches). Callers are responsible for policy validation; the
|
|
147
|
+
* service-layer caller typically wraps this with a check against the
|
|
148
|
+
* vertical's `FieldPolicyRegistry`.
|
|
149
|
+
*/
|
|
150
|
+
export async function writeOverlay(db, input) {
|
|
151
|
+
const locale = input.locale ?? OVERLAY_DEFAULT_SCOPE;
|
|
152
|
+
const audience = input.audience ?? OVERLAY_DEFAULT_SCOPE;
|
|
153
|
+
const market = input.market ?? OVERLAY_DEFAULT_SCOPE;
|
|
154
|
+
// Insert with ON CONFLICT for the active-row composite unique key.
|
|
155
|
+
// Drizzle's onConflictDoUpdate is the right primitive here.
|
|
156
|
+
const inserted = await db
|
|
157
|
+
.insert(catalogOverlayTable)
|
|
158
|
+
.values({
|
|
159
|
+
id: newId("catalog_overlay"),
|
|
160
|
+
entity_module: input.entity_module,
|
|
161
|
+
entity_id: input.entity_id,
|
|
162
|
+
field_path: input.field_path,
|
|
163
|
+
locale,
|
|
164
|
+
audience,
|
|
165
|
+
market,
|
|
166
|
+
value: input.value,
|
|
167
|
+
origin: input.origin,
|
|
168
|
+
})
|
|
169
|
+
.onConflictDoUpdate({
|
|
170
|
+
target: [
|
|
171
|
+
catalogOverlayTable.entity_module,
|
|
172
|
+
catalogOverlayTable.entity_id,
|
|
173
|
+
catalogOverlayTable.field_path,
|
|
174
|
+
catalogOverlayTable.locale,
|
|
175
|
+
catalogOverlayTable.audience,
|
|
176
|
+
catalogOverlayTable.market,
|
|
177
|
+
],
|
|
178
|
+
targetWhere: isNull(catalogOverlayTable.deleted_at),
|
|
179
|
+
set: {
|
|
180
|
+
value: input.value,
|
|
181
|
+
origin: input.origin,
|
|
182
|
+
updated_at: new Date(),
|
|
183
|
+
},
|
|
184
|
+
})
|
|
185
|
+
.returning();
|
|
186
|
+
if (!inserted[0]) {
|
|
187
|
+
throw new Error("writeOverlay: insert returned no rows");
|
|
188
|
+
}
|
|
189
|
+
return inserted[0];
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Soft-delete an overlay row by setting `deleted_at`. The row is preserved
|
|
193
|
+
* for retention / restore but no longer participates in resolver merges.
|
|
194
|
+
*/
|
|
195
|
+
export async function softDeleteOverlay(db, id) {
|
|
196
|
+
await db
|
|
197
|
+
.update(catalogOverlayTable)
|
|
198
|
+
.set({ deleted_at: new Date(), updated_at: new Date() })
|
|
199
|
+
.where(eq(catalogOverlayTable.id, id));
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Restore a soft-deleted overlay by clearing `deleted_at`. Used by source
|
|
203
|
+
* reconnection (§5.10.5) when an entity comes back within the retention
|
|
204
|
+
* window.
|
|
205
|
+
*/
|
|
206
|
+
export async function restoreOverlay(db, id) {
|
|
207
|
+
await db
|
|
208
|
+
.update(catalogOverlayTable)
|
|
209
|
+
.set({ deleted_at: null, updated_at: new Date() })
|
|
210
|
+
.where(eq(catalogOverlayTable.id, id));
|
|
211
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"overlay-service.test.d.ts","sourceRoot":"","sources":["../../src/services/overlay-service.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { fetchOverlaysForEntities } from "./overlay-service.js";
|
|
3
|
+
function drizzleStub(methods) {
|
|
4
|
+
return methods;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Minimal mock for the `db.select({...}).from(...).where(...)` chain the
|
|
8
|
+
* batched overlay fetch issues. Every `select` call is counted so tests can
|
|
9
|
+
* assert the one-query contract.
|
|
10
|
+
*/
|
|
11
|
+
function mockDb(rows) {
|
|
12
|
+
let selectCalls = 0;
|
|
13
|
+
const db = drizzleStub({
|
|
14
|
+
select: () => {
|
|
15
|
+
selectCalls++;
|
|
16
|
+
return {
|
|
17
|
+
from: () => ({
|
|
18
|
+
where: async () => rows,
|
|
19
|
+
}),
|
|
20
|
+
};
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
return { db, selectCount: () => selectCalls };
|
|
24
|
+
}
|
|
25
|
+
const overlayRow = (entityId, fieldPath, value) => ({
|
|
26
|
+
entity_id: entityId,
|
|
27
|
+
field_path: fieldPath,
|
|
28
|
+
locale: "en-GB",
|
|
29
|
+
audience: "customer",
|
|
30
|
+
market: "default",
|
|
31
|
+
value,
|
|
32
|
+
});
|
|
33
|
+
describe("fetchOverlaysForEntities", () => {
|
|
34
|
+
it("fetches overlays for many entities in one query, grouped by entity id", async () => {
|
|
35
|
+
const { db, selectCount } = mockDb([
|
|
36
|
+
overlayRow("prod_a", "name", "A!"),
|
|
37
|
+
overlayRow("prod_b", "name", "B!"),
|
|
38
|
+
overlayRow("prod_a", "description", "Desc A"),
|
|
39
|
+
]);
|
|
40
|
+
const result = await fetchOverlaysForEntities(db, "products", ["prod_a", "prod_b"]);
|
|
41
|
+
expect(selectCount()).toBe(1);
|
|
42
|
+
expect(result.get("prod_a")).toEqual([
|
|
43
|
+
{
|
|
44
|
+
field_path: "name",
|
|
45
|
+
locale: "en-GB",
|
|
46
|
+
audience: "customer",
|
|
47
|
+
market: "default",
|
|
48
|
+
value: "A!",
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
field_path: "description",
|
|
52
|
+
locale: "en-GB",
|
|
53
|
+
audience: "customer",
|
|
54
|
+
market: "default",
|
|
55
|
+
value: "Desc A",
|
|
56
|
+
},
|
|
57
|
+
]);
|
|
58
|
+
expect(result.get("prod_b")).toHaveLength(1);
|
|
59
|
+
});
|
|
60
|
+
it("pre-seeds every requested id so entities without overlays map to []", async () => {
|
|
61
|
+
const { db } = mockDb([overlayRow("prod_a", "name", "A!")]);
|
|
62
|
+
const result = await fetchOverlaysForEntities(db, "products", ["prod_a", "prod_none"]);
|
|
63
|
+
expect(result.get("prod_none")).toEqual([]);
|
|
64
|
+
expect([...result.keys()]).toEqual(["prod_a", "prod_none"]);
|
|
65
|
+
});
|
|
66
|
+
it("returns an empty map without querying when no ids are passed", async () => {
|
|
67
|
+
const { db, selectCount } = mockDb([]);
|
|
68
|
+
const result = await fetchOverlaysForEntities(db, "products", []);
|
|
69
|
+
expect(result.size).toBe(0);
|
|
70
|
+
expect(selectCount()).toBe(0);
|
|
71
|
+
});
|
|
72
|
+
it("dedupes repeated ids (one map entry, one query)", async () => {
|
|
73
|
+
const { db, selectCount } = mockDb([overlayRow("prod_a", "name", "A!")]);
|
|
74
|
+
const result = await fetchOverlaysForEntities(db, "products", ["prod_a", "prod_a"]);
|
|
75
|
+
expect(selectCount()).toBe(1);
|
|
76
|
+
expect(result.size).toBe(1);
|
|
77
|
+
expect(result.get("prod_a")).toHaveLength(1);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"snapshot-builder.test.d.ts","sourceRoot":"","sources":["../../src/services/snapshot-builder.test.ts"],"names":[],"mappings":""}
|