@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.
Files changed (243) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +190 -0
  3. package/dist/adapter/booking-forwarding.d.ts +2 -0
  4. package/dist/adapter/booking-forwarding.d.ts.map +1 -0
  5. package/dist/adapter/booking-forwarding.js +1 -0
  6. package/dist/adapter/channel-push-contracts.d.ts +2 -0
  7. package/dist/adapter/channel-push-contracts.d.ts.map +1 -0
  8. package/dist/adapter/channel-push-contracts.js +1 -0
  9. package/dist/adapter/contract.d.ts +2 -0
  10. package/dist/adapter/contract.d.ts.map +1 -0
  11. package/dist/adapter/contract.js +1 -0
  12. package/dist/adapter/contract.test.d.ts +2 -0
  13. package/dist/adapter/contract.test.d.ts.map +1 -0
  14. package/dist/adapter/contract.test.js +390 -0
  15. package/dist/adapter/provider-contracts.d.ts +2 -0
  16. package/dist/adapter/provider-contracts.d.ts.map +1 -0
  17. package/dist/adapter/provider-contracts.js +1 -0
  18. package/dist/adapter/provider-contracts.test.d.ts +2 -0
  19. package/dist/adapter/provider-contracts.test.d.ts.map +1 -0
  20. package/dist/adapter/provider-contracts.test.js +206 -0
  21. package/dist/adapter/schemas.d.ts +2 -0
  22. package/dist/adapter/schemas.d.ts.map +1 -0
  23. package/dist/adapter/schemas.js +1 -0
  24. package/dist/adapter/schemas.test.d.ts +2 -0
  25. package/dist/adapter/schemas.test.d.ts.map +1 -0
  26. package/dist/adapter/schemas.test.js +344 -0
  27. package/dist/booking-engine/book.d.ts +124 -0
  28. package/dist/booking-engine/book.d.ts.map +1 -0
  29. package/dist/booking-engine/book.js +311 -0
  30. package/dist/booking-engine/cancel.d.ts +40 -0
  31. package/dist/booking-engine/cancel.d.ts.map +1 -0
  32. package/dist/booking-engine/cancel.js +56 -0
  33. package/dist/booking-engine/checkout-finalize.d.ts +146 -0
  34. package/dist/booking-engine/checkout-finalize.d.ts.map +1 -0
  35. package/dist/booking-engine/checkout-finalize.js +132 -0
  36. package/dist/booking-engine/contracts.d.ts +9 -0
  37. package/dist/booking-engine/contracts.d.ts.map +1 -0
  38. package/dist/booking-engine/contracts.js +8 -0
  39. package/dist/booking-engine/contracts.test.d.ts +2 -0
  40. package/dist/booking-engine/contracts.test.d.ts.map +1 -0
  41. package/dist/booking-engine/contracts.test.js +116 -0
  42. package/dist/booking-engine/draft-shape.d.ts +10 -0
  43. package/dist/booking-engine/draft-shape.d.ts.map +1 -0
  44. package/dist/booking-engine/draft-shape.js +9 -0
  45. package/dist/booking-engine/draft-shape.test.d.ts +2 -0
  46. package/dist/booking-engine/draft-shape.test.d.ts.map +1 -0
  47. package/dist/booking-engine/draft-shape.test.js +74 -0
  48. package/dist/booking-engine/drafts-schema.d.ts +302 -0
  49. package/dist/booking-engine/drafts-schema.d.ts.map +1 -0
  50. package/dist/booking-engine/drafts-schema.js +53 -0
  51. package/dist/booking-engine/drafts-service.d.ts +41 -0
  52. package/dist/booking-engine/drafts-service.d.ts.map +1 -0
  53. package/dist/booking-engine/drafts-service.js +108 -0
  54. package/dist/booking-engine/errors.d.ts +81 -0
  55. package/dist/booking-engine/errors.d.ts.map +1 -0
  56. package/dist/booking-engine/errors.js +113 -0
  57. package/dist/booking-engine/index.d.ts +36 -0
  58. package/dist/booking-engine/index.d.ts.map +1 -0
  59. package/dist/booking-engine/index.js +34 -0
  60. package/dist/booking-engine/orders.d.ts +41 -0
  61. package/dist/booking-engine/orders.d.ts.map +1 -0
  62. package/dist/booking-engine/orders.js +49 -0
  63. package/dist/booking-engine/owned-handler.d.ts +166 -0
  64. package/dist/booking-engine/owned-handler.d.ts.map +1 -0
  65. package/dist/booking-engine/owned-handler.js +50 -0
  66. package/dist/booking-engine/owned-handler.test.d.ts +2 -0
  67. package/dist/booking-engine/owned-handler.test.d.ts.map +1 -0
  68. package/dist/booking-engine/owned-handler.test.js +63 -0
  69. package/dist/booking-engine/promotions-contract.d.ts +8 -0
  70. package/dist/booking-engine/promotions-contract.d.ts.map +1 -0
  71. package/dist/booking-engine/promotions-contract.js +7 -0
  72. package/dist/booking-engine/quote-enricher.test.d.ts +12 -0
  73. package/dist/booking-engine/quote-enricher.test.d.ts.map +1 -0
  74. package/dist/booking-engine/quote-enricher.test.js +138 -0
  75. package/dist/booking-engine/quote.d.ts +163 -0
  76. package/dist/booking-engine/quote.d.ts.map +1 -0
  77. package/dist/booking-engine/quote.js +259 -0
  78. package/dist/booking-engine/registry.d.ts +85 -0
  79. package/dist/booking-engine/registry.d.ts.map +1 -0
  80. package/dist/booking-engine/registry.js +118 -0
  81. package/dist/booking-engine/registry.test.d.ts +2 -0
  82. package/dist/booking-engine/registry.test.d.ts.map +1 -0
  83. package/dist/booking-engine/registry.test.js +132 -0
  84. package/dist/booking-engine/routes-contracts.d.ts +169 -0
  85. package/dist/booking-engine/routes-contracts.d.ts.map +1 -0
  86. package/dist/booking-engine/routes-contracts.js +63 -0
  87. package/dist/booking-engine/routes.d.ts +7 -0
  88. package/dist/booking-engine/routes.d.ts.map +1 -0
  89. package/dist/booking-engine/routes.js +443 -0
  90. package/dist/booking-engine/routes.test.d.ts +2 -0
  91. package/dist/booking-engine/routes.test.d.ts.map +1 -0
  92. package/dist/booking-engine/routes.test.js +304 -0
  93. package/dist/booking-engine/schema.d.ts +455 -0
  94. package/dist/booking-engine/schema.d.ts.map +1 -0
  95. package/dist/booking-engine/schema.js +75 -0
  96. package/dist/booking-engine/snapshot-content.d.ts +120 -0
  97. package/dist/booking-engine/snapshot-content.d.ts.map +1 -0
  98. package/dist/booking-engine/snapshot-content.js +110 -0
  99. package/dist/booking-engine/snapshot-content.test.d.ts +2 -0
  100. package/dist/booking-engine/snapshot-content.test.d.ts.map +1 -0
  101. package/dist/booking-engine/snapshot-content.test.js +213 -0
  102. package/dist/booking-engine/sync.d.ts +136 -0
  103. package/dist/booking-engine/sync.d.ts.map +1 -0
  104. package/dist/booking-engine/sync.js +177 -0
  105. package/dist/booking-engine/sync.test.d.ts +2 -0
  106. package/dist/booking-engine/sync.test.d.ts.map +1 -0
  107. package/dist/booking-engine/sync.test.js +377 -0
  108. package/dist/contract.d.ts +2 -0
  109. package/dist/contract.d.ts.map +1 -0
  110. package/dist/contract.js +1 -0
  111. package/dist/contract.test.d.ts +2 -0
  112. package/dist/contract.test.d.ts.map +1 -0
  113. package/dist/contract.test.js +107 -0
  114. package/dist/drift/events.d.ts +2 -0
  115. package/dist/drift/events.d.ts.map +1 -0
  116. package/dist/drift/events.js +1 -0
  117. package/dist/drift/events.test.d.ts +2 -0
  118. package/dist/drift/events.test.d.ts.map +1 -0
  119. package/dist/drift/events.test.js +100 -0
  120. package/dist/embeddings/contract.d.ts +85 -0
  121. package/dist/embeddings/contract.d.ts.map +1 -0
  122. package/dist/embeddings/contract.js +42 -0
  123. package/dist/embeddings/contract.test.d.ts +2 -0
  124. package/dist/embeddings/contract.test.d.ts.map +1 -0
  125. package/dist/embeddings/contract.test.js +30 -0
  126. package/dist/embeddings/gemini.d.ts +110 -0
  127. package/dist/embeddings/gemini.d.ts.map +1 -0
  128. package/dist/embeddings/gemini.js +118 -0
  129. package/dist/embeddings/gemini.test.d.ts +2 -0
  130. package/dist/embeddings/gemini.test.d.ts.map +1 -0
  131. package/dist/embeddings/gemini.test.js +132 -0
  132. package/dist/embeddings/model-registry.d.ts +62 -0
  133. package/dist/embeddings/model-registry.d.ts.map +1 -0
  134. package/dist/embeddings/model-registry.js +78 -0
  135. package/dist/embeddings/model-registry.test.d.ts +2 -0
  136. package/dist/embeddings/model-registry.test.d.ts.map +1 -0
  137. package/dist/embeddings/model-registry.test.js +81 -0
  138. package/dist/embeddings/openai.d.ts +81 -0
  139. package/dist/embeddings/openai.d.ts.map +1 -0
  140. package/dist/embeddings/openai.js +123 -0
  141. package/dist/embeddings/openai.test.d.ts +2 -0
  142. package/dist/embeddings/openai.test.d.ts.map +1 -0
  143. package/dist/embeddings/openai.test.js +164 -0
  144. package/dist/events/taxonomy.d.ts +158 -0
  145. package/dist/events/taxonomy.d.ts.map +1 -0
  146. package/dist/events/taxonomy.js +99 -0
  147. package/dist/events/taxonomy.test.d.ts +2 -0
  148. package/dist/events/taxonomy.test.d.ts.map +1 -0
  149. package/dist/events/taxonomy.test.js +48 -0
  150. package/dist/index.d.ts +27 -0
  151. package/dist/index.d.ts.map +1 -0
  152. package/dist/index.js +39 -0
  153. package/dist/indexer/contract.d.ts +203 -0
  154. package/dist/indexer/contract.d.ts.map +1 -0
  155. package/dist/indexer/contract.js +16 -0
  156. package/dist/indexer/typesense-search-query.d.ts +31 -0
  157. package/dist/indexer/typesense-search-query.d.ts.map +1 -0
  158. package/dist/indexer/typesense-search-query.js +185 -0
  159. package/dist/indexer/typesense.d.ts +105 -0
  160. package/dist/indexer/typesense.d.ts.map +1 -0
  161. package/dist/indexer/typesense.js +394 -0
  162. package/dist/indexer/typesense.test.d.ts +2 -0
  163. package/dist/indexer/typesense.test.d.ts.map +1 -0
  164. package/dist/indexer/typesense.test.js +253 -0
  165. package/dist/overlay/resolver.d.ts +101 -0
  166. package/dist/overlay/resolver.d.ts.map +1 -0
  167. package/dist/overlay/resolver.js +167 -0
  168. package/dist/overlay/resolver.test.d.ts +2 -0
  169. package/dist/overlay/resolver.test.d.ts.map +1 -0
  170. package/dist/overlay/resolver.test.js +179 -0
  171. package/dist/overlay/schema.d.ts +266 -0
  172. package/dist/overlay/schema.d.ts.map +1 -0
  173. package/dist/overlay/schema.js +71 -0
  174. package/dist/provenance.d.ts +2 -0
  175. package/dist/provenance.d.ts.map +1 -0
  176. package/dist/provenance.js +1 -0
  177. package/dist/schema-sourced-entries.d.ts +344 -0
  178. package/dist/schema-sourced-entries.d.ts.map +1 -0
  179. package/dist/schema-sourced-entries.js +75 -0
  180. package/dist/schema.d.ts +21 -0
  181. package/dist/schema.d.ts.map +1 -0
  182. package/dist/schema.js +20 -0
  183. package/dist/search/federate.d.ts +58 -0
  184. package/dist/search/federate.d.ts.map +1 -0
  185. package/dist/search/federate.js +103 -0
  186. package/dist/search/federate.test.d.ts +2 -0
  187. package/dist/search/federate.test.d.ts.map +1 -0
  188. package/dist/search/federate.test.js +146 -0
  189. package/dist/search/rerank.d.ts +77 -0
  190. package/dist/search/rerank.d.ts.map +1 -0
  191. package/dist/search/rerank.js +68 -0
  192. package/dist/search/rerank.test.d.ts +2 -0
  193. package/dist/search/rerank.test.d.ts.map +1 -0
  194. package/dist/search/rerank.test.js +60 -0
  195. package/dist/search/routes.d.ts +144 -0
  196. package/dist/search/routes.d.ts.map +1 -0
  197. package/dist/search/routes.js +288 -0
  198. package/dist/search/routes.test.d.ts +2 -0
  199. package/dist/search/routes.test.d.ts.map +1 -0
  200. package/dist/search/routes.test.js +322 -0
  201. package/dist/search/semantic.d.ts +63 -0
  202. package/dist/search/semantic.d.ts.map +1 -0
  203. package/dist/search/semantic.js +75 -0
  204. package/dist/search/semantic.test.d.ts +2 -0
  205. package/dist/search/semantic.test.d.ts.map +1 -0
  206. package/dist/search/semantic.test.js +143 -0
  207. package/dist/services/build-indexer-document.test.d.ts +2 -0
  208. package/dist/services/build-indexer-document.test.d.ts.map +1 -0
  209. package/dist/services/build-indexer-document.test.js +102 -0
  210. package/dist/services/content-service.d.ts +125 -0
  211. package/dist/services/content-service.d.ts.map +1 -0
  212. package/dist/services/content-service.js +139 -0
  213. package/dist/services/content-service.test.d.ts +2 -0
  214. package/dist/services/content-service.test.d.ts.map +1 -0
  215. package/dist/services/content-service.test.js +322 -0
  216. package/dist/services/indexer-service.d.ts +109 -0
  217. package/dist/services/indexer-service.d.ts.map +1 -0
  218. package/dist/services/indexer-service.js +123 -0
  219. package/dist/services/indexer-service.test.d.ts +2 -0
  220. package/dist/services/indexer-service.test.d.ts.map +1 -0
  221. package/dist/services/indexer-service.test.js +176 -0
  222. package/dist/services/overlay-service.d.ts +108 -0
  223. package/dist/services/overlay-service.d.ts.map +1 -0
  224. package/dist/services/overlay-service.js +211 -0
  225. package/dist/services/overlay-service.test.d.ts +2 -0
  226. package/dist/services/overlay-service.test.d.ts.map +1 -0
  227. package/dist/services/overlay-service.test.js +79 -0
  228. package/dist/services/snapshot-builder.test.d.ts +2 -0
  229. package/dist/services/snapshot-builder.test.d.ts.map +1 -0
  230. package/dist/services/snapshot-builder.test.js +93 -0
  231. package/dist/services/snapshot-service.d.ts +78 -0
  232. package/dist/services/snapshot-service.d.ts.map +1 -0
  233. package/dist/services/snapshot-service.js +165 -0
  234. package/dist/services/sourced-entry-service.d.ts +142 -0
  235. package/dist/services/sourced-entry-service.d.ts.map +1 -0
  236. package/dist/services/sourced-entry-service.js +203 -0
  237. package/dist/services/sourced-entry-service.test.d.ts +10 -0
  238. package/dist/services/sourced-entry-service.test.d.ts.map +1 -0
  239. package/dist/services/sourced-entry-service.test.js +66 -0
  240. package/dist/snapshot/schema.d.ts +362 -0
  241. package/dist/snapshot/schema.d.ts.map +1 -0
  242. package/dist/snapshot/schema.js +102 -0
  243. 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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=content-service.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"content-service.test.d.ts","sourceRoot":"","sources":["../../src/services/content-service.test.ts"],"names":[],"mappings":""}