@electric-ax/agents-server 0.3.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.
Files changed (68) hide show
  1. package/LICENSE +177 -0
  2. package/dist/chunk-Cl8Af3a2.js +11 -0
  3. package/dist/entrypoint.js +7319 -0
  4. package/dist/index.cjs +7090 -0
  5. package/dist/index.d.cts +4262 -0
  6. package/dist/index.d.ts +4263 -0
  7. package/dist/index.js +7053 -0
  8. package/drizzle/0000_baseline.sql +97 -0
  9. package/drizzle/0001_entity_tags_and_bridges.sql +45 -0
  10. package/drizzle/0002_tag_outbox_hardening.sql +14 -0
  11. package/drizzle/0003_entity_manifest_sources.sql +11 -0
  12. package/drizzle/0004_tenant_scoping.sql +139 -0
  13. package/drizzle/0005_pull_wake_control_plane.sql +156 -0
  14. package/drizzle/meta/0000_snapshot.json +593 -0
  15. package/drizzle/meta/_journal.json +48 -0
  16. package/package.json +89 -0
  17. package/src/authenticated-user-format.ts +17 -0
  18. package/src/claim-write-token-store.ts +74 -0
  19. package/src/db/index.ts +53 -0
  20. package/src/db/schema.ts +490 -0
  21. package/src/dev-asserted-auth.ts +46 -0
  22. package/src/dispatch-policy-schema.ts +52 -0
  23. package/src/electric-agents/adapter-types.ts +70 -0
  24. package/src/electric-agents/default-entity-schemas.ts +1 -0
  25. package/src/electric-agents/schema-validator.ts +143 -0
  26. package/src/electric-agents-http.ts +46 -0
  27. package/src/electric-agents-types.ts +335 -0
  28. package/src/entity-bridge-manager.ts +694 -0
  29. package/src/entity-manager.ts +2601 -0
  30. package/src/entity-projector.ts +765 -0
  31. package/src/entity-registry.ts +1162 -0
  32. package/src/entrypoint-lib.ts +295 -0
  33. package/src/entrypoint.ts +11 -0
  34. package/src/host.ts +323 -0
  35. package/src/index.ts +49 -0
  36. package/src/manifest-side-effects.ts +183 -0
  37. package/src/routing/agent-ui-router.ts +81 -0
  38. package/src/routing/context.ts +35 -0
  39. package/src/routing/cron-router.ts +45 -0
  40. package/src/routing/dispatch-policy.ts +248 -0
  41. package/src/routing/durable-streams-router.ts +407 -0
  42. package/src/routing/durable-streams-routing-adapter.ts +96 -0
  43. package/src/routing/electric-proxy-router.ts +61 -0
  44. package/src/routing/entities-router.ts +484 -0
  45. package/src/routing/entity-types-router.ts +229 -0
  46. package/src/routing/global-router.ts +33 -0
  47. package/src/routing/hooks.ts +123 -0
  48. package/src/routing/internal-router.ts +741 -0
  49. package/src/routing/oss-server-router.ts +56 -0
  50. package/src/routing/runners-router.ts +416 -0
  51. package/src/routing/schema.ts +141 -0
  52. package/src/routing/stream-append.ts +196 -0
  53. package/src/routing/tenant-stream-paths.ts +26 -0
  54. package/src/runtime-registry.ts +49 -0
  55. package/src/runtime.ts +537 -0
  56. package/src/scheduler.ts +788 -0
  57. package/src/schema-validation.ts +15 -0
  58. package/src/server.ts +374 -0
  59. package/src/standalone-runtime.ts +188 -0
  60. package/src/stream-client.ts +842 -0
  61. package/src/tag-stream-outbox-drainer.ts +188 -0
  62. package/src/tenant.ts +25 -0
  63. package/src/tracing.ts +57 -0
  64. package/src/utils/electric-url.ts +15 -0
  65. package/src/utils/log.ts +95 -0
  66. package/src/utils/server-utils.ts +245 -0
  67. package/src/utils/webhook-url.ts +33 -0
  68. package/src/wake-registry.ts +946 -0
@@ -0,0 +1,484 @@
1
+ /**
2
+ * HTTP routes for Electric Agents entity management.
3
+ */
4
+
5
+ import { Type, type Static } from '@sinclair/typebox'
6
+ import { Router, json, status } from 'itty-router'
7
+ import { apiError } from '../electric-agents-http.js'
8
+ import { dispatchPolicySchema } from '../dispatch-policy-schema.js'
9
+ import {
10
+ ErrCodeNotFound,
11
+ ErrCodeUnknownEntityType,
12
+ toPublicEntity,
13
+ } from '../electric-agents-types.js'
14
+ import {
15
+ assertDispatchPolicyAllowed,
16
+ backfillEntityDispatchPolicy,
17
+ linkEntityDispatchSubscription,
18
+ resolveEffectiveDispatchPolicyForSpawn,
19
+ unlinkEntityDispatchSubscription,
20
+ } from './dispatch-policy.js'
21
+ import { routeBody, withSchema } from './schema.js'
22
+ import type { ElectricAgentsEntity } from '../electric-agents-types.js'
23
+ import type { JsonRouteRequest } from './schema.js'
24
+ import type { RouterType } from 'itty-router'
25
+ import type { TenantContext } from './context.js'
26
+
27
+ interface AgentsRouteRequest extends JsonRouteRequest {
28
+ entityRoute?: ExistingEntityRoute
29
+ }
30
+
31
+ type ExistingEntityRoute = { entityUrl: string; entity: ElectricAgentsEntity }
32
+ type AgentsRouteArgs = [TenantContext]
33
+ type AgentsRouteResult = Response | undefined
34
+
35
+ export type EntitiesRoutes = RouterType<
36
+ AgentsRouteRequest,
37
+ AgentsRouteArgs,
38
+ AgentsRouteResult
39
+ >
40
+
41
+ const stringRecordSchema = Type.Record(Type.String(), Type.String())
42
+
43
+ function writeTokenFromRequest(request: AgentsRouteRequest): string {
44
+ const electricClaimToken = request.headers.get(`electric-claim-token`)?.trim()
45
+ if (electricClaimToken) return electricClaimToken
46
+ return (
47
+ request.headers
48
+ .get(`authorization`)
49
+ ?.replace(/^Bearer\s+/i, ``)
50
+ .trim() ?? ``
51
+ )
52
+ }
53
+
54
+ const wakeConditionSchema = Type.Union([
55
+ Type.Literal(`runFinished`),
56
+ Type.Object({
57
+ on: Type.Literal(`change`),
58
+ collections: Type.Optional(Type.Array(Type.String())),
59
+ ops: Type.Optional(
60
+ Type.Array(
61
+ Type.Union([
62
+ Type.Literal(`insert`),
63
+ Type.Literal(`update`),
64
+ Type.Literal(`delete`),
65
+ ])
66
+ )
67
+ ),
68
+ }),
69
+ ])
70
+
71
+ const spawnBodySchema = Type.Object({
72
+ args: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
73
+ tags: Type.Optional(stringRecordSchema),
74
+ parent: Type.Optional(Type.String()),
75
+ dispatch_policy: Type.Optional(dispatchPolicySchema),
76
+ initialMessage: Type.Optional(Type.Unknown()),
77
+ wake: Type.Optional(
78
+ Type.Object({
79
+ subscriberUrl: Type.String(),
80
+ condition: wakeConditionSchema,
81
+ debounceMs: Type.Optional(Type.Number()),
82
+ timeoutMs: Type.Optional(Type.Number()),
83
+ includeResponse: Type.Optional(Type.Boolean()),
84
+ })
85
+ ),
86
+ })
87
+
88
+ const sendBodySchema = Type.Object({
89
+ from: Type.Optional(Type.String()),
90
+ payload: Type.Optional(Type.Unknown()),
91
+ key: Type.Optional(Type.String()),
92
+ type: Type.Optional(Type.String()),
93
+ afterMs: Type.Optional(Type.Number()),
94
+ })
95
+
96
+ const forkBodySchema = Type.Object({
97
+ instance_id: Type.Optional(Type.String()),
98
+ waitTimeoutMs: Type.Optional(Type.Number()),
99
+ })
100
+
101
+ const setTagBodySchema = Type.Object({
102
+ value: Type.String(),
103
+ })
104
+
105
+ const scheduleBodySchema = Type.Union([
106
+ Type.Object({
107
+ scheduleType: Type.Literal(`cron`),
108
+ expression: Type.String(),
109
+ timezone: Type.Optional(Type.String()),
110
+ payload: Type.Unknown(),
111
+ debounceMs: Type.Optional(Type.Number()),
112
+ timeoutMs: Type.Optional(Type.Number()),
113
+ }),
114
+ Type.Object({
115
+ scheduleType: Type.Literal(`future_send`),
116
+ payload: Type.Unknown(),
117
+ targetUrl: Type.Optional(Type.String()),
118
+ fireAt: Type.String(),
119
+ from: Type.Optional(Type.String()),
120
+ messageType: Type.Optional(Type.String()),
121
+ }),
122
+ ])
123
+
124
+ const entitiesRegisterBodySchema = Type.Object({
125
+ tags: Type.Optional(stringRecordSchema),
126
+ })
127
+
128
+ type SpawnBody = Static<typeof spawnBodySchema>
129
+ type SendBody = Static<typeof sendBodySchema>
130
+ type ForkBody = Static<typeof forkBodySchema>
131
+ type SetTagBody = Static<typeof setTagBodySchema>
132
+ type ScheduleBody = Static<typeof scheduleBodySchema>
133
+ type EntitiesRegisterBody = Static<typeof entitiesRegisterBodySchema>
134
+
135
+ export const entitiesRouter: EntitiesRoutes = Router<
136
+ AgentsRouteRequest,
137
+ AgentsRouteArgs,
138
+ AgentsRouteResult
139
+ >({
140
+ base: `/_electric/entities`,
141
+ })
142
+
143
+ entitiesRouter.get(`/`, listEntities)
144
+ entitiesRouter.post(
145
+ `/register`,
146
+ withSchema(entitiesRegisterBodySchema),
147
+ registerEntitiesSource
148
+ )
149
+ entitiesRouter.put(
150
+ `/:type/:instanceId`,
151
+ withSpawnableEntityType,
152
+ withSchema(spawnBodySchema),
153
+ spawnEntity
154
+ )
155
+ entitiesRouter.get(`/:type/:instanceId`, withExistingEntity, getEntity)
156
+ entitiesRouter.head(`/:type/:instanceId`, withExistingEntity, headEntity)
157
+ entitiesRouter.delete(`/:type/:instanceId`, withExistingEntity, killEntity)
158
+ entitiesRouter.post(
159
+ `/:type/:instanceId/send`,
160
+ withExistingEntity,
161
+ withSchema(sendBodySchema),
162
+ sendEntity
163
+ )
164
+ entitiesRouter.post(
165
+ `/:type/:instanceId/fork`,
166
+ withExistingEntity,
167
+ withSchema(forkBodySchema),
168
+ forkEntity
169
+ )
170
+ entitiesRouter.post(
171
+ `/:type/:instanceId/tags/:tagKey`,
172
+ withExistingEntity,
173
+ withSchema(setTagBodySchema),
174
+ setTag
175
+ )
176
+ entitiesRouter.delete(
177
+ `/:type/:instanceId/tags/:tagKey`,
178
+ withExistingEntity,
179
+ removeTag
180
+ )
181
+ entitiesRouter.put(
182
+ `/:type/:instanceId/schedules/:scheduleId`,
183
+ withExistingEntity,
184
+ withSchema(scheduleBodySchema),
185
+ upsertSchedule
186
+ )
187
+ entitiesRouter.delete(
188
+ `/:type/:instanceId/schedules/:scheduleId`,
189
+ withExistingEntity,
190
+ deleteSchedule
191
+ )
192
+
193
+ function entityUrlFromSegments(
194
+ type: string,
195
+ instanceId: string
196
+ ): string | null {
197
+ if (!type || !instanceId) return null
198
+ if (type.startsWith(`_`) || type.includes(`*`) || instanceId.includes(`*`)) {
199
+ return null
200
+ }
201
+ return `/${type}/${instanceId}`
202
+ }
203
+
204
+ function firstQueryValue(
205
+ value: string | Array<string> | undefined
206
+ ): string | undefined {
207
+ return Array.isArray(value) ? value[0] : value
208
+ }
209
+
210
+ function requireExistingEntityRoute(
211
+ request: AgentsRouteRequest
212
+ ): ExistingEntityRoute {
213
+ if (!request.entityRoute) {
214
+ throw new Error(`existing entity middleware did not run`)
215
+ }
216
+ return request.entityRoute
217
+ }
218
+
219
+ async function withExistingEntity(
220
+ request: AgentsRouteRequest,
221
+ ctx: TenantContext
222
+ ): Promise<AgentsRouteResult> {
223
+ const entityUrl = entityUrlFromSegments(
224
+ request.params.type,
225
+ request.params.instanceId
226
+ )
227
+ if (!entityUrl) return undefined
228
+
229
+ const entity = await ctx.entityManager.registry.getEntity(entityUrl)
230
+ if (!entity) {
231
+ const entityType = await ctx.entityManager.registry.getEntityType(
232
+ request.params.type
233
+ )
234
+ if (entityType) {
235
+ return apiError(404, ErrCodeNotFound, `Entity not found at ${entityUrl}`)
236
+ }
237
+ return apiError(
238
+ 404,
239
+ ErrCodeUnknownEntityType,
240
+ `Entity type "${request.params.type}" not found`
241
+ )
242
+ }
243
+
244
+ request.entityRoute = { entityUrl, entity }
245
+ return undefined
246
+ }
247
+
248
+ async function withSpawnableEntityType(
249
+ request: AgentsRouteRequest,
250
+ ctx: TenantContext
251
+ ): Promise<AgentsRouteResult> {
252
+ if (!entityUrlFromSegments(request.params.type, request.params.instanceId)) {
253
+ return undefined
254
+ }
255
+
256
+ const entityType = await ctx.entityManager.registry.getEntityType(
257
+ request.params.type
258
+ )
259
+ if (!entityType) {
260
+ return apiError(
261
+ 404,
262
+ ErrCodeUnknownEntityType,
263
+ `Entity type "${request.params.type}" not found`
264
+ )
265
+ }
266
+
267
+ return undefined
268
+ }
269
+
270
+ async function listEntities(
271
+ { query }: AgentsRouteRequest,
272
+ ctx: TenantContext
273
+ ): Promise<Response> {
274
+ const { entities } = await ctx.entityManager.registry.listEntities({
275
+ type: firstQueryValue(query.type),
276
+ status: firstQueryValue(query.status),
277
+ parent: firstQueryValue(query.parent),
278
+ })
279
+ return json(entities.map((entity) => toPublicEntity(entity)))
280
+ }
281
+
282
+ async function registerEntitiesSource(
283
+ request: AgentsRouteRequest,
284
+ ctx: TenantContext
285
+ ): Promise<Response> {
286
+ const parsed = routeBody<EntitiesRegisterBody>(request)
287
+ const result = await ctx.entityManager.registerEntitiesSource(
288
+ parsed.tags ?? {}
289
+ )
290
+ return json(result)
291
+ }
292
+
293
+ async function upsertSchedule(
294
+ request: AgentsRouteRequest,
295
+ ctx: TenantContext
296
+ ): Promise<Response> {
297
+ const parsed = routeBody<ScheduleBody>(request)
298
+ const { entityUrl } = requireExistingEntityRoute(request)
299
+ const scheduleId = decodeURIComponent(request.params.scheduleId)
300
+
301
+ if (parsed.scheduleType === `cron`) {
302
+ const result = await ctx.entityManager.upsertCronSchedule(entityUrl, {
303
+ id: scheduleId,
304
+ expression: parsed.expression,
305
+ timezone: parsed.timezone,
306
+ payload: parsed.payload,
307
+ debounceMs: parsed.debounceMs,
308
+ timeoutMs: parsed.timeoutMs,
309
+ })
310
+ return json(result)
311
+ }
312
+
313
+ if (parsed.scheduleType === `future_send`) {
314
+ const result = await ctx.entityManager.upsertFutureSendSchedule(entityUrl, {
315
+ id: scheduleId,
316
+ payload: parsed.payload,
317
+ targetUrl: parsed.targetUrl,
318
+ fireAt: parsed.fireAt,
319
+ from: parsed.from,
320
+ messageType: parsed.messageType,
321
+ })
322
+ return json(result)
323
+ }
324
+
325
+ throw new Error(`schedule schema accepted an unknown scheduleType`)
326
+ }
327
+
328
+ async function deleteSchedule(
329
+ request: AgentsRouteRequest,
330
+ ctx: TenantContext
331
+ ): Promise<Response> {
332
+ const { entityUrl } = requireExistingEntityRoute(request)
333
+ const result = await ctx.entityManager.deleteSchedule(entityUrl, {
334
+ id: decodeURIComponent(request.params.scheduleId),
335
+ })
336
+ return json(result)
337
+ }
338
+
339
+ async function setTag(
340
+ request: AgentsRouteRequest,
341
+ ctx: TenantContext
342
+ ): Promise<Response> {
343
+ const parsed = routeBody<SetTagBody>(request)
344
+ const { entityUrl } = requireExistingEntityRoute(request)
345
+ const token = writeTokenFromRequest(request)
346
+ const updated = await ctx.entityManager.setTag(
347
+ entityUrl,
348
+ decodeURIComponent(request.params.tagKey),
349
+ { value: parsed.value },
350
+ token
351
+ )
352
+ return json(toPublicEntity(updated))
353
+ }
354
+
355
+ async function removeTag(
356
+ request: AgentsRouteRequest,
357
+ ctx: TenantContext
358
+ ): Promise<Response> {
359
+ const { entityUrl } = requireExistingEntityRoute(request)
360
+ const token = writeTokenFromRequest(request)
361
+ const updated = await ctx.entityManager.removeTag(
362
+ entityUrl,
363
+ decodeURIComponent(request.params.tagKey),
364
+ token
365
+ )
366
+ return json(toPublicEntity(updated))
367
+ }
368
+
369
+ async function forkEntity(
370
+ request: AgentsRouteRequest,
371
+ ctx: TenantContext
372
+ ): Promise<Response> {
373
+ const parsed = routeBody<ForkBody>(request)
374
+ const { entityUrl, entity } = requireExistingEntityRoute(request)
375
+ await assertDispatchPolicyAllowed(ctx, entity.dispatch_policy)
376
+ const result = await ctx.entityManager.forkSubtree(entityUrl, {
377
+ rootInstanceId: parsed.instance_id,
378
+ waitTimeoutMs: parsed.waitTimeoutMs,
379
+ })
380
+ for (const forkedEntity of result.entities) {
381
+ await linkEntityDispatchSubscription(ctx, forkedEntity)
382
+ }
383
+ return json(
384
+ {
385
+ root: toPublicEntity(result.root),
386
+ entities: result.entities.map((entity) => toPublicEntity(entity)),
387
+ },
388
+ { status: 201 }
389
+ )
390
+ }
391
+
392
+ async function sendEntity(
393
+ request: AgentsRouteRequest,
394
+ ctx: TenantContext
395
+ ): Promise<Response> {
396
+ const parsed = routeBody<SendBody>(request)
397
+ const { entityUrl, entity } = requireExistingEntityRoute(request)
398
+
399
+ if (!entity.dispatch_policy) {
400
+ const updatedEntity = await backfillEntityDispatchPolicy(ctx, entity)
401
+ await linkEntityDispatchSubscription(ctx, updatedEntity)
402
+ }
403
+
404
+ if (parsed.afterMs && parsed.afterMs > 0) {
405
+ await ctx.entityManager.enqueueDelayedSend(
406
+ entityUrl,
407
+ {
408
+ from: parsed.from,
409
+ payload: parsed.payload,
410
+ key: parsed.key,
411
+ type: parsed.type,
412
+ },
413
+ new Date(Date.now() + parsed.afterMs)
414
+ )
415
+ } else {
416
+ await ctx.entityManager.send(entityUrl, {
417
+ from: parsed.from,
418
+ payload: parsed.payload,
419
+ key: parsed.key,
420
+ type: parsed.type,
421
+ })
422
+ }
423
+
424
+ return status(204)
425
+ }
426
+
427
+ async function spawnEntity(
428
+ request: AgentsRouteRequest,
429
+ ctx: TenantContext
430
+ ): Promise<Response> {
431
+ const parsed = routeBody<SpawnBody>(request)
432
+ const dispatchPolicy = await resolveEffectiveDispatchPolicyForSpawn(
433
+ ctx,
434
+ request.params.type,
435
+ {
436
+ dispatchPolicy: parsed.dispatch_policy,
437
+ parent: parsed.parent,
438
+ }
439
+ )
440
+ await assertDispatchPolicyAllowed(ctx, dispatchPolicy)
441
+ const entity = await ctx.entityManager.spawn(request.params.type, {
442
+ instance_id: request.params.instanceId,
443
+ args: parsed.args,
444
+ tags: parsed.tags,
445
+ parent: parsed.parent,
446
+ dispatch_policy: dispatchPolicy,
447
+ initialMessage: undefined,
448
+ wake: parsed.wake,
449
+ })
450
+ await linkEntityDispatchSubscription(ctx, entity)
451
+ if (parsed.initialMessage !== undefined) {
452
+ await ctx.entityManager.send(entity.url, {
453
+ from: parsed.parent ?? `spawn`,
454
+ payload: parsed.initialMessage,
455
+ })
456
+ }
457
+
458
+ return json(
459
+ { ...toPublicEntity(entity), txid: entity.txid },
460
+ {
461
+ status: 201,
462
+ headers: { 'x-write-token': entity.write_token },
463
+ }
464
+ )
465
+ }
466
+
467
+ function getEntity(request: AgentsRouteRequest): Response {
468
+ return json(toPublicEntity(requireExistingEntityRoute(request).entity))
469
+ }
470
+
471
+ function headEntity(): Response {
472
+ return status(200)
473
+ }
474
+
475
+ async function killEntity(
476
+ request: AgentsRouteRequest,
477
+ ctx: TenantContext
478
+ ): Promise<Response> {
479
+ const { entityUrl, entity } = requireExistingEntityRoute(request)
480
+ await unlinkEntityDispatchSubscription(ctx, entity)
481
+ const result = await ctx.entityManager.kill(entityUrl)
482
+ ctx.runtime.claimWriteTokens.clearStream(ctx.service, entity.streams.main)
483
+ return json(result)
484
+ }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * HTTP routes for Electric Agents entity type management.
3
+ */
4
+
5
+ import { Type, type Static } from '@sinclair/typebox'
6
+ import { Router, json, status } from 'itty-router'
7
+ import { dispatchPolicySchema } from '../dispatch-policy-schema.js'
8
+ import { ElectricAgentsError } from '../entity-manager.js'
9
+ import {
10
+ ErrCodeNotFound,
11
+ ErrCodeServeEndpointNameMismatch,
12
+ ErrCodeServeEndpointUnreachable,
13
+ } from '../electric-agents-types.js'
14
+ import { apiError } from '../electric-agents-http.js'
15
+ import { routeBody, withSchema } from './schema.js'
16
+ import { rewriteLoopbackWebhookUrl } from '../utils/webhook-url.js'
17
+ import type {
18
+ ElectricAgentsEntityType,
19
+ RegisterEntityTypeRequest,
20
+ } from '../electric-agents-types.js'
21
+ import type { JsonRouteRequest } from './schema.js'
22
+ import type { RouterType } from 'itty-router'
23
+ import type { TenantContext } from './context.js'
24
+
25
+ export interface ElectricAgentsEntityTypeRouteRequest
26
+ extends JsonRouteRequest {}
27
+
28
+ type EntityTypeRouteArgs = [TenantContext]
29
+ type EntityTypeRouteResult = Response | undefined
30
+
31
+ export type ElectricAgentsEntityTypeRoutes = RouterType<
32
+ ElectricAgentsEntityTypeRouteRequest,
33
+ EntityTypeRouteArgs,
34
+ EntityTypeRouteResult
35
+ >
36
+
37
+ type PublicEntityTypeResponse = ElectricAgentsEntityType & {
38
+ input_schemas?: Record<string, Record<string, unknown>>
39
+ output_schemas?: Record<string, Record<string, unknown>>
40
+ revision: number
41
+ }
42
+
43
+ const jsonObjectSchema = Type.Record(Type.String(), Type.Unknown())
44
+ const schemaMapSchema = Type.Record(Type.String(), jsonObjectSchema)
45
+
46
+ const registerEntityTypeBodySchema = Type.Object({
47
+ name: Type.Optional(Type.String()),
48
+ description: Type.Optional(Type.String()),
49
+ creation_schema: Type.Optional(jsonObjectSchema),
50
+ inbox_schemas: Type.Optional(schemaMapSchema),
51
+ state_schemas: Type.Optional(schemaMapSchema),
52
+ input_schemas: Type.Optional(schemaMapSchema),
53
+ output_schemas: Type.Optional(schemaMapSchema),
54
+ serve_endpoint: Type.Optional(Type.String()),
55
+ default_dispatch_policy: Type.Optional(dispatchPolicySchema),
56
+ })
57
+
58
+ const amendEntityTypeSchemasBodySchema = Type.Object({
59
+ input_schemas: Type.Optional(schemaMapSchema),
60
+ output_schemas: Type.Optional(schemaMapSchema),
61
+ inbox_schemas: Type.Optional(schemaMapSchema),
62
+ state_schemas: Type.Optional(schemaMapSchema),
63
+ })
64
+
65
+ type RegisterEntityTypeBody = Static<typeof registerEntityTypeBodySchema>
66
+ type AmendEntityTypeSchemasBody = Static<
67
+ typeof amendEntityTypeSchemasBodySchema
68
+ >
69
+
70
+ export const entityTypesRouter: ElectricAgentsEntityTypeRoutes = Router<
71
+ ElectricAgentsEntityTypeRouteRequest,
72
+ EntityTypeRouteArgs,
73
+ EntityTypeRouteResult
74
+ >({
75
+ base: `/_electric/entity-types`,
76
+ })
77
+
78
+ entityTypesRouter.get(`/`, listEntityTypes)
79
+ entityTypesRouter.post(
80
+ `/`,
81
+ withSchema(registerEntityTypeBodySchema),
82
+ registerEntityType
83
+ )
84
+ entityTypesRouter.patch(
85
+ `/:name/schemas`,
86
+ withSchema(amendEntityTypeSchemasBodySchema),
87
+ amendSchemas
88
+ )
89
+ entityTypesRouter.get(`/:name`, getEntityType)
90
+ entityTypesRouter.delete(`/:name`, deleteEntityType)
91
+
92
+ async function registerEntityType(
93
+ request: ElectricAgentsEntityTypeRouteRequest,
94
+ ctx: TenantContext
95
+ ): Promise<EntityTypeRouteResult> {
96
+ const parsed = routeBody<RegisterEntityTypeBody>(request)
97
+ const normalized = normalizeEntityTypeRequest(parsed)
98
+
99
+ if (
100
+ normalized.serve_endpoint &&
101
+ !normalized.description &&
102
+ !normalized.creation_schema
103
+ ) {
104
+ return await discoverServeEndpoint(ctx, normalized)
105
+ }
106
+
107
+ const entityType = await ctx.entityManager.registerEntityType(normalized)
108
+ return json(toPublicEntityType(entityType), { status: 201 })
109
+ }
110
+
111
+ async function listEntityTypes(
112
+ _request: ElectricAgentsEntityTypeRouteRequest,
113
+ ctx: TenantContext
114
+ ): Promise<EntityTypeRouteResult> {
115
+ const entityTypes = await ctx.entityManager.registry.listEntityTypes()
116
+ return json(entityTypes.map((entityType) => toPublicEntityType(entityType)))
117
+ }
118
+
119
+ async function discoverServeEndpoint(
120
+ ctx: TenantContext,
121
+ parsed: RegisterEntityTypeRequest
122
+ ): Promise<Response> {
123
+ try {
124
+ const response = await fetch(parsed.serve_endpoint!, { method: `PUT` })
125
+
126
+ if (!response.ok) {
127
+ return apiError(
128
+ 502,
129
+ ErrCodeServeEndpointUnreachable,
130
+ `Serve endpoint returned status ${response.status}`
131
+ )
132
+ }
133
+
134
+ const manifest = (await response.json()) as RegisterEntityTypeRequest
135
+ if (manifest.name !== parsed.name) {
136
+ return apiError(
137
+ 400,
138
+ ErrCodeServeEndpointNameMismatch,
139
+ `Serve endpoint returned name "${manifest.name}" but expected "${parsed.name}"`
140
+ )
141
+ }
142
+
143
+ manifest.serve_endpoint = parsed.serve_endpoint
144
+
145
+ const entityType = await ctx.entityManager.registerEntityType(
146
+ normalizeEntityTypeRequest(manifest)
147
+ )
148
+ return json(toPublicEntityType(entityType), { status: 201 })
149
+ } catch (err) {
150
+ if (err instanceof ElectricAgentsError) {
151
+ throw err
152
+ }
153
+ return apiError(
154
+ 502,
155
+ ErrCodeServeEndpointUnreachable,
156
+ `Failed to reach serve endpoint: ${
157
+ err instanceof Error ? err.message : String(err)
158
+ }`
159
+ )
160
+ }
161
+ }
162
+
163
+ async function getEntityType(
164
+ request: ElectricAgentsEntityTypeRouteRequest,
165
+ ctx: TenantContext
166
+ ): Promise<EntityTypeRouteResult> {
167
+ const entityType = await ctx.entityManager.registry.getEntityType(
168
+ request.params.name
169
+ )
170
+ if (!entityType) {
171
+ return apiError(404, ErrCodeNotFound, `Entity type not found`)
172
+ }
173
+
174
+ return json(toPublicEntityType(entityType))
175
+ }
176
+
177
+ async function amendSchemas(
178
+ request: ElectricAgentsEntityTypeRouteRequest,
179
+ ctx: TenantContext
180
+ ): Promise<EntityTypeRouteResult> {
181
+ const parsed = routeBody<AmendEntityTypeSchemasBody>(request)
182
+
183
+ const updated = await ctx.entityManager.amendSchemas(request.params.name, {
184
+ inbox_schemas: parsed.inbox_schemas ?? parsed.input_schemas,
185
+ state_schemas: parsed.state_schemas ?? parsed.output_schemas,
186
+ })
187
+ return json(toPublicEntityType(updated))
188
+ }
189
+
190
+ async function deleteEntityType(
191
+ request: ElectricAgentsEntityTypeRouteRequest,
192
+ ctx: TenantContext
193
+ ): Promise<EntityTypeRouteResult> {
194
+ await ctx.entityManager.deleteEntityType(request.params.name)
195
+ return status(204)
196
+ }
197
+
198
+ function normalizeEntityTypeRequest(
199
+ parsed: RegisterEntityTypeBody | RegisterEntityTypeRequest
200
+ ): RegisterEntityTypeRequest {
201
+ const serveEndpoint = rewriteLoopbackWebhookUrl(parsed.serve_endpoint)
202
+ const compatibilityFields = parsed as RegisterEntityTypeBody
203
+ return {
204
+ name: parsed.name ?? ``,
205
+ description: parsed.description ?? ``,
206
+ creation_schema: parsed.creation_schema,
207
+ inbox_schemas: parsed.inbox_schemas ?? compatibilityFields.input_schemas,
208
+ state_schemas: parsed.state_schemas ?? compatibilityFields.output_schemas,
209
+ serve_endpoint: serveEndpoint,
210
+ default_dispatch_policy:
211
+ parsed.default_dispatch_policy ??
212
+ (serveEndpoint
213
+ ? ({
214
+ targets: [{ type: `webhook`, url: serveEndpoint }],
215
+ } as RegisterEntityTypeRequest[`default_dispatch_policy`])
216
+ : undefined),
217
+ }
218
+ }
219
+
220
+ function toPublicEntityType(
221
+ entityType: ElectricAgentsEntityType
222
+ ): PublicEntityTypeResponse {
223
+ return {
224
+ ...entityType,
225
+ input_schemas: entityType.inbox_schemas,
226
+ output_schemas: entityType.state_schemas,
227
+ revision: entityType.revision,
228
+ }
229
+ }