@electric-ax/agents-server 0.4.20 → 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.
@@ -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
@@ -149,6 +149,19 @@ const spawnBodySchema = Type.Object({
149
149
  ),
150
150
  })
151
151
 
152
+ const writeCollectionBodySchema = Type.Object(
153
+ {
154
+ operation: Type.Union([
155
+ Type.Literal(`insert`),
156
+ Type.Literal(`update`),
157
+ Type.Literal(`delete`),
158
+ ]),
159
+ key: Type.Optional(Type.String()),
160
+ value: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
161
+ },
162
+ { additionalProperties: false }
163
+ )
164
+
152
165
  const sendBodySchema = Type.Object({
153
166
  payload: Type.Optional(Type.Unknown()),
154
167
  key: Type.Optional(Type.String()),
@@ -318,8 +331,8 @@ const subscriptionLifetimeSchema = Type.Union([
318
331
  Type.Object({ kind: Type.Literal(`manual`) }),
319
332
  ])
320
333
 
321
- const eventSourceSubscriptionBodySchema = Type.Object({
322
- sourceKey: Type.String(),
334
+ const webhookSourceSubscriptionBodySchema = Type.Object({
335
+ webhookKey: Type.String(),
323
336
  bucketKey: Type.Optional(Type.String()),
324
337
  params: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
325
338
  filterKey: Type.Optional(Type.String()),
@@ -328,14 +341,15 @@ const eventSourceSubscriptionBodySchema = Type.Object({
328
341
  })
329
342
 
330
343
  type SpawnBody = Static<typeof spawnBodySchema>
344
+ type WriteCollectionBody = Static<typeof writeCollectionBodySchema>
331
345
  type SendBody = Static<typeof sendBodySchema>
332
346
  type InboxMessageBody = Static<typeof inboxMessageBodySchema>
333
347
  type ForkBody = Static<typeof forkBodySchema>
334
348
  type SetTagBody = Static<typeof setTagBodySchema>
335
349
  type SignalBody = Static<typeof signalBodySchema>
336
350
  type ScheduleBody = Static<typeof scheduleBodySchema>
337
- type EventSourceSubscriptionBody = Static<
338
- typeof eventSourceSubscriptionBodySchema
351
+ type WebhookSourceSubscriptionBody = Static<
352
+ typeof webhookSourceSubscriptionBodySchema
339
353
  >
340
354
  type EntityPermissionGrantInput = Static<
341
355
  typeof entityPermissionGrantInputSchema
@@ -408,6 +422,13 @@ entitiesRouter.post(
408
422
  withEntityPermission(`write`),
409
423
  sendEntity
410
424
  )
425
+ entitiesRouter.post(
426
+ `/:type/:instanceId/collections/:collection`,
427
+ withExistingEntity,
428
+ withSchema(writeCollectionBodySchema),
429
+ withEntityPermission(`write`),
430
+ writeCollection
431
+ )
411
432
  entitiesRouter.post(
412
433
  `/:type/:instanceId/attachments`,
413
434
  withExistingEntity,
@@ -473,17 +494,23 @@ entitiesRouter.delete(
473
494
  deleteSchedule
474
495
  )
475
496
  entitiesRouter.put(
476
- `/:type/:instanceId/event-source-subscriptions/:subscriptionId`,
497
+ `/:type/:instanceId/webhook-source-subscriptions/:subscriptionId`,
498
+ withExistingEntity,
499
+ withSchema(webhookSourceSubscriptionBodySchema),
500
+ withEntityPermission(`write`),
501
+ upsertWebhookSourceSubscription
502
+ )
503
+ entitiesRouter.delete(
504
+ `/:type/:instanceId/webhook-source-subscriptions/:subscriptionId`,
477
505
  withExistingEntity,
478
- withSchema(eventSourceSubscriptionBodySchema),
479
506
  withEntityPermission(`write`),
480
- upsertEventSourceSubscription
507
+ deleteWebhookSourceSubscription
481
508
  )
482
509
  entitiesRouter.delete(
483
- `/:type/:instanceId/event-source-subscriptions/:subscriptionId`,
510
+ `/:type/:instanceId/pg-sync-observations/:sourceRef`,
484
511
  withExistingEntity,
485
512
  withEntityPermission(`write`),
486
- deleteEventSourceSubscription
513
+ deletePgSyncObservation
487
514
  )
488
515
  entitiesRouter.get(
489
516
  `/:type/:instanceId/grants`,
@@ -997,33 +1024,33 @@ async function deleteSchedule(
997
1024
  return json(result)
998
1025
  }
999
1026
 
1000
- async function upsertEventSourceSubscription(
1027
+ async function upsertWebhookSourceSubscription(
1001
1028
  request: AgentsRouteRequest,
1002
1029
  ctx: TenantContext
1003
1030
  ): Promise<Response> {
1004
1031
  const principalMutationError = rejectPrincipalEntityMutation(
1005
1032
  request,
1006
- `subscribed to event sources`
1033
+ `subscribed to webhook sources`
1007
1034
  )
1008
1035
  if (principalMutationError) return principalMutationError
1009
1036
 
1010
- const catalog = ctx.eventSources
1037
+ const catalog = ctx.webhookSources
1011
1038
  if (!catalog) {
1012
1039
  return apiError(
1013
1040
  404,
1014
1041
  ErrCodeNotFound,
1015
- `No event source catalog is configured`
1042
+ `No webhook source catalog is configured`
1016
1043
  )
1017
1044
  }
1018
1045
 
1019
1046
  const { entityUrl } = requireExistingEntityRoute(request)
1020
- const parsed = routeBody<EventSourceSubscriptionBody>(request)
1021
- const source = await catalog.getEventSource(parsed.sourceKey)
1047
+ const parsed = routeBody<WebhookSourceSubscriptionBody>(request)
1048
+ const source = await catalog.getWebhookSource(parsed.webhookKey)
1022
1049
  if (!source) {
1023
1050
  return apiError(
1024
1051
  404,
1025
1052
  ErrCodeNotFound,
1026
- `Event source "${parsed.sourceKey}" not found`
1053
+ `Webhook source "${parsed.webhookKey}" not found`
1027
1054
  )
1028
1055
  }
1029
1056
 
@@ -1038,13 +1065,13 @@ async function upsertEventSourceSubscription(
1038
1065
  }
1039
1066
  }
1040
1067
 
1041
- let resolved: ReturnType<typeof resolveEventSourceSubscription>
1068
+ let resolved: ReturnType<typeof resolveWebhookSourceSubscription>
1042
1069
  try {
1043
- resolved = resolveEventSourceSubscription({
1070
+ resolved = resolveWebhookSourceSubscription({
1044
1071
  contract: source,
1045
1072
  entityUrl,
1046
1073
  request: {
1047
- ...(parsed as EventSourceSubscriptionInput),
1074
+ ...(parsed as WebhookSourceSubscriptionInput),
1048
1075
  id: decodeURIComponent(request.params.subscriptionId),
1049
1076
  },
1050
1077
  createdBy: `tool`,
@@ -1057,30 +1084,30 @@ async function upsertEventSourceSubscription(
1057
1084
  )
1058
1085
  }
1059
1086
 
1060
- await ctx.ensureEventSourceWakeSource?.(resolved.subscription.sourceUrl)
1087
+ await ctx.ensureWebhookSourceWakeSource?.(resolved.subscription.sourceUrl)
1061
1088
 
1062
- const result = await ctx.entityManager.upsertEventSourceSubscription(
1089
+ const result = await ctx.entityManager.upsertWebhookSourceSubscription(
1063
1090
  entityUrl,
1064
1091
  {
1065
1092
  subscription: resolved.subscription,
1066
- manifest: buildEventSourceManifestEntry(resolved),
1093
+ manifest: buildWebhookSourceManifestEntry(resolved),
1067
1094
  }
1068
1095
  )
1069
1096
  return json(result)
1070
1097
  }
1071
1098
 
1072
- async function deleteEventSourceSubscription(
1099
+ async function deleteWebhookSourceSubscription(
1073
1100
  request: AgentsRouteRequest,
1074
1101
  ctx: TenantContext
1075
1102
  ): Promise<Response> {
1076
1103
  const principalMutationError = rejectPrincipalEntityMutation(
1077
1104
  request,
1078
- `unsubscribed from event sources`
1105
+ `unsubscribed from webhook sources`
1079
1106
  )
1080
1107
  if (principalMutationError) return principalMutationError
1081
1108
 
1082
1109
  const { entityUrl } = requireExistingEntityRoute(request)
1083
- const result = await ctx.entityManager.deleteEventSourceSubscription(
1110
+ const result = await ctx.entityManager.deleteWebhookSourceSubscription(
1084
1111
  entityUrl,
1085
1112
  {
1086
1113
  id: decodeURIComponent(request.params.subscriptionId),
@@ -1089,6 +1116,23 @@ async function deleteEventSourceSubscription(
1089
1116
  return json(result)
1090
1117
  }
1091
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
+
1092
1136
  function tagResponseBody(
1093
1137
  entity: ElectricAgentsEntity & { txid?: number }
1094
1138
  ): PublicElectricAgentsEntity & { txid?: number } {
@@ -1308,6 +1352,31 @@ async function sendEntity(
1308
1352
  return json(result)
1309
1353
  }
1310
1354
 
1355
+ async function writeCollection(
1356
+ request: AgentsRouteRequest,
1357
+ ctx: TenantContext
1358
+ ): Promise<Response> {
1359
+ const parsed = routeBody<WriteCollectionBody>(request)
1360
+ await ctx.entityManager.ensurePrincipal(ctx.principal)
1361
+ const { entityUrl } = requireExistingEntityRoute(request)
1362
+ const collection = request.params.collection
1363
+ const result = await ctx.entityManager.writeCollection(
1364
+ entityUrl,
1365
+ collection,
1366
+ {
1367
+ operation: parsed.operation,
1368
+ key: parsed.key,
1369
+ value: parsed.value,
1370
+ principal: {
1371
+ url: ctx.principal.url,
1372
+ kind: ctx.principal.kind,
1373
+ id: ctx.principal.id,
1374
+ },
1375
+ }
1376
+ )
1377
+ return json(result, { status: parsed.operation === `insert` ? 201 : 200 })
1378
+ }
1379
+
1311
1380
  async function createAttachment(
1312
1381
  request: AgentsRouteRequest,
1313
1382
  ctx: TenantContext
@@ -1473,8 +1542,21 @@ async function spawnEntity(
1473
1542
  )
1474
1543
  }
1475
1544
 
1476
- function getEntity(request: AgentsRouteRequest): Response {
1477
- return json(toPublicEntity(requireExistingEntityRoute(request).entity))
1545
+ async function getEntity(
1546
+ request: AgentsRouteRequest,
1547
+ ctx: TenantContext
1548
+ ): Promise<Response> {
1549
+ const { entity } = requireExistingEntityRoute(request)
1550
+ const entityType = entity.type
1551
+ ? await ctx.entityManager.registry.getEntityType(entity.type)
1552
+ : null
1553
+ return json({
1554
+ ...toPublicEntity(entity),
1555
+ ...(entityType?.externally_writable_collections && {
1556
+ externally_writable_collections:
1557
+ entityType.externally_writable_collections,
1558
+ }),
1559
+ })
1478
1560
  }
1479
1561
 
1480
1562
  function headEntity(): Response {