@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,102 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createFieldPolicyRegistry, defineFieldPolicy } from "../contract.js";
|
|
3
|
+
import { buildIndexerDocument } from "./indexer-service.js";
|
|
4
|
+
const registry = createFieldPolicyRegistry(defineFieldPolicy([
|
|
5
|
+
{
|
|
6
|
+
path: "title",
|
|
7
|
+
class: "merchandisable",
|
|
8
|
+
merge: "replace",
|
|
9
|
+
editRole: "marketing",
|
|
10
|
+
overrideFriction: "none",
|
|
11
|
+
snapshot: "on-book",
|
|
12
|
+
query: "indexed-column",
|
|
13
|
+
visibility: ["staff", "customer", "partner"],
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
path: "description",
|
|
17
|
+
class: "merchandisable",
|
|
18
|
+
merge: "replace",
|
|
19
|
+
editRole: "marketing",
|
|
20
|
+
overrideFriction: "none",
|
|
21
|
+
snapshot: "on-book",
|
|
22
|
+
query: "blob-only", // stored on the entity row but not indexed
|
|
23
|
+
visibility: ["staff", "customer", "partner"],
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
path: "internal_notes",
|
|
27
|
+
class: "merchandisable",
|
|
28
|
+
merge: "replace",
|
|
29
|
+
editRole: "ops",
|
|
30
|
+
overrideFriction: "none",
|
|
31
|
+
snapshot: "never",
|
|
32
|
+
query: "indexed-column",
|
|
33
|
+
visibility: ["staff"],
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
path: "tags[]",
|
|
37
|
+
class: "merchandisable",
|
|
38
|
+
merge: "additive-set",
|
|
39
|
+
editRole: "marketing",
|
|
40
|
+
overrideFriction: "none",
|
|
41
|
+
snapshot: "on-book",
|
|
42
|
+
query: "indexed-column",
|
|
43
|
+
visibility: ["staff", "customer", "partner"],
|
|
44
|
+
},
|
|
45
|
+
]));
|
|
46
|
+
const customerSlice = {
|
|
47
|
+
vertical: "products",
|
|
48
|
+
locale: "en-GB",
|
|
49
|
+
audience: "customer",
|
|
50
|
+
market: "default",
|
|
51
|
+
};
|
|
52
|
+
const adminSlice = {
|
|
53
|
+
vertical: "products",
|
|
54
|
+
locale: "en-GB",
|
|
55
|
+
audience: "staff-admin",
|
|
56
|
+
market: "default",
|
|
57
|
+
};
|
|
58
|
+
const projection = new Map([
|
|
59
|
+
["title", "Bali Wellness"],
|
|
60
|
+
["description", "A long description"],
|
|
61
|
+
["internal_notes", "Margin tight, push hard in Q3"],
|
|
62
|
+
["tags[]", ["wellness", "yoga"]],
|
|
63
|
+
]);
|
|
64
|
+
describe("buildIndexerDocument", () => {
|
|
65
|
+
it("includes the entity id in the returned document", () => {
|
|
66
|
+
const doc = buildIndexerDocument(registry, projection, customerSlice, "prod_xyz");
|
|
67
|
+
expect(doc.id).toBe("prod_xyz");
|
|
68
|
+
});
|
|
69
|
+
it("skips blob-only fields (description) — stored on row, not indexed", () => {
|
|
70
|
+
const doc = buildIndexerDocument(registry, projection, customerSlice, "prod_xyz");
|
|
71
|
+
expect(doc.fields).not.toHaveProperty("description");
|
|
72
|
+
});
|
|
73
|
+
it("excludes staff-only fields from customer-audience documents", () => {
|
|
74
|
+
const doc = buildIndexerDocument(registry, projection, customerSlice, "prod_xyz");
|
|
75
|
+
expect(doc.fields).not.toHaveProperty("internal_notes");
|
|
76
|
+
expect(doc.fields).toHaveProperty("title");
|
|
77
|
+
});
|
|
78
|
+
it("includes staff-only fields in admin documents (cross-audience denormalization)", () => {
|
|
79
|
+
const doc = buildIndexerDocument(registry, projection, adminSlice, "prod_xyz");
|
|
80
|
+
expect(doc.fields).toHaveProperty("internal_notes");
|
|
81
|
+
expect(doc.fields).toHaveProperty("title");
|
|
82
|
+
});
|
|
83
|
+
it("strips `[]` suffix from list-field names", () => {
|
|
84
|
+
const doc = buildIndexerDocument(registry, projection, customerSlice, "prod_xyz");
|
|
85
|
+
expect(doc.fields).toHaveProperty("tags");
|
|
86
|
+
expect(doc.fields).not.toHaveProperty("tags[]");
|
|
87
|
+
expect(doc.fields.tags).toEqual(["wellness", "yoga"]);
|
|
88
|
+
});
|
|
89
|
+
it("accepts natural projection keys for list policies", () => {
|
|
90
|
+
const doc = buildIndexerDocument(registry, new Map([["tags", ["wellness", "yoga"]]]), customerSlice, "prod_xyz");
|
|
91
|
+
expect(doc.fields.tags).toEqual(["wellness", "yoga"]);
|
|
92
|
+
});
|
|
93
|
+
it("ignores fields not declared in the registry", () => {
|
|
94
|
+
const projectionWithExtra = new Map([
|
|
95
|
+
["title", "Hello"],
|
|
96
|
+
["phantom_field", "????"],
|
|
97
|
+
]);
|
|
98
|
+
const doc = buildIndexerDocument(registry, projectionWithExtra, customerSlice, "prod_xyz");
|
|
99
|
+
expect(doc.fields).toHaveProperty("title");
|
|
100
|
+
expect(doc.fields).not.toHaveProperty("phantom_field");
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog content-service primitives.
|
|
3
|
+
*
|
|
4
|
+
* Phase C of the sourced-content architecture. This module is the runtime
|
|
5
|
+
* home for per-vertical content services.
|
|
6
|
+
*
|
|
7
|
+
* The pure, dependency-free primitives — `isStale`, `pickBestCachedLocale`,
|
|
8
|
+
* `parseJsonPointer` / `applyJsonPointerOverlay`, and
|
|
9
|
+
* `mergeOverlaysIntoContent` — now live in `@voyant-travel/catalog-contracts`
|
|
10
|
+
* so external adapter authors can compose and validate content payloads
|
|
11
|
+
* without the catalog runtime. They are re-exported here so existing
|
|
12
|
+
* `@voyant-travel/catalog` import paths stay stable.
|
|
13
|
+
*
|
|
14
|
+
* What remains defined here are the runtime-bound primitives that need a
|
|
15
|
+
* Drizzle/Postgres connection and therefore cannot live in the contracts
|
|
16
|
+
* package:
|
|
17
|
+
*
|
|
18
|
+
* - `withContentRefreshLock` — Postgres advisory-lock singleflight for
|
|
19
|
+
* cross-worker SWR refresh dedup.
|
|
20
|
+
* - `createInvalidateOnDrift` / `buildDriftInvalidationPredicate` —
|
|
21
|
+
* content-drift → cache-invalidation against
|
|
22
|
+
* a per-vertical `*_sourced_content` table.
|
|
23
|
+
*
|
|
24
|
+
* See `docs/architecture/catalog-sourced-content.md` §3.4, §3.4.1, §3.5.3,
|
|
25
|
+
* §3.5.4.
|
|
26
|
+
*/
|
|
27
|
+
import type { AnyDrizzleDb } from "@voyant-travel/db";
|
|
28
|
+
import type { PgColumn, PgTable } from "drizzle-orm/pg-core";
|
|
29
|
+
import type { ContentDriftEvent } from "../drift/events.js";
|
|
30
|
+
export { applyJsonPointerOverlay, type ContentLocaleMatchKind, type ContentLocaleResolution, type ContentOverlay, isStale, JsonPointerError, type MergeOverlaysOptions, mergeOverlaysIntoContent, parseJsonPointer, pickBestCachedLocale, } from "@voyant-travel/catalog-contracts/content";
|
|
31
|
+
/**
|
|
32
|
+
* Acquire a Postgres advisory lock keyed on
|
|
33
|
+
* `content:${entityModule}:${entityId}:${locale}:${market}` and run
|
|
34
|
+
* `fn`. If the lock is held by another worker, returns `null` without
|
|
35
|
+
* running `fn` — the caller serves the stale row and lets the
|
|
36
|
+
* lock-holder do the refresh.
|
|
37
|
+
*
|
|
38
|
+
* Uses session-level `pg_try_advisory_lock` + `pg_advisory_unlock`. The
|
|
39
|
+
* lock is released when the function returns or throws. Caller is
|
|
40
|
+
* responsible for serving stale data when this returns null.
|
|
41
|
+
*
|
|
42
|
+
* Cross-worker dedup is the point — in-process Map-based singleflight
|
|
43
|
+
* only collapses requests inside one worker; CF Workers / multi-pod
|
|
44
|
+
* deployments need DB-level coordination (see sourced-content §3.4).
|
|
45
|
+
*/
|
|
46
|
+
export declare function withContentRefreshLock<T>(db: AnyDrizzleDb, options: {
|
|
47
|
+
entityModule: string;
|
|
48
|
+
entityId: string;
|
|
49
|
+
locale: string;
|
|
50
|
+
market?: string;
|
|
51
|
+
}, fn: () => Promise<T>): Promise<T | null>;
|
|
52
|
+
/**
|
|
53
|
+
* Build the SQL predicate that matches cache rows affected by a
|
|
54
|
+
* `ContentDriftEvent`. Verticals call this to scope an invalidation
|
|
55
|
+
* `UPDATE … SET fresh_until = now()` against their own content table.
|
|
56
|
+
*
|
|
57
|
+
* Predicate semantics:
|
|
58
|
+
* - Always matches `entity_module = event.entity_module` AND
|
|
59
|
+
* `entity_id = event.entity_id`.
|
|
60
|
+
* - When `event.locale` is set, narrows to that locale only;
|
|
61
|
+
* otherwise matches all locales.
|
|
62
|
+
* - When `event.market` is set, narrows to that market only;
|
|
63
|
+
* otherwise matches all markets.
|
|
64
|
+
*
|
|
65
|
+
* Returned shape is a `BuiltDriftPredicate` carrying the column names
|
|
66
|
+
* the vertical needs to match, plus the values. Verticals build their
|
|
67
|
+
* `where(...)` clause from this; we don't import per-vertical tables
|
|
68
|
+
* here.
|
|
69
|
+
*/
|
|
70
|
+
export interface BuiltDriftPredicate {
|
|
71
|
+
entity_module: string;
|
|
72
|
+
entity_id: string;
|
|
73
|
+
locale: string | null;
|
|
74
|
+
market: string | null;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Per-vertical `invalidateOnDrift(db, event)` runner. Built via
|
|
78
|
+
* `createInvalidateOnDrift(table)` against the vertical's
|
|
79
|
+
* `*_sourced_content` table. When a `ContentDriftEvent` fires, the
|
|
80
|
+
* vertical's wired runner sets `fresh_until = now()` on every row
|
|
81
|
+
* matching `(entity_module, entity_id [, locale [, market]])` so the
|
|
82
|
+
* next read serves stale + schedules a SWR refresh (sourced-content
|
|
83
|
+
* §3.4.1).
|
|
84
|
+
*/
|
|
85
|
+
export type InvalidateOnDrift = (db: AnyDrizzleDb, event: ContentDriftEvent) => Promise<{
|
|
86
|
+
invalidated: number;
|
|
87
|
+
}>;
|
|
88
|
+
/**
|
|
89
|
+
* Column shape every vertical's `*_sourced_content` table satisfies.
|
|
90
|
+
* The factory uses these to build the WHERE clause without importing
|
|
91
|
+
* per-vertical tables — keeps the catalog package neutral.
|
|
92
|
+
*/
|
|
93
|
+
export interface VerticalContentInvalidatableTable {
|
|
94
|
+
entity_id: PgColumn;
|
|
95
|
+
locale: PgColumn;
|
|
96
|
+
market: PgColumn;
|
|
97
|
+
fresh_until: PgColumn;
|
|
98
|
+
}
|
|
99
|
+
export interface CreateInvalidateOnDriftOptions {
|
|
100
|
+
/**
|
|
101
|
+
* Entity module this invalidator handles (e.g. `"products"`,
|
|
102
|
+
* `"cruises"`). Events targeting other modules are skipped silently.
|
|
103
|
+
* Templates wire one runner per vertical and dispatch by
|
|
104
|
+
* `event.entity_module`.
|
|
105
|
+
*/
|
|
106
|
+
entityModule: string;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Build a per-vertical `invalidateOnDrift` runner against the
|
|
110
|
+
* vertical's `*_sourced_content` drizzle table. The returned function
|
|
111
|
+
* is the sourced-content §3.4.1 invalidation primitive — verticals
|
|
112
|
+
* subscribe their runner to the drift-event bus.
|
|
113
|
+
*
|
|
114
|
+
* Semantics:
|
|
115
|
+
* - Skips events whose `entity_module` doesn't match `options.entityModule`.
|
|
116
|
+
* Templates compose runners across verticals; mismatched events are
|
|
117
|
+
* not this runner's concern.
|
|
118
|
+
* - When the event scopes `locale` and/or `market`, the WHERE clause
|
|
119
|
+
* narrows accordingly. Wildcards (event.locale unset / event.market
|
|
120
|
+
* unset) match all rows for that axis — full-entity invalidation.
|
|
121
|
+
* - Returns `{ invalidated }` count for ops dashboards.
|
|
122
|
+
*/
|
|
123
|
+
export declare function createInvalidateOnDrift<TTable extends PgTable & VerticalContentInvalidatableTable>(table: TTable, options: CreateInvalidateOnDriftOptions): InvalidateOnDrift;
|
|
124
|
+
export declare function buildDriftInvalidationPredicate(event: ContentDriftEvent): BuiltDriftPredicate;
|
|
125
|
+
//# sourceMappingURL=content-service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"content-service.d.ts","sourceRoot":"","sources":["../../src/services/content-service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAErD,OAAO,KAAK,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAE5D,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAA;AAM3D,OAAO,EACL,uBAAuB,EACvB,KAAK,sBAAsB,EAC3B,KAAK,uBAAuB,EAC5B,KAAK,cAAc,EACnB,OAAO,EACP,gBAAgB,EAChB,KAAK,oBAAoB,EACzB,wBAAwB,EACxB,gBAAgB,EAChB,oBAAoB,GACrB,MAAM,0CAA0C,CAAA;AAMjD;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,sBAAsB,CAAC,CAAC,EAC5C,EAAE,EAAE,YAAY,EAChB,OAAO,EAAE;IACP,YAAY,EAAE,MAAM,CAAA;IACpB,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB,EACD,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GACnB,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CASnB;AA2CD;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,WAAW,mBAAmB;IAClC,aAAa,EAAE,MAAM,CAAA;IACrB,SAAS,EAAE,MAAM,CAAA;IACjB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;CACtB;AAED;;;;;;;;GAQG;AACH,MAAM,MAAM,iBAAiB,GAAG,CAC9B,EAAE,EAAE,YAAY,EAChB,KAAK,EAAE,iBAAiB,KACrB,OAAO,CAAC;IAAE,WAAW,EAAE,MAAM,CAAA;CAAE,CAAC,CAAA;AAErC;;;;GAIG;AACH,MAAM,WAAW,iCAAiC;IAChD,SAAS,EAAE,QAAQ,CAAA;IACnB,MAAM,EAAE,QAAQ,CAAA;IAChB,MAAM,EAAE,QAAQ,CAAA;IAChB,WAAW,EAAE,QAAQ,CAAA;CACtB;AAED,MAAM,WAAW,8BAA8B;IAC7C;;;;;OAKG;IACH,YAAY,EAAE,MAAM,CAAA;CACrB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,uBAAuB,CAAC,MAAM,SAAS,OAAO,GAAG,iCAAiC,EAChG,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,8BAA8B,GACtC,iBAAiB,CA2BnB;AAED,wBAAgB,+BAA+B,CAAC,KAAK,EAAE,iBAAiB,GAAG,mBAAmB,CAO7F"}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog content-service primitives.
|
|
3
|
+
*
|
|
4
|
+
* Phase C of the sourced-content architecture. This module is the runtime
|
|
5
|
+
* home for per-vertical content services.
|
|
6
|
+
*
|
|
7
|
+
* The pure, dependency-free primitives — `isStale`, `pickBestCachedLocale`,
|
|
8
|
+
* `parseJsonPointer` / `applyJsonPointerOverlay`, and
|
|
9
|
+
* `mergeOverlaysIntoContent` — now live in `@voyant-travel/catalog-contracts`
|
|
10
|
+
* so external adapter authors can compose and validate content payloads
|
|
11
|
+
* without the catalog runtime. They are re-exported here so existing
|
|
12
|
+
* `@voyant-travel/catalog` import paths stay stable.
|
|
13
|
+
*
|
|
14
|
+
* What remains defined here are the runtime-bound primitives that need a
|
|
15
|
+
* Drizzle/Postgres connection and therefore cannot live in the contracts
|
|
16
|
+
* package:
|
|
17
|
+
*
|
|
18
|
+
* - `withContentRefreshLock` — Postgres advisory-lock singleflight for
|
|
19
|
+
* cross-worker SWR refresh dedup.
|
|
20
|
+
* - `createInvalidateOnDrift` / `buildDriftInvalidationPredicate` —
|
|
21
|
+
* content-drift → cache-invalidation against
|
|
22
|
+
* a per-vertical `*_sourced_content` table.
|
|
23
|
+
*
|
|
24
|
+
* See `docs/architecture/catalog-sourced-content.md` §3.4, §3.4.1, §3.5.3,
|
|
25
|
+
* §3.5.4.
|
|
26
|
+
*/
|
|
27
|
+
import { and, eq, sql } from "drizzle-orm";
|
|
28
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
29
|
+
// Pure content primitives — single source of truth in @voyant-travel/catalog-contracts
|
|
30
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
31
|
+
export { applyJsonPointerOverlay, isStale, JsonPointerError, mergeOverlaysIntoContent, parseJsonPointer, pickBestCachedLocale, } from "@voyant-travel/catalog-contracts/content";
|
|
32
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
33
|
+
// Cross-worker singleflight via Postgres advisory lock
|
|
34
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
35
|
+
/**
|
|
36
|
+
* Acquire a Postgres advisory lock keyed on
|
|
37
|
+
* `content:${entityModule}:${entityId}:${locale}:${market}` and run
|
|
38
|
+
* `fn`. If the lock is held by another worker, returns `null` without
|
|
39
|
+
* running `fn` — the caller serves the stale row and lets the
|
|
40
|
+
* lock-holder do the refresh.
|
|
41
|
+
*
|
|
42
|
+
* Uses session-level `pg_try_advisory_lock` + `pg_advisory_unlock`. The
|
|
43
|
+
* lock is released when the function returns or throws. Caller is
|
|
44
|
+
* responsible for serving stale data when this returns null.
|
|
45
|
+
*
|
|
46
|
+
* Cross-worker dedup is the point — in-process Map-based singleflight
|
|
47
|
+
* only collapses requests inside one worker; CF Workers / multi-pod
|
|
48
|
+
* deployments need DB-level coordination (see sourced-content §3.4).
|
|
49
|
+
*/
|
|
50
|
+
export async function withContentRefreshLock(db, options, fn) {
|
|
51
|
+
const key = contentLockKey(options);
|
|
52
|
+
const acquired = await tryAdvisoryLock(db, key);
|
|
53
|
+
if (!acquired)
|
|
54
|
+
return null;
|
|
55
|
+
try {
|
|
56
|
+
return await fn();
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
await releaseAdvisoryLock(db, key);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function contentLockKey(options) {
|
|
63
|
+
return `content:${options.entityModule}:${options.entityId}:${options.locale}:${options.market ?? "*"}`;
|
|
64
|
+
}
|
|
65
|
+
async function tryAdvisoryLock(db, key) {
|
|
66
|
+
// pg_try_advisory_lock(bigint) — hash the string into a bigint. Use
|
|
67
|
+
// hashtextextended which Postgres exposes for stable string hashing
|
|
68
|
+
// (bigint output, not int4 like hashtext).
|
|
69
|
+
const rows = await db.execute(
|
|
70
|
+
// agent-quality: raw-sql reviewed -- owner: catalog; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
|
|
71
|
+
sql `SELECT pg_try_advisory_lock(hashtextextended(${key}, 0)) AS locked`);
|
|
72
|
+
// Drizzle's execute() result shape varies by driver; we accept both
|
|
73
|
+
// an array-shaped result and a `.rows` wrapper.
|
|
74
|
+
const first = pickFirstRow(rows);
|
|
75
|
+
return Boolean(first?.locked);
|
|
76
|
+
}
|
|
77
|
+
async function releaseAdvisoryLock(db, key) {
|
|
78
|
+
// agent-quality: raw-sql reviewed -- owner: catalog; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
|
|
79
|
+
await db.execute(sql `SELECT pg_advisory_unlock(hashtextextended(${key}, 0))`);
|
|
80
|
+
}
|
|
81
|
+
function pickFirstRow(result) {
|
|
82
|
+
if (Array.isArray(result))
|
|
83
|
+
return result[0];
|
|
84
|
+
if (result && typeof result === "object" && "rows" in result) {
|
|
85
|
+
const rows = result.rows;
|
|
86
|
+
if (Array.isArray(rows))
|
|
87
|
+
return rows[0];
|
|
88
|
+
}
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Build a per-vertical `invalidateOnDrift` runner against the
|
|
93
|
+
* vertical's `*_sourced_content` drizzle table. The returned function
|
|
94
|
+
* is the sourced-content §3.4.1 invalidation primitive — verticals
|
|
95
|
+
* subscribe their runner to the drift-event bus.
|
|
96
|
+
*
|
|
97
|
+
* Semantics:
|
|
98
|
+
* - Skips events whose `entity_module` doesn't match `options.entityModule`.
|
|
99
|
+
* Templates compose runners across verticals; mismatched events are
|
|
100
|
+
* not this runner's concern.
|
|
101
|
+
* - When the event scopes `locale` and/or `market`, the WHERE clause
|
|
102
|
+
* narrows accordingly. Wildcards (event.locale unset / event.market
|
|
103
|
+
* unset) match all rows for that axis — full-entity invalidation.
|
|
104
|
+
* - Returns `{ invalidated }` count for ops dashboards.
|
|
105
|
+
*/
|
|
106
|
+
export function createInvalidateOnDrift(table, options) {
|
|
107
|
+
return async function invalidateOnDrift(db, event) {
|
|
108
|
+
if (event.entity_module !== options.entityModule) {
|
|
109
|
+
return { invalidated: 0 };
|
|
110
|
+
}
|
|
111
|
+
const conditions = [eq(table.entity_id, event.entity_id)];
|
|
112
|
+
if (event.locale)
|
|
113
|
+
conditions.push(eq(table.locale, event.locale));
|
|
114
|
+
if (event.market)
|
|
115
|
+
conditions.push(eq(table.market, event.market));
|
|
116
|
+
const where = conditions.length === 1 ? conditions[0] : and(...conditions);
|
|
117
|
+
// Drizzle's update-set typing is generic over the table's
|
|
118
|
+
// $inferInsert keys; the generic wrapper here narrows away those
|
|
119
|
+
// keys, so we use raw SQL for the SET clause and the table reference
|
|
120
|
+
// for the WHERE/RETURNING. This keeps the SQL identical to a
|
|
121
|
+
// typed-call while the catalog package stays neutral about the
|
|
122
|
+
// vertical's exact table schema.
|
|
123
|
+
// biome-ignore lint/suspicious/noExplicitAny: see comment above -- owner: catalog; existing suppression is intentional pending typed cleanup.
|
|
124
|
+
const updateBuilder = db.update(table);
|
|
125
|
+
const result = (await updateBuilder
|
|
126
|
+
.set({ fresh_until: sql `now()` })
|
|
127
|
+
.where(where)
|
|
128
|
+
.returning({ entity_id: table.entity_id }));
|
|
129
|
+
return { invalidated: result.length };
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
export function buildDriftInvalidationPredicate(event) {
|
|
133
|
+
return {
|
|
134
|
+
entity_module: event.entity_module,
|
|
135
|
+
entity_id: event.entity_id,
|
|
136
|
+
locale: event.locale ?? null,
|
|
137
|
+
market: event.market ?? null,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"content-service.test.d.ts","sourceRoot":"","sources":["../../src/services/content-service.test.ts"],"names":[],"mappings":""}
|