@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.
- package/LICENSE +177 -0
- package/dist/chunk-Cl8Af3a2.js +11 -0
- package/dist/entrypoint.js +7319 -0
- package/dist/index.cjs +7090 -0
- package/dist/index.d.cts +4262 -0
- package/dist/index.d.ts +4263 -0
- package/dist/index.js +7053 -0
- package/drizzle/0000_baseline.sql +97 -0
- package/drizzle/0001_entity_tags_and_bridges.sql +45 -0
- package/drizzle/0002_tag_outbox_hardening.sql +14 -0
- package/drizzle/0003_entity_manifest_sources.sql +11 -0
- package/drizzle/0004_tenant_scoping.sql +139 -0
- package/drizzle/0005_pull_wake_control_plane.sql +156 -0
- package/drizzle/meta/0000_snapshot.json +593 -0
- package/drizzle/meta/_journal.json +48 -0
- package/package.json +89 -0
- package/src/authenticated-user-format.ts +17 -0
- package/src/claim-write-token-store.ts +74 -0
- package/src/db/index.ts +53 -0
- package/src/db/schema.ts +490 -0
- package/src/dev-asserted-auth.ts +46 -0
- package/src/dispatch-policy-schema.ts +52 -0
- package/src/electric-agents/adapter-types.ts +70 -0
- package/src/electric-agents/default-entity-schemas.ts +1 -0
- package/src/electric-agents/schema-validator.ts +143 -0
- package/src/electric-agents-http.ts +46 -0
- package/src/electric-agents-types.ts +335 -0
- package/src/entity-bridge-manager.ts +694 -0
- package/src/entity-manager.ts +2601 -0
- package/src/entity-projector.ts +765 -0
- package/src/entity-registry.ts +1162 -0
- package/src/entrypoint-lib.ts +295 -0
- package/src/entrypoint.ts +11 -0
- package/src/host.ts +323 -0
- package/src/index.ts +49 -0
- package/src/manifest-side-effects.ts +183 -0
- package/src/routing/agent-ui-router.ts +81 -0
- package/src/routing/context.ts +35 -0
- package/src/routing/cron-router.ts +45 -0
- package/src/routing/dispatch-policy.ts +248 -0
- package/src/routing/durable-streams-router.ts +407 -0
- package/src/routing/durable-streams-routing-adapter.ts +96 -0
- package/src/routing/electric-proxy-router.ts +61 -0
- package/src/routing/entities-router.ts +484 -0
- package/src/routing/entity-types-router.ts +229 -0
- package/src/routing/global-router.ts +33 -0
- package/src/routing/hooks.ts +123 -0
- package/src/routing/internal-router.ts +741 -0
- package/src/routing/oss-server-router.ts +56 -0
- package/src/routing/runners-router.ts +416 -0
- package/src/routing/schema.ts +141 -0
- package/src/routing/stream-append.ts +196 -0
- package/src/routing/tenant-stream-paths.ts +26 -0
- package/src/runtime-registry.ts +49 -0
- package/src/runtime.ts +537 -0
- package/src/scheduler.ts +788 -0
- package/src/schema-validation.ts +15 -0
- package/src/server.ts +374 -0
- package/src/standalone-runtime.ts +188 -0
- package/src/stream-client.ts +842 -0
- package/src/tag-stream-outbox-drainer.ts +188 -0
- package/src/tenant.ts +25 -0
- package/src/tracing.ts +57 -0
- package/src/utils/electric-url.ts +15 -0
- package/src/utils/log.ts +95 -0
- package/src/utils/server-utils.ts +245 -0
- package/src/utils/webhook-url.ts +33 -0
- 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
|
+
}
|
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'
|