@electric-ax/agents-server 0.4.19 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/entrypoint.js +692 -45
- package/dist/index.cjs +678 -41
- package/dist/index.d.cts +2519 -2216
- package/dist/index.d.ts +2518 -2217
- package/dist/index.js +679 -42
- package/drizzle/0015_pg_sync_bridges.sql +14 -0
- package/drizzle/0016_entity_type_externally_writable_collections.sql +1 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +6 -6
- package/src/db/schema.ts +32 -0
- package/src/electric-agents-types.ts +23 -0
- package/src/entity-manager.ts +160 -29
- package/src/entity-registry.ts +158 -3
- package/src/manifest-side-effects.ts +10 -0
- package/src/pg-sync-bridge-manager.ts +552 -0
- package/src/routing/context.ts +2 -0
- package/src/routing/entities-router.ts +89 -18
- package/src/routing/entity-types-router.ts +56 -0
- package/src/routing/global-router.ts +3 -0
- package/src/routing/hooks.ts +7 -0
- package/src/routing/internal-router.ts +2 -0
- package/src/routing/pg-sync-router.ts +113 -0
- package/src/runtime.ts +20 -1
- package/src/scheduler.ts +26 -0
- package/src/server.ts +4 -0
- package/src/standalone-runtime.ts +16 -0
- package/src/utils/server-utils.ts +97 -1
- package/src/wake-registry.ts +27 -2
|
@@ -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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
1293
|
-
await ctx.entityManager.send(entityUrl, sendReq)
|
|
1325
|
+
return status(204)
|
|
1294
1326
|
}
|
|
1295
1327
|
|
|
1296
|
-
|
|
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
|
|
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
|
|
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(
|
|
1465
|
-
|
|
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)
|
package/src/routing/hooks.ts
CHANGED
|
@@ -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
|
|
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
|
}
|