@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,295 @@
1
+ import { DurableStreamTestServer } from '@durable-streams/server'
2
+ import {
3
+ createDevAssertedAuthenticateRequest,
4
+ devAssertedAuthOptionsFromEnv,
5
+ } from './dev-asserted-auth.js'
6
+ import { ElectricAgentsServer } from './server.js'
7
+ import type { ElectricAgentsServerOptions } from './server.js'
8
+
9
+ const DEFAULT_HOST = `0.0.0.0`
10
+ const DEFAULT_PORT = 4437
11
+ const DEFAULT_STREAMS_HOST = `127.0.0.1`
12
+
13
+ type EnvSource = Record<string, string | undefined>
14
+
15
+ export interface ElectricAgentsEntrypointOptions
16
+ extends ElectricAgentsServerOptions {}
17
+
18
+ export interface ElectricAgentsEntrypointServer {
19
+ start: () => Promise<string>
20
+ stop: () => Promise<void>
21
+ }
22
+
23
+ export interface RunElectricAgentsEntrypointOptions {
24
+ env?: EnvSource
25
+ cwd?: string
26
+ createServer?: (
27
+ options: ElectricAgentsEntrypointOptions
28
+ ) => ElectricAgentsEntrypointServer
29
+ }
30
+
31
+ function readEnv(env: EnvSource, names: Array<string>): string | undefined {
32
+ for (const name of names) {
33
+ const value = env[name]?.trim()
34
+ if (value) {
35
+ return value
36
+ }
37
+ }
38
+ return undefined
39
+ }
40
+
41
+ function readRequiredEnv(
42
+ env: EnvSource,
43
+ names: Array<string>,
44
+ description: string
45
+ ): string {
46
+ const value = readEnv(env, names)
47
+ if (value) {
48
+ return value
49
+ }
50
+
51
+ throw new Error(
52
+ `Missing ${description}. Set one of: ${names.map((name) => `"${name}"`).join(`, `)}`
53
+ )
54
+ }
55
+
56
+ function readPort(env: EnvSource): number {
57
+ const raw = readEnv(env, [`ELECTRIC_AGENTS_PORT`, `PORT`])
58
+ if (!raw) {
59
+ return DEFAULT_PORT
60
+ }
61
+
62
+ const port = Number(raw)
63
+ if (!Number.isInteger(port) || port < 1 || port > 65_535) {
64
+ throw new Error(
65
+ `Invalid ELECTRIC_AGENTS port "${raw}". Expected an integer between 1 and 65535.`
66
+ )
67
+ }
68
+
69
+ return port
70
+ }
71
+
72
+ function validateUrl(name: string, value: string): string {
73
+ try {
74
+ new URL(value)
75
+ return value
76
+ } catch {
77
+ throw new Error(`Invalid ${name}: "${value}"`)
78
+ }
79
+ }
80
+
81
+ function readOptionalPositiveInteger(
82
+ env: EnvSource,
83
+ names: Array<string>,
84
+ description: string
85
+ ): number | undefined {
86
+ const raw = readEnv(env, names)
87
+ if (!raw) return undefined
88
+
89
+ const value = Number(raw)
90
+ if (!Number.isInteger(value) || value <= 0) {
91
+ throw new Error(
92
+ `Invalid ${description} "${raw}". Expected a positive integer.`
93
+ )
94
+ }
95
+
96
+ return value
97
+ }
98
+
99
+ function readOptionalPort(
100
+ env: EnvSource,
101
+ names: Array<string>,
102
+ description: string
103
+ ): number | undefined {
104
+ const raw = readEnv(env, names)
105
+ if (!raw) {
106
+ return undefined
107
+ }
108
+
109
+ const port = Number(raw)
110
+ if (!Number.isInteger(port) || port < 0 || port > 65_535) {
111
+ throw new Error(
112
+ `Invalid ${description} "${raw}". Expected an integer between 0 and 65535.`
113
+ )
114
+ }
115
+
116
+ return port
117
+ }
118
+
119
+ export function resolveElectricAgentsEntrypointOptions(
120
+ env: EnvSource = process.env,
121
+ cwd = process.cwd()
122
+ ): ElectricAgentsEntrypointOptions {
123
+ const durableStreamsUrl = readEnv(env, [
124
+ `ELECTRIC_AGENTS_DURABLE_STREAMS_URL`,
125
+ `DURABLE_STREAMS_URL`,
126
+ `STREAMS_URL`,
127
+ ])
128
+ const durableStreamsBearer = readEnv(env, [
129
+ `ELECTRIC_AGENTS_DURABLE_STREAMS_BEARER`,
130
+ `DURABLE_STREAMS_BEARER`,
131
+ ])
132
+ const postgresUrl = validateUrl(
133
+ `Postgres URL`,
134
+ readRequiredEnv(
135
+ env,
136
+ [`ELECTRIC_AGENTS_DATABASE_URL`, `DATABASE_URL`],
137
+ `Postgres connection URL`
138
+ )
139
+ )
140
+
141
+ const electricUrl = readEnv(env, [
142
+ `ELECTRIC_AGENTS_ELECTRIC_URL`,
143
+ `ELECTRIC_URL`,
144
+ ])
145
+ const electricSecret = readEnv(env, [`ELECTRIC_AGENTS_ELECTRIC_SECRET`])
146
+ const baseUrl = readEnv(env, [`ELECTRIC_AGENTS_BASE_URL`, `BASE_URL`])
147
+ const authenticateRequest = createDevAssertedAuthenticateRequest(
148
+ devAssertedAuthOptionsFromEnv(env)
149
+ )
150
+
151
+ return {
152
+ ...(authenticateRequest ? { authenticateRequest } : {}),
153
+ service: readEnv(env, [`ELECTRIC_AGENTS_SERVICE`, `SERVICE`]),
154
+ tenantId: readEnv(env, [`ELECTRIC_AGENTS_TENANT_ID`, `TENANT_ID`]),
155
+ baseUrl: baseUrl ? validateUrl(`base URL`, baseUrl) : undefined,
156
+ durableStreamsUrl: durableStreamsUrl
157
+ ? validateUrl(`durable streams URL`, durableStreamsUrl)
158
+ : undefined,
159
+ ...(durableStreamsBearer ? { durableStreamsBearer } : {}),
160
+ postgresUrl,
161
+ electricUrl: electricUrl
162
+ ? validateUrl(`Electric URL`, electricUrl)
163
+ : undefined,
164
+ electricSecret,
165
+ host: readEnv(env, [`ELECTRIC_AGENTS_HOST`, `HOST`]) ?? DEFAULT_HOST,
166
+ port: readPort(env),
167
+ workingDirectory:
168
+ readEnv(env, [
169
+ `ELECTRIC_AGENTS_WORKING_DIRECTORY`,
170
+ `WORKING_DIRECTORY`,
171
+ ]) ?? cwd,
172
+ dispatchRecoveryIntervalMs: readOptionalPositiveInteger(
173
+ env,
174
+ [`ELECTRIC_AGENTS_DISPATCH_RECOVERY_INTERVAL_MS`],
175
+ `dispatch recovery interval`
176
+ ),
177
+ staleOutstandingWakeAfterMs: readOptionalPositiveInteger(
178
+ env,
179
+ [`ELECTRIC_AGENTS_STALE_OUTSTANDING_WAKE_AFTER_MS`],
180
+ `stale outstanding wake age`
181
+ ),
182
+ }
183
+ }
184
+
185
+ function createEmbeddedStreamsServer(
186
+ env: EnvSource,
187
+ cwd: string
188
+ ): DurableStreamTestServer | undefined {
189
+ const externalUrl = readEnv(env, [
190
+ `ELECTRIC_AGENTS_DURABLE_STREAMS_URL`,
191
+ `DURABLE_STREAMS_URL`,
192
+ `STREAMS_URL`,
193
+ ])
194
+ if (externalUrl) {
195
+ return undefined
196
+ }
197
+
198
+ const dataDir =
199
+ readEnv(env, [`ELECTRIC_AGENTS_STREAMS_DATA_DIR`, `STREAMS_DATA_DIR`]) ??
200
+ `${cwd}/.streams-data`
201
+
202
+ return new DurableStreamTestServer({
203
+ host:
204
+ readEnv(env, [`ELECTRIC_AGENTS_STREAMS_HOST`, `STREAMS_HOST`]) ??
205
+ DEFAULT_STREAMS_HOST,
206
+ port:
207
+ readOptionalPort(
208
+ env,
209
+ [`ELECTRIC_AGENTS_STREAMS_PORT`, `STREAMS_PORT`],
210
+ `embedded streams port`
211
+ ) ?? 0,
212
+ dataDir,
213
+ webhooks: true,
214
+ })
215
+ }
216
+
217
+ export async function runElectricAgentsEntrypoint({
218
+ env = process.env,
219
+ cwd = process.cwd(),
220
+ createServer = (options) => new ElectricAgentsServer(options),
221
+ }: RunElectricAgentsEntrypointOptions = {}): Promise<{
222
+ options: ElectricAgentsEntrypointOptions
223
+ server: ElectricAgentsEntrypointServer
224
+ url: string
225
+ }> {
226
+ const embeddedStreamsServer = createEmbeddedStreamsServer(env, cwd)
227
+ const options = {
228
+ ...resolveElectricAgentsEntrypointOptions(env, cwd),
229
+ durableStreamsServer: embeddedStreamsServer,
230
+ }
231
+ const server = createServer(options)
232
+ const url = await server.start()
233
+
234
+ return { options, server, url }
235
+ }
236
+
237
+ export async function main(): Promise<void> {
238
+ try {
239
+ process.loadEnvFile()
240
+ } catch {}
241
+
242
+ let server: ElectricAgentsEntrypointServer | null = null
243
+ let stopping: Promise<void> | null = null
244
+
245
+ const stop = async (exitCode: number): Promise<never> => {
246
+ if (!stopping) {
247
+ stopping =
248
+ server?.stop().catch((error) => {
249
+ console.error(`[agent-server] failed to stop cleanly`, error)
250
+ }) ?? Promise.resolve()
251
+ }
252
+
253
+ await stopping
254
+ process.exit(exitCode)
255
+ }
256
+
257
+ try {
258
+ const started = await runElectricAgentsEntrypoint()
259
+ server = started.server
260
+
261
+ console.log(`Electric Agents server running at ${started.url}`)
262
+ console.log(
263
+ `Durable Streams: ${started.options.durableStreamsUrl ?? `(embedded DurableStreamTestServer)`}`
264
+ )
265
+ console.log(`Postgres: ${started.options.postgresUrl}`)
266
+ if (started.options.electricUrl) {
267
+ console.log(`Electric: ${started.options.electricUrl}`)
268
+ }
269
+ console.log(`Working directory: ${started.options.workingDirectory}`)
270
+
271
+ process.on(`SIGINT`, () => {
272
+ void stop(0)
273
+ })
274
+
275
+ process.on(`SIGTERM`, () => {
276
+ void stop(0)
277
+ })
278
+
279
+ process.on(`uncaughtException`, (error) => {
280
+ console.error(error)
281
+ void stop(1)
282
+ })
283
+
284
+ process.on(`unhandledRejection`, (error) => {
285
+ console.error(error)
286
+ void stop(1)
287
+ })
288
+ } catch (error) {
289
+ console.error(error)
290
+ if (server) {
291
+ await server.stop().catch(() => {})
292
+ }
293
+ process.exit(1)
294
+ }
295
+ }
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { pathToFileURL } from 'node:url'
4
+ import { main } from './entrypoint-lib.js'
5
+
6
+ if (
7
+ process.argv[1] &&
8
+ import.meta.url === pathToFileURL(process.argv[1]).href
9
+ ) {
10
+ void main()
11
+ }
package/src/host.ts ADDED
@@ -0,0 +1,323 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { PostgresRegistry } from './entity-registry.js'
3
+ import { EntityProjector } from './entity-projector.js'
4
+ import { ElectricAgentsTenantRuntime } from './runtime.js'
5
+ import { PostgresSchedulerClient, Scheduler } from './scheduler.js'
6
+ import { StreamClient, durableStreamsServiceUrl } from './stream-client.js'
7
+ import { TagStreamOutboxDrainer } from './tag-stream-outbox-drainer.js'
8
+ import { DEFAULT_TENANT_ID, UnregisteredTenantError } from './tenant.js'
9
+ import { WakeRegistry } from './wake-registry.js'
10
+ import type { DrizzleDB, PgClient } from './db/index.js'
11
+ import type { DurableStreamsBearerProvider } from './stream-client.js'
12
+
13
+ export interface AgentsHostTenantConfig {
14
+ serviceId: string
15
+ durableStreamsUrl?: string
16
+ durableStreamsBearer?: DurableStreamsBearerProvider
17
+ streamClient?: StreamClient
18
+ }
19
+
20
+ export type AgentsHostTenantRuntime = ElectricAgentsTenantRuntime
21
+
22
+ export interface AgentsHostOptions {
23
+ db: DrizzleDB
24
+ pgClient: PgClient
25
+ electricUrl?: string
26
+ electricSecret?: string
27
+ instanceId?: string
28
+ wakeRegistry?: WakeRegistry
29
+ entityProjector?: EntityProjector
30
+ startEntityBridgeManager?: boolean
31
+ rehydrateTenantOnStart?: boolean
32
+ }
33
+
34
+ export class AgentsHost {
35
+ readonly db: DrizzleDB
36
+ readonly pgClient: PgClient
37
+ readonly wakeRegistry: WakeRegistry
38
+ readonly entityProjector: EntityProjector
39
+ readonly scheduler: Scheduler
40
+ readonly tagStreamOutboxDrainer: TagStreamOutboxDrainer
41
+
42
+ private readonly electricUrl?: string
43
+ private readonly electricSecret?: string
44
+ private readonly instanceId: string
45
+ private readonly ownsWakeRegistry: boolean
46
+ private readonly ownsEntityProjector: boolean
47
+ private readonly startEntityBridgeManager: boolean
48
+ private readonly rehydrateTenantOnStart: boolean
49
+ private readonly tenantRegistrations = new Map<
50
+ string,
51
+ Promise<AgentsHostTenantRuntime>
52
+ >()
53
+ private readonly tenantRuntimes = new Map<string, AgentsHostTenantRuntime>()
54
+ private readonly tenantOperations = new Map<string, Promise<void>>()
55
+ private running = false
56
+
57
+ constructor(options: AgentsHostOptions) {
58
+ this.db = options.db
59
+ this.pgClient = options.pgClient
60
+ this.electricUrl = options.electricUrl
61
+ this.electricSecret = options.electricSecret
62
+ this.instanceId = options.instanceId ?? randomUUID()
63
+ this.ownsWakeRegistry = !options.wakeRegistry
64
+ this.wakeRegistry = options.wakeRegistry ?? new WakeRegistry(this.db, null)
65
+ this.ownsEntityProjector = !options.entityProjector
66
+ this.startEntityBridgeManager = options.startEntityBridgeManager ?? true
67
+ this.rehydrateTenantOnStart = options.rehydrateTenantOnStart ?? true
68
+ this.entityProjector =
69
+ options.entityProjector ??
70
+ new EntityProjector({
71
+ db: this.db,
72
+ electricUrl: this.electricUrl,
73
+ electricSecret: this.electricSecret,
74
+ streamClientForTenant: (tenantId) =>
75
+ this.requireTenantForSharedProcess(tenantId, `entity-projector`)
76
+ .streamClient,
77
+ tenantIds: () => this.registeredTenantIds(),
78
+ })
79
+
80
+ this.scheduler = new Scheduler({
81
+ pgClient: this.pgClient,
82
+ instanceId: `${this.instanceId}:scheduler`,
83
+ tenantId: null,
84
+ tenantIds: () => this.registeredTenantIds(),
85
+ executors: {
86
+ delayed_send: async (payload, taskId, tenantId) => {
87
+ const runtime = this.requireTenantForSharedProcess(
88
+ tenantId,
89
+ `scheduler delayed_send`
90
+ )
91
+ await runtime.executeDelayedSend(payload, taskId)
92
+ },
93
+ cron_tick: async (payload, tickNumber, _taskId, tenantId) => {
94
+ const runtime = this.requireTenantForSharedProcess(
95
+ tenantId,
96
+ `scheduler cron_tick`
97
+ )
98
+ await runtime.executeCronTick(payload, tickNumber)
99
+ },
100
+ },
101
+ })
102
+
103
+ this.tagStreamOutboxDrainer = new TagStreamOutboxDrainer(
104
+ new PostgresRegistry(this.db, DEFAULT_TENANT_ID),
105
+ (tenantId) =>
106
+ this.requireTenantForSharedProcess(tenantId, `tag-outbox`).streamClient,
107
+ { tenantId: null, tenantIds: () => this.registeredTenantIds() }
108
+ )
109
+ }
110
+
111
+ async start(): Promise<void> {
112
+ if (this.running) return
113
+ this.running = true
114
+
115
+ try {
116
+ if (this.electricUrl) {
117
+ await this.wakeRegistry.startSync(this.electricUrl, this.electricSecret)
118
+ } else {
119
+ await this.wakeRegistry.loadRegistrations()
120
+ }
121
+ if (this.startEntityBridgeManager) {
122
+ await this.entityProjector.start()
123
+ }
124
+ await this.startRegisteredTenants()
125
+ this.tagStreamOutboxDrainer.start()
126
+ await this.scheduler.start()
127
+ } catch (error) {
128
+ await this.stop().catch(() => {})
129
+ throw error
130
+ }
131
+ }
132
+
133
+ async stop(): Promise<void> {
134
+ if (!this.running) return
135
+ this.running = false
136
+
137
+ await this.scheduler.stop()
138
+ await this.tagStreamOutboxDrainer.stop()
139
+
140
+ const runtimes = await Promise.allSettled(this.tenantRegistrations.values())
141
+ await Promise.allSettled(
142
+ runtimes
143
+ .filter(
144
+ (result): result is PromiseFulfilledResult<AgentsHostTenantRuntime> =>
145
+ result.status === `fulfilled`
146
+ )
147
+ .map((result) => result.value.stop())
148
+ )
149
+ this.tenantRegistrations.clear()
150
+ this.tenantRuntimes.clear()
151
+
152
+ if (this.ownsWakeRegistry) {
153
+ await this.wakeRegistry.stopSync()
154
+ }
155
+ if (this.ownsEntityProjector) {
156
+ await this.entityProjector.stop()
157
+ }
158
+ }
159
+
160
+ async registerTenant(
161
+ config: AgentsHostTenantConfig
162
+ ): Promise<AgentsHostTenantRuntime> {
163
+ const serviceId = config.serviceId
164
+ return await this.withTenantOperation(serviceId, async () => {
165
+ const runtime = this.tenantRuntimes.get(serviceId)
166
+ if (runtime) return runtime
167
+
168
+ const existing = this.tenantRegistrations.get(serviceId)
169
+ if (existing) return existing
170
+
171
+ const runtimePromise = this.createTenantRuntime(config)
172
+ this.tenantRegistrations.set(serviceId, runtimePromise)
173
+
174
+ try {
175
+ const registeredRuntime = await runtimePromise
176
+ this.tenantRuntimes.set(serviceId, registeredRuntime)
177
+ if (this.running) {
178
+ await this.startTenantRuntime(registeredRuntime)
179
+ this.scheduler.wake()
180
+ }
181
+ return registeredRuntime
182
+ } catch (error) {
183
+ if (this.tenantRegistrations.get(serviceId) === runtimePromise) {
184
+ this.tenantRegistrations.delete(serviceId)
185
+ }
186
+ if (this.tenantRuntimes.get(serviceId)) {
187
+ this.tenantRuntimes.delete(serviceId)
188
+ }
189
+ throw error
190
+ }
191
+ })
192
+ }
193
+
194
+ getTenant(
195
+ serviceId = DEFAULT_TENANT_ID
196
+ ): AgentsHostTenantRuntime | undefined {
197
+ return this.tenantRuntimes.get(serviceId)
198
+ }
199
+
200
+ requireTenant(serviceId = DEFAULT_TENANT_ID): AgentsHostTenantRuntime {
201
+ const runtime = this.getTenant(serviceId)
202
+ if (!runtime) {
203
+ throw new Error(`AgentsHost tenant "${serviceId}" is not registered`)
204
+ }
205
+ return runtime
206
+ }
207
+
208
+ async unregisterTenant(serviceId = DEFAULT_TENANT_ID): Promise<void> {
209
+ await this.withTenantOperation(serviceId, async () => {
210
+ const registration = this.tenantRegistrations.get(serviceId)
211
+ const runtime = this.tenantRuntimes.get(serviceId)
212
+
213
+ this.tenantRegistrations.delete(serviceId)
214
+ this.tenantRuntimes.delete(serviceId)
215
+
216
+ const resolvedRuntime =
217
+ runtime ??
218
+ (registration ? await registration.catch(() => undefined) : undefined)
219
+ if (!resolvedRuntime) return
220
+
221
+ await resolvedRuntime.stop()
222
+ if (this.running) {
223
+ this.scheduler.wake()
224
+ }
225
+ })
226
+ }
227
+
228
+ private async withTenantOperation<T>(
229
+ serviceId: string,
230
+ operation: () => Promise<T>
231
+ ): Promise<T> {
232
+ const previous = this.tenantOperations.get(serviceId) ?? Promise.resolve()
233
+ const result = previous.catch(() => {}).then(operation)
234
+ const current = result.then(
235
+ () => undefined,
236
+ () => undefined
237
+ )
238
+ this.tenantOperations.set(serviceId, current)
239
+
240
+ try {
241
+ return await result
242
+ } finally {
243
+ if (this.tenantOperations.get(serviceId) === current) {
244
+ this.tenantOperations.delete(serviceId)
245
+ }
246
+ }
247
+ }
248
+
249
+ private registeredTenantIds(): Array<string> {
250
+ return [...this.tenantRuntimes.keys()]
251
+ }
252
+
253
+ private requireTenantForSharedProcess(
254
+ serviceId: string,
255
+ processName: string
256
+ ): AgentsHostTenantRuntime {
257
+ const runtime = this.getTenant(serviceId)
258
+ if (!runtime) {
259
+ throw new UnregisteredTenantError(serviceId, processName)
260
+ }
261
+ return runtime
262
+ }
263
+
264
+ private async startRegisteredTenants(): Promise<void> {
265
+ const runtimes = await Promise.all(this.tenantRegistrations.values())
266
+ for (const runtime of runtimes) {
267
+ await this.startTenantRuntime(runtime)
268
+ }
269
+ }
270
+
271
+ private async startTenantRuntime(
272
+ runtime: AgentsHostTenantRuntime
273
+ ): Promise<void> {
274
+ if (this.rehydrateTenantOnStart) {
275
+ await runtime.rehydrateCronSchedules()
276
+ }
277
+ if (this.startEntityBridgeManager) {
278
+ await this.entityProjector.loadTenantBridges(
279
+ runtime.serviceId,
280
+ runtime.registry
281
+ )
282
+ }
283
+ }
284
+
285
+ private async createTenantRuntime(
286
+ config: AgentsHostTenantConfig
287
+ ): Promise<AgentsHostTenantRuntime> {
288
+ const serviceId = config.serviceId
289
+ const streamClient = this.createStreamClient(config)
290
+ const registry = new PostgresRegistry(this.db, serviceId)
291
+ const scheduler = new PostgresSchedulerClient(
292
+ this.pgClient,
293
+ serviceId,
294
+ () => this.scheduler.wake()
295
+ )
296
+ const runtime = new ElectricAgentsTenantRuntime({
297
+ service: serviceId,
298
+ db: this.db,
299
+ registry,
300
+ streamClient,
301
+ wakeRegistry: this.wakeRegistry,
302
+ scheduler,
303
+ entityBridgeManager: this.entityProjector.forTenant(serviceId, registry),
304
+ })
305
+
306
+ return runtime
307
+ }
308
+
309
+ private createStreamClient(config: AgentsHostTenantConfig): StreamClient {
310
+ if (config.streamClient) return config.streamClient
311
+ if (config.durableStreamsUrl) {
312
+ return new StreamClient(
313
+ durableStreamsServiceUrl(config.durableStreamsUrl, config.serviceId),
314
+ { bearer: config.durableStreamsBearer }
315
+ )
316
+ }
317
+ throw new Error(
318
+ `AgentsHost tenant "${config.serviceId}" must provide a streamClient or durableStreamsUrl`
319
+ )
320
+ }
321
+ }
322
+
323
+ export { AgentsHost as ElectricAgentsHost }
package/src/index.ts ADDED
@@ -0,0 +1,49 @@
1
+ export { createDb, runMigrations } from './db/index.js'
2
+ export type { DrizzleDB, PgClient } from './db/index.js'
3
+ export { AgentsHost } from './host.js'
4
+ export type {
5
+ AgentsHostOptions,
6
+ AgentsHostTenantConfig,
7
+ AgentsHostTenantRuntime,
8
+ } from './host.js'
9
+ export { StreamClient } from './stream-client.js'
10
+ export type {
11
+ DurableStreamsBearerProvider,
12
+ StreamClientOptions,
13
+ SubscriptionClaimResponse,
14
+ SubscriptionCreateInput,
15
+ SubscriptionResponse,
16
+ SubscriptionStreamInfo,
17
+ } from './stream-client.js'
18
+ export type {
19
+ AuthenticatedRequestUser,
20
+ AuthenticateRequest,
21
+ ConsumerClaim,
22
+ DispatchPolicy,
23
+ DispatchTarget,
24
+ ElectricAgentsRunner,
25
+ ElectricAgentsUser,
26
+ EntityDispatchState,
27
+ PublicWakeNotification,
28
+ RegisterRunnerRequest,
29
+ RunnerAdminStatus,
30
+ RunnerHeartbeatRequest,
31
+ RunnerKind,
32
+ RunnerLiveness,
33
+ SourceStreamOffset,
34
+ WakeNotificationRow,
35
+ } from './electric-agents-types.js'
36
+ export { globalRouter } from './routing/global-router.js'
37
+ export type { GlobalRoutes } from './routing/global-router.js'
38
+ export type { TenantContext } from './routing/context.js'
39
+ export { pathPrefixedSingleTenantDurableStreamsRoutingAdapter } from './routing/durable-streams-routing-adapter.js'
40
+ export type {
41
+ DurableStreamsRoutingAdapter,
42
+ DurableStreamsRoutingInput,
43
+ } from './routing/durable-streams-routing-adapter.js'
44
+ export type { EntityBridgeCoordinator } from './entity-bridge-manager.js'
45
+ export {
46
+ DEFAULT_TENANT_ID,
47
+ UnregisteredTenantError,
48
+ isUnregisteredTenantError,
49
+ } from './tenant.js'