@checkstack/integration-backend 0.1.29 → 0.2.0
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/CHANGELOG.md +92 -0
- package/package.json +9 -9
- package/src/index.ts +24 -187
- package/src/provider-registry.test.ts +56 -286
- package/src/provider-registry.ts +5 -20
- package/src/provider-types.ts +23 -149
- package/src/router.ts +42 -605
- package/src/schema.ts +13 -59
- package/src/delivery-coordinator.ts +0 -391
- package/src/event-registry.test.ts +0 -396
- package/src/event-registry.ts +0 -99
- package/src/hook-subscriber.ts +0 -105
package/src/schema.ts
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
|
-
import {
|
|
2
|
-
pgTable,
|
|
3
|
-
text,
|
|
4
|
-
boolean,
|
|
5
|
-
timestamp,
|
|
6
|
-
jsonb,
|
|
7
|
-
integer,
|
|
8
|
-
} from "drizzle-orm/pg-core";
|
|
1
|
+
import { boolean, jsonb, pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
|
9
2
|
|
|
10
3
|
/**
|
|
11
|
-
*
|
|
4
|
+
* Legacy webhook subscriptions table.
|
|
5
|
+
*
|
|
6
|
+
* Kept in the schema (and DB) so the one-time data migration in
|
|
7
|
+
* `@checkstack/automation-backend` can read existing rows and convert
|
|
8
|
+
* them to automations. Once Automation Platform consumers have all
|
|
9
|
+
* been migrated and at least one release has shipped without
|
|
10
|
+
* regressions, a follow-up PR will drop this table along with the
|
|
11
|
+
* schema entry.
|
|
12
|
+
*
|
|
13
|
+
* The `delivery_logs` table is also retained in the database for the
|
|
14
|
+
* same reason but no longer modelled here — nothing in the platform
|
|
15
|
+
* reads or writes it.
|
|
12
16
|
*/
|
|
13
17
|
export const webhookSubscriptions = pgTable("webhook_subscriptions", {
|
|
14
18
|
id: text("id")
|
|
@@ -16,63 +20,13 @@ export const webhookSubscriptions = pgTable("webhook_subscriptions", {
|
|
|
16
20
|
.$defaultFn(() => crypto.randomUUID()),
|
|
17
21
|
name: text("name").notNull(),
|
|
18
22
|
description: text("description"),
|
|
19
|
-
|
|
20
|
-
/** Fully qualified provider ID: {pluginId}.{providerId} */
|
|
21
23
|
providerId: text("provider_id").notNull(),
|
|
22
|
-
|
|
23
|
-
/** Provider-specific configuration (encrypted if contains secrets) */
|
|
24
24
|
providerConfig: jsonb("provider_config")
|
|
25
25
|
.notNull()
|
|
26
26
|
.$type<Record<string, unknown>>(),
|
|
27
|
-
|
|
28
|
-
/** Single event to subscribe to (fully qualified event ID) */
|
|
29
27
|
eventId: text("event_id").notNull(),
|
|
30
|
-
|
|
31
|
-
/** Optional: Filter by system IDs */
|
|
32
28
|
systemFilter: text("system_filter").array(),
|
|
33
|
-
|
|
34
|
-
/** Subscription enabled state */
|
|
35
29
|
enabled: boolean("enabled").notNull().default(true),
|
|
36
|
-
|
|
37
30
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
38
31
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
|
39
32
|
});
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Delivery logs - track webhook delivery attempts and results
|
|
43
|
-
*/
|
|
44
|
-
export const deliveryLogs = pgTable("delivery_logs", {
|
|
45
|
-
id: text("id")
|
|
46
|
-
.primaryKey()
|
|
47
|
-
.$defaultFn(() => crypto.randomUUID()),
|
|
48
|
-
subscriptionId: text("subscription_id")
|
|
49
|
-
.notNull()
|
|
50
|
-
.references(() => webhookSubscriptions.id, { onDelete: "cascade" }),
|
|
51
|
-
|
|
52
|
-
eventType: text("event_type").notNull(),
|
|
53
|
-
eventPayload: jsonb("event_payload")
|
|
54
|
-
.notNull()
|
|
55
|
-
.$type<Record<string, unknown>>(),
|
|
56
|
-
|
|
57
|
-
/** Delivery status: pending, success, failed, retrying */
|
|
58
|
-
status: text("status")
|
|
59
|
-
.notNull()
|
|
60
|
-
.$type<"pending" | "success" | "failed" | "retrying">(),
|
|
61
|
-
|
|
62
|
-
/** Number of delivery attempts */
|
|
63
|
-
attempts: integer("attempts").notNull().default(0),
|
|
64
|
-
|
|
65
|
-
/** Timestamp of last delivery attempt */
|
|
66
|
-
lastAttemptAt: timestamp("last_attempt_at"),
|
|
67
|
-
|
|
68
|
-
/** Next retry timestamp (if status is retrying) */
|
|
69
|
-
nextRetryAt: timestamp("next_retry_at"),
|
|
70
|
-
|
|
71
|
-
/** External ID returned by the target system (e.g., Jira issue key) */
|
|
72
|
-
externalId: text("external_id"),
|
|
73
|
-
|
|
74
|
-
/** Error message from last failed attempt */
|
|
75
|
-
errorMessage: text("error_message"),
|
|
76
|
-
|
|
77
|
-
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
78
|
-
});
|
|
@@ -1,391 +0,0 @@
|
|
|
1
|
-
import type { SafeDatabase } from "@checkstack/backend-api";
|
|
2
|
-
import type { Logger } from "@checkstack/backend-api";
|
|
3
|
-
import type { QueueManager } from "@checkstack/queue-api";
|
|
4
|
-
import type { SignalService } from "@checkstack/signal-common";
|
|
5
|
-
import { eq, sql } from "drizzle-orm";
|
|
6
|
-
|
|
7
|
-
import type { IntegrationProviderRegistry } from "./provider-registry";
|
|
8
|
-
import type { ConnectionStore } from "./connection-store";
|
|
9
|
-
import * as schema from "./schema";
|
|
10
|
-
import { INTEGRATION_DELIVERY_COMPLETED } from "@checkstack/integration-common";
|
|
11
|
-
import { extractErrorMessage } from "@checkstack/common";
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Event payload for delivery routing
|
|
15
|
-
*/
|
|
16
|
-
export interface IntegrationEventPayload {
|
|
17
|
-
eventId: string;
|
|
18
|
-
payload: Record<string, unknown>;
|
|
19
|
-
timestamp: string;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Job data for the delivery queue
|
|
24
|
-
*/
|
|
25
|
-
interface DeliveryJobData {
|
|
26
|
-
logId: string;
|
|
27
|
-
subscriptionId: string;
|
|
28
|
-
subscriptionName: string;
|
|
29
|
-
providerId: string;
|
|
30
|
-
providerConfig: Record<string, unknown>;
|
|
31
|
-
eventId: string;
|
|
32
|
-
payload: Record<string, unknown>;
|
|
33
|
-
timestamp: string;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Delivery coordinator - routes events to subscriptions and manages delivery via queue
|
|
38
|
-
*/
|
|
39
|
-
export interface DeliveryCoordinator {
|
|
40
|
-
/**
|
|
41
|
-
* Route an event to all matching subscriptions.
|
|
42
|
-
* This is called by the hook subscriber when a registered event is emitted.
|
|
43
|
-
*/
|
|
44
|
-
routeEvent(event: IntegrationEventPayload): Promise<void>;
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Start the delivery worker that processes queued deliveries.
|
|
48
|
-
* Must be called during afterPluginsReady.
|
|
49
|
-
*/
|
|
50
|
-
startWorker(): Promise<void>;
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Retry a specific failed delivery
|
|
54
|
-
*/
|
|
55
|
-
retryDelivery(logId: string): Promise<{ success: boolean; message?: string }>;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
interface DeliveryCoordinatorDeps {
|
|
59
|
-
db: SafeDatabase<typeof schema>;
|
|
60
|
-
providerRegistry: IntegrationProviderRegistry;
|
|
61
|
-
connectionStore: ConnectionStore;
|
|
62
|
-
queueManager: QueueManager;
|
|
63
|
-
signalService: SignalService;
|
|
64
|
-
logger: Logger;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Create a delivery coordinator instance
|
|
69
|
-
*/
|
|
70
|
-
export function createDeliveryCoordinator(
|
|
71
|
-
deps: DeliveryCoordinatorDeps
|
|
72
|
-
): DeliveryCoordinator {
|
|
73
|
-
const {
|
|
74
|
-
db,
|
|
75
|
-
providerRegistry,
|
|
76
|
-
connectionStore,
|
|
77
|
-
queueManager,
|
|
78
|
-
signalService,
|
|
79
|
-
logger,
|
|
80
|
-
} = deps;
|
|
81
|
-
|
|
82
|
-
const QUEUE_NAME = "integration-delivery";
|
|
83
|
-
const MAX_RETRIES = 3;
|
|
84
|
-
const RETRY_DELAYS = [60_000, 300_000, 900_000]; // 1min, 5min, 15min
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Find all subscriptions that match the given event
|
|
88
|
-
*/
|
|
89
|
-
async function findMatchingSubscriptions(
|
|
90
|
-
eventId: string,
|
|
91
|
-
payload: Record<string, unknown>
|
|
92
|
-
) {
|
|
93
|
-
// Get all enabled subscriptions
|
|
94
|
-
const subscriptions = await db
|
|
95
|
-
.select()
|
|
96
|
-
.from(schema.webhookSubscriptions)
|
|
97
|
-
.where(eq(schema.webhookSubscriptions.enabled, true));
|
|
98
|
-
|
|
99
|
-
// Filter to those that match this event
|
|
100
|
-
return subscriptions.filter((sub) => {
|
|
101
|
-
// Check event ID matches
|
|
102
|
-
if (sub.eventId !== eventId) {
|
|
103
|
-
return false;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// Check system filter if present
|
|
107
|
-
if (sub.systemFilter && sub.systemFilter.length > 0) {
|
|
108
|
-
const systemId = payload["systemId"] as string | undefined;
|
|
109
|
-
if (!systemId || !sub.systemFilter.includes(systemId)) {
|
|
110
|
-
return false;
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// Check if provider supports this event
|
|
115
|
-
const provider = providerRegistry.getProvider(sub.providerId);
|
|
116
|
-
if (!provider) {
|
|
117
|
-
logger.warn(
|
|
118
|
-
`Provider not found for subscription ${sub.id}: ${sub.providerId}`
|
|
119
|
-
);
|
|
120
|
-
return false;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
if (
|
|
124
|
-
provider.supportedEvents &&
|
|
125
|
-
!provider.supportedEvents.includes(eventId)
|
|
126
|
-
) {
|
|
127
|
-
return false;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
return true;
|
|
131
|
-
});
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Execute delivery for a single job
|
|
136
|
-
*/
|
|
137
|
-
async function executeDelivery(job: DeliveryJobData): Promise<void> {
|
|
138
|
-
const provider = providerRegistry.getProvider(job.providerId);
|
|
139
|
-
if (!provider) {
|
|
140
|
-
throw new Error(`Provider not found: ${job.providerId}`);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// Update log to show attempt in progress
|
|
144
|
-
await db
|
|
145
|
-
.update(schema.deliveryLogs)
|
|
146
|
-
.set({
|
|
147
|
-
status: "retrying",
|
|
148
|
-
attempts: sql`${schema.deliveryLogs.attempts} + 1`,
|
|
149
|
-
lastAttemptAt: new Date(),
|
|
150
|
-
})
|
|
151
|
-
.where(eq(schema.deliveryLogs.id, job.logId));
|
|
152
|
-
|
|
153
|
-
try {
|
|
154
|
-
// Call the provider's deliver method
|
|
155
|
-
const result = await provider.deliver({
|
|
156
|
-
event: {
|
|
157
|
-
eventId: job.eventId,
|
|
158
|
-
payload: job.payload,
|
|
159
|
-
timestamp: job.timestamp,
|
|
160
|
-
deliveryId: job.logId,
|
|
161
|
-
},
|
|
162
|
-
subscription: {
|
|
163
|
-
id: job.subscriptionId,
|
|
164
|
-
name: job.subscriptionName,
|
|
165
|
-
},
|
|
166
|
-
providerConfig: job.providerConfig,
|
|
167
|
-
logger: logger,
|
|
168
|
-
getConnectionWithCredentials:
|
|
169
|
-
connectionStore.getConnectionWithCredentials.bind(connectionStore),
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
if (result.success) {
|
|
173
|
-
// Mark as successful
|
|
174
|
-
await db
|
|
175
|
-
.update(schema.deliveryLogs)
|
|
176
|
-
.set({
|
|
177
|
-
status: "success",
|
|
178
|
-
externalId: result.externalId,
|
|
179
|
-
errorMessage: undefined,
|
|
180
|
-
nextRetryAt: undefined,
|
|
181
|
-
})
|
|
182
|
-
.where(eq(schema.deliveryLogs.id, job.logId));
|
|
183
|
-
|
|
184
|
-
// Emit success signal
|
|
185
|
-
await signalService.broadcast(INTEGRATION_DELIVERY_COMPLETED, {
|
|
186
|
-
logId: job.logId,
|
|
187
|
-
subscriptionId: job.subscriptionId,
|
|
188
|
-
eventType: job.eventId,
|
|
189
|
-
status: "success",
|
|
190
|
-
externalId: result.externalId,
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
logger.debug(
|
|
194
|
-
`Delivery successful: ${job.logId} -> ${
|
|
195
|
-
result.externalId ?? "no external ID"
|
|
196
|
-
}`
|
|
197
|
-
);
|
|
198
|
-
} else {
|
|
199
|
-
throw new Error(
|
|
200
|
-
result.error ?? "Delivery failed without error message"
|
|
201
|
-
);
|
|
202
|
-
}
|
|
203
|
-
} catch (error) {
|
|
204
|
-
const errorMessage =
|
|
205
|
-
extractErrorMessage(error);
|
|
206
|
-
|
|
207
|
-
// Get current attempt count
|
|
208
|
-
const [log] = await db
|
|
209
|
-
.select({ attempts: schema.deliveryLogs.attempts })
|
|
210
|
-
.from(schema.deliveryLogs)
|
|
211
|
-
.where(eq(schema.deliveryLogs.id, job.logId));
|
|
212
|
-
|
|
213
|
-
const attempts = log?.attempts ?? 1;
|
|
214
|
-
|
|
215
|
-
if (attempts < MAX_RETRIES) {
|
|
216
|
-
// Schedule retry
|
|
217
|
-
const retryDelay =
|
|
218
|
-
RETRY_DELAYS.at(attempts - 1) ??
|
|
219
|
-
RETRY_DELAYS.at(-1) ??
|
|
220
|
-
RETRY_DELAYS[0];
|
|
221
|
-
const nextRetryAt = new Date(Date.now() + retryDelay);
|
|
222
|
-
|
|
223
|
-
await db
|
|
224
|
-
.update(schema.deliveryLogs)
|
|
225
|
-
.set({
|
|
226
|
-
status: "retrying",
|
|
227
|
-
errorMessage,
|
|
228
|
-
nextRetryAt,
|
|
229
|
-
})
|
|
230
|
-
.where(eq(schema.deliveryLogs.id, job.logId));
|
|
231
|
-
|
|
232
|
-
// Re-queue with delay
|
|
233
|
-
const queue = await queueManager.getQueue<DeliveryJobData>(QUEUE_NAME);
|
|
234
|
-
await queue.enqueue(job, { startDelay: retryDelay / 1000 }); // Convert ms to seconds
|
|
235
|
-
|
|
236
|
-
logger.warn(
|
|
237
|
-
`Delivery failed (attempt ${attempts}/${MAX_RETRIES}), retrying at ${nextRetryAt.toISOString()}: ${errorMessage}`
|
|
238
|
-
);
|
|
239
|
-
} else {
|
|
240
|
-
// Max retries exceeded, mark as failed
|
|
241
|
-
await db
|
|
242
|
-
.update(schema.deliveryLogs)
|
|
243
|
-
.set({
|
|
244
|
-
status: "failed",
|
|
245
|
-
errorMessage,
|
|
246
|
-
nextRetryAt: undefined,
|
|
247
|
-
})
|
|
248
|
-
.where(eq(schema.deliveryLogs.id, job.logId));
|
|
249
|
-
|
|
250
|
-
// Emit failure signal
|
|
251
|
-
await signalService.broadcast(INTEGRATION_DELIVERY_COMPLETED, {
|
|
252
|
-
logId: job.logId,
|
|
253
|
-
subscriptionId: job.subscriptionId,
|
|
254
|
-
eventType: job.eventId,
|
|
255
|
-
status: "failed",
|
|
256
|
-
errorMessage,
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
logger.error(
|
|
260
|
-
`Delivery failed permanently after ${MAX_RETRIES} attempts: ${errorMessage}`
|
|
261
|
-
);
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
return {
|
|
267
|
-
async routeEvent(event: IntegrationEventPayload): Promise<void> {
|
|
268
|
-
const { eventId, payload, timestamp } = event;
|
|
269
|
-
|
|
270
|
-
logger.debug(`Routing integration event: ${eventId}`);
|
|
271
|
-
|
|
272
|
-
// Find matching subscriptions
|
|
273
|
-
const subscriptions = await findMatchingSubscriptions(eventId, payload);
|
|
274
|
-
|
|
275
|
-
if (subscriptions.length === 0) {
|
|
276
|
-
logger.debug(`No matching subscriptions for event: ${eventId}`);
|
|
277
|
-
return;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
logger.debug(
|
|
281
|
-
`Found ${subscriptions.length} matching subscriptions for event: ${eventId}`
|
|
282
|
-
);
|
|
283
|
-
|
|
284
|
-
// Create delivery log entries and queue jobs
|
|
285
|
-
const queue = await queueManager.getQueue<DeliveryJobData>(QUEUE_NAME);
|
|
286
|
-
|
|
287
|
-
for (const subscription of subscriptions) {
|
|
288
|
-
const logId = crypto.randomUUID();
|
|
289
|
-
|
|
290
|
-
// Create delivery log entry
|
|
291
|
-
await db.insert(schema.deliveryLogs).values({
|
|
292
|
-
id: logId,
|
|
293
|
-
subscriptionId: subscription.id,
|
|
294
|
-
eventType: eventId,
|
|
295
|
-
eventPayload: payload,
|
|
296
|
-
status: "pending",
|
|
297
|
-
attempts: 0,
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
// Queue delivery job
|
|
301
|
-
const jobData: DeliveryJobData = {
|
|
302
|
-
logId,
|
|
303
|
-
subscriptionId: subscription.id,
|
|
304
|
-
subscriptionName: subscription.name,
|
|
305
|
-
providerId: subscription.providerId,
|
|
306
|
-
providerConfig: subscription.providerConfig,
|
|
307
|
-
eventId,
|
|
308
|
-
payload,
|
|
309
|
-
timestamp,
|
|
310
|
-
};
|
|
311
|
-
|
|
312
|
-
await queue.enqueue(jobData);
|
|
313
|
-
|
|
314
|
-
logger.debug(
|
|
315
|
-
`Queued delivery: ${logId} for subscription: ${subscription.name}`
|
|
316
|
-
);
|
|
317
|
-
}
|
|
318
|
-
},
|
|
319
|
-
|
|
320
|
-
async startWorker(): Promise<void> {
|
|
321
|
-
const queue = await queueManager.getQueue<DeliveryJobData>(QUEUE_NAME);
|
|
322
|
-
|
|
323
|
-
await queue.consume(
|
|
324
|
-
async (job) => {
|
|
325
|
-
await executeDelivery(job.data);
|
|
326
|
-
},
|
|
327
|
-
{ consumerGroup: "integration-delivery" }
|
|
328
|
-
);
|
|
329
|
-
|
|
330
|
-
logger.info(
|
|
331
|
-
`Integration delivery worker started on queue: ${QUEUE_NAME}`
|
|
332
|
-
);
|
|
333
|
-
},
|
|
334
|
-
|
|
335
|
-
async retryDelivery(
|
|
336
|
-
logId: string
|
|
337
|
-
): Promise<{ success: boolean; message?: string }> {
|
|
338
|
-
// Get the delivery log
|
|
339
|
-
const [log] = await db
|
|
340
|
-
.select()
|
|
341
|
-
.from(schema.deliveryLogs)
|
|
342
|
-
.where(eq(schema.deliveryLogs.id, logId));
|
|
343
|
-
|
|
344
|
-
if (!log) {
|
|
345
|
-
return { success: false, message: "Delivery log not found" };
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
if (log.status !== "failed") {
|
|
349
|
-
return { success: false, message: "Can only retry failed deliveries" };
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
// Get the subscription
|
|
353
|
-
const [subscription] = await db
|
|
354
|
-
.select()
|
|
355
|
-
.from(schema.webhookSubscriptions)
|
|
356
|
-
.where(eq(schema.webhookSubscriptions.id, log.subscriptionId));
|
|
357
|
-
|
|
358
|
-
if (!subscription) {
|
|
359
|
-
return { success: false, message: "Subscription not found" };
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
// Reset the log and re-queue
|
|
363
|
-
await db
|
|
364
|
-
.update(schema.deliveryLogs)
|
|
365
|
-
.set({
|
|
366
|
-
status: "pending",
|
|
367
|
-
attempts: 0,
|
|
368
|
-
errorMessage: undefined,
|
|
369
|
-
nextRetryAt: undefined,
|
|
370
|
-
})
|
|
371
|
-
.where(eq(schema.deliveryLogs.id, logId));
|
|
372
|
-
|
|
373
|
-
// Queue delivery job
|
|
374
|
-
const jobData: DeliveryJobData = {
|
|
375
|
-
logId,
|
|
376
|
-
subscriptionId: subscription.id,
|
|
377
|
-
subscriptionName: subscription.name,
|
|
378
|
-
providerId: subscription.providerId,
|
|
379
|
-
providerConfig: subscription.providerConfig,
|
|
380
|
-
eventId: log.eventType,
|
|
381
|
-
payload: log.eventPayload,
|
|
382
|
-
timestamp: new Date().toISOString(),
|
|
383
|
-
};
|
|
384
|
-
|
|
385
|
-
const queue = await queueManager.getQueue<DeliveryJobData>(QUEUE_NAME);
|
|
386
|
-
await queue.enqueue(jobData);
|
|
387
|
-
|
|
388
|
-
return { success: true, message: "Delivery re-queued" };
|
|
389
|
-
},
|
|
390
|
-
};
|
|
391
|
-
}
|