@electric-ax/agents-server 0.4.19 → 0.5.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/dist/entrypoint.js +692 -45
- package/dist/index.cjs +678 -41
- package/dist/index.d.cts +2519 -2216
- package/dist/index.d.ts +2518 -2217
- package/dist/index.js +679 -42
- package/drizzle/0015_pg_sync_bridges.sql +14 -0
- package/drizzle/0016_entity_type_externally_writable_collections.sql +1 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +6 -6
- package/src/db/schema.ts +32 -0
- package/src/electric-agents-types.ts +23 -0
- package/src/entity-manager.ts +160 -29
- package/src/entity-registry.ts +158 -3
- package/src/manifest-side-effects.ts +10 -0
- package/src/pg-sync-bridge-manager.ts +552 -0
- package/src/routing/context.ts +2 -0
- package/src/routing/entities-router.ts +89 -18
- package/src/routing/entity-types-router.ts +56 -0
- package/src/routing/global-router.ts +3 -0
- package/src/routing/hooks.ts +7 -0
- package/src/routing/internal-router.ts +2 -0
- package/src/routing/pg-sync-router.ts +113 -0
- package/src/runtime.ts +20 -1
- package/src/scheduler.ts +26 -0
- package/src/server.ts +4 -0
- package/src/standalone-runtime.ts +16 -0
- package/src/utils/server-utils.ts +97 -1
- package/src/wake-registry.ts +27 -2
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
import { DurableStream, IdempotentProducer } from '@durable-streams/client'
|
|
2
|
+
import {
|
|
3
|
+
canonicalPgSyncOptions,
|
|
4
|
+
getPgSyncStreamPath,
|
|
5
|
+
sourceRefForPgSync,
|
|
6
|
+
type CanonicalPgSyncConfig,
|
|
7
|
+
type PgSyncOptions,
|
|
8
|
+
type PgSyncRequestMetadata,
|
|
9
|
+
} from '@electric-ax/agents-runtime'
|
|
10
|
+
import {
|
|
11
|
+
ShapeStream,
|
|
12
|
+
isChangeMessage,
|
|
13
|
+
isControlMessage,
|
|
14
|
+
} from '@electric-sql/client'
|
|
15
|
+
import { serverLog } from './utils/log.js'
|
|
16
|
+
import type { StreamClient } from './stream-client.js'
|
|
17
|
+
import type { PgSyncBridgeRow, PostgresRegistry } from './entity-registry.js'
|
|
18
|
+
import type {
|
|
19
|
+
LogMode,
|
|
20
|
+
Offset,
|
|
21
|
+
ShapeStreamInterface,
|
|
22
|
+
} from '@electric-sql/client'
|
|
23
|
+
|
|
24
|
+
export const PG_SYNC_ELECTRIC_SHAPE_URL =
|
|
25
|
+
process.env.ELECTRIC_AGENTS_PG_SYNC_ELECTRIC_URL ??
|
|
26
|
+
`http://localhost:3000/v1/shape`
|
|
27
|
+
|
|
28
|
+
type PgSyncOperation = `insert` | `update` | `delete`
|
|
29
|
+
type WakeEvaluator = (
|
|
30
|
+
sourceUrl: string,
|
|
31
|
+
event: Record<string, unknown>
|
|
32
|
+
) => Promise<void> | void
|
|
33
|
+
|
|
34
|
+
export type PgSyncResolvedSource = {
|
|
35
|
+
url: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface PgSyncBridgeManagerOptions {
|
|
39
|
+
url?: string
|
|
40
|
+
retry?: {
|
|
41
|
+
initialDelayMs?: number
|
|
42
|
+
maxDelayMs?: number
|
|
43
|
+
random?: () => number
|
|
44
|
+
sleep?: (ms: number) => Promise<void>
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const DEFAULT_RETRY_INITIAL_DELAY_MS = 1_000
|
|
49
|
+
const DEFAULT_RETRY_MAX_DELAY_MS = 30_000
|
|
50
|
+
|
|
51
|
+
type PgSyncChangeMessage = {
|
|
52
|
+
headers: Record<string, unknown> & {
|
|
53
|
+
operation?: PgSyncOperation | string
|
|
54
|
+
offset?: unknown
|
|
55
|
+
key?: unknown
|
|
56
|
+
rowKey?: unknown
|
|
57
|
+
}
|
|
58
|
+
value?: Record<string, unknown>
|
|
59
|
+
key?: string
|
|
60
|
+
old_value?: Record<string, unknown>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
type PgSyncCursor = {
|
|
64
|
+
handle: string
|
|
65
|
+
offset: string
|
|
66
|
+
initialSnapshotComplete: boolean
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface PgSyncBridgeCoordinator {
|
|
70
|
+
start?(): Promise<void>
|
|
71
|
+
register(
|
|
72
|
+
options: PgSyncOptions,
|
|
73
|
+
metadata?: PgSyncRequestMetadata
|
|
74
|
+
): Promise<{ sourceRef: string; streamUrl: string }>
|
|
75
|
+
stop(): Promise<void>
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function buildElectricShapeParams(
|
|
79
|
+
options: PgSyncOptions
|
|
80
|
+
): Record<string, unknown> {
|
|
81
|
+
return {
|
|
82
|
+
table: options.table,
|
|
83
|
+
...(options.columns !== undefined ? { columns: [...options.columns] } : {}),
|
|
84
|
+
...(options.where !== undefined ? { where: options.where } : {}),
|
|
85
|
+
...(options.params !== undefined
|
|
86
|
+
? {
|
|
87
|
+
params: Array.isArray(options.params)
|
|
88
|
+
? [...options.params]
|
|
89
|
+
: { ...options.params },
|
|
90
|
+
}
|
|
91
|
+
: {}),
|
|
92
|
+
...(options.replica !== undefined ? { replica: options.replica } : {}),
|
|
93
|
+
...(options.metadata?.tenantId
|
|
94
|
+
? { electric_agents_tenant_id: options.metadata.tenantId }
|
|
95
|
+
: {}),
|
|
96
|
+
...(options.metadata?.principalKind
|
|
97
|
+
? { electric_agents_principal_kind: options.metadata.principalKind }
|
|
98
|
+
: {}),
|
|
99
|
+
...(options.metadata?.principalId
|
|
100
|
+
? { electric_agents_principal_id: options.metadata.principalId }
|
|
101
|
+
: {}),
|
|
102
|
+
...(options.metadata?.principalKey
|
|
103
|
+
? { electric_agents_principal_key: options.metadata.principalKey }
|
|
104
|
+
: {}),
|
|
105
|
+
...(options.metadata?.principalUrl
|
|
106
|
+
? { electric_agents_principal_url: options.metadata.principalUrl }
|
|
107
|
+
: {}),
|
|
108
|
+
...(options.metadata?.entityUrl
|
|
109
|
+
? { electric_agents_entity_url: options.metadata.entityUrl }
|
|
110
|
+
: {}),
|
|
111
|
+
...(options.metadata?.entityType
|
|
112
|
+
? { electric_agents_entity_type: options.metadata.entityType }
|
|
113
|
+
: {}),
|
|
114
|
+
...(options.metadata?.streamPath
|
|
115
|
+
? { electric_agents_stream_path: options.metadata.streamPath }
|
|
116
|
+
: {}),
|
|
117
|
+
...(options.metadata?.runtimeConsumerId
|
|
118
|
+
? {
|
|
119
|
+
electric_agents_runtime_consumer_id:
|
|
120
|
+
options.metadata.runtimeConsumerId,
|
|
121
|
+
}
|
|
122
|
+
: {}),
|
|
123
|
+
...(options.metadata?.wakeId
|
|
124
|
+
? { electric_agents_wake_id: options.metadata.wakeId }
|
|
125
|
+
: {}),
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function jsonSafe(value: unknown): unknown {
|
|
130
|
+
if (typeof value === `bigint`) return value.toString()
|
|
131
|
+
if (value === null || typeof value !== `object`) return value
|
|
132
|
+
if (Array.isArray(value)) return value.map(jsonSafe)
|
|
133
|
+
return Object.fromEntries(
|
|
134
|
+
Object.entries(value as Record<string, unknown>).map(([key, item]) => [
|
|
135
|
+
key,
|
|
136
|
+
jsonSafe(item),
|
|
137
|
+
])
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function stableJson(value: unknown): string {
|
|
142
|
+
if (typeof value === `bigint`) return JSON.stringify(value.toString())
|
|
143
|
+
if (value === null || typeof value !== `object`) return JSON.stringify(value)
|
|
144
|
+
if (Array.isArray(value)) return `[${value.map(stableJson).join(`,`)}]`
|
|
145
|
+
return `{${Object.keys(value as Record<string, unknown>)
|
|
146
|
+
.sort()
|
|
147
|
+
.map(
|
|
148
|
+
(key) =>
|
|
149
|
+
`${JSON.stringify(key)}:${stableJson((value as Record<string, unknown>)[key])}`
|
|
150
|
+
)
|
|
151
|
+
.join(`,`)}}`
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function parseElectricOffset(offset: string): Offset | null {
|
|
155
|
+
if (offset === `-1`) return offset
|
|
156
|
+
return /^\d+_\d+$/.test(offset) ? (offset as Offset) : null
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function rowKeyForMessage(message: PgSyncChangeMessage): string | undefined {
|
|
160
|
+
const headers = message.headers as Record<string, unknown>
|
|
161
|
+
const candidate =
|
|
162
|
+
headers.key ??
|
|
163
|
+
headers.rowKey ??
|
|
164
|
+
message.value?.id ??
|
|
165
|
+
message.value?.key ??
|
|
166
|
+
message.old_value?.id ??
|
|
167
|
+
message.old_value?.key
|
|
168
|
+
return candidate === undefined ? undefined : stableJson(candidate)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function pgSyncMessageToDurableEvent(
|
|
172
|
+
message: PgSyncChangeMessage,
|
|
173
|
+
optionsOrSourceRef: PgSyncOptions | string
|
|
174
|
+
): {
|
|
175
|
+
type: `pg_sync_change`
|
|
176
|
+
key: string
|
|
177
|
+
value: Record<string, unknown>
|
|
178
|
+
headers: { operation: PgSyncOperation; timestamp: string }
|
|
179
|
+
} | null {
|
|
180
|
+
const operation = message.headers.operation
|
|
181
|
+
if (
|
|
182
|
+
operation !== `insert` &&
|
|
183
|
+
operation !== `update` &&
|
|
184
|
+
operation !== `delete`
|
|
185
|
+
)
|
|
186
|
+
return null
|
|
187
|
+
|
|
188
|
+
const sourceRef =
|
|
189
|
+
typeof optionsOrSourceRef === `string`
|
|
190
|
+
? optionsOrSourceRef
|
|
191
|
+
: sourceRefForPgSync(optionsOrSourceRef)
|
|
192
|
+
const rowKey = rowKeyForMessage(message)
|
|
193
|
+
const offset = message.headers.offset
|
|
194
|
+
if (typeof offset !== `string` || offset.length === 0) return null
|
|
195
|
+
const messageKeyPart = offset
|
|
196
|
+
const messageKey = `${sourceRef}:${operation}:${messageKeyPart}`
|
|
197
|
+
const timestamp = new Date().toISOString()
|
|
198
|
+
const oldValue = message.old_value
|
|
199
|
+
const safeValue = jsonSafe(message.value)
|
|
200
|
+
const safeOldValue = jsonSafe(oldValue)
|
|
201
|
+
const safeHeaders = jsonSafe(message.headers)
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
type: `pg_sync_change`,
|
|
205
|
+
key: messageKey,
|
|
206
|
+
value: {
|
|
207
|
+
key: messageKey,
|
|
208
|
+
table:
|
|
209
|
+
typeof optionsOrSourceRef === `string`
|
|
210
|
+
? undefined
|
|
211
|
+
: optionsOrSourceRef.table,
|
|
212
|
+
operation,
|
|
213
|
+
...(rowKey !== undefined ? { rowKey } : {}),
|
|
214
|
+
...(message.value !== undefined ? { value: safeValue } : {}),
|
|
215
|
+
...(oldValue !== undefined ? { oldValue: safeOldValue } : {}),
|
|
216
|
+
headers: safeHeaders,
|
|
217
|
+
...(typeof offset === `string` ? { offset } : {}),
|
|
218
|
+
receivedAt: timestamp,
|
|
219
|
+
},
|
|
220
|
+
headers: { operation, timestamp },
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function cursorFromRow(
|
|
225
|
+
row:
|
|
226
|
+
| Pick<
|
|
227
|
+
PgSyncBridgeRow,
|
|
228
|
+
`shapeHandle` | `shapeOffset` | `initialSnapshotComplete`
|
|
229
|
+
>
|
|
230
|
+
| undefined
|
|
231
|
+
): PgSyncCursor | undefined {
|
|
232
|
+
return row?.shapeHandle && row.shapeOffset
|
|
233
|
+
? {
|
|
234
|
+
handle: row.shapeHandle,
|
|
235
|
+
offset: row.shapeOffset,
|
|
236
|
+
initialSnapshotComplete: row.initialSnapshotComplete,
|
|
237
|
+
}
|
|
238
|
+
: undefined
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
class PgSyncBridge {
|
|
242
|
+
private producer: IdempotentProducer | null = null
|
|
243
|
+
private unsubscribe: (() => void) | null = null
|
|
244
|
+
private abortController: AbortController | null = null
|
|
245
|
+
private skipChangesUntilUpToDate = false
|
|
246
|
+
private recovering = false
|
|
247
|
+
private committedCursor?: PgSyncCursor
|
|
248
|
+
private retryAttempt = 0
|
|
249
|
+
|
|
250
|
+
constructor(
|
|
251
|
+
readonly sourceRef: string,
|
|
252
|
+
readonly streamUrl: string,
|
|
253
|
+
private options: CanonicalPgSyncConfig,
|
|
254
|
+
private resolvedSource: PgSyncResolvedSource,
|
|
255
|
+
private retry: Required<NonNullable<PgSyncBridgeManagerOptions[`retry`]>>,
|
|
256
|
+
private streamClient: StreamClient,
|
|
257
|
+
private registry?: PostgresRegistry,
|
|
258
|
+
private evaluateWakes?: WakeEvaluator,
|
|
259
|
+
private initialCursor?: PgSyncCursor
|
|
260
|
+
) {
|
|
261
|
+
this.committedCursor = initialCursor
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async start(): Promise<void> {
|
|
265
|
+
if (!this.producer) {
|
|
266
|
+
this.producer = new IdempotentProducer(
|
|
267
|
+
new DurableStream({
|
|
268
|
+
url: `${this.streamClient.baseUrl}${this.streamUrl}`,
|
|
269
|
+
contentType: `application/json`,
|
|
270
|
+
}),
|
|
271
|
+
`pg-sync-bridge-${this.sourceRef}`
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
if (this.initialCursor) {
|
|
275
|
+
const offset = parseElectricOffset(this.initialCursor.offset)
|
|
276
|
+
if (offset) {
|
|
277
|
+
this.startStream(
|
|
278
|
+
offset,
|
|
279
|
+
this.initialCursor.handle,
|
|
280
|
+
!this.initialCursor.initialSnapshotComplete
|
|
281
|
+
)
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
await this.registry?.clearPgSyncBridgeCursor(this.sourceRef)
|
|
286
|
+
this.startStream(`now`, undefined, true)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async stop(): Promise<void> {
|
|
290
|
+
this.unsubscribe?.()
|
|
291
|
+
this.abortController?.abort()
|
|
292
|
+
this.unsubscribe = null
|
|
293
|
+
this.abortController = null
|
|
294
|
+
try {
|
|
295
|
+
await this.producer?.flush()
|
|
296
|
+
} finally {
|
|
297
|
+
await this.producer?.detach()
|
|
298
|
+
this.producer = null
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private startStream(
|
|
303
|
+
offset: Offset,
|
|
304
|
+
handle?: string,
|
|
305
|
+
skipChangesUntilUpToDate = false,
|
|
306
|
+
log: LogMode = offset === `now` ? `changes_only` : `full`
|
|
307
|
+
): void {
|
|
308
|
+
this.unsubscribe?.()
|
|
309
|
+
this.abortController?.abort()
|
|
310
|
+
this.skipChangesUntilUpToDate = skipChangesUntilUpToDate
|
|
311
|
+
this.abortController = new AbortController()
|
|
312
|
+
const stream: ShapeStreamInterface<Record<string, unknown>> =
|
|
313
|
+
new ShapeStream({
|
|
314
|
+
url: this.resolvedSource.url,
|
|
315
|
+
params: buildElectricShapeParams(this.options) as never,
|
|
316
|
+
offset,
|
|
317
|
+
log,
|
|
318
|
+
...(handle ? { handle } : {}),
|
|
319
|
+
signal: this.abortController.signal,
|
|
320
|
+
})
|
|
321
|
+
this.unsubscribe = stream.subscribe(
|
|
322
|
+
async (messages) => {
|
|
323
|
+
try {
|
|
324
|
+
for (const message of messages) {
|
|
325
|
+
if (isControlMessage(message)) {
|
|
326
|
+
if (message.headers.control === `must-refetch`) {
|
|
327
|
+
await this.registry?.clearPgSyncBridgeCursor(this.sourceRef)
|
|
328
|
+
this.startStream(`now`, undefined, true)
|
|
329
|
+
return
|
|
330
|
+
}
|
|
331
|
+
if (message.headers.control === `up-to-date`) {
|
|
332
|
+
this.skipChangesUntilUpToDate = false
|
|
333
|
+
await this.persistCursor(stream, true)
|
|
334
|
+
continue
|
|
335
|
+
}
|
|
336
|
+
await this.persistCursor(stream)
|
|
337
|
+
continue
|
|
338
|
+
}
|
|
339
|
+
if (!isChangeMessage(message)) continue
|
|
340
|
+
if (!this.skipChangesUntilUpToDate) {
|
|
341
|
+
const event = pgSyncMessageToDurableEvent(message, this.options)
|
|
342
|
+
if (event) {
|
|
343
|
+
if (!this.producer)
|
|
344
|
+
throw new Error(`pg-sync producer is not started`)
|
|
345
|
+
await this.producer.append(JSON.stringify(event))
|
|
346
|
+
await this.producer.flush?.()
|
|
347
|
+
await this.evaluateWakes?.(this.streamUrl, event)
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
await this.persistCursor(stream)
|
|
351
|
+
this.retryAttempt = 0
|
|
352
|
+
}
|
|
353
|
+
} catch (error) {
|
|
354
|
+
serverLog.warn(
|
|
355
|
+
`[pg-sync-bridge] subscription callback failed for ${this.sourceRef}:`,
|
|
356
|
+
error
|
|
357
|
+
)
|
|
358
|
+
await this.recoverStream()
|
|
359
|
+
}
|
|
360
|
+
},
|
|
361
|
+
(error) => {
|
|
362
|
+
if (this.abortController?.signal.aborted) return
|
|
363
|
+
serverLog.warn(
|
|
364
|
+
`[pg-sync-bridge] subscription failed for ${this.sourceRef}:`,
|
|
365
|
+
error
|
|
366
|
+
)
|
|
367
|
+
void this.recoverStream()
|
|
368
|
+
}
|
|
369
|
+
)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
private async recoverStream(): Promise<void> {
|
|
373
|
+
if (this.recovering) return
|
|
374
|
+
this.recovering = true
|
|
375
|
+
try {
|
|
376
|
+
const attempt = this.retryAttempt++
|
|
377
|
+
const baseDelay = Math.min(
|
|
378
|
+
this.retry.initialDelayMs * 2 ** attempt,
|
|
379
|
+
this.retry.maxDelayMs
|
|
380
|
+
)
|
|
381
|
+
const jitter = Math.floor(baseDelay * 0.2 * this.retry.random())
|
|
382
|
+
const delay = baseDelay + jitter
|
|
383
|
+
if (delay > 0) await this.retry.sleep(delay)
|
|
384
|
+
|
|
385
|
+
const offset = this.committedCursor
|
|
386
|
+
? parseElectricOffset(this.committedCursor.offset)
|
|
387
|
+
: null
|
|
388
|
+
if (offset && this.committedCursor) {
|
|
389
|
+
this.startStream(
|
|
390
|
+
offset,
|
|
391
|
+
this.committedCursor.handle,
|
|
392
|
+
!this.committedCursor.initialSnapshotComplete
|
|
393
|
+
)
|
|
394
|
+
} else {
|
|
395
|
+
await this.registry?.clearPgSyncBridgeCursor(this.sourceRef)
|
|
396
|
+
this.startStream(`now`, undefined, true)
|
|
397
|
+
}
|
|
398
|
+
} finally {
|
|
399
|
+
this.recovering = false
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
private async persistCursor(
|
|
404
|
+
stream: ShapeStreamInterface<Record<string, unknown>>,
|
|
405
|
+
initialSnapshotComplete = !this.skipChangesUntilUpToDate
|
|
406
|
+
): Promise<void> {
|
|
407
|
+
const shapeHandle = stream.shapeHandle
|
|
408
|
+
const shapeOffset = stream.lastOffset
|
|
409
|
+
if (!shapeHandle || !shapeOffset || shapeOffset === `-1`) return
|
|
410
|
+
await this.registry?.updatePgSyncBridgeCursor(
|
|
411
|
+
this.sourceRef,
|
|
412
|
+
shapeHandle,
|
|
413
|
+
shapeOffset,
|
|
414
|
+
initialSnapshotComplete
|
|
415
|
+
)
|
|
416
|
+
this.committedCursor = {
|
|
417
|
+
handle: shapeHandle,
|
|
418
|
+
offset: shapeOffset,
|
|
419
|
+
initialSnapshotComplete,
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export class PgSyncBridgeManager implements PgSyncBridgeCoordinator {
|
|
425
|
+
private bridges = new Map<string, PgSyncBridge>()
|
|
426
|
+
private starting = new Map<string, Promise<void>>()
|
|
427
|
+
|
|
428
|
+
private readonly url: string
|
|
429
|
+
private readonly retry: Required<
|
|
430
|
+
NonNullable<PgSyncBridgeManagerOptions[`retry`]>
|
|
431
|
+
>
|
|
432
|
+
|
|
433
|
+
constructor(
|
|
434
|
+
private streamClient: StreamClient,
|
|
435
|
+
private evaluateWakes?: WakeEvaluator,
|
|
436
|
+
private registry?: PostgresRegistry,
|
|
437
|
+
options: PgSyncBridgeManagerOptions = {}
|
|
438
|
+
) {
|
|
439
|
+
this.url = options.url ?? PG_SYNC_ELECTRIC_SHAPE_URL
|
|
440
|
+
this.retry = {
|
|
441
|
+
initialDelayMs:
|
|
442
|
+
options.retry?.initialDelayMs ?? DEFAULT_RETRY_INITIAL_DELAY_MS,
|
|
443
|
+
maxDelayMs: options.retry?.maxDelayMs ?? DEFAULT_RETRY_MAX_DELAY_MS,
|
|
444
|
+
random: options.retry?.random ?? Math.random,
|
|
445
|
+
sleep:
|
|
446
|
+
options.retry?.sleep ??
|
|
447
|
+
((ms: number) =>
|
|
448
|
+
new Promise<void>((resolve) => setTimeout(resolve, ms))),
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async start(): Promise<void> {
|
|
453
|
+
const rows = await this.registry?.listPgSyncBridges?.()
|
|
454
|
+
if (!rows) return
|
|
455
|
+
await Promise.all(
|
|
456
|
+
rows.map((row) =>
|
|
457
|
+
this.ensureBridge(row).catch((error) => {
|
|
458
|
+
serverLog.warn(
|
|
459
|
+
`[pg-sync-bridge] failed to start ${row.sourceRef}:`,
|
|
460
|
+
error
|
|
461
|
+
)
|
|
462
|
+
})
|
|
463
|
+
)
|
|
464
|
+
)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async register(
|
|
468
|
+
options: PgSyncOptions,
|
|
469
|
+
metadata?: PgSyncRequestMetadata
|
|
470
|
+
): Promise<{ sourceRef: string; streamUrl: string }> {
|
|
471
|
+
const mergedMetadata = { ...options.metadata, ...metadata }
|
|
472
|
+
const canonicalOptions = {
|
|
473
|
+
...canonicalPgSyncOptions(options),
|
|
474
|
+
...(Object.keys(mergedMetadata).length > 0
|
|
475
|
+
? { metadata: mergedMetadata }
|
|
476
|
+
: {}),
|
|
477
|
+
}
|
|
478
|
+
const resolvedSource = this.resolveSource(canonicalOptions)
|
|
479
|
+
const sourceRef = sourceRefForPgSync(canonicalOptions)
|
|
480
|
+
const streamUrl = getPgSyncStreamPath(sourceRef, this.registry?.tenantId)
|
|
481
|
+
const row = await this.registry?.upsertPgSyncBridge({
|
|
482
|
+
sourceRef,
|
|
483
|
+
options: canonicalOptions,
|
|
484
|
+
streamUrl,
|
|
485
|
+
})
|
|
486
|
+
await this.streamClient.ensure(streamUrl, {
|
|
487
|
+
contentType: `application/json`,
|
|
488
|
+
})
|
|
489
|
+
if (!this.bridges.has(sourceRef)) {
|
|
490
|
+
let start = this.starting.get(sourceRef)
|
|
491
|
+
if (!start) {
|
|
492
|
+
start = (async () => {
|
|
493
|
+
const bridge = new PgSyncBridge(
|
|
494
|
+
sourceRef,
|
|
495
|
+
streamUrl,
|
|
496
|
+
canonicalOptions,
|
|
497
|
+
resolvedSource,
|
|
498
|
+
this.retry,
|
|
499
|
+
this.streamClient,
|
|
500
|
+
this.registry,
|
|
501
|
+
this.evaluateWakes,
|
|
502
|
+
cursorFromRow(row)
|
|
503
|
+
)
|
|
504
|
+
await bridge.start()
|
|
505
|
+
this.bridges.set(sourceRef, bridge)
|
|
506
|
+
})().finally(() => this.starting.delete(sourceRef))
|
|
507
|
+
this.starting.set(sourceRef, start)
|
|
508
|
+
}
|
|
509
|
+
await start
|
|
510
|
+
}
|
|
511
|
+
return { sourceRef, streamUrl }
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
private async ensureBridge(row: PgSyncBridgeRow): Promise<void> {
|
|
515
|
+
if (this.bridges.has(row.sourceRef)) return
|
|
516
|
+
let start = this.starting.get(row.sourceRef)
|
|
517
|
+
if (!start) {
|
|
518
|
+
start = (async () => {
|
|
519
|
+
await this.streamClient.ensure(row.streamUrl, {
|
|
520
|
+
contentType: `application/json`,
|
|
521
|
+
})
|
|
522
|
+
const canonicalOptions = canonicalPgSyncOptions(row.options)
|
|
523
|
+
const resolvedSource = this.resolveSource(canonicalOptions)
|
|
524
|
+
const bridge = new PgSyncBridge(
|
|
525
|
+
row.sourceRef,
|
|
526
|
+
row.streamUrl,
|
|
527
|
+
canonicalOptions,
|
|
528
|
+
resolvedSource,
|
|
529
|
+
this.retry,
|
|
530
|
+
this.streamClient,
|
|
531
|
+
this.registry,
|
|
532
|
+
this.evaluateWakes,
|
|
533
|
+
cursorFromRow(row)
|
|
534
|
+
)
|
|
535
|
+
await bridge.start()
|
|
536
|
+
this.bridges.set(row.sourceRef, bridge)
|
|
537
|
+
})().finally(() => this.starting.delete(row.sourceRef))
|
|
538
|
+
this.starting.set(row.sourceRef, start)
|
|
539
|
+
}
|
|
540
|
+
await start
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
private resolveSource(options: CanonicalPgSyncConfig): PgSyncResolvedSource {
|
|
544
|
+
return { url: options.url ?? this.url }
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
async stop(): Promise<void> {
|
|
548
|
+
await Promise.allSettled(this.starting.values())
|
|
549
|
+
await Promise.all([...this.bridges.values()].map((bridge) => bridge.stop()))
|
|
550
|
+
this.bridges.clear()
|
|
551
|
+
}
|
|
552
|
+
}
|
package/src/routing/context.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
} from '@electric-ax/agents-runtime'
|
|
6
6
|
import type { DrizzleDB } from '../db/index.js'
|
|
7
7
|
import type { EntityBridgeCoordinator } from '../entity-bridge-manager.js'
|
|
8
|
+
import type { PgSyncBridgeCoordinator } from '../pg-sync-bridge-manager.js'
|
|
8
9
|
import type { EntityManager } from '../entity-manager.js'
|
|
9
10
|
import type { ElectricAgentsTenantRuntime } from '../runtime.js'
|
|
10
11
|
import type { StreamClient } from '../stream-client.js'
|
|
@@ -54,6 +55,7 @@ export interface TenantContext {
|
|
|
54
55
|
streamClient: StreamClient
|
|
55
56
|
runtime: ElectricAgentsTenantRuntime
|
|
56
57
|
entityBridgeManager: EntityBridgeCoordinator
|
|
58
|
+
pgSyncBridgeManager?: PgSyncBridgeCoordinator
|
|
57
59
|
eventSources?: EventSourceCatalog
|
|
58
60
|
ensureEventSourceWakeSource?: (sourceUrl: string) => Promise<void> | void
|
|
59
61
|
authorizeRequest?: AuthorizeRequest
|