@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.
- package/LICENSE +201 -0
- package/README.md +42 -0
- package/dist/booking-extension.d.ts +168 -0
- package/dist/booking-extension.d.ts.map +1 -0
- package/dist/booking-extension.js +102 -0
- package/dist/channel-push/admin-routes.d.ts +31 -0
- package/dist/channel-push/admin-routes.d.ts.map +1 -0
- package/dist/channel-push/admin-routes.js +165 -0
- package/dist/channel-push/availability-push.d.ts +76 -0
- package/dist/channel-push/availability-push.d.ts.map +1 -0
- package/dist/channel-push/availability-push.js +236 -0
- package/dist/channel-push/booking-push-helpers.d.ts +36 -0
- package/dist/channel-push/booking-push-helpers.d.ts.map +1 -0
- package/dist/channel-push/booking-push-helpers.js +169 -0
- package/dist/channel-push/booking-push.d.ts +108 -0
- package/dist/channel-push/booking-push.d.ts.map +1 -0
- package/dist/channel-push/booking-push.js +335 -0
- package/dist/channel-push/boundary-sql.d.ts +23 -0
- package/dist/channel-push/boundary-sql.d.ts.map +1 -0
- package/dist/channel-push/boundary-sql.js +75 -0
- package/dist/channel-push/content-push.d.ts +60 -0
- package/dist/channel-push/content-push.d.ts.map +1 -0
- package/dist/channel-push/content-push.js +252 -0
- package/dist/channel-push/index.d.ts +15 -0
- package/dist/channel-push/index.d.ts.map +1 -0
- package/dist/channel-push/index.js +18 -0
- package/dist/channel-push/plugin.d.ts +18 -0
- package/dist/channel-push/plugin.d.ts.map +1 -0
- package/dist/channel-push/plugin.js +21 -0
- package/dist/channel-push/reconciler.d.ts +85 -0
- package/dist/channel-push/reconciler.d.ts.map +1 -0
- package/dist/channel-push/reconciler.js +179 -0
- package/dist/channel-push/subscriber.d.ts +40 -0
- package/dist/channel-push/subscriber.d.ts.map +1 -0
- package/dist/channel-push/subscriber.js +199 -0
- package/dist/channel-push/types.d.ts +43 -0
- package/dist/channel-push/types.d.ts.map +1 -0
- package/dist/channel-push/types.js +32 -0
- package/dist/channel-push/workflows.d.ts +56 -0
- package/dist/channel-push/workflows.d.ts.map +1 -0
- package/dist/channel-push/workflows.js +100 -0
- package/dist/external-refs/index.d.ts +11 -0
- package/dist/external-refs/index.d.ts.map +1 -0
- package/dist/external-refs/index.js +12 -0
- package/dist/external-refs/routes.d.ts +253 -0
- package/dist/external-refs/routes.d.ts.map +1 -0
- package/dist/external-refs/routes.js +52 -0
- package/dist/external-refs/schema.d.ts +251 -0
- package/dist/external-refs/schema.d.ts.map +1 -0
- package/dist/external-refs/schema.js +32 -0
- package/dist/external-refs/service.d.ts +82 -0
- package/dist/external-refs/service.d.ts.map +1 -0
- package/dist/external-refs/service.js +112 -0
- package/dist/external-refs/validation.d.ts +91 -0
- package/dist/external-refs/validation.d.ts.map +1 -0
- package/dist/external-refs/validation.js +40 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/interface-types.d.ts +128 -0
- package/dist/interface-types.d.ts.map +1 -0
- package/dist/interface-types.js +1 -0
- package/dist/interface.d.ts +10 -0
- package/dist/interface.d.ts.map +1 -0
- package/dist/interface.js +286 -0
- package/dist/rate-limit.d.ts +69 -0
- package/dist/rate-limit.d.ts.map +1 -0
- package/dist/rate-limit.js +135 -0
- package/dist/routes/batch.d.ts +200 -0
- package/dist/routes/batch.d.ts.map +1 -0
- package/dist/routes/batch.js +52 -0
- package/dist/routes/env.d.ts +8 -0
- package/dist/routes/env.d.ts.map +1 -0
- package/dist/routes/env.js +1 -0
- package/dist/routes/inventory.d.ts +604 -0
- package/dist/routes/inventory.d.ts.map +1 -0
- package/dist/routes/inventory.js +138 -0
- package/dist/routes/settlements.d.ts +1649 -0
- package/dist/routes/settlements.d.ts.map +1 -0
- package/dist/routes/settlements.js +265 -0
- package/dist/routes.d.ts +3909 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +323 -0
- package/dist/schema-automation.d.ts +680 -0
- package/dist/schema-automation.d.ts.map +1 -0
- package/dist/schema-automation.js +76 -0
- package/dist/schema-core.d.ts +1674 -0
- package/dist/schema-core.d.ts.map +1 -0
- package/dist/schema-core.js +227 -0
- package/dist/schema-finance.d.ts +1372 -0
- package/dist/schema-finance.d.ts.map +1 -0
- package/dist/schema-finance.js +153 -0
- package/dist/schema-inventory.d.ts +855 -0
- package/dist/schema-inventory.d.ts.map +1 -0
- package/dist/schema-inventory.js +102 -0
- package/dist/schema-push-intents.d.ts +387 -0
- package/dist/schema-push-intents.d.ts.map +1 -0
- package/dist/schema-push-intents.js +77 -0
- package/dist/schema-relations.d.ts +95 -0
- package/dist/schema-relations.d.ts.map +1 -0
- package/dist/schema-relations.js +196 -0
- package/dist/schema-shared.d.ts +24 -0
- package/dist/schema-shared.d.ts.map +1 -0
- package/dist/schema-shared.js +123 -0
- package/dist/schema.d.ts +9 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +8 -0
- package/dist/service/channels.d.ts +167 -0
- package/dist/service/channels.d.ts.map +1 -0
- package/dist/service/channels.js +305 -0
- package/dist/service/commercial.d.ts +385 -0
- package/dist/service/commercial.d.ts.map +1 -0
- package/dist/service/commercial.js +248 -0
- package/dist/service/helpers.d.ts +10 -0
- package/dist/service/helpers.d.ts.map +1 -0
- package/dist/service/helpers.js +7 -0
- package/dist/service/inventory.d.ts +193 -0
- package/dist/service/inventory.d.ts.map +1 -0
- package/dist/service/inventory.js +154 -0
- package/dist/service/settlement-policies.d.ts +325 -0
- package/dist/service/settlement-policies.d.ts.map +1 -0
- package/dist/service/settlement-policies.js +272 -0
- package/dist/service/settlements.d.ts +357 -0
- package/dist/service/settlements.d.ts.map +1 -0
- package/dist/service/settlements.js +319 -0
- package/dist/service/types.d.ts +60 -0
- package/dist/service/types.d.ts.map +1 -0
- package/dist/service/types.js +1 -0
- package/dist/service.d.ts +1418 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +17 -0
- package/dist/suppliers/index.d.ts +15 -0
- package/dist/suppliers/index.d.ts.map +1 -0
- package/dist/suppliers/index.js +23 -0
- package/dist/suppliers/routes.d.ts +1202 -0
- package/dist/suppliers/routes.d.ts.map +1 -0
- package/dist/suppliers/routes.js +290 -0
- package/dist/suppliers/schema.d.ts +1272 -0
- package/dist/suppliers/schema.d.ts.map +1 -0
- package/dist/suppliers/schema.js +219 -0
- package/dist/suppliers/service-aggregates.d.ts +23 -0
- package/dist/suppliers/service-aggregates.d.ts.map +1 -0
- package/dist/suppliers/service-aggregates.js +51 -0
- package/dist/suppliers/service-core.d.ts +89 -0
- package/dist/suppliers/service-core.d.ts.map +1 -0
- package/dist/suppliers/service-core.js +164 -0
- package/dist/suppliers/service-identity.d.ts +162 -0
- package/dist/suppliers/service-identity.d.ts.map +1 -0
- package/dist/suppliers/service-identity.js +101 -0
- package/dist/suppliers/service-operations.d.ts +1500 -0
- package/dist/suppliers/service-operations.d.ts.map +1 -0
- package/dist/suppliers/service-operations.js +157 -0
- package/dist/suppliers/service-shared.d.ts +45 -0
- package/dist/suppliers/service-shared.d.ts.map +1 -0
- package/dist/suppliers/service-shared.js +294 -0
- package/dist/suppliers/service.d.ts +41 -0
- package/dist/suppliers/service.d.ts.map +1 -0
- package/dist/suppliers/service.js +40 -0
- package/dist/suppliers/validation.d.ts +2 -0
- package/dist/suppliers/validation.d.ts.map +1 -0
- package/dist/suppliers/validation.js +1 -0
- package/dist/validation.d.ts +1371 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +445 -0
- package/dist/webhook-deliveries.d.ts +86 -0
- package/dist/webhook-deliveries.d.ts.map +1 -0
- package/dist/webhook-deliveries.js +296 -0
- 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
|
+
}
|