@voyant-travel/distribution 0.109.8

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 (168) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +42 -0
  3. package/dist/booking-extension.d.ts +168 -0
  4. package/dist/booking-extension.d.ts.map +1 -0
  5. package/dist/booking-extension.js +102 -0
  6. package/dist/channel-push/admin-routes.d.ts +31 -0
  7. package/dist/channel-push/admin-routes.d.ts.map +1 -0
  8. package/dist/channel-push/admin-routes.js +165 -0
  9. package/dist/channel-push/availability-push.d.ts +76 -0
  10. package/dist/channel-push/availability-push.d.ts.map +1 -0
  11. package/dist/channel-push/availability-push.js +236 -0
  12. package/dist/channel-push/booking-push-helpers.d.ts +36 -0
  13. package/dist/channel-push/booking-push-helpers.d.ts.map +1 -0
  14. package/dist/channel-push/booking-push-helpers.js +169 -0
  15. package/dist/channel-push/booking-push.d.ts +108 -0
  16. package/dist/channel-push/booking-push.d.ts.map +1 -0
  17. package/dist/channel-push/booking-push.js +335 -0
  18. package/dist/channel-push/boundary-sql.d.ts +23 -0
  19. package/dist/channel-push/boundary-sql.d.ts.map +1 -0
  20. package/dist/channel-push/boundary-sql.js +75 -0
  21. package/dist/channel-push/content-push.d.ts +60 -0
  22. package/dist/channel-push/content-push.d.ts.map +1 -0
  23. package/dist/channel-push/content-push.js +252 -0
  24. package/dist/channel-push/index.d.ts +15 -0
  25. package/dist/channel-push/index.d.ts.map +1 -0
  26. package/dist/channel-push/index.js +18 -0
  27. package/dist/channel-push/plugin.d.ts +18 -0
  28. package/dist/channel-push/plugin.d.ts.map +1 -0
  29. package/dist/channel-push/plugin.js +21 -0
  30. package/dist/channel-push/reconciler.d.ts +85 -0
  31. package/dist/channel-push/reconciler.d.ts.map +1 -0
  32. package/dist/channel-push/reconciler.js +179 -0
  33. package/dist/channel-push/subscriber.d.ts +40 -0
  34. package/dist/channel-push/subscriber.d.ts.map +1 -0
  35. package/dist/channel-push/subscriber.js +199 -0
  36. package/dist/channel-push/types.d.ts +43 -0
  37. package/dist/channel-push/types.d.ts.map +1 -0
  38. package/dist/channel-push/types.js +32 -0
  39. package/dist/channel-push/workflows.d.ts +56 -0
  40. package/dist/channel-push/workflows.d.ts.map +1 -0
  41. package/dist/channel-push/workflows.js +100 -0
  42. package/dist/external-refs/index.d.ts +11 -0
  43. package/dist/external-refs/index.d.ts.map +1 -0
  44. package/dist/external-refs/index.js +12 -0
  45. package/dist/external-refs/routes.d.ts +253 -0
  46. package/dist/external-refs/routes.d.ts.map +1 -0
  47. package/dist/external-refs/routes.js +52 -0
  48. package/dist/external-refs/schema.d.ts +251 -0
  49. package/dist/external-refs/schema.d.ts.map +1 -0
  50. package/dist/external-refs/schema.js +32 -0
  51. package/dist/external-refs/service.d.ts +82 -0
  52. package/dist/external-refs/service.d.ts.map +1 -0
  53. package/dist/external-refs/service.js +112 -0
  54. package/dist/external-refs/validation.d.ts +91 -0
  55. package/dist/external-refs/validation.d.ts.map +1 -0
  56. package/dist/external-refs/validation.js +40 -0
  57. package/dist/index.d.ts +21 -0
  58. package/dist/index.d.ts.map +1 -0
  59. package/dist/index.js +20 -0
  60. package/dist/interface-types.d.ts +128 -0
  61. package/dist/interface-types.d.ts.map +1 -0
  62. package/dist/interface-types.js +1 -0
  63. package/dist/interface.d.ts +10 -0
  64. package/dist/interface.d.ts.map +1 -0
  65. package/dist/interface.js +286 -0
  66. package/dist/rate-limit.d.ts +69 -0
  67. package/dist/rate-limit.d.ts.map +1 -0
  68. package/dist/rate-limit.js +135 -0
  69. package/dist/routes/batch.d.ts +200 -0
  70. package/dist/routes/batch.d.ts.map +1 -0
  71. package/dist/routes/batch.js +52 -0
  72. package/dist/routes/env.d.ts +8 -0
  73. package/dist/routes/env.d.ts.map +1 -0
  74. package/dist/routes/env.js +1 -0
  75. package/dist/routes/inventory.d.ts +604 -0
  76. package/dist/routes/inventory.d.ts.map +1 -0
  77. package/dist/routes/inventory.js +138 -0
  78. package/dist/routes/settlements.d.ts +1649 -0
  79. package/dist/routes/settlements.d.ts.map +1 -0
  80. package/dist/routes/settlements.js +265 -0
  81. package/dist/routes.d.ts +3909 -0
  82. package/dist/routes.d.ts.map +1 -0
  83. package/dist/routes.js +323 -0
  84. package/dist/schema-automation.d.ts +680 -0
  85. package/dist/schema-automation.d.ts.map +1 -0
  86. package/dist/schema-automation.js +76 -0
  87. package/dist/schema-core.d.ts +1674 -0
  88. package/dist/schema-core.d.ts.map +1 -0
  89. package/dist/schema-core.js +227 -0
  90. package/dist/schema-finance.d.ts +1372 -0
  91. package/dist/schema-finance.d.ts.map +1 -0
  92. package/dist/schema-finance.js +153 -0
  93. package/dist/schema-inventory.d.ts +855 -0
  94. package/dist/schema-inventory.d.ts.map +1 -0
  95. package/dist/schema-inventory.js +102 -0
  96. package/dist/schema-push-intents.d.ts +387 -0
  97. package/dist/schema-push-intents.d.ts.map +1 -0
  98. package/dist/schema-push-intents.js +77 -0
  99. package/dist/schema-relations.d.ts +95 -0
  100. package/dist/schema-relations.d.ts.map +1 -0
  101. package/dist/schema-relations.js +196 -0
  102. package/dist/schema-shared.d.ts +24 -0
  103. package/dist/schema-shared.d.ts.map +1 -0
  104. package/dist/schema-shared.js +123 -0
  105. package/dist/schema.d.ts +9 -0
  106. package/dist/schema.d.ts.map +1 -0
  107. package/dist/schema.js +8 -0
  108. package/dist/service/channels.d.ts +167 -0
  109. package/dist/service/channels.d.ts.map +1 -0
  110. package/dist/service/channels.js +305 -0
  111. package/dist/service/commercial.d.ts +385 -0
  112. package/dist/service/commercial.d.ts.map +1 -0
  113. package/dist/service/commercial.js +248 -0
  114. package/dist/service/helpers.d.ts +10 -0
  115. package/dist/service/helpers.d.ts.map +1 -0
  116. package/dist/service/helpers.js +7 -0
  117. package/dist/service/inventory.d.ts +193 -0
  118. package/dist/service/inventory.d.ts.map +1 -0
  119. package/dist/service/inventory.js +154 -0
  120. package/dist/service/settlement-policies.d.ts +325 -0
  121. package/dist/service/settlement-policies.d.ts.map +1 -0
  122. package/dist/service/settlement-policies.js +272 -0
  123. package/dist/service/settlements.d.ts +357 -0
  124. package/dist/service/settlements.d.ts.map +1 -0
  125. package/dist/service/settlements.js +319 -0
  126. package/dist/service/types.d.ts +60 -0
  127. package/dist/service/types.d.ts.map +1 -0
  128. package/dist/service/types.js +1 -0
  129. package/dist/service.d.ts +1418 -0
  130. package/dist/service.d.ts.map +1 -0
  131. package/dist/service.js +17 -0
  132. package/dist/suppliers/index.d.ts +15 -0
  133. package/dist/suppliers/index.d.ts.map +1 -0
  134. package/dist/suppliers/index.js +23 -0
  135. package/dist/suppliers/routes.d.ts +1202 -0
  136. package/dist/suppliers/routes.d.ts.map +1 -0
  137. package/dist/suppliers/routes.js +290 -0
  138. package/dist/suppliers/schema.d.ts +1272 -0
  139. package/dist/suppliers/schema.d.ts.map +1 -0
  140. package/dist/suppliers/schema.js +219 -0
  141. package/dist/suppliers/service-aggregates.d.ts +23 -0
  142. package/dist/suppliers/service-aggregates.d.ts.map +1 -0
  143. package/dist/suppliers/service-aggregates.js +51 -0
  144. package/dist/suppliers/service-core.d.ts +89 -0
  145. package/dist/suppliers/service-core.d.ts.map +1 -0
  146. package/dist/suppliers/service-core.js +164 -0
  147. package/dist/suppliers/service-identity.d.ts +162 -0
  148. package/dist/suppliers/service-identity.d.ts.map +1 -0
  149. package/dist/suppliers/service-identity.js +101 -0
  150. package/dist/suppliers/service-operations.d.ts +1500 -0
  151. package/dist/suppliers/service-operations.d.ts.map +1 -0
  152. package/dist/suppliers/service-operations.js +157 -0
  153. package/dist/suppliers/service-shared.d.ts +45 -0
  154. package/dist/suppliers/service-shared.d.ts.map +1 -0
  155. package/dist/suppliers/service-shared.js +294 -0
  156. package/dist/suppliers/service.d.ts +41 -0
  157. package/dist/suppliers/service.d.ts.map +1 -0
  158. package/dist/suppliers/service.js +40 -0
  159. package/dist/suppliers/validation.d.ts +2 -0
  160. package/dist/suppliers/validation.d.ts.map +1 -0
  161. package/dist/suppliers/validation.js +1 -0
  162. package/dist/validation.d.ts +1371 -0
  163. package/dist/validation.d.ts.map +1 -0
  164. package/dist/validation.js +445 -0
  165. package/dist/webhook-deliveries.d.ts +86 -0
  166. package/dist/webhook-deliveries.d.ts.map +1 -0
  167. package/dist/webhook-deliveries.js +296 -0
  168. package/package.json +71 -0
@@ -0,0 +1,75 @@
1
+ import { sql } from "drizzle-orm";
2
+ async function executeBoundaryRows(db, query) {
3
+ // biome-ignore lint/suspicious/noExplicitAny: #1141 keeps cross-package SQL boundary reads driver-agnostic.
4
+ const result = await db.execute(query);
5
+ return (Array.isArray(result) ? result : (result?.rows ?? []));
6
+ }
7
+ function sqlList(values) {
8
+ // agent-quality: raw-sql reviewed -- owner: distribution; callers pass only parameter-bound scalar ids into the joined SQL fragment.
9
+ return sql.join(values.map((value) => sql `${value}`), sql `, `);
10
+ }
11
+ function toDate(value) {
12
+ return value instanceof Date ? value : new Date(value);
13
+ }
14
+ function toNumber(value) {
15
+ if (value === null)
16
+ return null;
17
+ return typeof value === "number" ? value : Number(value);
18
+ }
19
+ function mapAvailabilitySlot(row) {
20
+ return {
21
+ id: row.id,
22
+ productId: row.product_id,
23
+ optionId: row.option_id,
24
+ startsAt: toDate(row.starts_at),
25
+ unlimited: row.unlimited,
26
+ remainingPax: toNumber(row.remaining_pax),
27
+ updatedAt: toDate(row.updated_at),
28
+ };
29
+ }
30
+ export async function loadAvailabilityPushSlot(db, slotId) {
31
+ const rows = await executeBoundaryRows(db,
32
+ // agent-quality: raw-sql reviewed -- owner: distribution; Availability is a read-only push source and slot id is parameter-bound.
33
+ sql `
34
+ SELECT id, product_id, option_id, starts_at, unlimited, remaining_pax, updated_at
35
+ FROM availability_slots
36
+ WHERE id = ${slotId}
37
+ LIMIT 1
38
+ `);
39
+ return rows[0] ? mapAvailabilitySlot(rows[0]) : null;
40
+ }
41
+ export async function loadRecentlyUpdatedAvailabilityPushSlots(db, input) {
42
+ const rows = await executeBoundaryRows(db,
43
+ // agent-quality: raw-sql reviewed -- owner: distribution; Availability slots are read-only reconciler sources with parameter-bound cursor and limit.
44
+ sql `
45
+ SELECT id, product_id, option_id, starts_at, unlimited, remaining_pax, updated_at
46
+ FROM availability_slots
47
+ WHERE updated_at > ${input.updatedAfter}
48
+ ORDER BY updated_at ASC
49
+ LIMIT ${input.limit}
50
+ `);
51
+ return rows.map(mapAvailabilitySlot);
52
+ }
53
+ export async function loadContentPushProduct(db, productId) {
54
+ const rows = await executeBoundaryRows(db,
55
+ // agent-quality: raw-sql reviewed -- owner: distribution; Product is a read-only content-push source and product id is parameter-bound.
56
+ sql `
57
+ SELECT id, name, description
58
+ FROM products
59
+ WHERE id = ${productId}
60
+ LIMIT 1
61
+ `);
62
+ return rows[0] ?? null;
63
+ }
64
+ export async function loadContentPushProducts(db, productIds) {
65
+ if (productIds.length === 0)
66
+ return new Map();
67
+ const rows = await executeBoundaryRows(db,
68
+ // agent-quality: raw-sql reviewed -- owner: distribution; Product is a read-only content-push source and product ids are parameter-bound.
69
+ sql `
70
+ SELECT id, name, description
71
+ FROM products
72
+ WHERE id IN (${sqlList(productIds)})
73
+ `);
74
+ return new Map(rows.map((row) => [row.id, row]));
75
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Content-push pipeline.
3
+ *
4
+ * Triggered by `product.content.changed`. The subscriber upserts a
5
+ * `channel_content_push_intents` row per (channel, product); concurrent
6
+ * edits collapse to one row. The processor drains intents, hashes the
7
+ * current content, and skips when the hash equals
8
+ * `channel_product_mappings.last_pushed_content_hash` — channel-side
9
+ * idempotency per §6.1.
10
+ *
11
+ * Per docs/architecture/channel-push-architecture.md §6 + §12.3.
12
+ */
13
+ import type { AnyDrizzleDb } from "@voyant-travel/db";
14
+ import { channelProductMappings, channels } from "../schema.js";
15
+ import { type ChannelPushDeps } from "./types.js";
16
+ /** Stable string identifier for the content-push workflow. */
17
+ export declare const CHANNEL_CONTENT_PUSH_WORKFLOW_ID: "channel.content.push";
18
+ /**
19
+ * Resolve the channels that want a content push for this product.
20
+ * Per §7.4 — content push uses `channel_product_mappings` (content is
21
+ * product-shaped, not slot-shaped).
22
+ */
23
+ export declare function resolveContentPushTargets(db: AnyDrizzleDb, productId: string): Promise<Array<{
24
+ channelId: string;
25
+ sourceConnectionId: string;
26
+ mapping: typeof channelProductMappings.$inferSelect;
27
+ channel: typeof channels.$inferSelect;
28
+ }>>;
29
+ export declare function upsertContentIntent(db: AnyDrizzleDb, input: {
30
+ channelId: string;
31
+ sourceConnectionId: string;
32
+ productId: string;
33
+ }): Promise<void>;
34
+ export interface ProcessContentPushInput {
35
+ channelId?: string;
36
+ limit?: number;
37
+ }
38
+ export interface ProcessContentPushResult {
39
+ attempted: number;
40
+ succeeded: number;
41
+ failed: number;
42
+ skipped: number;
43
+ }
44
+ /**
45
+ * Drain pending content intents. Hashes current product content and
46
+ * skips when the hash matches the upstream's last-known hash.
47
+ *
48
+ * v1 ships a minimal `content` payload (product row fields). Real
49
+ * verticals supply richer payloads via a future content provider hook.
50
+ *
51
+ * Per §6 + §12.3.
52
+ */
53
+ export declare function processContentPushIntents(input?: ProcessContentPushInput, deps?: ChannelPushDeps): Promise<ProcessContentPushResult>;
54
+ /**
55
+ * Stable canonical-JSON hash. Mirrors the body-fingerprint behavior
56
+ * of `webhook-deliveries.ts` — purely a "is this the same content as
57
+ * before?" fingerprint, not a cryptographic hash.
58
+ */
59
+ export declare function canonicalHash(value: unknown): string;
60
+ //# sourceMappingURL=content-push.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"content-push.d.ts","sourceRoot":"","sources":["../../src/channel-push/content-push.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAOH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAIrD,OAAO,EAA6B,sBAAsB,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAA;AAI1F,OAAO,EAAE,KAAK,eAAe,EAA4C,MAAM,YAAY,CAAA;AAE3F,8DAA8D;AAC9D,eAAO,MAAM,gCAAgC,EAAG,sBAA+B,CAAA;AAE/E;;;;GAIG;AACH,wBAAsB,yBAAyB,CAC7C,EAAE,EAAE,YAAY,EAChB,SAAS,EAAE,MAAM,GAChB,OAAO,CACR,KAAK,CAAC;IACJ,SAAS,EAAE,MAAM,CAAA;IACjB,kBAAkB,EAAE,MAAM,CAAA;IAC1B,OAAO,EAAE,OAAO,sBAAsB,CAAC,YAAY,CAAA;IACnD,OAAO,EAAE,OAAO,QAAQ,CAAC,YAAY,CAAA;CACtC,CAAC,CACH,CA4BA;AAED,wBAAsB,mBAAmB,CACvC,EAAE,EAAE,YAAY,EAChB,KAAK,EAAE;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,kBAAkB,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,GAC1E,OAAO,CAAC,IAAI,CAAC,CAkBf;AAED,MAAM,WAAW,uBAAuB;IACtC,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED,MAAM,WAAW,wBAAwB;IACvC,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;IACjB,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,EAAE,MAAM,CAAA;CAChB;AAED;;;;;;;;GAQG;AACH,wBAAsB,yBAAyB,CAC7C,KAAK,GAAE,uBAA4B,EACnC,IAAI,CAAC,EAAE,eAAe,GACrB,OAAO,CAAC,wBAAwB,CAAC,CAmKnC;AAkCD;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAepD"}
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Content-push pipeline.
3
+ *
4
+ * Triggered by `product.content.changed`. The subscriber upserts a
5
+ * `channel_content_push_intents` row per (channel, product); concurrent
6
+ * edits collapse to one row. The processor drains intents, hashes the
7
+ * current content, and skips when the hash equals
8
+ * `channel_product_mappings.last_pushed_content_hash` — channel-side
9
+ * idempotency per §6.1.
10
+ *
11
+ * Per docs/architecture/channel-push-architecture.md §6 + §12.3.
12
+ */
13
+ import { AdapterRateLimitedError, } from "@voyant-travel/catalog";
14
+ import { newId } from "@voyant-travel/db/lib/typeid";
15
+ import { and, asc, eq, sql } from "drizzle-orm";
16
+ import { acquireToken, channelScopeKey, drainBucket } from "../rate-limit.js";
17
+ import { channelContentPushIntents, channelProductMappings, channels } from "../schema.js";
18
+ import { prepareOutboundEnvelope } from "../webhook-deliveries.js";
19
+ import { loadContentPushProduct } from "./boundary-sql.js";
20
+ import { defaultLogger, getChannelPushDepsOrThrow } from "./types.js";
21
+ /** Stable string identifier for the content-push workflow. */
22
+ export const CHANNEL_CONTENT_PUSH_WORKFLOW_ID = "channel.content.push";
23
+ /**
24
+ * Resolve the channels that want a content push for this product.
25
+ * Per §7.4 — content push uses `channel_product_mappings` (content is
26
+ * product-shaped, not slot-shaped).
27
+ */
28
+ export async function resolveContentPushTargets(db, productId) {
29
+ const rows = (await db
30
+ .select({
31
+ mapping: channelProductMappings,
32
+ channel: channels,
33
+ })
34
+ .from(channelProductMappings)
35
+ .innerJoin(channels, eq(channelProductMappings.channelId, channels.id))
36
+ .where(and(eq(channelProductMappings.productId, productId), eq(channelProductMappings.active, true), eq(channelProductMappings.pushContent, true), eq(channels.status, "active"))));
37
+ return rows
38
+ .filter((row) => row.mapping.sourceConnectionId)
39
+ .map((row) => ({
40
+ channelId: row.channel.id,
41
+ sourceConnectionId: row.mapping.sourceConnectionId,
42
+ mapping: row.mapping,
43
+ channel: row.channel,
44
+ }));
45
+ }
46
+ export async function upsertContentIntent(db, input) {
47
+ await db
48
+ .insert(channelContentPushIntents)
49
+ .values({
50
+ id: newId("channel_content_push_intents"),
51
+ channelId: input.channelId,
52
+ sourceConnectionId: input.sourceConnectionId,
53
+ productId: input.productId,
54
+ })
55
+ .onConflictDoUpdate({
56
+ target: [channelContentPushIntents.channelId, channelContentPushIntents.productId],
57
+ set: {
58
+ requestedAt: new Date(),
59
+ updatedAt: new Date(),
60
+ attempts: 0,
61
+ lastError: null,
62
+ },
63
+ });
64
+ }
65
+ /**
66
+ * Drain pending content intents. Hashes current product content and
67
+ * skips when the hash matches the upstream's last-known hash.
68
+ *
69
+ * v1 ships a minimal `content` payload (product row fields). Real
70
+ * verticals supply richer payloads via a future content provider hook.
71
+ *
72
+ * Per §6 + §12.3.
73
+ */
74
+ export async function processContentPushIntents(input = {}, deps) {
75
+ const { db, registry, logger = defaultLogger } = deps ?? getChannelPushDepsOrThrow();
76
+ const limit = input.limit ?? 100;
77
+ const intents = (await db
78
+ .select({
79
+ intent: channelContentPushIntents,
80
+ channel: channels,
81
+ })
82
+ .from(channelContentPushIntents)
83
+ .innerJoin(channels, eq(channelContentPushIntents.channelId, channels.id))
84
+ .where(and(input.channelId ? eq(channelContentPushIntents.channelId, input.channelId) : sql `true`, eq(channels.status, "active")))
85
+ .orderBy(asc(channelContentPushIntents.requestedAt))
86
+ .limit(limit));
87
+ let succeeded = 0;
88
+ let failed = 0;
89
+ let skipped = 0;
90
+ for (const { intent, channel } of intents) {
91
+ const product = await loadContentPushProduct(db, intent.productId);
92
+ if (!product) {
93
+ await db.delete(channelContentPushIntents).where(eq(channelContentPushIntents.id, intent.id));
94
+ skipped += 1;
95
+ continue;
96
+ }
97
+ const adapter = registry.resolveByConnection(intent.sourceConnectionId);
98
+ if (!adapter?.capabilities.supportsContentPush || !adapter.pushContent) {
99
+ await stampIntentError(db, intent.id, intent.attempts + 1, adapter ? "adapter_unsupported" : "no_adapter_registered");
100
+ failed += 1;
101
+ continue;
102
+ }
103
+ const [mapping] = (await db
104
+ .select()
105
+ .from(channelProductMappings)
106
+ .where(and(eq(channelProductMappings.channelId, channel.id), eq(channelProductMappings.productId, intent.productId)))
107
+ .limit(1));
108
+ if (!mapping) {
109
+ await stampIntentError(db, intent.id, intent.attempts + 1, "no_mapping");
110
+ failed += 1;
111
+ continue;
112
+ }
113
+ // Build the content payload + hash. v1 = product row fields. Future
114
+ // iterations call a per-vertical content provider so the payload
115
+ // mirrors `GetContentResult` (itinerary, media, options, …).
116
+ const content = buildMinimalContent(product);
117
+ const contentHash = canonicalHash(content);
118
+ // Idempotency: skip when the upstream's last-known hash equals
119
+ // the current hash. Per §6.1.
120
+ if (mapping.lastPushedContentHash === contentHash) {
121
+ await db.delete(channelContentPushIntents).where(eq(channelContentPushIntents.id, intent.id));
122
+ skipped += 1;
123
+ continue;
124
+ }
125
+ const rlConfig = rateLimitConfigForChannel(channel);
126
+ if (rlConfig) {
127
+ const acq = await acquireToken(db, channelScopeKey(channel.id, intent.sourceConnectionId), rlConfig, "content");
128
+ if (!acq.acquired) {
129
+ await stampIntentError(db, intent.id, intent.attempts + 1, "rate_limited");
130
+ failed += 1;
131
+ continue;
132
+ }
133
+ }
134
+ const request = {
135
+ channelId: channel.id,
136
+ externalProductId: mapping.externalProductId ?? "",
137
+ productId: intent.productId,
138
+ contentHash,
139
+ content,
140
+ contentSchemaVersion: "products/v1",
141
+ };
142
+ const adapterCtx = {
143
+ connection_id: intent.sourceConnectionId,
144
+ };
145
+ const envelope = await prepareOutboundEnvelope(db, {
146
+ sourceModule: "distribution",
147
+ sourceEvent: "channel.content.push",
148
+ sourceEntityModule: "products",
149
+ sourceEntityId: intent.productId,
150
+ targetUrl: `adapter:${adapter.kind}`,
151
+ targetKind: `channel:${adapter.kind}`,
152
+ targetRef: channel.id,
153
+ requestMethod: "POST",
154
+ requestBody: request,
155
+ attemptNumber: intent.attempts + 1,
156
+ });
157
+ try {
158
+ const result = await adapter.pushContent(adapterCtx, request);
159
+ await envelope.complete({ responseStatus: 200, responseBody: result });
160
+ // Persist the acknowledged hash so subsequent pushes skip if
161
+ // content hasn't changed. Per §6.1.
162
+ await db
163
+ .update(channelProductMappings)
164
+ .set({
165
+ lastPushedContentHash: result.acknowledgedHash ?? contentHash,
166
+ lastPushedContentAt: new Date(),
167
+ updatedAt: new Date(),
168
+ })
169
+ .where(eq(channelProductMappings.id, mapping.id));
170
+ await db.delete(channelContentPushIntents).where(eq(channelContentPushIntents.id, intent.id));
171
+ succeeded += 1;
172
+ }
173
+ catch (err) {
174
+ const message = err instanceof Error ? err.message : String(err);
175
+ const isRateLimited = err instanceof AdapterRateLimitedError;
176
+ if (isRateLimited) {
177
+ await drainBucket(db, channelScopeKey(channel.id, intent.sourceConnectionId), err.retryAfterMs);
178
+ }
179
+ await envelope.complete({
180
+ errorClass: isRateLimited ? "rate_limited" : "adapter_error",
181
+ errorMessage: message,
182
+ });
183
+ await stampIntentError(db, intent.id, intent.attempts + 1, message);
184
+ failed += 1;
185
+ logger.error?.(`pushContent failed for product ${intent.productId} channel ${channel.id}`, {
186
+ error: message,
187
+ });
188
+ }
189
+ }
190
+ return {
191
+ attempted: intents.length,
192
+ succeeded,
193
+ failed,
194
+ skipped,
195
+ };
196
+ }
197
+ async function stampIntentError(db, id, attempts, message) {
198
+ await db
199
+ .update(channelContentPushIntents)
200
+ .set({ attempts, lastError: message, updatedAt: new Date() })
201
+ .where(eq(channelContentPushIntents.id, id));
202
+ }
203
+ function rateLimitConfigForChannel(channel) {
204
+ if (!channel.rateLimitRps || !channel.rateLimitBurst)
205
+ return null;
206
+ return {
207
+ rps: channel.rateLimitRps,
208
+ burst: channel.rateLimitBurst,
209
+ priorityGates: channel.rateLimitPriorityGates ?? undefined,
210
+ };
211
+ }
212
+ function buildMinimalContent(product) {
213
+ // v1 minimal shape — enough for the demo upstream to acknowledge.
214
+ // Production wiring composes per-vertical content (itinerary, media,
215
+ // options, …) per the doc's `GetContentResult`.
216
+ return {
217
+ id: product.id,
218
+ name: product.name,
219
+ description: product.description ?? null,
220
+ };
221
+ }
222
+ /**
223
+ * Stable canonical-JSON hash. Mirrors the body-fingerprint behavior
224
+ * of `webhook-deliveries.ts` — purely a "is this the same content as
225
+ * before?" fingerprint, not a cryptographic hash.
226
+ */
227
+ export function canonicalHash(value) {
228
+ let text;
229
+ try {
230
+ text = canonicalJson(value);
231
+ }
232
+ catch {
233
+ text = String(value);
234
+ }
235
+ let h = 0xcbf29ce484222325n;
236
+ const prime = 0x100000001b3n;
237
+ const mask = 0xffffffffffffffffn;
238
+ for (let i = 0; i < text.length; i++) {
239
+ h ^= BigInt(text.charCodeAt(i));
240
+ h = (h * prime) & mask;
241
+ }
242
+ return h.toString(16).padStart(16, "0");
243
+ }
244
+ function canonicalJson(value) {
245
+ if (value === null || typeof value !== "object")
246
+ return JSON.stringify(value);
247
+ if (Array.isArray(value))
248
+ return `[${value.map(canonicalJson).join(",")}]`;
249
+ const keys = Object.keys(value).sort();
250
+ const parts = keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`);
251
+ return `{${parts.join(",")}}`;
252
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * `@voyant-travel/distribution` — outbound channel-push pipeline.
3
+ *
4
+ * Per docs/architecture/channel-push-architecture.md.
5
+ */
6
+ export { type ChannelPushAdminRoutes, createChannelPushAdminRoutes, } from "./admin-routes.js";
7
+ export { CHANNEL_AVAILABILITY_PUSH_WORKFLOW_ID, type ProcessAvailabilityPushInput, type ProcessAvailabilityPushResult, processAvailabilityPushIntents, type ResolveAllotmentTargetsForSlotInput, resolveAllotmentTargetsForSlot, upsertAvailabilityIntent, } from "./availability-push.js";
8
+ export { bookingPushIdempotencyKey, CHANNEL_BOOKING_PUSH_WORKFLOW_ID, type ProcessBookingPushInput, type ProcessBookingPushResult, processBookingPush, resolveBookingPushTargets, upsertPendingBookingLinks, } from "./booking-push.js";
9
+ export { CHANNEL_CONTENT_PUSH_WORKFLOW_ID, canonicalHash, type ProcessContentPushInput, type ProcessContentPushResult, processContentPushIntents, resolveContentPushTargets, upsertContentIntent, } from "./content-push.js";
10
+ export { type ChannelPushPluginOptions, channelPushPlugin, } from "./plugin.js";
11
+ export { type AvailabilityReconcilerOptions, type BookingLinkReconcilerOptions, type ContentReconcilerOptions, type ReconcilerResult, reconcileAvailability, reconcileBookingLinks, reconcileContent, runAllReconcilers, } from "./reconciler.js";
12
+ export { type ChannelPushSubscribersOptions, createChannelPushSubscribers, triggerBookingPushForBooking, } from "./subscriber.js";
13
+ export { type ChannelPushDeps, type ChannelPushLogger, clearChannelPushDeps, defaultLogger, getChannelPushDeps, getChannelPushDepsOrThrow, setChannelPushDeps, } from "./types.js";
14
+ export { channelAvailabilityPushWorkflow, channelBookingPushWorkflow, channelContentPushWorkflow, } from "./workflows.js";
15
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/channel-push/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EACL,KAAK,sBAAsB,EAC3B,4BAA4B,GAC7B,MAAM,mBAAmB,CAAA;AAC1B,OAAO,EACL,qCAAqC,EACrC,KAAK,4BAA4B,EACjC,KAAK,6BAA6B,EAClC,8BAA8B,EAC9B,KAAK,mCAAmC,EACxC,8BAA8B,EAC9B,wBAAwB,GACzB,MAAM,wBAAwB,CAAA;AAC/B,OAAO,EACL,yBAAyB,EACzB,gCAAgC,EAChC,KAAK,uBAAuB,EAC5B,KAAK,wBAAwB,EAC7B,kBAAkB,EAClB,yBAAyB,EACzB,yBAAyB,GAC1B,MAAM,mBAAmB,CAAA;AAC1B,OAAO,EACL,gCAAgC,EAChC,aAAa,EACb,KAAK,uBAAuB,EAC5B,KAAK,wBAAwB,EAC7B,yBAAyB,EACzB,yBAAyB,EACzB,mBAAmB,GACpB,MAAM,mBAAmB,CAAA;AAC1B,OAAO,EACL,KAAK,wBAAwB,EAC7B,iBAAiB,GAClB,MAAM,aAAa,CAAA;AACpB,OAAO,EACL,KAAK,6BAA6B,EAClC,KAAK,4BAA4B,EACjC,KAAK,wBAAwB,EAC7B,KAAK,gBAAgB,EACrB,qBAAqB,EACrB,qBAAqB,EACrB,gBAAgB,EAChB,iBAAiB,GAClB,MAAM,iBAAiB,CAAA;AACxB,OAAO,EACL,KAAK,6BAA6B,EAClC,4BAA4B,EAC5B,4BAA4B,GAC7B,MAAM,iBAAiB,CAAA;AACxB,OAAO,EACL,KAAK,eAAe,EACpB,KAAK,iBAAiB,EACtB,oBAAoB,EACpB,aAAa,EACb,kBAAkB,EAClB,yBAAyB,EACzB,kBAAkB,GACnB,MAAM,YAAY,CAAA;AAKnB,OAAO,EACL,+BAA+B,EAC/B,0BAA0B,EAC1B,0BAA0B,GAC3B,MAAM,gBAAgB,CAAA"}
@@ -0,0 +1,18 @@
1
+ /**
2
+ * `@voyant-travel/distribution` — outbound channel-push pipeline.
3
+ *
4
+ * Per docs/architecture/channel-push-architecture.md.
5
+ */
6
+ export { createChannelPushAdminRoutes, } from "./admin-routes.js";
7
+ export { CHANNEL_AVAILABILITY_PUSH_WORKFLOW_ID, processAvailabilityPushIntents, resolveAllotmentTargetsForSlot, upsertAvailabilityIntent, } from "./availability-push.js";
8
+ export { bookingPushIdempotencyKey, CHANNEL_BOOKING_PUSH_WORKFLOW_ID, processBookingPush, resolveBookingPushTargets, upsertPendingBookingLinks, } from "./booking-push.js";
9
+ export { CHANNEL_CONTENT_PUSH_WORKFLOW_ID, canonicalHash, processContentPushIntents, resolveContentPushTargets, upsertContentIntent, } from "./content-push.js";
10
+ export { channelPushPlugin, } from "./plugin.js";
11
+ export { reconcileAvailability, reconcileBookingLinks, reconcileContent, runAllReconcilers, } from "./reconciler.js";
12
+ export { createChannelPushSubscribers, triggerBookingPushForBooking, } from "./subscriber.js";
13
+ export { clearChannelPushDeps, defaultLogger, getChannelPushDeps, getChannelPushDepsOrThrow, setChannelPushDeps, } from "./types.js";
14
+ // Importing this module registers all three durable workflows in the
15
+ // process-local @voyant-travel/workflows registry. Hosts that don't run an
16
+ // orchestrator (e.g. the operator starter's inline drain) can still
17
+ // import safely — registration is a no-op without a runtime to dispatch.
18
+ export { channelAvailabilityPushWorkflow, channelBookingPushWorkflow, channelContentPushWorkflow, } from "./workflows.js";
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Channel-push core plugin.
3
+ *
4
+ * Bundles the EventBus subscribers that listen to `booking.confirmed`
5
+ * (and later `availability.slot.changed`, `product.content.changed`)
6
+ * and writes durable intent rows.
7
+ *
8
+ * Templates wire this via `registerPlugins([channelPushPlugin({ ... })],
9
+ * { eventBus })` AFTER calling `setChannelPushDeps({ db, registry })`.
10
+ *
11
+ * Per docs/architecture/channel-push-architecture.md §4 + §10 (Phase D).
12
+ */
13
+ import { type Plugin } from "@voyant-travel/core";
14
+ import { type ChannelPushSubscribersOptions } from "./subscriber.js";
15
+ export interface ChannelPushPluginOptions extends ChannelPushSubscribersOptions {
16
+ }
17
+ export declare function channelPushPlugin(options?: ChannelPushPluginOptions): Plugin;
18
+ //# sourceMappingURL=plugin.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../../src/channel-push/plugin.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAgB,KAAK,MAAM,EAAE,MAAM,qBAAqB,CAAA;AAE/D,OAAO,EAAE,KAAK,6BAA6B,EAAgC,MAAM,iBAAiB,CAAA;AAElG,MAAM,WAAW,wBAAyB,SAAQ,6BAA6B;CAAG;AAElF,wBAAgB,iBAAiB,CAAC,OAAO,GAAE,wBAA6B,GAAG,MAAM,CAMhF"}
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Channel-push core plugin.
3
+ *
4
+ * Bundles the EventBus subscribers that listen to `booking.confirmed`
5
+ * (and later `availability.slot.changed`, `product.content.changed`)
6
+ * and writes durable intent rows.
7
+ *
8
+ * Templates wire this via `registerPlugins([channelPushPlugin({ ... })],
9
+ * { eventBus })` AFTER calling `setChannelPushDeps({ db, registry })`.
10
+ *
11
+ * Per docs/architecture/channel-push-architecture.md §4 + §10 (Phase D).
12
+ */
13
+ import { definePlugin } from "@voyant-travel/core";
14
+ import { createChannelPushSubscribers } from "./subscriber.js";
15
+ export function channelPushPlugin(options = {}) {
16
+ return definePlugin({
17
+ name: "channel-push",
18
+ version: "0.1.0",
19
+ subscribers: createChannelPushSubscribers(options),
20
+ });
21
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Channel-push reconciler — self-healing catch-up for divergence.
3
+ *
4
+ * Eventually-consistent push works while the channel is reachable most
5
+ * of the time. After a long outage (or when an integration is first
6
+ * turned on for a channel that already has local state), the channel's
7
+ * view of our inventory diverges from ours. The reconciler closes that
8
+ * gap by re-reading current state from owned tables and recreating
9
+ * intent rows for divergent ones — same intent + worker pipeline, not
10
+ * a parallel push path.
11
+ *
12
+ * v1 cadences (per §13.2, tunable per channel):
13
+ * - Booking-link reconciler: every 15 min
14
+ * - Availability reconciler: hourly
15
+ * - Content reconciler: nightly
16
+ *
17
+ * Templates schedule these via cron / the workflows runtime; the
18
+ * functions themselves are plain async so they're testable.
19
+ *
20
+ * Per docs/architecture/channel-push-architecture.md §13.
21
+ */
22
+ import { type ChannelPushDeps } from "./types.js";
23
+ export interface BookingLinkReconcilerOptions {
24
+ /**
25
+ * Re-process links whose `lastPushAt` is older than this many ms (or
26
+ * has never been pushed). Default 15 min, matching the v1 cadence.
27
+ */
28
+ staleAfterMs?: number;
29
+ /** Max links to re-process per call. Default 200. */
30
+ limit?: number;
31
+ /** When set, scope the pass to one channel. */
32
+ channelId?: string;
33
+ }
34
+ export interface ReconcilerResult {
35
+ scanned: number;
36
+ triggered: number;
37
+ }
38
+ /**
39
+ * Walk `channel_booking_links` where push_status != 'ok' and reissue
40
+ * via `processBookingPush`. The processor is idempotent on
41
+ * `idempotency_key`, so reissuing succeeded-then-edited links never
42
+ * doubles upstream.
43
+ *
44
+ * Per §13.1.
45
+ */
46
+ export declare function reconcileBookingLinks(options?: BookingLinkReconcilerOptions, deps?: ChannelPushDeps): Promise<ReconcilerResult>;
47
+ export interface AvailabilityReconcilerOptions {
48
+ /** Look-back window for recent slot changes. Default 1 hour. */
49
+ lookbackMs?: number;
50
+ /** Max slots to enqueue per pass. Default 500. */
51
+ limit?: number;
52
+ channelId?: string;
53
+ }
54
+ /**
55
+ * Walk recently-updated slots and ensure an intent row exists per
56
+ * (channel, slot) where the channel holds an active allotment. The
57
+ * worker's existing UNIQUE constraint collapses duplicates, so this is
58
+ * safe to re-run.
59
+ *
60
+ * Per §13.1 (availability reconciler).
61
+ */
62
+ export declare function reconcileAvailability(options?: AvailabilityReconcilerOptions, deps?: ChannelPushDeps): Promise<ReconcilerResult>;
63
+ export interface ContentReconcilerOptions {
64
+ /** Max products to scan per pass. Default 200. */
65
+ limit?: number;
66
+ channelId?: string;
67
+ }
68
+ /**
69
+ * Walk syndicated products, hash current content, and recreate an
70
+ * intent row for every (channel, product) where the upstream's
71
+ * `last_pushed_content_hash` doesn't match. Per §13.1 (content
72
+ * reconciler) — content drift converges nightly.
73
+ */
74
+ export declare function reconcileContent(options?: ContentReconcilerOptions, deps?: ChannelPushDeps): Promise<ReconcilerResult>;
75
+ /**
76
+ * Convenience: run all three reconcilers with default cadences.
77
+ * Templates can call this from a single nightly cron, or schedule
78
+ * each independently for finer control.
79
+ */
80
+ export declare function runAllReconcilers(deps?: ChannelPushDeps): Promise<{
81
+ bookings: ReconcilerResult;
82
+ availability: ReconcilerResult;
83
+ content: ReconcilerResult;
84
+ }>;
85
+ //# sourceMappingURL=reconciler.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reconciler.d.ts","sourceRoot":"","sources":["../../src/channel-push/reconciler.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAiBH,OAAO,EAAE,KAAK,eAAe,EAA4C,MAAM,YAAY,CAAA;AAE3F,MAAM,WAAW,4BAA4B;IAC3C;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,qDAAqD;IACrD,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,+CAA+C;IAC/C,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,MAAM,WAAW,gBAAgB;IAC/B,OAAO,EAAE,MAAM,CAAA;IACf,SAAS,EAAE,MAAM,CAAA;CAClB;AAED;;;;;;;GAOG;AACH,wBAAsB,qBAAqB,CACzC,OAAO,GAAE,4BAAiC,EAC1C,IAAI,CAAC,EAAE,eAAe,GACrB,OAAO,CAAC,gBAAgB,CAAC,CAqC3B;AAED,MAAM,WAAW,6BAA6B;IAC5C,gEAAgE;IAChE,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,kDAAkD;IAClD,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED;;;;;;;GAOG;AACH,wBAAsB,qBAAqB,CACzC,OAAO,GAAE,6BAAkC,EAC3C,IAAI,CAAC,EAAE,eAAe,GACrB,OAAO,CAAC,gBAAgB,CAAC,CAgE3B;AAED,MAAM,WAAW,wBAAwB;IACvC,kDAAkD;IAClD,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED;;;;;GAKG;AACH,wBAAsB,gBAAgB,CACpC,OAAO,GAAE,wBAA6B,EACtC,IAAI,CAAC,EAAE,eAAe,GACrB,OAAO,CAAC,gBAAgB,CAAC,CA8C3B;AAED;;;;GAIG;AACH,wBAAsB,iBAAiB,CAAC,IAAI,CAAC,EAAE,eAAe,GAAG,OAAO,CAAC;IACvE,QAAQ,EAAE,gBAAgB,CAAA;IAC1B,YAAY,EAAE,gBAAgB,CAAA;IAC9B,OAAO,EAAE,gBAAgB,CAAA;CAC1B,CAAC,CAOD"}