@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,196 @@
1
+ /**
2
+ * HTTP routing for durable stream appends that affect Electric Agents state.
3
+ */
4
+
5
+ import { Router } from 'itty-router'
6
+ import { apiError, readRequestBody } from '../electric-agents-http.js'
7
+ import {
8
+ ErrCodeForkInProgress,
9
+ ErrCodeNotRunning,
10
+ ErrCodeUnauthorized,
11
+ } from '../electric-agents-types.js'
12
+ import { serverLog } from '../utils/log.js'
13
+ import type { EntityManager } from '../entity-manager.js'
14
+ import type { IRequest, RouterType } from 'itty-router'
15
+
16
+ type StreamAppendEvent =
17
+ | Record<string, unknown>
18
+ | Array<Record<string, unknown>>
19
+
20
+ export interface ElectricAgentsStreamAppendRouteRequest extends IRequest {
21
+ method: string
22
+ url: string
23
+ headers: Headers
24
+ readBody(): Promise<Uint8Array>
25
+ }
26
+
27
+ export interface ElectricAgentsStreamAppendRuntime {
28
+ manager: EntityManager
29
+ evaluateWakePayload(
30
+ sourceUrl: string,
31
+ event: StreamAppendEvent
32
+ ): Promise<void>
33
+ checkRunFinished(sourceUrl: string, event: StreamAppendEvent): void
34
+ syncManifestWakes(
35
+ subscriberUrl: string,
36
+ event: StreamAppendEvent
37
+ ): Promise<void>
38
+ syncManifestEntitySources(
39
+ ownerEntityUrl: string,
40
+ event: StreamAppendEvent
41
+ ): Promise<void>
42
+ syncManifestSchedules(
43
+ ownerEntityUrl: string,
44
+ event: StreamAppendEvent
45
+ ): Promise<void>
46
+ }
47
+
48
+ export type DurableStreamsAppendForwarder = (
49
+ request: ElectricAgentsStreamAppendRouteRequest,
50
+ body: Uint8Array
51
+ ) => Promise<Response>
52
+
53
+ type StreamAppendRouteArgs = [
54
+ ElectricAgentsStreamAppendRuntime,
55
+ DurableStreamsAppendForwarder,
56
+ ]
57
+ type StreamAppendRouteResult = Response | undefined
58
+
59
+ export type ElectricAgentsStreamAppendRoutes = RouterType<
60
+ ElectricAgentsStreamAppendRouteRequest,
61
+ StreamAppendRouteArgs,
62
+ StreamAppendRouteResult
63
+ >
64
+
65
+ export const electricAgentsStreamAppendRouter: ElectricAgentsStreamAppendRoutes =
66
+ Router<
67
+ ElectricAgentsStreamAppendRouteRequest,
68
+ StreamAppendRouteArgs,
69
+ StreamAppendRouteResult
70
+ >()
71
+
72
+ electricAgentsStreamAppendRouter.post(`*`, handleStreamAppend)
73
+
74
+ export function createStreamAppendRouteRequest(
75
+ request: Request
76
+ ): ElectricAgentsStreamAppendRouteRequest {
77
+ return {
78
+ method: request.method,
79
+ url: request.url,
80
+ headers: request.headers,
81
+ readBody: () => readRequestBody(request),
82
+ } as ElectricAgentsStreamAppendRouteRequest
83
+ }
84
+
85
+ async function handleStreamAppend(
86
+ request: ElectricAgentsStreamAppendRouteRequest,
87
+ runtime: ElectricAgentsStreamAppendRuntime,
88
+ forward: DurableStreamsAppendForwarder
89
+ ): Promise<StreamAppendRouteResult> {
90
+ const path = new URL(request.url).pathname
91
+ const { manager } = runtime
92
+ const entity = await manager.registry.getEntityByStream(path)
93
+ const isSharedState = path.startsWith(`/_electric/shared-state/`)
94
+ if (!entity && !isSharedState) {
95
+ return undefined
96
+ }
97
+
98
+ const body = await request.readBody()
99
+ const event = decodeStreamAppendEvent(body)
100
+
101
+ if (entity) {
102
+ const token = writeTokenFromHeaders(request.headers)
103
+ if (!manager.isValidWriteToken(entity, token)) {
104
+ return apiError(401, ErrCodeUnauthorized, `Invalid write token`)
105
+ }
106
+ if (manager.isForkWriteLockedEntity(entity.url)) {
107
+ return apiError(
108
+ 409,
109
+ ErrCodeForkInProgress,
110
+ `Entity subtree is being forked`
111
+ )
112
+ }
113
+ if (entity.status === `stopped`) {
114
+ return apiError(409, ErrCodeNotRunning, `Entity is stopped`)
115
+ }
116
+
117
+ if (event) {
118
+ const events = Array.isArray(event) ? event : [event]
119
+ for (const eventItem of events) {
120
+ const validationError = await manager.validateWriteEvent(
121
+ entity,
122
+ eventItem
123
+ )
124
+ if (validationError) {
125
+ return apiError(
126
+ validationError.status,
127
+ validationError.code,
128
+ validationError.message
129
+ )
130
+ }
131
+ }
132
+ }
133
+ } else if (manager.isForkWriteLockedStream(path)) {
134
+ return apiError(
135
+ 409,
136
+ ErrCodeForkInProgress,
137
+ `Entity subtree is being forked`
138
+ )
139
+ }
140
+
141
+ const upstream = await forward(request, body)
142
+ if (!upstream.ok || !event) {
143
+ return upstream
144
+ }
145
+
146
+ if (entity) {
147
+ void runtime
148
+ .evaluateWakePayload(entity.url, event)
149
+ .catch((err) =>
150
+ serverLog.warn(`[agent-server] wake evaluation failed:`, err)
151
+ )
152
+ runtime.checkRunFinished(entity.url, event)
153
+ void runtime
154
+ .syncManifestWakes(entity.url, event)
155
+ .catch((err) =>
156
+ serverLog.warn(`[agent-server] manifest wake sync failed:`, err)
157
+ )
158
+ void runtime
159
+ .syncManifestEntitySources(entity.url, event)
160
+ .catch((err) =>
161
+ serverLog.warn(`[agent-server] manifest source sync failed:`, err)
162
+ )
163
+ void runtime
164
+ .syncManifestSchedules(entity.url, event)
165
+ .catch((err) =>
166
+ serverLog.warn(`[agent-server] manifest schedule sync failed:`, err)
167
+ )
168
+ } else {
169
+ void runtime
170
+ .evaluateWakePayload(path, event)
171
+ .catch((err) =>
172
+ serverLog.warn(`[agent-server] wake evaluation failed:`, err)
173
+ )
174
+ }
175
+
176
+ return upstream
177
+ }
178
+
179
+ function decodeStreamAppendEvent(body: Uint8Array): StreamAppendEvent | null {
180
+ try {
181
+ return JSON.parse(new TextDecoder().decode(body)) as StreamAppendEvent
182
+ } catch {
183
+ return null
184
+ }
185
+ }
186
+
187
+ function writeTokenFromHeaders(headers: Headers): string {
188
+ const electricClaimToken = headers.get(`electric-claim-token`)?.trim()
189
+ if (electricClaimToken) return electricClaimToken
190
+ return (
191
+ headers
192
+ .get(`authorization`)
193
+ ?.replace(/^Bearer\s+/i, ``)
194
+ .trim() ?? ``
195
+ )
196
+ }
@@ -0,0 +1,26 @@
1
+ export function withoutLeadingSlash(path: string): string {
2
+ return path.replace(/^\/+/, ``)
3
+ }
4
+
5
+ export function withLeadingSlash(path: string): string {
6
+ return path.startsWith(`/`) ? path : `/${path}`
7
+ }
8
+
9
+ export function prefixTenantStreamPath(path: string, tenantId: string): string {
10
+ const normalized = withoutLeadingSlash(path)
11
+ if (!normalized || normalized === tenantId) return tenantId
12
+ if (normalized.startsWith(`${tenantId}/`)) return normalized
13
+ return `${tenantId}/${normalized}`
14
+ }
15
+
16
+ export function stripTenantStreamPrefix(
17
+ path: string,
18
+ tenantId: string
19
+ ): string {
20
+ const normalized = withoutLeadingSlash(path)
21
+ if (normalized === tenantId) return ``
22
+ if (normalized.startsWith(`${tenantId}/`)) {
23
+ return normalized.slice(tenantId.length + 1)
24
+ }
25
+ return normalized
26
+ }
@@ -0,0 +1,49 @@
1
+ export interface RuntimeRegistration {
2
+ name: string
3
+ publicUrl?: string
4
+ types: string[]
5
+ }
6
+
7
+ export interface RuntimeRegistry {
8
+ register(r: RuntimeRegistration): void
9
+ list(): Array<Required<RuntimeRegistration>>
10
+ }
11
+
12
+ export function createRuntimeRegistry(): RuntimeRegistry {
13
+ const map = new Map<string, RuntimeRegistration>()
14
+ return {
15
+ register(r) {
16
+ if (!r.publicUrl) {
17
+ console.warn(
18
+ `[agents-server] runtime "${r.name}" registered without publicUrl; omitted from /api/runtimes`
19
+ )
20
+ }
21
+ // Producers POST entity types one at a time, so merge each
22
+ // single-type registration into the runtime's accumulated list
23
+ // (deduped, first-seen order). Latest publicUrl wins.
24
+ const existing = map.get(r.name)
25
+ if (!existing) {
26
+ map.set(r.name, { ...r, types: [...r.types] })
27
+ return
28
+ }
29
+ const seen = new Set(existing.types)
30
+ const mergedTypes = [...existing.types]
31
+ for (const t of r.types) {
32
+ if (!seen.has(t)) {
33
+ seen.add(t)
34
+ mergedTypes.push(t)
35
+ }
36
+ }
37
+ map.set(r.name, {
38
+ name: r.name,
39
+ publicUrl: r.publicUrl ?? existing.publicUrl,
40
+ types: mergedTypes,
41
+ })
42
+ },
43
+ list() {
44
+ return [...map.values()].filter((r) => !!r.publicUrl) as Array<
45
+ Required<RuntimeRegistration>
46
+ >
47
+ },
48
+ }
49
+ }