@electric-ax/agents-server 0.5.0 → 0.6.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.
@@ -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; timestamp: string }
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 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)
246
+ const safeMessage = jsonSafe(message) as Record<string, unknown>
202
247
 
203
248
  return {
204
249
  type: `pg_sync_change`,
205
- key: messageKey,
206
- value: {
207
- key: messageKey,
208
- table:
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, this.options)
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.url = options.url ?? PG_SYNC_ELECTRIC_SHAPE_URL
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
- this.ensureBridge(row).catch((error) => {
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
- return { url: options.url ?? this.url }
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> {
@@ -1,6 +1,6 @@
1
1
  import type { Agent } from 'undici'
2
2
  import type {
3
- EventSourceContract,
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 EventSourceCatalog {
19
- listEventSources: () =>
20
- | Array<EventSourceContract>
21
- | Promise<Array<EventSourceContract>>
22
- getEventSource: (
23
- sourceKey: string
18
+ export interface WebhookSourceCatalog {
19
+ listWebhookSources: () =>
20
+ | Array<WebhookSourceContract>
21
+ | Promise<Array<WebhookSourceContract>>
22
+ getWebhookSource: (
23
+ webhookKey: string
24
24
  ) =>
25
- | EventSourceContract
25
+ | WebhookSourceContract
26
26
  | undefined
27
- | Promise<EventSourceContract | undefined>
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
- eventSources?: EventSourceCatalog
60
- ensureEventSourceWakeSource?: (sourceUrl: string) => Promise<void> | void
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
- buildEventSourceManifestEntry,
8
- resolveEventSourceSubscription,
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 { EventSourceSubscriptionInput } from '@electric-ax/agents-runtime'
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 eventSourceSubscriptionBodySchema = Type.Object({
335
- sourceKey: Type.String(),
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 EventSourceSubscriptionBody = Static<
352
- typeof eventSourceSubscriptionBodySchema
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/event-source-subscriptions/:subscriptionId`,
497
+ `/:type/:instanceId/webhook-source-subscriptions/:subscriptionId`,
498
498
  withExistingEntity,
499
- withSchema(eventSourceSubscriptionBodySchema),
499
+ withSchema(webhookSourceSubscriptionBodySchema),
500
500
  withEntityPermission(`write`),
501
- upsertEventSourceSubscription
501
+ upsertWebhookSourceSubscription
502
502
  )
503
503
  entitiesRouter.delete(
504
- `/:type/:instanceId/event-source-subscriptions/:subscriptionId`,
504
+ `/:type/:instanceId/webhook-source-subscriptions/:subscriptionId`,
505
505
  withExistingEntity,
506
506
  withEntityPermission(`write`),
507
- deleteEventSourceSubscription
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 upsertEventSourceSubscription(
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 event sources`
1033
+ `subscribed to webhook sources`
1028
1034
  )
1029
1035
  if (principalMutationError) return principalMutationError
1030
1036
 
1031
- const catalog = ctx.eventSources
1037
+ const catalog = ctx.webhookSources
1032
1038
  if (!catalog) {
1033
1039
  return apiError(
1034
1040
  404,
1035
1041
  ErrCodeNotFound,
1036
- `No event source catalog is configured`
1042
+ `No webhook source catalog is configured`
1037
1043
  )
1038
1044
  }
1039
1045
 
1040
1046
  const { entityUrl } = requireExistingEntityRoute(request)
1041
- const parsed = routeBody<EventSourceSubscriptionBody>(request)
1042
- const source = await catalog.getEventSource(parsed.sourceKey)
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
- `Event source "${parsed.sourceKey}" not found`
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 resolveEventSourceSubscription>
1068
+ let resolved: ReturnType<typeof resolveWebhookSourceSubscription>
1063
1069
  try {
1064
- resolved = resolveEventSourceSubscription({
1070
+ resolved = resolveWebhookSourceSubscription({
1065
1071
  contract: source,
1066
1072
  entityUrl,
1067
1073
  request: {
1068
- ...(parsed as EventSourceSubscriptionInput),
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.ensureEventSourceWakeSource?.(resolved.subscription.sourceUrl)
1087
+ await ctx.ensureWebhookSourceWakeSource?.(resolved.subscription.sourceUrl)
1082
1088
 
1083
- const result = await ctx.entityManager.upsertEventSourceSubscription(
1089
+ const result = await ctx.entityManager.upsertWebhookSourceSubscription(
1084
1090
  entityUrl,
1085
1091
  {
1086
1092
  subscription: resolved.subscription,
1087
- manifest: buildEventSourceManifestEntry(resolved),
1093
+ manifest: buildWebhookSourceManifestEntry(resolved),
1088
1094
  }
1089
1095
  )
1090
1096
  return json(result)
1091
1097
  }
1092
1098
 
1093
- async function deleteEventSourceSubscription(
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 event sources`
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.deleteEventSourceSubscription(
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
- EventSourceContract,
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(`/event-sources`, listEventSources)
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 listEventSources(
369
+ async function listWebhookSources(
370
370
  _request: IRequest,
371
371
  ctx: TenantContext
372
372
  ): Promise<Response> {
373
- const eventSources = ctx.eventSources
374
- ? await ctx.eventSources.listEventSources()
373
+ const webhookSources = ctx.webhookSources
374
+ ? await ctx.webhookSources.listWebhookSources()
375
375
  : []
376
- return json({ eventSources: eventSources.filter(isAgentVisibleEventSource) })
376
+ return json({
377
+ webhookSources: webhookSources.filter(isAgentVisibleWebhookSource),
378
+ })
377
379
  }
378
380
 
379
- function isAgentVisibleEventSource(source: EventSourceContract): boolean {
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.Optional(Type.String()),
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 { EventSourceCatalog } from './routing/context.js'
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
- eventSources?: EventSourceCatalog
75
- ensureEventSourceWakeSource?: (sourceUrl: string) => Promise<void> | void
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.eventSources
454
- ? { eventSources: this.options.eventSources }
453
+ ...(this.options.webhookSources
454
+ ? { webhookSources: this.options.webhookSources }
455
455
  : {}),
456
- ...(this.options.ensureEventSourceWakeSource
456
+ ...(this.options.ensureWebhookSourceWakeSource
457
457
  ? {
458
- ensureEventSourceWakeSource:
459
- this.options.ensureEventSourceWakeSource,
458
+ ensureWebhookSourceWakeSource:
459
+ this.options.ensureWebhookSourceWakeSource,
460
460
  }
461
461
  : {}),
462
462
  ...(this.options.authorizeRequest
@@ -967,6 +967,8 @@ export class WakeRegistry {
967
967
  }
968
968
  if (value && `oldValue` in value) {
969
969
  change.oldValue = value.oldValue
970
+ } else if (value && `old_value` in value) {
971
+ change.oldValue = value.old_value
970
972
  }
971
973
 
972
974
  if (eventType === `inbox`) {