@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.
@@ -39,6 +39,7 @@ import type {
39
39
  ElectricAgentsEntity,
40
40
  ElectricAgentsEntityType,
41
41
  EntityPermission,
42
+ PublicElectricAgentsEntity,
42
43
  SendRequest,
43
44
  } from '../electric-agents-types.js'
44
45
  import type { JsonRouteRequest } from './schema.js'
@@ -148,6 +149,19 @@ const spawnBodySchema = Type.Object({
148
149
  ),
149
150
  })
150
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
+
151
165
  const sendBodySchema = Type.Object({
152
166
  payload: Type.Optional(Type.Unknown()),
153
167
  key: Type.Optional(Type.String()),
@@ -327,6 +341,7 @@ const eventSourceSubscriptionBodySchema = Type.Object({
327
341
  })
328
342
 
329
343
  type SpawnBody = Static<typeof spawnBodySchema>
344
+ type WriteCollectionBody = Static<typeof writeCollectionBodySchema>
330
345
  type SendBody = Static<typeof sendBodySchema>
331
346
  type InboxMessageBody = Static<typeof inboxMessageBodySchema>
332
347
  type ForkBody = Static<typeof forkBodySchema>
@@ -407,6 +422,13 @@ entitiesRouter.post(
407
422
  withEntityPermission(`write`),
408
423
  sendEntity
409
424
  )
425
+ entitiesRouter.post(
426
+ `/:type/:instanceId/collections/:collection`,
427
+ withExistingEntity,
428
+ withSchema(writeCollectionBodySchema),
429
+ withEntityPermission(`write`),
430
+ writeCollection
431
+ )
410
432
  entitiesRouter.post(
411
433
  `/:type/:instanceId/attachments`,
412
434
  withExistingEntity,
@@ -658,7 +680,12 @@ async function parseAttachmentForm(
658
680
  }
659
681
 
660
682
  function contentDisposition(filename: string): string {
661
- const fallback = filename.replace(/["\\\r\n]/g, `_`)
683
+ // Header values are converted to WebIDL ByteString by undici, so every
684
+ // character in the raw header value must fit in a single byte. Keep the
685
+ // RFC 5987 filename* parameter for the full UTF-8 filename, but make the
686
+ // legacy filename fallback ASCII-only to avoid throwing on names containing
687
+ // e.g. narrow no-break spaces or emoji.
688
+ const fallback = filename.replace(/[^\x20-\x7e]|["\\]/g, `_`)
662
689
  return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(filename)}`
663
690
  }
664
691
 
@@ -1083,6 +1110,16 @@ async function deleteEventSourceSubscription(
1083
1110
  return json(result)
1084
1111
  }
1085
1112
 
1113
+ function tagResponseBody(
1114
+ entity: ElectricAgentsEntity & { txid?: number }
1115
+ ): PublicElectricAgentsEntity & { txid?: number } {
1116
+ const publicEntity = toPublicEntity(entity)
1117
+ if (entity.txid !== undefined) {
1118
+ return { ...publicEntity, txid: entity.txid }
1119
+ }
1120
+ return publicEntity
1121
+ }
1122
+
1086
1123
  async function setTag(
1087
1124
  request: AgentsRouteRequest,
1088
1125
  ctx: TenantContext
@@ -1095,14 +1132,12 @@ async function setTag(
1095
1132
 
1096
1133
  const parsed = routeBody<SetTagBody>(request)
1097
1134
  const { entityUrl } = requireExistingEntityRoute(request)
1098
- const token = writeTokenFromRequest(request)
1099
1135
  const updated = await ctx.entityManager.setTag(
1100
1136
  entityUrl,
1101
1137
  decodeURIComponent(request.params.tagKey),
1102
- { value: parsed.value },
1103
- token
1138
+ { value: parsed.value }
1104
1139
  )
1105
- return json(toPublicEntity(updated))
1140
+ return json(tagResponseBody(updated))
1106
1141
  }
1107
1142
 
1108
1143
  async function deleteTag(
@@ -1116,13 +1151,11 @@ async function deleteTag(
1116
1151
  if (principalMutationError) return principalMutationError
1117
1152
 
1118
1153
  const { entityUrl } = requireExistingEntityRoute(request)
1119
- const token = writeTokenFromRequest(request)
1120
1154
  const updated = await ctx.entityManager.deleteTag(
1121
1155
  entityUrl,
1122
- decodeURIComponent(request.params.tagKey),
1123
- token
1156
+ decodeURIComponent(request.params.tagKey)
1124
1157
  )
1125
- return json(toPublicEntity(updated))
1158
+ return json(tagResponseBody(updated))
1126
1159
  }
1127
1160
 
1128
1161
  async function forkEntity(
@@ -1289,11 +1322,36 @@ async function sendEntity(
1289
1322
  sendReq,
1290
1323
  new Date(Date.now() + parsed.afterMs)
1291
1324
  )
1292
- } else {
1293
- await ctx.entityManager.send(entityUrl, sendReq)
1325
+ return status(204)
1294
1326
  }
1295
1327
 
1296
- return status(204)
1328
+ const result = await ctx.entityManager.send(entityUrl, sendReq)
1329
+ return json(result)
1330
+ }
1331
+
1332
+ async function writeCollection(
1333
+ request: AgentsRouteRequest,
1334
+ ctx: TenantContext
1335
+ ): Promise<Response> {
1336
+ const parsed = routeBody<WriteCollectionBody>(request)
1337
+ await ctx.entityManager.ensurePrincipal(ctx.principal)
1338
+ const { entityUrl } = requireExistingEntityRoute(request)
1339
+ const collection = request.params.collection
1340
+ const result = await ctx.entityManager.writeCollection(
1341
+ entityUrl,
1342
+ collection,
1343
+ {
1344
+ operation: parsed.operation,
1345
+ key: parsed.key,
1346
+ value: parsed.value,
1347
+ principal: {
1348
+ url: ctx.principal.url,
1349
+ kind: ctx.principal.kind,
1350
+ id: ctx.principal.id,
1351
+ },
1352
+ }
1353
+ )
1354
+ return json(result, { status: parsed.operation === `insert` ? 201 : 200 })
1297
1355
  }
1298
1356
 
1299
1357
  async function createAttachment(
@@ -1368,12 +1426,12 @@ async function updateInboxMessage(
1368
1426
  ): Promise<Response> {
1369
1427
  const parsed = routeBody<InboxMessageBody>(request)
1370
1428
  const { entityUrl } = requireExistingEntityRoute(request)
1371
- await ctx.entityManager.updateInboxMessage(
1429
+ const result = await ctx.entityManager.updateInboxMessage(
1372
1430
  entityUrl,
1373
1431
  decodeURIComponent(request.params.messageKey),
1374
1432
  parsed
1375
1433
  )
1376
- return status(204)
1434
+ return json(result)
1377
1435
  }
1378
1436
 
1379
1437
  async function deleteInboxMessage(
@@ -1381,11 +1439,11 @@ async function deleteInboxMessage(
1381
1439
  ctx: TenantContext
1382
1440
  ): Promise<Response> {
1383
1441
  const { entityUrl } = requireExistingEntityRoute(request)
1384
- await ctx.entityManager.deleteInboxMessage(
1442
+ const result = await ctx.entityManager.deleteInboxMessage(
1385
1443
  entityUrl,
1386
1444
  decodeURIComponent(request.params.messageKey)
1387
1445
  )
1388
- return status(204)
1446
+ return json(result)
1389
1447
  }
1390
1448
 
1391
1449
  async function spawnEntity(
@@ -1461,8 +1519,21 @@ async function spawnEntity(
1461
1519
  )
1462
1520
  }
1463
1521
 
1464
- function getEntity(request: AgentsRouteRequest): Response {
1465
- return json(toPublicEntity(requireExistingEntityRoute(request).entity))
1522
+ async function getEntity(
1523
+ request: AgentsRouteRequest,
1524
+ ctx: TenantContext
1525
+ ): Promise<Response> {
1526
+ const { entity } = requireExistingEntityRoute(request)
1527
+ const entityType = entity.type
1528
+ ? await ctx.entityManager.registry.getEntityType(entity.type)
1529
+ : null
1530
+ return json({
1531
+ ...toPublicEntity(entity),
1532
+ ...(entityType?.externally_writable_collections && {
1533
+ externally_writable_collections:
1534
+ entityType.externally_writable_collections,
1535
+ }),
1536
+ })
1466
1537
  }
1467
1538
 
1468
1539
  function headEntity(): Response {
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { Type, type Static } from '@sinclair/typebox'
6
6
  import { Router, json, status } from 'itty-router'
7
+ import { COMMENTS_CONTRACT } from '@electric-ax/agents-runtime'
7
8
  import { dispatchPolicySchema } from '../dispatch-policy-schema.js'
8
9
  import { ElectricAgentsError } from '../entity-manager.js'
9
10
  import {
@@ -45,6 +46,29 @@ type PublicEntityTypeResponse = ElectricAgentsEntityType & {
45
46
 
46
47
  const jsonObjectSchema = Type.Record(Type.String(), Type.Unknown())
47
48
  const schemaMapSchema = Type.Record(Type.String(), jsonObjectSchema)
49
+ // `principalColumn` is accepted and ignored: older runtimes still send it
50
+ // (the column is fixed to `_principal` now), and rejecting it would break
51
+ // registration during version skew.
52
+ const externallyWritableCollectionsSchema = Type.Record(
53
+ Type.String(),
54
+ Type.Object(
55
+ {
56
+ type: Type.String(),
57
+ contract: Type.Optional(Type.String()),
58
+ operations: Type.Optional(
59
+ Type.Array(
60
+ Type.Union([
61
+ Type.Literal(`insert`),
62
+ Type.Literal(`update`),
63
+ Type.Literal(`delete`),
64
+ ])
65
+ )
66
+ ),
67
+ principalColumn: Type.Optional(Type.String()),
68
+ },
69
+ { additionalProperties: false }
70
+ )
71
+ )
48
72
  const slashCommandArgumentSchema = Type.Object(
49
73
  {
50
74
  name: Type.String(),
@@ -93,6 +117,9 @@ const registerEntityTypeBodySchema = Type.Object(
93
117
  permission_grants: Type.Optional(
94
118
  Type.Array(typePermissionGrantInputSchema)
95
119
  ),
120
+ externally_writable_collections: Type.Optional(
121
+ externallyWritableCollectionsSchema
122
+ ),
96
123
  },
97
124
  { additionalProperties: false }
98
125
  )
@@ -445,9 +472,37 @@ function parseExpiresAt(value: string | undefined): Date | undefined {
445
472
  return expiresAt
446
473
  }
447
474
 
475
+ /**
476
+ * The `comments` collection name is reserved for the canonical comments
477
+ * contract: the UI keys its comment affordances on it, so a divergent
478
+ * collection registered under that name (or the contract mounted under
479
+ * another name) would break that assumption silently.
480
+ */
481
+ function validateExternallyWritableCollections(
482
+ collections: RegisterEntityTypeRequest[`externally_writable_collections`]
483
+ ): void {
484
+ for (const [name, config] of Object.entries(collections ?? {})) {
485
+ if (name === `comments` && config.contract !== COMMENTS_CONTRACT) {
486
+ throw new ElectricAgentsError(
487
+ ErrCodeInvalidRequest,
488
+ `The externally-writable collection name "comments" is reserved for the "${COMMENTS_CONTRACT}" contract`,
489
+ 400
490
+ )
491
+ }
492
+ if (config.contract === COMMENTS_CONTRACT && name !== `comments`) {
493
+ throw new ElectricAgentsError(
494
+ ErrCodeInvalidRequest,
495
+ `The "${COMMENTS_CONTRACT}" contract must be registered under the collection name "comments"`,
496
+ 400
497
+ )
498
+ }
499
+ }
500
+ }
501
+
448
502
  function normalizeEntityTypeRequest(
449
503
  parsed: RegisterEntityTypeBody | RegisterEntityTypeRequest
450
504
  ): RegisterEntityTypeRequest {
505
+ validateExternallyWritableCollections(parsed.externally_writable_collections)
451
506
  const serveEndpoint = rewriteLoopbackWebhookUrl(parsed.serve_endpoint)
452
507
  return {
453
508
  name: parsed.name ?? ``,
@@ -465,6 +520,7 @@ function normalizeEntityTypeRequest(
465
520
  } as RegisterEntityTypeRequest[`default_dispatch_policy`])
466
521
  : undefined),
467
522
  permission_grants: parsed.permission_grants,
523
+ externally_writable_collections: parsed.externally_writable_collections,
468
524
  }
469
525
  }
470
526
 
@@ -29,5 +29,8 @@ export const globalRouter: GlobalRoutes = AutoRouter<
29
29
  })
30
30
 
31
31
  globalRouter.all(`/_electric/shared-state/*`, durableStreamsRouter.fetch)
32
+ globalRouter.all(`/_electric/pg-sync/register`, internalRouter.fetch)
33
+ globalRouter.get(`/_electric/pg-sync/*`, durableStreamsRouter.fetch)
34
+ globalRouter.head(`/_electric/pg-sync/*`, durableStreamsRouter.fetch)
32
35
  globalRouter.all(`/_electric/*`, internalRouter.fetch)
33
36
  globalRouter.all(`*`, durableStreamsRouter.fetch)
@@ -1,6 +1,7 @@
1
1
  import { SpanKind, SpanStatusCode } from '@opentelemetry/api'
2
2
  import { apiError } from '../electric-agents-http.js'
3
3
  import { ElectricAgentsError } from '../entity-manager.js'
4
+ import { ElectricProxyError } from '../utils/server-utils.js'
4
5
  import { ELECTRIC_PRINCIPAL_HEADER } from '../principal.js'
5
6
  import { ATTR, extractTraceContext, tracer } from '../tracing.js'
6
7
  import { serverLog } from '../utils/log.js'
@@ -112,6 +113,12 @@ export function errorMapper(err: unknown, req: IRequest): Response {
112
113
  if (err instanceof ElectricAgentsError) {
113
114
  return apiError(err.status, err.code, err.message, err.details)
114
115
  }
116
+ if (err instanceof ElectricProxyError) {
117
+ serverLog.warn(
118
+ `[agent-server] Electric proxy rejected request (${err.code}): ${req.url}`
119
+ )
120
+ return apiError(err.status, err.code, err.message)
121
+ }
115
122
  serverLog.error(`[agent-server] Unhandled error:`, err)
116
123
  return apiError(500, `INTERNAL_SERVER_ERROR`, `Internal server error`)
117
124
  }
@@ -30,6 +30,7 @@ import { resolveDurableStreamsRoutingAdapter } from './durable-streams-routing-a
30
30
  import { electricProxyRouter } from './electric-proxy-router.js'
31
31
  import { entitiesRouter } from './entities-router.js'
32
32
  import { entityTypesRouter } from './entity-types-router.js'
33
+ import { pgSyncRouter } from './pg-sync-router.js'
33
34
  import { getRequestSpan } from './hooks.js'
34
35
  import { observationsRouter } from './observations-router.js'
35
36
  import { runnersRouter } from './runners-router.js'
@@ -135,6 +136,7 @@ internalRouter.all(`/runners`, runnersRouter.fetch)
135
136
  internalRouter.all(`/runners/*`, runnersRouter.fetch)
136
137
  internalRouter.all(`/entities/*`, entitiesRouter.fetch)
137
138
  internalRouter.all(`/entity-types/*`, entityTypesRouter.fetch)
139
+ internalRouter.all(`/pg-sync/*`, pgSyncRouter.fetch)
138
140
  internalRouter.all(`/observations/*`, observationsRouter.fetch)
139
141
  internalRouter.get(`/electric/*`, electricProxyRouter.fetch)
140
142
  internalRouter.all(`*`, () => status(404))
@@ -0,0 +1,113 @@
1
+ /**
2
+ * HTTP routes for pg-sync observation source registration.
3
+ */
4
+
5
+ import type {
6
+ PgSyncOptions,
7
+ PgSyncRequestMetadata,
8
+ } from '@electric-ax/agents-runtime'
9
+ import { Type, type Static } from '@sinclair/typebox'
10
+ import { Router, json } from 'itty-router'
11
+ import { apiError } from '../electric-agents-http.js'
12
+ import { ErrCodeInvalidRequest } from '../electric-agents-types.js'
13
+ import { routeBody, withSchema } from './schema.js'
14
+ import type { JsonRouteRequest } from './schema.js'
15
+ import type { RouterType } from 'itty-router'
16
+ import type { TenantContext } from './context.js'
17
+
18
+ const pgSyncOptionsSchema = Type.Object({
19
+ url: Type.Optional(Type.String()),
20
+ table: Type.String(),
21
+ columns: Type.Optional(Type.Array(Type.String())),
22
+ where: Type.Optional(Type.String()),
23
+ params: Type.Optional(
24
+ Type.Union([
25
+ Type.Array(Type.String()),
26
+ Type.Record(Type.String(), Type.String()),
27
+ ])
28
+ ),
29
+ replica: Type.Optional(
30
+ Type.Union([Type.Literal(`default`), Type.Literal(`full`)])
31
+ ),
32
+ })
33
+
34
+ const pgSyncRequestMetadataSchema = Type.Object({
35
+ entityUrl: Type.Optional(Type.String()),
36
+ entityType: Type.Optional(Type.String()),
37
+ streamPath: Type.Optional(Type.String()),
38
+ runtimeConsumerId: Type.Optional(Type.String()),
39
+ wakeId: Type.Optional(Type.String()),
40
+ })
41
+
42
+ const pgSyncRegisterBodySchema = Type.Object({
43
+ options: pgSyncOptionsSchema,
44
+ metadata: Type.Optional(pgSyncRequestMetadataSchema),
45
+ })
46
+
47
+ type PgSyncRegisterBody = Static<typeof pgSyncRegisterBodySchema>
48
+
49
+ export type PgSyncRoutes = RouterType<
50
+ JsonRouteRequest,
51
+ [TenantContext],
52
+ Response | undefined
53
+ >
54
+
55
+ export const pgSyncRouter: PgSyncRoutes = Router<
56
+ JsonRouteRequest,
57
+ [TenantContext],
58
+ Response | undefined
59
+ >({
60
+ base: `/_electric/pg-sync`,
61
+ })
62
+
63
+ pgSyncRouter.post(
64
+ `/register`,
65
+ withSchema(pgSyncRegisterBodySchema),
66
+ registerPgSync
67
+ )
68
+
69
+ async function registerPgSync(
70
+ request: JsonRouteRequest,
71
+ ctx: TenantContext
72
+ ): Promise<Response> {
73
+ const { options, metadata } = routeBody<PgSyncRegisterBody>(request)
74
+
75
+ if (options.table.trim() === ``) {
76
+ return apiError(
77
+ 400,
78
+ ErrCodeInvalidRequest,
79
+ `pgSync table must be non-empty`
80
+ )
81
+ }
82
+
83
+ if (!ctx.pgSyncBridgeManager) {
84
+ return apiError(
85
+ 503,
86
+ ErrCodeInvalidRequest,
87
+ `pgSync bridge manager is not configured`
88
+ )
89
+ }
90
+
91
+ try {
92
+ const requestMetadata: PgSyncRequestMetadata = {
93
+ tenantId: ctx.service,
94
+ principalKind: ctx.principal.kind,
95
+ principalId: ctx.principal.id,
96
+ principalKey: ctx.principal.key,
97
+ principalUrl: ctx.principal.url,
98
+ ...(metadata ?? {}),
99
+ }
100
+ const result = await ctx.pgSyncBridgeManager.register(
101
+ options as PgSyncOptions,
102
+ requestMetadata
103
+ )
104
+
105
+ return json(result)
106
+ } catch (error) {
107
+ return apiError(
108
+ 500,
109
+ ErrCodeInvalidRequest,
110
+ `pgSync registration failed: ${error instanceof Error ? error.message : String(error)}`
111
+ )
112
+ }
113
+ }
package/src/runtime.ts CHANGED
@@ -14,7 +14,12 @@ import { isPermanentElectricAgentsError } from './scheduler.js'
14
14
  import { StreamClient } from './stream-client.js'
15
15
  import { DEFAULT_TENANT_ID } from './tenant.js'
16
16
  import type { DrizzleDB } from './db/index.js'
17
+ import { PgSyncBridgeManager } from './pg-sync-bridge-manager.js'
17
18
  import type { EntityBridgeCoordinator } from './entity-bridge-manager.js'
19
+ import type {
20
+ PgSyncBridgeCoordinator,
21
+ PgSyncBridgeManagerOptions,
22
+ } from './pg-sync-bridge-manager.js'
18
23
  import type { DurableStreamsBearerProvider } from './stream-client.js'
19
24
  import type {
20
25
  CronTickPayload,
@@ -40,6 +45,8 @@ export interface ElectricAgentsTenantRuntimeOptions {
40
45
  wakeRegistry: WakeRegistry
41
46
  scheduler: SchedulerClient
42
47
  entityBridgeManager: EntityBridgeCoordinator
48
+ pgSyncBridgeManager?: PgSyncBridgeCoordinator
49
+ pgSync?: PgSyncBridgeManagerOptions
43
50
  claimWriteTokens?: ClaimWriteTokenStore
44
51
  stopWakeRegistryOnShutdown?: boolean
45
52
  }
@@ -53,6 +60,7 @@ export class ElectricAgentsTenantRuntime {
53
60
  readonly wakeRegistry: WakeRegistry
54
61
  readonly scheduler: SchedulerClient
55
62
  readonly entityBridgeManager: EntityBridgeCoordinator
63
+ readonly pgSyncBridgeManager: PgSyncBridgeCoordinator
56
64
  readonly claimWriteTokens: ClaimWriteTokenStore
57
65
  readonly manager: EntityManager
58
66
 
@@ -92,10 +100,21 @@ export class ElectricAgentsTenantRuntime {
92
100
  ),
93
101
  stopWakeRegistryOnShutdown: options.stopWakeRegistryOnShutdown ?? false,
94
102
  })
103
+ this.pgSyncBridgeManager =
104
+ options.pgSyncBridgeManager ??
105
+ new PgSyncBridgeManager(
106
+ this.streamClient,
107
+ (sourceUrl, event) => this.manager.evaluateWakes(sourceUrl, event),
108
+ this.registry,
109
+ options.pgSync
110
+ )
95
111
  }
96
112
 
97
113
  async stop(): Promise<void> {
98
- await this.manager.shutdown()
114
+ await Promise.all([
115
+ this.manager.shutdown(),
116
+ this.pgSyncBridgeManager.stop(),
117
+ ])
99
118
  }
100
119
 
101
120
  async rehydrateCronSchedules(): Promise<void> {
package/src/scheduler.ts CHANGED
@@ -228,6 +228,14 @@ export function isPermanentElectricAgentsError(err: unknown): boolean {
228
228
  )
229
229
  }
230
230
 
231
+ function cronTaskStreamPath(
232
+ payload: DelayedSendPayload | CronTickPayload
233
+ ): string | null {
234
+ return typeof (payload as { streamPath?: unknown }).streamPath === `string`
235
+ ? (payload as { streamPath: string }).streamPath
236
+ : null
237
+ }
238
+
231
239
  function normalizeTask(row: ScheduledTaskRow): {
232
240
  id: number
233
241
  tenantId: string
@@ -680,6 +688,24 @@ export class Scheduler implements SchedulerClient {
680
688
  task.fireAt
681
689
  )
682
690
 
691
+ const streamPath = cronTaskStreamPath(task.payload)
692
+ const subscriberRows = streamPath
693
+ ? await sql<Array<{ exists: number }>>`
694
+ select 1 as exists
695
+ from wake_registrations
696
+ where tenant_id = ${tenantId}
697
+ and source_url = ${streamPath}
698
+ limit 1
699
+ `
700
+ : []
701
+
702
+ // Cron streams are virtual shared sources. If no wake registrations
703
+ // still point at this cron stream (e.g. the owning manifest schedule was
704
+ // deleted), stop the chain here instead of keeping a forever-global tick
705
+ // alive. Rehydration/getOrCreateCronStream will seed a fresh tick when a
706
+ // subscription is recreated.
707
+ if (subscriberRows.length === 0) return
708
+
683
709
  await sql`
684
710
  insert into scheduled_tasks (
685
711
  tenant_id,
package/src/server.ts CHANGED
@@ -36,6 +36,7 @@ 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
38
  import type { EventSourceCatalog } from './routing/context.js'
39
+ import type { PgSyncBridgeManagerOptions } from './pg-sync-bridge-manager.js'
39
40
  import type { StartedStandaloneAgentsRuntime } from './standalone-runtime.js'
40
41
  import type { DurableStreamsBearerProvider } from './stream-client.js'
41
42
  import type {
@@ -72,6 +73,7 @@ export interface ElectricAgentsServerOptions {
72
73
  allowDevPrincipalFallback?: boolean
73
74
  eventSources?: EventSourceCatalog
74
75
  ensureEventSourceWakeSource?: (sourceUrl: string) => Promise<void> | void
76
+ pgSync?: PgSyncBridgeManagerOptions
75
77
  /**
76
78
  * Disabled by default. When set to a positive interval, periodically
77
79
  * recovers expired dispatch claims and stale outstanding wakes.
@@ -242,6 +244,7 @@ export class ElectricAgentsServer {
242
244
  streamClient: this.streamClient,
243
245
  electricUrl: this.options.electricUrl,
244
246
  electricSecret: this.options.electricSecret,
247
+ pgSync: this.options.pgSync,
245
248
  })
246
249
  this.electricAgentsManager = this.standaloneRuntime.manager
247
250
  this.entityBridgeManager = this.standaloneRuntime.entityBridgeManager
@@ -446,6 +449,7 @@ export class ElectricAgentsServer {
446
449
  streamClient: this.streamClient,
447
450
  runtime: this.standaloneRuntime.runtime,
448
451
  entityBridgeManager: this.entityBridgeManager,
452
+ pgSyncBridgeManager: this.standaloneRuntime.runtime.pgSyncBridgeManager,
449
453
  ...(this.options.eventSources
450
454
  ? { eventSources: this.options.eventSources }
451
455
  : {}),
@@ -11,6 +11,10 @@ import { WakeRegistry } from './wake-registry.js'
11
11
  import type { DrizzleDB, PgClient } from './db/index.js'
12
12
  import type { EntityManager } from './entity-manager.js'
13
13
  import type { EntityBridgeCoordinator } from './entity-bridge-manager.js'
14
+ import type {
15
+ PgSyncBridgeCoordinator,
16
+ PgSyncBridgeManagerOptions,
17
+ } from './pg-sync-bridge-manager.js'
14
18
  import type { CronTickPayload, DelayedSendPayload } from './scheduler.js'
15
19
  import type { DurableStreamsBearerProvider } from './stream-client.js'
16
20
 
@@ -30,8 +34,11 @@ export interface StandaloneAgentsRuntimeOptions {
30
34
  startScheduler?: boolean
31
35
  startTagStreamOutboxDrainer?: boolean
32
36
  startEntityBridgeManager?: boolean
37
+ startPgSyncBridgeManager?: boolean
33
38
  rehydrateOnStart?: boolean
34
39
  entityBridgeManager?: EntityBridgeCoordinator
40
+ pgSyncBridgeManager?: PgSyncBridgeCoordinator
41
+ pgSync?: PgSyncBridgeManagerOptions
35
42
  }
36
43
 
37
44
  export interface StartedStandaloneAgentsRuntime {
@@ -46,6 +53,7 @@ export interface StartedStandaloneAgentsRuntime {
46
53
  manager: EntityManager
47
54
  scheduler: Scheduler
48
55
  entityBridgeManager: EntityBridgeCoordinator
56
+ pgSyncBridgeManager: PgSyncBridgeCoordinator
49
57
  tagStreamOutboxDrainer: TagStreamOutboxDrainer
50
58
  stop: () => Promise<void>
51
59
  }
@@ -104,6 +112,8 @@ export async function startStandaloneAgentsRuntime(
104
112
  wakeRegistry,
105
113
  scheduler,
106
114
  entityBridgeManager,
115
+ pgSyncBridgeManager: options.pgSyncBridgeManager,
116
+ pgSync: options.pgSync,
107
117
  stopWakeRegistryOnShutdown: options.wakeRegistry ? false : true,
108
118
  })
109
119
 
@@ -112,6 +122,7 @@ export async function startStandaloneAgentsRuntime(
112
122
  const startTagStreamOutboxDrainer =
113
123
  options.startTagStreamOutboxDrainer ?? true
114
124
  const startEntityBridgeManager = options.startEntityBridgeManager ?? true
125
+ const startPgSyncBridgeManager = options.startPgSyncBridgeManager ?? true
115
126
  const rehydrateOnStart = options.rehydrateOnStart ?? true
116
127
  let entityBridgeManagerStarted = false
117
128
  let tagStreamOutboxDrainerStarted = false
@@ -153,6 +164,10 @@ export async function startStandaloneAgentsRuntime(
153
164
  await entityBridgeManager.start()
154
165
  entityBridgeManagerStarted = true
155
166
  }
167
+ if (startPgSyncBridgeManager) {
168
+ serverLog.info(`[agent-server] starting pg-sync bridge manager...`)
169
+ await runtime.pgSyncBridgeManager.start?.()
170
+ }
156
171
  if (startTagStreamOutboxDrainer) {
157
172
  serverLog.info(`[agent-server] starting tag stream outbox drainer...`)
158
173
  tagStreamOutboxDrainer.start()
@@ -181,6 +196,7 @@ export async function startStandaloneAgentsRuntime(
181
196
  manager: runtime.manager,
182
197
  scheduler,
183
198
  entityBridgeManager,
199
+ pgSyncBridgeManager: runtime.pgSyncBridgeManager,
184
200
  tagStreamOutboxDrainer,
185
201
  stop,
186
202
  }