@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,286 @@
1
+ import { and, eq, ne } from "drizzle-orm";
2
+ import { externalRefs } from "./external-refs/schema.js";
3
+ import { channelReconciliationItems, channels, channelWebhookEvents } from "./schema.js";
4
+ import { suppliers } from "./suppliers/schema.js";
5
+ export function counterpartyRoleToEntityType(role) {
6
+ return role;
7
+ }
8
+ export function counterpartyEntityTypeToRole(entityType) {
9
+ if (entityType === "supplier" || entityType === "channel") {
10
+ return entityType;
11
+ }
12
+ return null;
13
+ }
14
+ export async function resolveCounterparty(db, input) {
15
+ const externalRef = input.externalRef
16
+ ? await findExternalRef(db, {
17
+ ...input.externalRef,
18
+ entityType: input.role ? counterpartyRoleToEntityType(input.role) : undefined,
19
+ entityId: input.id,
20
+ })
21
+ : null;
22
+ const role = input.role ?? (externalRef ? counterpartyEntityTypeToRole(externalRef.entityType) : null);
23
+ if (!role) {
24
+ return input.id
25
+ ? { status: "ambiguous", reason: "role_required_for_id_lookup" }
26
+ : { status: "not_found", reason: "external_ref_not_found" };
27
+ }
28
+ const id = input.id ?? externalRef?.entityId;
29
+ if (!id) {
30
+ return { status: "not_found", reason: "external_ref_not_found" };
31
+ }
32
+ const counterparty = await getCounterpartyByRole(db, role, id, externalRef);
33
+ if (!counterparty) {
34
+ return externalRef
35
+ ? { status: "not_found", reason: "counterparty_not_found" }
36
+ : {
37
+ status: "not_found",
38
+ reason: input.externalRef ? "external_ref_not_found" : "counterparty_not_found",
39
+ };
40
+ }
41
+ return { status: "resolved", counterparty };
42
+ }
43
+ export async function linkExternalReference(db, input) {
44
+ const resolved = await resolveCounterparty(db, input.counterparty);
45
+ if (resolved.status !== "resolved") {
46
+ return resolved;
47
+ }
48
+ const entityType = resolved.counterparty.entityType;
49
+ const namespace = input.namespace ?? "default";
50
+ if (input.isPrimary) {
51
+ await clearPrimaryExternalRefs(db, {
52
+ entityType,
53
+ entityId: resolved.counterparty.id,
54
+ sourceSystem: input.sourceSystem,
55
+ });
56
+ }
57
+ const existing = await findExternalRef(db, {
58
+ entityType,
59
+ entityId: resolved.counterparty.id,
60
+ sourceSystem: input.sourceSystem,
61
+ objectType: input.objectType,
62
+ namespace,
63
+ externalId: input.externalId,
64
+ });
65
+ if (existing) {
66
+ const [externalRef] = await db
67
+ .update(externalRefs)
68
+ .set({
69
+ externalParentId: input.externalParentId,
70
+ isPrimary: input.isPrimary,
71
+ status: input.status,
72
+ lastSyncedAt: toDate(input.lastSyncedAt),
73
+ metadata: input.metadata,
74
+ updatedAt: new Date(),
75
+ })
76
+ .where(eq(externalRefs.id, existing.id))
77
+ .returning();
78
+ return {
79
+ status: "linked",
80
+ counterparty: resolved.counterparty,
81
+ externalRef: externalRef ?? existing,
82
+ created: false,
83
+ };
84
+ }
85
+ const [externalRef] = await db
86
+ .insert(externalRefs)
87
+ .values({
88
+ entityType,
89
+ entityId: resolved.counterparty.id,
90
+ sourceSystem: input.sourceSystem,
91
+ objectType: input.objectType,
92
+ namespace,
93
+ externalId: input.externalId,
94
+ externalParentId: input.externalParentId ?? null,
95
+ isPrimary: input.isPrimary ?? false,
96
+ status: input.status ?? "active",
97
+ lastSyncedAt: toDate(input.lastSyncedAt),
98
+ metadata: input.metadata,
99
+ })
100
+ .returning();
101
+ if (!externalRef) {
102
+ throw new Error("Failed to link external reference");
103
+ }
104
+ return {
105
+ status: "linked",
106
+ counterparty: resolved.counterparty,
107
+ externalRef,
108
+ created: true,
109
+ };
110
+ }
111
+ export async function routeCounterpartyEvent(db, input) {
112
+ const resolved = await resolveCounterparty(db, input.counterparty);
113
+ if (resolved.status !== "resolved") {
114
+ return resolved;
115
+ }
116
+ if (resolved.counterparty.role === "supplier") {
117
+ return {
118
+ status: "routed",
119
+ counterparty: resolved.counterparty,
120
+ destination: "supplier_adapter",
121
+ event: null,
122
+ };
123
+ }
124
+ const [event] = await db
125
+ .insert(channelWebhookEvents)
126
+ .values({
127
+ channelId: resolved.counterparty.id,
128
+ eventType: input.eventType,
129
+ externalEventId: input.externalEventId ?? null,
130
+ payload: input.payload ?? {},
131
+ receivedAt: toDate(input.receivedAt) ?? new Date(),
132
+ status: input.status ?? "pending",
133
+ })
134
+ .returning();
135
+ if (!event) {
136
+ throw new Error("Failed to route channel event");
137
+ }
138
+ return {
139
+ status: "routed",
140
+ counterparty: resolved.counterparty,
141
+ destination: "channel_webhook_events",
142
+ event,
143
+ };
144
+ }
145
+ export async function reconcileCounterpartyActivity(db, input) {
146
+ const expected = input.counterparty ? await resolveCounterparty(db, input.counterparty) : null;
147
+ if (expected && expected.status !== "resolved") {
148
+ return expected;
149
+ }
150
+ const externalRef = await findExternalRef(db, input.externalRef);
151
+ if (!externalRef) {
152
+ if (!expected || !input.createExternalReference) {
153
+ return {
154
+ status: "unmatched",
155
+ reason: expected ? "external_ref_not_found" : "counterparty_required_to_create_link",
156
+ };
157
+ }
158
+ const linked = await linkExternalReference(db, {
159
+ counterparty: {
160
+ role: expected.counterparty.role,
161
+ id: expected.counterparty.id,
162
+ },
163
+ ...input.externalRef,
164
+ metadata: input.metadata,
165
+ lastSyncedAt: new Date(),
166
+ });
167
+ if (linked.status !== "linked") {
168
+ return linked;
169
+ }
170
+ return {
171
+ status: "linked",
172
+ counterparty: linked.counterparty,
173
+ externalRef: linked.externalRef,
174
+ reconciliationItem: await maybeCreateChannelReconciliationItem(db, linked.counterparty, input.channelReconciliation),
175
+ };
176
+ }
177
+ const actualRole = counterpartyEntityTypeToRole(externalRef.entityType);
178
+ if (!actualRole) {
179
+ return { status: "unsupported", entityType: externalRef.entityType };
180
+ }
181
+ const actual = await getCounterpartyByRole(db, actualRole, externalRef.entityId, externalRef);
182
+ if (!actual) {
183
+ return { status: "not_found", reason: "counterparty_not_found" };
184
+ }
185
+ if (expected &&
186
+ (expected.counterparty.role !== actual.role || expected.counterparty.id !== actual.id)) {
187
+ return {
188
+ status: "conflict",
189
+ expected: expected.counterparty,
190
+ actual,
191
+ externalRef,
192
+ };
193
+ }
194
+ return {
195
+ status: "matched",
196
+ counterparty: actual,
197
+ externalRef,
198
+ reconciliationItem: await maybeCreateChannelReconciliationItem(db, actual, input.channelReconciliation),
199
+ };
200
+ }
201
+ async function getCounterpartyByRole(db, role, id, externalRef) {
202
+ if (role === "supplier") {
203
+ const [record] = await db.select().from(suppliers).where(eq(suppliers.id, id)).limit(1);
204
+ return record
205
+ ? {
206
+ role,
207
+ entityType: "supplier",
208
+ id: record.id,
209
+ record,
210
+ externalRef,
211
+ }
212
+ : null;
213
+ }
214
+ const [record] = await db.select().from(channels).where(eq(channels.id, id)).limit(1);
215
+ return record
216
+ ? {
217
+ role,
218
+ entityType: "channel",
219
+ id: record.id,
220
+ record,
221
+ externalRef,
222
+ }
223
+ : null;
224
+ }
225
+ async function findExternalRef(db, input) {
226
+ const conditions = [
227
+ eq(externalRefs.sourceSystem, input.sourceSystem),
228
+ eq(externalRefs.objectType, input.objectType),
229
+ eq(externalRefs.namespace, input.namespace ?? "default"),
230
+ eq(externalRefs.externalId, input.externalId),
231
+ ];
232
+ if (input.entityType) {
233
+ conditions.push(eq(externalRefs.entityType, input.entityType));
234
+ }
235
+ if (input.entityId) {
236
+ conditions.push(eq(externalRefs.entityId, input.entityId));
237
+ }
238
+ const [row] = await db
239
+ .select()
240
+ .from(externalRefs)
241
+ .where(and(...conditions))
242
+ .limit(1);
243
+ return row ?? null;
244
+ }
245
+ async function clearPrimaryExternalRefs(db, input) {
246
+ const conditions = [
247
+ eq(externalRefs.entityType, input.entityType),
248
+ eq(externalRefs.entityId, input.entityId),
249
+ eq(externalRefs.sourceSystem, input.sourceSystem),
250
+ ];
251
+ if (input.exceptId) {
252
+ conditions.push(ne(externalRefs.id, input.exceptId));
253
+ }
254
+ await db
255
+ .update(externalRefs)
256
+ .set({ isPrimary: false, updatedAt: new Date() })
257
+ .where(and(...conditions));
258
+ }
259
+ async function maybeCreateChannelReconciliationItem(db, counterparty, input) {
260
+ if (!input || counterparty.role !== "channel") {
261
+ return null;
262
+ }
263
+ const [item] = await db
264
+ .insert(channelReconciliationItems)
265
+ .values({
266
+ reconciliationRunId: input.reconciliationRunId,
267
+ bookingLinkId: input.bookingLinkId ?? null,
268
+ bookingId: input.bookingId ?? null,
269
+ externalBookingId: input.externalBookingId ?? null,
270
+ issueType: input.issueType ?? "other",
271
+ severity: input.severity ?? "warning",
272
+ resolutionStatus: input.resolutionStatus ?? "open",
273
+ notes: input.notes ?? null,
274
+ })
275
+ .returning();
276
+ return item ?? null;
277
+ }
278
+ function toDate(value) {
279
+ if (value === undefined) {
280
+ return undefined;
281
+ }
282
+ if (value === null) {
283
+ return null;
284
+ }
285
+ return value instanceof Date ? value : new Date(value);
286
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Per-channel rate limiting (token bucket on Postgres).
3
+ *
4
+ * `acquireToken` is the canonical channel-push wrapper around the
5
+ * generic `infra.rate_limit_buckets` primitive. Each call:
6
+ *
7
+ * 1. Atomically refills the bucket based on `(now - last_refill_at) *
8
+ * refill_rate`, capped at capacity.
9
+ * 2. Checks the priority gate: tokens_available >= gate * capacity
10
+ * AND tokens_available >= 1.
11
+ * 3. On success, decrements by 1 and returns `{ acquired: true }`.
12
+ * 4. On denial, returns `{ acquired: false, retryAfterMs }` computed
13
+ * from how long until enough tokens refill to clear the gate.
14
+ *
15
+ * Whole thing is one round-trip (an UPSERT with conditional UPDATE).
16
+ *
17
+ * Per docs/architecture/channel-push-architecture.md §14.2 and §14.3.
18
+ */
19
+ import type { AnyDrizzleDb } from "@voyant-travel/db";
20
+ export type ChannelPushPriority = "booking" | "availability" | "content";
21
+ export interface RateLimitConfig {
22
+ /** Sustained refill rate (tokens per second). */
23
+ rps: number;
24
+ /** Burst capacity (max tokens in the bucket). */
25
+ burst: number;
26
+ /**
27
+ * Per-priority reserve thresholds. Defaults to:
28
+ * { booking: 0, availability: 0.3, content: 0.7 }
29
+ * Read as: bookings dispatch with any tokens; availability when
30
+ * bucket ≥ 30% full; content when ≥ 70% full.
31
+ */
32
+ priorityGates?: Partial<Record<ChannelPushPriority, number>>;
33
+ }
34
+ export declare const DEFAULT_PRIORITY_GATES: Record<ChannelPushPriority, number>;
35
+ export interface AcquireTokenAcquired {
36
+ acquired: true;
37
+ /** Tokens left in the bucket after this call. */
38
+ tokensRemaining: number;
39
+ }
40
+ export interface AcquireTokenDenied {
41
+ acquired: false;
42
+ /** Suggested wait time in milliseconds before retrying. */
43
+ retryAfterMs: number;
44
+ /** Tokens currently in the bucket (post-refill). */
45
+ tokensAvailable: number;
46
+ }
47
+ export type AcquireTokenResult = AcquireTokenAcquired | AcquireTokenDenied;
48
+ /**
49
+ * Build the channel-push scope key from a (channel, connection) pair.
50
+ * Same shape used by the workflow + reconciler so all paths address the
51
+ * same bucket.
52
+ */
53
+ export declare function channelScopeKey(channelId: string, connectionId: string): string;
54
+ /**
55
+ * Acquire one token from the bucket at `scope`, applying the priority
56
+ * gate for `priority`. Creates the bucket on first call (UPSERT with
57
+ * full capacity).
58
+ */
59
+ export declare function acquireToken(db: AnyDrizzleDb, scope: string, config: RateLimitConfig, priority: ChannelPushPriority): Promise<AcquireTokenResult>;
60
+ /**
61
+ * Drain the bucket to zero and freeze it for `cooldownMs`.
62
+ *
63
+ * Called when an upstream returns 429 with a `Retry-After` hint —
64
+ * prevents subsequent dispatchers from immediately retrying through
65
+ * the same bucket and lets our outbound estimate converge with the
66
+ * channel's authoritative state. Per §14.4.
67
+ */
68
+ export declare function drainBucket(db: AnyDrizzleDb, scope: string, cooldownMs: number): Promise<void>;
69
+ //# sourceMappingURL=rate-limit.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rate-limit.d.ts","sourceRoot":"","sources":["../src/rate-limit.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAIrD,MAAM,MAAM,mBAAmB,GAAG,SAAS,GAAG,cAAc,GAAG,SAAS,CAAA;AAExE,MAAM,WAAW,eAAe;IAC9B,iDAAiD;IACjD,GAAG,EAAE,MAAM,CAAA;IACX,iDAAiD;IACjD,KAAK,EAAE,MAAM,CAAA;IACb;;;;;OAKG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,mBAAmB,EAAE,MAAM,CAAC,CAAC,CAAA;CAC7D;AAED,eAAO,MAAM,sBAAsB,EAAE,MAAM,CAAC,mBAAmB,EAAE,MAAM,CAItE,CAAA;AAED,MAAM,WAAW,oBAAoB;IACnC,QAAQ,EAAE,IAAI,CAAA;IACd,iDAAiD;IACjD,eAAe,EAAE,MAAM,CAAA;CACxB;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,EAAE,KAAK,CAAA;IACf,2DAA2D;IAC3D,YAAY,EAAE,MAAM,CAAA;IACpB,oDAAoD;IACpD,eAAe,EAAE,MAAM,CAAA;CACxB;AAED,MAAM,MAAM,kBAAkB,GAAG,oBAAoB,GAAG,kBAAkB,CAAA;AAE1E;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,SAAS,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,MAAM,CAE/E;AAED;;;;GAIG;AACH,wBAAsB,YAAY,CAChC,EAAE,EAAE,YAAY,EAChB,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,eAAe,EACvB,QAAQ,EAAE,mBAAmB,GAC5B,OAAO,CAAC,kBAAkB,CAAC,CAkF7B;AAED;;;;;;;GAOG;AACH,wBAAsB,WAAW,CAC/B,EAAE,EAAE,YAAY,EAChB,KAAK,EAAE,MAAM,EACb,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,IAAI,CAAC,CAUf"}
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Per-channel rate limiting (token bucket on Postgres).
3
+ *
4
+ * `acquireToken` is the canonical channel-push wrapper around the
5
+ * generic `infra.rate_limit_buckets` primitive. Each call:
6
+ *
7
+ * 1. Atomically refills the bucket based on `(now - last_refill_at) *
8
+ * refill_rate`, capped at capacity.
9
+ * 2. Checks the priority gate: tokens_available >= gate * capacity
10
+ * AND tokens_available >= 1.
11
+ * 3. On success, decrements by 1 and returns `{ acquired: true }`.
12
+ * 4. On denial, returns `{ acquired: false, retryAfterMs }` computed
13
+ * from how long until enough tokens refill to clear the gate.
14
+ *
15
+ * Whole thing is one round-trip (an UPSERT with conditional UPDATE).
16
+ *
17
+ * Per docs/architecture/channel-push-architecture.md §14.2 and §14.3.
18
+ */
19
+ import { infraRateLimitBucketsTable } from "@voyant-travel/db/schema/infra";
20
+ import { eq } from "drizzle-orm";
21
+ export const DEFAULT_PRIORITY_GATES = {
22
+ booking: 0,
23
+ availability: 0.3,
24
+ content: 0.7,
25
+ };
26
+ /**
27
+ * Build the channel-push scope key from a (channel, connection) pair.
28
+ * Same shape used by the workflow + reconciler so all paths address the
29
+ * same bucket.
30
+ */
31
+ export function channelScopeKey(channelId, connectionId) {
32
+ return `channel:${channelId}:${connectionId}`;
33
+ }
34
+ /**
35
+ * Acquire one token from the bucket at `scope`, applying the priority
36
+ * gate for `priority`. Creates the bucket on first call (UPSERT with
37
+ * full capacity).
38
+ */
39
+ export async function acquireToken(db, scope, config, priority) {
40
+ const gate = config.priorityGates?.[priority] ?? DEFAULT_PRIORITY_GATES[priority];
41
+ const gateThreshold = Math.max(0, gate) * config.burst;
42
+ const now = new Date();
43
+ // Read or create the bucket. We use an UPSERT to keep the operation
44
+ // single-call.
45
+ const existing = await db
46
+ .select()
47
+ .from(infraRateLimitBucketsTable)
48
+ .where(eq(infraRateLimitBucketsTable.scope, scope))
49
+ .limit(1);
50
+ let bucket = existing[0];
51
+ if (!bucket) {
52
+ const created = await db
53
+ .insert(infraRateLimitBucketsTable)
54
+ .values({
55
+ scope,
56
+ tokensAvailable: String(config.burst),
57
+ capacity: String(config.burst),
58
+ refillRatePerSec: String(config.rps),
59
+ lastRefillAt: now,
60
+ })
61
+ .onConflictDoNothing()
62
+ .returning();
63
+ if (created[0]) {
64
+ bucket = created[0];
65
+ }
66
+ else {
67
+ // Lost the race — re-read.
68
+ const reread = await db
69
+ .select()
70
+ .from(infraRateLimitBucketsTable)
71
+ .where(eq(infraRateLimitBucketsTable.scope, scope))
72
+ .limit(1);
73
+ bucket = reread[0];
74
+ }
75
+ if (!bucket) {
76
+ throw new Error(`acquireToken: failed to create bucket for scope "${scope}"`);
77
+ }
78
+ }
79
+ // Refill based on elapsed time, then check the gate.
80
+ const tokensBefore = Number.parseFloat(bucket.tokensAvailable);
81
+ const capacity = Number.parseFloat(bucket.capacity);
82
+ const refillRate = Number.parseFloat(bucket.refillRatePerSec);
83
+ const elapsedMs = now.getTime() - new Date(bucket.lastRefillAt).getTime();
84
+ const refilled = Math.min(capacity, tokensBefore + (elapsedMs / 1000) * refillRate);
85
+ if (refilled < 1 || refilled < gateThreshold) {
86
+ // Not enough tokens. Compute the wait until we cross the higher of
87
+ // (1, gateThreshold).
88
+ const target = Math.max(1, gateThreshold);
89
+ const deficit = target - refilled;
90
+ const retryAfterMs = refillRate > 0 ? Math.ceil((deficit / refillRate) * 1000) : 60_000;
91
+ // Persist the refill so concurrent acquirers see the same baseline.
92
+ await db
93
+ .update(infraRateLimitBucketsTable)
94
+ .set({
95
+ tokensAvailable: String(refilled),
96
+ lastRefillAt: now,
97
+ updatedAt: now,
98
+ })
99
+ .where(eq(infraRateLimitBucketsTable.scope, scope));
100
+ return {
101
+ acquired: false,
102
+ retryAfterMs: Math.max(retryAfterMs, 0),
103
+ tokensAvailable: refilled,
104
+ };
105
+ }
106
+ const after = refilled - 1;
107
+ await db
108
+ .update(infraRateLimitBucketsTable)
109
+ .set({
110
+ tokensAvailable: String(after),
111
+ lastRefillAt: now,
112
+ updatedAt: now,
113
+ })
114
+ .where(eq(infraRateLimitBucketsTable.scope, scope));
115
+ return { acquired: true, tokensRemaining: after };
116
+ }
117
+ /**
118
+ * Drain the bucket to zero and freeze it for `cooldownMs`.
119
+ *
120
+ * Called when an upstream returns 429 with a `Retry-After` hint —
121
+ * prevents subsequent dispatchers from immediately retrying through
122
+ * the same bucket and lets our outbound estimate converge with the
123
+ * channel's authoritative state. Per §14.4.
124
+ */
125
+ export async function drainBucket(db, scope, cooldownMs) {
126
+ const lastRefillAt = new Date(Date.now() + cooldownMs);
127
+ await db
128
+ .update(infraRateLimitBucketsTable)
129
+ .set({
130
+ tokensAvailable: "0",
131
+ lastRefillAt,
132
+ updatedAt: new Date(),
133
+ })
134
+ .where(eq(infraRateLimitBucketsTable.scope, scope));
135
+ }