@electric-ax/agents-server 0.5.0 → 0.5.1
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 +938 -869
- package/dist/index.cjs +135 -66
- package/dist/index.d.cts +26 -12
- package/dist/index.d.ts +26 -12
- package/dist/index.js +136 -67
- package/package.json +5 -5
- package/src/entity-manager.ts +31 -7
- package/src/entity-registry.ts +11 -1
- package/src/index.ts +6 -6
- package/src/manifest-side-effects.ts +2 -6
- package/src/pg-sync-bridge-manager.ts +147 -47
- package/src/routing/context.ts +11 -11
- package/src/routing/entities-router.ts +51 -28
- package/src/routing/internal-router.ts +9 -7
- package/src/routing/pg-sync-router.ts +14 -1
- package/src/server.ts +8 -8
- package/src/wake-registry.ts +2 -0
|
@@ -21,10 +21,6 @@ import type {
|
|
|
21
21
|
ShapeStreamInterface,
|
|
22
22
|
} from '@electric-sql/client'
|
|
23
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
24
|
type PgSyncOperation = `insert` | `update` | `delete`
|
|
29
25
|
type WakeEvaluator = (
|
|
30
26
|
sourceUrl: string,
|
|
@@ -36,22 +32,28 @@ export type PgSyncResolvedSource = {
|
|
|
36
32
|
}
|
|
37
33
|
|
|
38
34
|
export interface PgSyncBridgeManagerOptions {
|
|
39
|
-
url?: string
|
|
40
35
|
retry?: {
|
|
41
36
|
initialDelayMs?: number
|
|
42
37
|
maxDelayMs?: number
|
|
43
38
|
random?: () => number
|
|
44
39
|
sleep?: (ms: number) => Promise<void>
|
|
45
40
|
}
|
|
41
|
+
fetchFn?: typeof fetch
|
|
42
|
+
probeTimeoutMs?: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Registration was rejected because the source itself is invalid — map to a 4xx. */
|
|
46
|
+
export class PgSyncSourceValidationError extends Error {
|
|
47
|
+
override name = `PgSyncSourceValidationError`
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
const DEFAULT_RETRY_INITIAL_DELAY_MS = 1_000
|
|
49
51
|
const DEFAULT_RETRY_MAX_DELAY_MS = 30_000
|
|
52
|
+
const DEFAULT_PROBE_TIMEOUT_MS = 10_000
|
|
50
53
|
|
|
51
54
|
type PgSyncChangeMessage = {
|
|
52
55
|
headers: Record<string, unknown> & {
|
|
53
56
|
operation?: PgSyncOperation | string
|
|
54
|
-
offset?: unknown
|
|
55
57
|
key?: unknown
|
|
56
58
|
rowKey?: unknown
|
|
57
59
|
}
|
|
@@ -126,6 +128,54 @@ export function buildElectricShapeParams(
|
|
|
126
128
|
}
|
|
127
129
|
}
|
|
128
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Build the one-shot URL used to validate a shape source at registration
|
|
133
|
+
* time. Approximates the query-param encoding of the Electric TS client
|
|
134
|
+
* (arrays comma-joined, where-clause params as `params[n]`) — unlike the
|
|
135
|
+
* client it does not quote column identifiers, so probe and stream encoding
|
|
136
|
+
* can diverge for exotic column names.
|
|
137
|
+
*/
|
|
138
|
+
export function buildShapeProbeUrl(
|
|
139
|
+
sourceUrl: string,
|
|
140
|
+
options: PgSyncOptions
|
|
141
|
+
): URL {
|
|
142
|
+
let url: URL
|
|
143
|
+
try {
|
|
144
|
+
url = new URL(sourceUrl)
|
|
145
|
+
} catch {
|
|
146
|
+
throw new PgSyncSourceValidationError(
|
|
147
|
+
`pgSync url "${sourceUrl}" is not a valid URL`
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
if (url.protocol !== `http:` && url.protocol !== `https:`) {
|
|
151
|
+
throw new PgSyncSourceValidationError(
|
|
152
|
+
`pgSync url "${sourceUrl}" must be an HTTP(S) Electric shape endpoint, not a database connection string`
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
for (const [key, value] of Object.entries(
|
|
156
|
+
buildElectricShapeParams(options)
|
|
157
|
+
)) {
|
|
158
|
+
if (value === undefined || value === null) continue
|
|
159
|
+
if (Array.isArray(value)) {
|
|
160
|
+
if (key === `params`) {
|
|
161
|
+
value.forEach((item, index) =>
|
|
162
|
+
url.searchParams.set(`params[${index + 1}]`, String(item))
|
|
163
|
+
)
|
|
164
|
+
} else {
|
|
165
|
+
url.searchParams.set(key, value.join(`,`))
|
|
166
|
+
}
|
|
167
|
+
} else if (typeof value === `object`) {
|
|
168
|
+
for (const [k, v] of Object.entries(value)) {
|
|
169
|
+
url.searchParams.set(`${key}[${k}]`, String(v))
|
|
170
|
+
}
|
|
171
|
+
} else {
|
|
172
|
+
url.searchParams.set(key, String(value))
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
url.searchParams.set(`offset`, `now`)
|
|
176
|
+
return url
|
|
177
|
+
}
|
|
178
|
+
|
|
129
179
|
function jsonSafe(value: unknown): unknown {
|
|
130
180
|
if (typeof value === `bigint`) return value.toString()
|
|
131
181
|
if (value === null || typeof value !== `object`) return value
|
|
@@ -168,56 +218,41 @@ function rowKeyForMessage(message: PgSyncChangeMessage): string | undefined {
|
|
|
168
218
|
return candidate === undefined ? undefined : stableJson(candidate)
|
|
169
219
|
}
|
|
170
220
|
|
|
171
|
-
export function pgSyncMessageToDurableEvent(
|
|
172
|
-
message: PgSyncChangeMessage,
|
|
173
|
-
optionsOrSourceRef: PgSyncOptions | string
|
|
174
|
-
): {
|
|
221
|
+
export function pgSyncMessageToDurableEvent(message: PgSyncChangeMessage): {
|
|
175
222
|
type: `pg_sync_change`
|
|
176
223
|
key: string
|
|
177
224
|
value: Record<string, unknown>
|
|
178
|
-
headers: { operation: PgSyncOperation
|
|
225
|
+
headers: Record<string, unknown> & { operation: PgSyncOperation }
|
|
179
226
|
} | null {
|
|
180
227
|
const operation = message.headers.operation
|
|
181
228
|
if (
|
|
182
229
|
operation !== `insert` &&
|
|
183
230
|
operation !== `update` &&
|
|
184
231
|
operation !== `delete`
|
|
185
|
-
)
|
|
232
|
+
) {
|
|
233
|
+
return null
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const key =
|
|
237
|
+
message.key ??
|
|
238
|
+
(typeof message.headers.key === `string`
|
|
239
|
+
? message.headers.key
|
|
240
|
+
: undefined) ??
|
|
241
|
+
rowKeyForMessage(message)
|
|
242
|
+
if (!key) {
|
|
186
243
|
return null
|
|
244
|
+
}
|
|
187
245
|
|
|
188
|
-
const
|
|
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)
|
|
246
|
+
const safeMessage = jsonSafe(message) as Record<string, unknown>
|
|
202
247
|
|
|
203
248
|
return {
|
|
204
249
|
type: `pg_sync_change`,
|
|
205
|
-
key
|
|
206
|
-
value:
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
typeof optionsOrSourceRef === `string`
|
|
210
|
-
? undefined
|
|
211
|
-
: optionsOrSourceRef.table,
|
|
250
|
+
key,
|
|
251
|
+
value: safeMessage,
|
|
252
|
+
headers: {
|
|
253
|
+
...(jsonSafe(message.headers) as Record<string, unknown>),
|
|
212
254
|
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
255
|
},
|
|
220
|
-
headers: { operation, timestamp },
|
|
221
256
|
}
|
|
222
257
|
}
|
|
223
258
|
|
|
@@ -338,13 +373,18 @@ class PgSyncBridge {
|
|
|
338
373
|
}
|
|
339
374
|
if (!isChangeMessage(message)) continue
|
|
340
375
|
if (!this.skipChangesUntilUpToDate) {
|
|
341
|
-
const event = pgSyncMessageToDurableEvent(message
|
|
376
|
+
const event = pgSyncMessageToDurableEvent(message)
|
|
342
377
|
if (event) {
|
|
343
378
|
if (!this.producer)
|
|
344
379
|
throw new Error(`pg-sync producer is not started`)
|
|
345
380
|
await this.producer.append(JSON.stringify(event))
|
|
346
381
|
await this.producer.flush?.()
|
|
347
382
|
await this.evaluateWakes?.(this.streamUrl, event)
|
|
383
|
+
} else {
|
|
384
|
+
serverLog.warn(
|
|
385
|
+
`[pg-sync-bridge] dropped change message for ${this.sourceRef} (unknown operation or missing row key):`,
|
|
386
|
+
message.headers
|
|
387
|
+
)
|
|
348
388
|
}
|
|
349
389
|
}
|
|
350
390
|
await this.persistCursor(stream)
|
|
@@ -425,10 +465,11 @@ export class PgSyncBridgeManager implements PgSyncBridgeCoordinator {
|
|
|
425
465
|
private bridges = new Map<string, PgSyncBridge>()
|
|
426
466
|
private starting = new Map<string, Promise<void>>()
|
|
427
467
|
|
|
428
|
-
private readonly url: string
|
|
429
468
|
private readonly retry: Required<
|
|
430
469
|
NonNullable<PgSyncBridgeManagerOptions[`retry`]>
|
|
431
470
|
>
|
|
471
|
+
private readonly fetchFn?: typeof fetch
|
|
472
|
+
private readonly probeTimeoutMs: number
|
|
432
473
|
|
|
433
474
|
constructor(
|
|
434
475
|
private streamClient: StreamClient,
|
|
@@ -436,7 +477,8 @@ export class PgSyncBridgeManager implements PgSyncBridgeCoordinator {
|
|
|
436
477
|
private registry?: PostgresRegistry,
|
|
437
478
|
options: PgSyncBridgeManagerOptions = {}
|
|
438
479
|
) {
|
|
439
|
-
this.
|
|
480
|
+
this.fetchFn = options.fetchFn
|
|
481
|
+
this.probeTimeoutMs = options.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS
|
|
440
482
|
this.retry = {
|
|
441
483
|
initialDelayMs:
|
|
442
484
|
options.retry?.initialDelayMs ?? DEFAULT_RETRY_INITIAL_DELAY_MS,
|
|
@@ -453,14 +495,23 @@ export class PgSyncBridgeManager implements PgSyncBridgeCoordinator {
|
|
|
453
495
|
const rows = await this.registry?.listPgSyncBridges?.()
|
|
454
496
|
if (!rows) return
|
|
455
497
|
await Promise.all(
|
|
456
|
-
rows.map((row) =>
|
|
457
|
-
|
|
498
|
+
rows.map(async (row) => {
|
|
499
|
+
if (!row.options.url) {
|
|
500
|
+
// Rows persisted before source URLs were required can never
|
|
501
|
+
// resume — remove them instead of warning on every boot.
|
|
502
|
+
serverLog.warn(
|
|
503
|
+
`[pg-sync-bridge] deleting registration ${row.sourceRef}: it predates required source URLs; re-register the observation with an explicit Electric shape URL`
|
|
504
|
+
)
|
|
505
|
+
await this.registry?.deletePgSyncBridge?.(row.sourceRef)
|
|
506
|
+
return
|
|
507
|
+
}
|
|
508
|
+
await this.ensureBridge(row).catch((error) => {
|
|
458
509
|
serverLog.warn(
|
|
459
510
|
`[pg-sync-bridge] failed to start ${row.sourceRef}:`,
|
|
460
511
|
error
|
|
461
512
|
)
|
|
462
513
|
})
|
|
463
|
-
)
|
|
514
|
+
})
|
|
464
515
|
)
|
|
465
516
|
}
|
|
466
517
|
|
|
@@ -478,6 +529,9 @@ export class PgSyncBridgeManager implements PgSyncBridgeCoordinator {
|
|
|
478
529
|
const resolvedSource = this.resolveSource(canonicalOptions)
|
|
479
530
|
const sourceRef = sourceRefForPgSync(canonicalOptions)
|
|
480
531
|
const streamUrl = getPgSyncStreamPath(sourceRef, this.registry?.tenantId)
|
|
532
|
+
if (!this.bridges.has(sourceRef) && !this.starting.has(sourceRef)) {
|
|
533
|
+
await this.probeSource(resolvedSource, canonicalOptions)
|
|
534
|
+
}
|
|
481
535
|
const row = await this.registry?.upsertPgSyncBridge({
|
|
482
536
|
sourceRef,
|
|
483
537
|
options: canonicalOptions,
|
|
@@ -541,7 +595,53 @@ export class PgSyncBridgeManager implements PgSyncBridgeCoordinator {
|
|
|
541
595
|
}
|
|
542
596
|
|
|
543
597
|
private resolveSource(options: CanonicalPgSyncConfig): PgSyncResolvedSource {
|
|
544
|
-
|
|
598
|
+
if (!options.url) {
|
|
599
|
+
throw new PgSyncSourceValidationError(
|
|
600
|
+
`pgSync source url is required; no server default is configured`
|
|
601
|
+
)
|
|
602
|
+
}
|
|
603
|
+
return { url: options.url }
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* One-shot fetch of the shape log before a bridge is created, so a bad
|
|
608
|
+
* URL or rejected shape fails the registration instead of dying silently
|
|
609
|
+
* in the bridge's retry loop.
|
|
610
|
+
*/
|
|
611
|
+
private async probeSource(
|
|
612
|
+
source: PgSyncResolvedSource,
|
|
613
|
+
options: CanonicalPgSyncConfig
|
|
614
|
+
): Promise<void> {
|
|
615
|
+
const probeUrl = buildShapeProbeUrl(source.url, options)
|
|
616
|
+
const fetchFn = this.fetchFn ?? globalThis.fetch
|
|
617
|
+
let response: Response
|
|
618
|
+
try {
|
|
619
|
+
response = await fetchFn(probeUrl, {
|
|
620
|
+
signal: AbortSignal.timeout(this.probeTimeoutMs),
|
|
621
|
+
})
|
|
622
|
+
} catch (error) {
|
|
623
|
+
throw new PgSyncSourceValidationError(
|
|
624
|
+
`pgSync source at ${source.url} is unreachable: ${error instanceof Error ? error.message : String(error)}`
|
|
625
|
+
)
|
|
626
|
+
}
|
|
627
|
+
if (!response.ok) {
|
|
628
|
+
const body = (
|
|
629
|
+
await response.text().catch(() => `<failed to read body>`)
|
|
630
|
+
).slice(0, 500)
|
|
631
|
+
throw new PgSyncSourceValidationError(
|
|
632
|
+
`pgSync source at ${source.url} rejected the shape request (${response.status})${body ? `: ${body}` : ``}`
|
|
633
|
+
)
|
|
634
|
+
}
|
|
635
|
+
// Electric answers 200 on paths that aren't the shape API (e.g. its
|
|
636
|
+
// root), so an ok status alone doesn't prove the URL is right. Real
|
|
637
|
+
// shape responses always carry the electric-handle header.
|
|
638
|
+
if (!response.headers.get(`electric-handle`)) {
|
|
639
|
+
const suggestion = new URL(source.url)
|
|
640
|
+
suggestion.pathname = `/v1/shape`
|
|
641
|
+
throw new PgSyncSourceValidationError(
|
|
642
|
+
`pgSync source at ${source.url} responded but is not a shape log (missing electric-handle header) — the Electric shape API is usually served at ${suggestion.origin}/v1/shape`
|
|
643
|
+
)
|
|
644
|
+
}
|
|
545
645
|
}
|
|
546
646
|
|
|
547
647
|
async stop(): Promise<void> {
|
package/src/routing/context.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Agent } from 'undici'
|
|
2
2
|
import type {
|
|
3
|
-
|
|
3
|
+
WebhookSourceContract,
|
|
4
4
|
WebhookSignatureVerifierConfig,
|
|
5
5
|
} from '@electric-ax/agents-runtime'
|
|
6
6
|
import type { DrizzleDB } from '../db/index.js'
|
|
@@ -15,16 +15,16 @@ import type { DurableStreamsBearerProvider } from '../stream-client.js'
|
|
|
15
15
|
import type { WebhookSigner } from '../webhook-signing.js'
|
|
16
16
|
import type { AuthorizeRequest } from '../electric-agents-types.js'
|
|
17
17
|
|
|
18
|
-
export interface
|
|
19
|
-
|
|
20
|
-
| Array<
|
|
21
|
-
| Promise<Array<
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
export interface WebhookSourceCatalog {
|
|
19
|
+
listWebhookSources: () =>
|
|
20
|
+
| Array<WebhookSourceContract>
|
|
21
|
+
| Promise<Array<WebhookSourceContract>>
|
|
22
|
+
getWebhookSource: (
|
|
23
|
+
webhookKey: string
|
|
24
24
|
) =>
|
|
25
|
-
|
|
|
25
|
+
| WebhookSourceContract
|
|
26
26
|
| undefined
|
|
27
|
-
| Promise<
|
|
27
|
+
| Promise<WebhookSourceContract | undefined>
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
/**
|
|
@@ -56,8 +56,8 @@ export interface TenantContext {
|
|
|
56
56
|
runtime: ElectricAgentsTenantRuntime
|
|
57
57
|
entityBridgeManager: EntityBridgeCoordinator
|
|
58
58
|
pgSyncBridgeManager?: PgSyncBridgeCoordinator
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
webhookSources?: WebhookSourceCatalog
|
|
60
|
+
ensureWebhookSourceWakeSource?: (sourceUrl: string) => Promise<void> | void
|
|
61
61
|
authorizeRequest?: AuthorizeRequest
|
|
62
62
|
isShuttingDown: () => boolean
|
|
63
63
|
}
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
import { Type, type Static } from '@sinclair/typebox'
|
|
6
6
|
import {
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
buildWebhookSourceManifestEntry,
|
|
8
|
+
resolveWebhookSourceSubscription,
|
|
9
9
|
} from '@electric-ax/agents-runtime'
|
|
10
10
|
import { Router, json, status } from 'itty-router'
|
|
11
11
|
import { apiError } from '../electric-agents-http.js'
|
|
@@ -45,7 +45,7 @@ import type {
|
|
|
45
45
|
import type { JsonRouteRequest } from './schema.js'
|
|
46
46
|
import type { RouterType } from 'itty-router'
|
|
47
47
|
import type { TenantContext } from './context.js'
|
|
48
|
-
import type {
|
|
48
|
+
import type { WebhookSourceSubscriptionInput } from '@electric-ax/agents-runtime'
|
|
49
49
|
|
|
50
50
|
interface AgentsRouteRequest extends JsonRouteRequest {
|
|
51
51
|
entityRoute?: ExistingEntityRoute
|
|
@@ -331,8 +331,8 @@ const subscriptionLifetimeSchema = Type.Union([
|
|
|
331
331
|
Type.Object({ kind: Type.Literal(`manual`) }),
|
|
332
332
|
])
|
|
333
333
|
|
|
334
|
-
const
|
|
335
|
-
|
|
334
|
+
const webhookSourceSubscriptionBodySchema = Type.Object({
|
|
335
|
+
webhookKey: Type.String(),
|
|
336
336
|
bucketKey: Type.Optional(Type.String()),
|
|
337
337
|
params: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
338
338
|
filterKey: Type.Optional(Type.String()),
|
|
@@ -348,8 +348,8 @@ type ForkBody = Static<typeof forkBodySchema>
|
|
|
348
348
|
type SetTagBody = Static<typeof setTagBodySchema>
|
|
349
349
|
type SignalBody = Static<typeof signalBodySchema>
|
|
350
350
|
type ScheduleBody = Static<typeof scheduleBodySchema>
|
|
351
|
-
type
|
|
352
|
-
typeof
|
|
351
|
+
type WebhookSourceSubscriptionBody = Static<
|
|
352
|
+
typeof webhookSourceSubscriptionBodySchema
|
|
353
353
|
>
|
|
354
354
|
type EntityPermissionGrantInput = Static<
|
|
355
355
|
typeof entityPermissionGrantInputSchema
|
|
@@ -494,17 +494,23 @@ entitiesRouter.delete(
|
|
|
494
494
|
deleteSchedule
|
|
495
495
|
)
|
|
496
496
|
entitiesRouter.put(
|
|
497
|
-
`/:type/:instanceId/
|
|
497
|
+
`/:type/:instanceId/webhook-source-subscriptions/:subscriptionId`,
|
|
498
498
|
withExistingEntity,
|
|
499
|
-
withSchema(
|
|
499
|
+
withSchema(webhookSourceSubscriptionBodySchema),
|
|
500
500
|
withEntityPermission(`write`),
|
|
501
|
-
|
|
501
|
+
upsertWebhookSourceSubscription
|
|
502
502
|
)
|
|
503
503
|
entitiesRouter.delete(
|
|
504
|
-
`/:type/:instanceId/
|
|
504
|
+
`/:type/:instanceId/webhook-source-subscriptions/:subscriptionId`,
|
|
505
505
|
withExistingEntity,
|
|
506
506
|
withEntityPermission(`write`),
|
|
507
|
-
|
|
507
|
+
deleteWebhookSourceSubscription
|
|
508
|
+
)
|
|
509
|
+
entitiesRouter.delete(
|
|
510
|
+
`/:type/:instanceId/pg-sync-observations/:sourceRef`,
|
|
511
|
+
withExistingEntity,
|
|
512
|
+
withEntityPermission(`write`),
|
|
513
|
+
deletePgSyncObservation
|
|
508
514
|
)
|
|
509
515
|
entitiesRouter.get(
|
|
510
516
|
`/:type/:instanceId/grants`,
|
|
@@ -1018,33 +1024,33 @@ async function deleteSchedule(
|
|
|
1018
1024
|
return json(result)
|
|
1019
1025
|
}
|
|
1020
1026
|
|
|
1021
|
-
async function
|
|
1027
|
+
async function upsertWebhookSourceSubscription(
|
|
1022
1028
|
request: AgentsRouteRequest,
|
|
1023
1029
|
ctx: TenantContext
|
|
1024
1030
|
): Promise<Response> {
|
|
1025
1031
|
const principalMutationError = rejectPrincipalEntityMutation(
|
|
1026
1032
|
request,
|
|
1027
|
-
`subscribed to
|
|
1033
|
+
`subscribed to webhook sources`
|
|
1028
1034
|
)
|
|
1029
1035
|
if (principalMutationError) return principalMutationError
|
|
1030
1036
|
|
|
1031
|
-
const catalog = ctx.
|
|
1037
|
+
const catalog = ctx.webhookSources
|
|
1032
1038
|
if (!catalog) {
|
|
1033
1039
|
return apiError(
|
|
1034
1040
|
404,
|
|
1035
1041
|
ErrCodeNotFound,
|
|
1036
|
-
`No
|
|
1042
|
+
`No webhook source catalog is configured`
|
|
1037
1043
|
)
|
|
1038
1044
|
}
|
|
1039
1045
|
|
|
1040
1046
|
const { entityUrl } = requireExistingEntityRoute(request)
|
|
1041
|
-
const parsed = routeBody<
|
|
1042
|
-
const source = await catalog.
|
|
1047
|
+
const parsed = routeBody<WebhookSourceSubscriptionBody>(request)
|
|
1048
|
+
const source = await catalog.getWebhookSource(parsed.webhookKey)
|
|
1043
1049
|
if (!source) {
|
|
1044
1050
|
return apiError(
|
|
1045
1051
|
404,
|
|
1046
1052
|
ErrCodeNotFound,
|
|
1047
|
-
`
|
|
1053
|
+
`Webhook source "${parsed.webhookKey}" not found`
|
|
1048
1054
|
)
|
|
1049
1055
|
}
|
|
1050
1056
|
|
|
@@ -1059,13 +1065,13 @@ async function upsertEventSourceSubscription(
|
|
|
1059
1065
|
}
|
|
1060
1066
|
}
|
|
1061
1067
|
|
|
1062
|
-
let resolved: ReturnType<typeof
|
|
1068
|
+
let resolved: ReturnType<typeof resolveWebhookSourceSubscription>
|
|
1063
1069
|
try {
|
|
1064
|
-
resolved =
|
|
1070
|
+
resolved = resolveWebhookSourceSubscription({
|
|
1065
1071
|
contract: source,
|
|
1066
1072
|
entityUrl,
|
|
1067
1073
|
request: {
|
|
1068
|
-
...(parsed as
|
|
1074
|
+
...(parsed as WebhookSourceSubscriptionInput),
|
|
1069
1075
|
id: decodeURIComponent(request.params.subscriptionId),
|
|
1070
1076
|
},
|
|
1071
1077
|
createdBy: `tool`,
|
|
@@ -1078,30 +1084,30 @@ async function upsertEventSourceSubscription(
|
|
|
1078
1084
|
)
|
|
1079
1085
|
}
|
|
1080
1086
|
|
|
1081
|
-
await ctx.
|
|
1087
|
+
await ctx.ensureWebhookSourceWakeSource?.(resolved.subscription.sourceUrl)
|
|
1082
1088
|
|
|
1083
|
-
const result = await ctx.entityManager.
|
|
1089
|
+
const result = await ctx.entityManager.upsertWebhookSourceSubscription(
|
|
1084
1090
|
entityUrl,
|
|
1085
1091
|
{
|
|
1086
1092
|
subscription: resolved.subscription,
|
|
1087
|
-
manifest:
|
|
1093
|
+
manifest: buildWebhookSourceManifestEntry(resolved),
|
|
1088
1094
|
}
|
|
1089
1095
|
)
|
|
1090
1096
|
return json(result)
|
|
1091
1097
|
}
|
|
1092
1098
|
|
|
1093
|
-
async function
|
|
1099
|
+
async function deleteWebhookSourceSubscription(
|
|
1094
1100
|
request: AgentsRouteRequest,
|
|
1095
1101
|
ctx: TenantContext
|
|
1096
1102
|
): Promise<Response> {
|
|
1097
1103
|
const principalMutationError = rejectPrincipalEntityMutation(
|
|
1098
1104
|
request,
|
|
1099
|
-
`unsubscribed from
|
|
1105
|
+
`unsubscribed from webhook sources`
|
|
1100
1106
|
)
|
|
1101
1107
|
if (principalMutationError) return principalMutationError
|
|
1102
1108
|
|
|
1103
1109
|
const { entityUrl } = requireExistingEntityRoute(request)
|
|
1104
|
-
const result = await ctx.entityManager.
|
|
1110
|
+
const result = await ctx.entityManager.deleteWebhookSourceSubscription(
|
|
1105
1111
|
entityUrl,
|
|
1106
1112
|
{
|
|
1107
1113
|
id: decodeURIComponent(request.params.subscriptionId),
|
|
@@ -1110,6 +1116,23 @@ async function deleteEventSourceSubscription(
|
|
|
1110
1116
|
return json(result)
|
|
1111
1117
|
}
|
|
1112
1118
|
|
|
1119
|
+
async function deletePgSyncObservation(
|
|
1120
|
+
request: AgentsRouteRequest,
|
|
1121
|
+
ctx: TenantContext
|
|
1122
|
+
): Promise<Response> {
|
|
1123
|
+
const principalMutationError = rejectPrincipalEntityMutation(
|
|
1124
|
+
request,
|
|
1125
|
+
`unobserved a pg-sync source`
|
|
1126
|
+
)
|
|
1127
|
+
if (principalMutationError) return principalMutationError
|
|
1128
|
+
|
|
1129
|
+
const { entityUrl } = requireExistingEntityRoute(request)
|
|
1130
|
+
const result = await ctx.entityManager.deletePgSyncObservation(entityUrl, {
|
|
1131
|
+
sourceRef: decodeURIComponent(request.params.sourceRef),
|
|
1132
|
+
})
|
|
1133
|
+
return json(result)
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1113
1136
|
function tagResponseBody(
|
|
1114
1137
|
entity: ElectricAgentsEntity & { txid?: number }
|
|
1115
1138
|
): PublicElectricAgentsEntity & { txid?: number } {
|
|
@@ -38,7 +38,7 @@ import { routeBody, validateOptionalJsonBody, withSchema } from './schema.js'
|
|
|
38
38
|
import { withLeadingSlash } from './tenant-stream-paths.js'
|
|
39
39
|
import type { IRequest, RouterType } from 'itty-router'
|
|
40
40
|
import type {
|
|
41
|
-
|
|
41
|
+
WebhookSourceContract,
|
|
42
42
|
WebhookSignatureVerifierConfig,
|
|
43
43
|
} from '@electric-ax/agents-runtime'
|
|
44
44
|
import type { TenantContext } from './context.js'
|
|
@@ -121,7 +121,7 @@ export const internalRouter: InternalRoutes = Router<
|
|
|
121
121
|
})
|
|
122
122
|
|
|
123
123
|
internalRouter.get(`/health`, () => json({ status: `ok` }))
|
|
124
|
-
internalRouter.get(`/
|
|
124
|
+
internalRouter.get(`/webhook-sources`, listWebhookSources)
|
|
125
125
|
internalRouter.post(
|
|
126
126
|
`/wake`,
|
|
127
127
|
withSchema(wakeRegistrationBodySchema),
|
|
@@ -366,17 +366,19 @@ async function registerWake(
|
|
|
366
366
|
return status(204)
|
|
367
367
|
}
|
|
368
368
|
|
|
369
|
-
async function
|
|
369
|
+
async function listWebhookSources(
|
|
370
370
|
_request: IRequest,
|
|
371
371
|
ctx: TenantContext
|
|
372
372
|
): Promise<Response> {
|
|
373
|
-
const
|
|
374
|
-
? await ctx.
|
|
373
|
+
const webhookSources = ctx.webhookSources
|
|
374
|
+
? await ctx.webhookSources.listWebhookSources()
|
|
375
375
|
: []
|
|
376
|
-
return json({
|
|
376
|
+
return json({
|
|
377
|
+
webhookSources: webhookSources.filter(isAgentVisibleWebhookSource),
|
|
378
|
+
})
|
|
377
379
|
}
|
|
378
380
|
|
|
379
|
-
function
|
|
381
|
+
function isAgentVisibleWebhookSource(source: WebhookSourceContract): boolean {
|
|
380
382
|
return source.agentVisible === true && source.status === `active`
|
|
381
383
|
}
|
|
382
384
|
|
|
@@ -8,6 +8,8 @@ import type {
|
|
|
8
8
|
} from '@electric-ax/agents-runtime'
|
|
9
9
|
import { Type, type Static } from '@sinclair/typebox'
|
|
10
10
|
import { Router, json } from 'itty-router'
|
|
11
|
+
import { PgSyncSourceValidationError } from '../pg-sync-bridge-manager.js'
|
|
12
|
+
import { serverLog } from '../utils/log.js'
|
|
11
13
|
import { apiError } from '../electric-agents-http.js'
|
|
12
14
|
import { ErrCodeInvalidRequest } from '../electric-agents-types.js'
|
|
13
15
|
import { routeBody, withSchema } from './schema.js'
|
|
@@ -16,7 +18,7 @@ import type { RouterType } from 'itty-router'
|
|
|
16
18
|
import type { TenantContext } from './context.js'
|
|
17
19
|
|
|
18
20
|
const pgSyncOptionsSchema = Type.Object({
|
|
19
|
-
url: Type.
|
|
21
|
+
url: Type.String(),
|
|
20
22
|
table: Type.String(),
|
|
21
23
|
columns: Type.Optional(Type.Array(Type.String())),
|
|
22
24
|
where: Type.Optional(Type.String()),
|
|
@@ -72,6 +74,10 @@ async function registerPgSync(
|
|
|
72
74
|
): Promise<Response> {
|
|
73
75
|
const { options, metadata } = routeBody<PgSyncRegisterBody>(request)
|
|
74
76
|
|
|
77
|
+
if (options.url.trim() === ``) {
|
|
78
|
+
return apiError(400, ErrCodeInvalidRequest, `pgSync url must be non-empty`)
|
|
79
|
+
}
|
|
80
|
+
|
|
75
81
|
if (options.table.trim() === ``) {
|
|
76
82
|
return apiError(
|
|
77
83
|
400,
|
|
@@ -104,6 +110,13 @@ async function registerPgSync(
|
|
|
104
110
|
|
|
105
111
|
return json(result)
|
|
106
112
|
} catch (error) {
|
|
113
|
+
if (error instanceof PgSyncSourceValidationError) {
|
|
114
|
+
return apiError(400, ErrCodeInvalidRequest, error.message)
|
|
115
|
+
}
|
|
116
|
+
serverLog.error(
|
|
117
|
+
`[pg-sync] registration failed for table "${options.table}":`,
|
|
118
|
+
error
|
|
119
|
+
)
|
|
107
120
|
return apiError(
|
|
108
121
|
500,
|
|
109
122
|
ErrCodeInvalidRequest,
|
package/src/server.ts
CHANGED
|
@@ -35,7 +35,7 @@ import type { Principal } from './principal.js'
|
|
|
35
35
|
import type { EntityBridgeCoordinator } from './entity-bridge-manager.js'
|
|
36
36
|
import type { DurableStreamsRoutingAdapter } from './routing/durable-streams-routing-adapter.js'
|
|
37
37
|
import type { OssServerContext } from './routing/oss-server-router.js'
|
|
38
|
-
import type {
|
|
38
|
+
import type { WebhookSourceCatalog } from './routing/context.js'
|
|
39
39
|
import type { PgSyncBridgeManagerOptions } from './pg-sync-bridge-manager.js'
|
|
40
40
|
import type { StartedStandaloneAgentsRuntime } from './standalone-runtime.js'
|
|
41
41
|
import type { DurableStreamsBearerProvider } from './stream-client.js'
|
|
@@ -71,8 +71,8 @@ export interface ElectricAgentsServerOptions {
|
|
|
71
71
|
) => Promise<Principal | null> | Principal | null
|
|
72
72
|
authorizeRequest?: AuthorizeRequest
|
|
73
73
|
allowDevPrincipalFallback?: boolean
|
|
74
|
-
|
|
75
|
-
|
|
74
|
+
webhookSources?: WebhookSourceCatalog
|
|
75
|
+
ensureWebhookSourceWakeSource?: (sourceUrl: string) => Promise<void> | void
|
|
76
76
|
pgSync?: PgSyncBridgeManagerOptions
|
|
77
77
|
/**
|
|
78
78
|
* Disabled by default. When set to a positive interval, periodically
|
|
@@ -450,13 +450,13 @@ export class ElectricAgentsServer {
|
|
|
450
450
|
runtime: this.standaloneRuntime.runtime,
|
|
451
451
|
entityBridgeManager: this.entityBridgeManager,
|
|
452
452
|
pgSyncBridgeManager: this.standaloneRuntime.runtime.pgSyncBridgeManager,
|
|
453
|
-
...(this.options.
|
|
454
|
-
? {
|
|
453
|
+
...(this.options.webhookSources
|
|
454
|
+
? { webhookSources: this.options.webhookSources }
|
|
455
455
|
: {}),
|
|
456
|
-
...(this.options.
|
|
456
|
+
...(this.options.ensureWebhookSourceWakeSource
|
|
457
457
|
? {
|
|
458
|
-
|
|
459
|
-
this.options.
|
|
458
|
+
ensureWebhookSourceWakeSource:
|
|
459
|
+
this.options.ensureWebhookSourceWakeSource,
|
|
460
460
|
}
|
|
461
461
|
: {}),
|
|
462
462
|
...(this.options.authorizeRequest
|
package/src/wake-registry.ts
CHANGED