@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,188 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { entityStateSchema } from '@electric-ax/agents-runtime'
3
+ import { serverLog } from './utils/log.js'
4
+ import { DEFAULT_TENANT_ID, isUnregisteredTenantError } from './tenant.js'
5
+ import type { ChangeEvent } from '@durable-streams/state'
6
+ import type { PostgresRegistry, TagStreamOutboxRow } from './entity-registry.js'
7
+ import type { StreamClient } from './stream-client.js'
8
+
9
+ const DRAIN_INTERVAL_MS = 500
10
+ const MAX_FAILURE_ATTEMPTS = 10
11
+
12
+ type StreamClientResolver = (
13
+ tenantId: string
14
+ ) => StreamClient | Promise<StreamClient>
15
+ type TenantIdsProvider = () => Iterable<string>
16
+
17
+ interface TagStreamOutboxDrainerOptions {
18
+ tenantId?: string | null
19
+ tenantIds?: TenantIdsProvider
20
+ }
21
+
22
+ export class TagStreamOutboxDrainer {
23
+ private timer: NodeJS.Timeout | null = null
24
+ private draining = false
25
+ private activeDrain: Promise<void> | null = null
26
+ private stopping = false
27
+ private workerId = randomUUID()
28
+ private readonly streamClientForTenant: StreamClientResolver
29
+ private readonly tenantId: string | null
30
+ private readonly tenantIds?: TenantIdsProvider
31
+
32
+ constructor(
33
+ private registry: PostgresRegistry,
34
+ streamClient: StreamClient | StreamClientResolver,
35
+ options?: TagStreamOutboxDrainerOptions
36
+ ) {
37
+ this.streamClientForTenant =
38
+ typeof streamClient === `function` ? streamClient : () => streamClient
39
+ this.tenantId =
40
+ options?.tenantId !== undefined
41
+ ? options.tenantId
42
+ : (registry.tenantId ?? DEFAULT_TENANT_ID)
43
+ this.tenantIds = options?.tenantIds
44
+ }
45
+
46
+ start(): void {
47
+ if (this.timer) return
48
+ this.timer = setInterval(() => {
49
+ void this.runDrain().catch((error) => {
50
+ serverLog.warn(`[tag-outbox] drain failed:`, error)
51
+ })
52
+ }, DRAIN_INTERVAL_MS)
53
+ }
54
+
55
+ async stop(): Promise<void> {
56
+ this.stopping = true
57
+ if (this.timer) {
58
+ clearInterval(this.timer)
59
+ this.timer = null
60
+ }
61
+ try {
62
+ await this.activeDrain
63
+ } finally {
64
+ await this.registry.releaseTagOutboxClaims(this.workerId, this.tenantId)
65
+ }
66
+ }
67
+
68
+ async drainOnce(): Promise<void> {
69
+ await this.runDrain()
70
+ }
71
+
72
+ private async runDrain(): Promise<void> {
73
+ if (this.stopping) return
74
+ if (this.draining) return
75
+ this.draining = true
76
+ const drainPromise = (async () => {
77
+ const rows = await this.claimRows(25)
78
+ for (const row of rows) {
79
+ await this.publishRow(row).catch((error) => {
80
+ return this.handlePublishFailure(row, error)
81
+ })
82
+ }
83
+ })()
84
+ this.activeDrain = drainPromise
85
+
86
+ try {
87
+ await drainPromise
88
+ } finally {
89
+ this.activeDrain = null
90
+ this.draining = false
91
+ }
92
+ }
93
+
94
+ private async claimRows(limit: number): Promise<Array<TagStreamOutboxRow>> {
95
+ const tenantIds = this.sharedTenantIds()
96
+ if (!tenantIds) {
97
+ return await this.registry.claimTagOutboxRows(
98
+ this.workerId,
99
+ limit,
100
+ this.tenantId
101
+ )
102
+ }
103
+ if (tenantIds.length === 0) return []
104
+
105
+ const rows: Array<TagStreamOutboxRow> = []
106
+ for (const tenantId of tenantIds) {
107
+ const remaining = limit - rows.length
108
+ if (remaining <= 0) break
109
+ rows.push(
110
+ ...(await this.registry.claimTagOutboxRows(
111
+ this.workerId,
112
+ remaining,
113
+ tenantId
114
+ ))
115
+ )
116
+ }
117
+ return rows
118
+ }
119
+
120
+ // Per-row producer identity (producerId = tag-outbox-${row.id}, epoch=0,
121
+ // seq=0) is deliberate: the protocol requires same-epoch seqs to be
122
+ // contiguous (PROTOCOL.md), so a shared per-entity producerId with
123
+ // seq = outbox.id would 409 the first time another entity's row
124
+ // interleaves. Each row is its own single-append producer; retries
125
+ // replay the same triple and dedupe server-side as 204.
126
+ private async publishRow(row: TagStreamOutboxRow): Promise<void> {
127
+ const event = buildTagChangeEvent(row)
128
+ const streamClient = await this.streamClientForTenant(row.tenantId)
129
+ await streamClient.appendWithProducerHeaders(
130
+ `${row.entityUrl}/main`,
131
+ JSON.stringify(event),
132
+ {
133
+ producerId: `tag-outbox-${row.id}`,
134
+ epoch: 0,
135
+ seq: 0,
136
+ }
137
+ )
138
+ await this.registry.deleteTagOutboxRow(row.id, row.tenantId)
139
+ }
140
+
141
+ private async handlePublishFailure(
142
+ row: TagStreamOutboxRow,
143
+ error: unknown
144
+ ): Promise<void> {
145
+ const message = error instanceof Error ? error.message : String(error)
146
+ if (isUnregisteredTenantError(error)) {
147
+ await this.registry.releaseTagOutboxClaims(this.workerId, row.tenantId)
148
+ serverLog.warn(
149
+ `[tag-outbox] skipped row ${row.id} for unregistered tenant "${row.tenantId}": ${message}`
150
+ )
151
+ return
152
+ }
153
+
154
+ const result = await this.registry.failTagOutboxRow(
155
+ row.id,
156
+ this.workerId,
157
+ message,
158
+ MAX_FAILURE_ATTEMPTS,
159
+ row.tenantId
160
+ )
161
+
162
+ const logLine = `[tag-outbox] row ${row.id} failed (attempt ${result.attemptCount}/${MAX_FAILURE_ATTEMPTS})`
163
+ if (result.deadLettered) {
164
+ serverLog.error(`${logLine}; dead-lettered: ${message}`)
165
+ return
166
+ }
167
+ serverLog.warn(`${logLine}: ${message}`)
168
+ }
169
+
170
+ private sharedTenantIds(): Array<string> | null {
171
+ if (this.tenantId !== null || !this.tenantIds) return null
172
+ return [...new Set(this.tenantIds())]
173
+ }
174
+ }
175
+
176
+ function buildTagChangeEvent(row: TagStreamOutboxRow): ChangeEvent<unknown> {
177
+ const headers = { timestamp: new Date().toISOString() }
178
+
179
+ if (row.op === `delete`) {
180
+ return entityStateSchema.tags.delete({ key: row.key, headers })
181
+ }
182
+
183
+ const value = row.rowData ?? { key: row.key, value: `` }
184
+ if (row.op === `insert`) {
185
+ return entityStateSchema.tags.insert({ key: row.key, value, headers })
186
+ }
187
+ return entityStateSchema.tags.update({ key: row.key, value, headers })
188
+ }
package/src/tenant.ts ADDED
@@ -0,0 +1,25 @@
1
+ export const DEFAULT_TENANT_ID = `default`
2
+
3
+ export class UnregisteredTenantError extends Error {
4
+ constructor(
5
+ readonly tenantId: string,
6
+ readonly processName: string
7
+ ) {
8
+ super(
9
+ `tenant "${tenantId}" is not registered on this host for ${processName}`
10
+ )
11
+ this.name = `UnregisteredTenantError`
12
+ }
13
+ }
14
+
15
+ export function isUnregisteredTenantError(
16
+ error: unknown
17
+ ): error is UnregisteredTenantError {
18
+ return (
19
+ error instanceof UnregisteredTenantError ||
20
+ (typeof error === `object` &&
21
+ error !== null &&
22
+ `name` in error &&
23
+ (error as { name?: unknown }).name === `UnregisteredTenantError`)
24
+ )
25
+ }
package/src/tracing.ts ADDED
@@ -0,0 +1,57 @@
1
+ import { SpanStatusCode, context, propagation, trace } from '@opentelemetry/api'
2
+ import type { Context, Span, SpanOptions, Tracer } from '@opentelemetry/api'
3
+
4
+ export const tracer: Tracer = trace.getTracer(`agent-server`)
5
+
6
+ export const ATTR = {
7
+ ENTITY_URL: `electric_agents.entity.url`,
8
+ ENTITY_TYPE: `electric_agents.entity.type`,
9
+ PARENT_URL: `electric_agents.entity.parent`,
10
+ WAKE_SOURCE: `electric_agents.wake.source`,
11
+ WAKE_SUBSCRIBER: `electric_agents.wake.subscriber`,
12
+ WAKE_KIND: `electric_agents.wake.kind`,
13
+ STREAM_PATH: `electric_agents.stream.path`,
14
+ STREAM_OP: `electric_agents.stream.op`,
15
+ DB_OP: `electric_agents.db.op`,
16
+ HTTP_METHOD: `http.method`,
17
+ HTTP_ROUTE: `http.route`,
18
+ HTTP_STATUS: `http.status_code`,
19
+ } as const
20
+
21
+ /**
22
+ * Run `fn` inside an active span. Errors are recorded + status set to ERROR,
23
+ * then re-thrown. Span ends in a finally block.
24
+ */
25
+ export async function withSpan<T>(
26
+ name: string,
27
+ fn: (span: Span) => Promise<T>,
28
+ opts?: SpanOptions
29
+ ): Promise<T> {
30
+ return await tracer.startActiveSpan(name, opts ?? {}, async (span) => {
31
+ try {
32
+ return await fn(span)
33
+ } catch (err) {
34
+ span.recordException(err as Error)
35
+ span.setStatus({
36
+ code: SpanStatusCode.ERROR,
37
+ message: err instanceof Error ? err.message : String(err),
38
+ })
39
+ throw err
40
+ } finally {
41
+ span.end()
42
+ }
43
+ })
44
+ }
45
+
46
+ export function injectTraceHeaders(
47
+ headers: Record<string, string>,
48
+ ctx: Context = context.active()
49
+ ): void {
50
+ propagation.inject(ctx, headers)
51
+ }
52
+
53
+ export function extractTraceContext(
54
+ headers: Record<string, string | Array<string> | undefined>
55
+ ): Context {
56
+ return propagation.extract(context.active(), headers)
57
+ }
@@ -0,0 +1,15 @@
1
+ export function applyElectricUrlQueryParams(
2
+ target: URL,
3
+ electricUrl: string
4
+ ): void {
5
+ const configured = new URL(electricUrl)
6
+ configured.searchParams.forEach((value, key) => {
7
+ target.searchParams.set(key, value)
8
+ })
9
+ }
10
+
11
+ export function electricUrlWithPath(electricUrl: string, path: string): URL {
12
+ const target = new URL(path, electricUrl)
13
+ applyElectricUrlQueryParams(target, electricUrl)
14
+ return target
15
+ }
@@ -0,0 +1,95 @@
1
+ import path from 'node:path'
2
+ import fs from 'node:fs'
3
+ import pino from 'pino'
4
+
5
+ const LOG_LEVEL = process.env.ELECTRIC_AGENTS_LOG_LEVEL ?? `info`
6
+ const IS_ELECTRON_MAIN = Boolean(process.versions.electron)
7
+ const USE_FILE_LOGS = process.env.ELECTRIC_AGENTS_LOG_FILE !== `false`
8
+ const USE_PRETTY_LOGS =
9
+ LOG_LEVEL !== `silent` && !process.env.VITEST && !IS_ELECTRON_MAIN
10
+
11
+ const LOG_DIR = USE_FILE_LOGS
12
+ ? (process.env.ELECTRIC_AGENTS_LOG_DIR ?? path.resolve(process.cwd(), `logs`))
13
+ : undefined
14
+ const LOG_FILE = LOG_DIR
15
+ ? path.join(LOG_DIR, `agent-server-${Date.now()}.jsonl`)
16
+ : undefined
17
+
18
+ if (LOG_DIR) {
19
+ fs.mkdirSync(LOG_DIR, { recursive: true })
20
+ }
21
+
22
+ export const LOG_FILE_PATH = LOG_FILE
23
+
24
+ const streams: Array<pino.StreamEntry> = []
25
+ if (LOG_FILE) {
26
+ streams.push({
27
+ stream: pino.destination({
28
+ dest: LOG_FILE,
29
+ sync: IS_ELECTRON_MAIN,
30
+ }),
31
+ })
32
+ }
33
+ if (USE_PRETTY_LOGS) {
34
+ streams.push({
35
+ stream: pino.transport({
36
+ target: `pino-pretty`,
37
+ options: {
38
+ colorize: true,
39
+ ignore: `pid,hostname,name`,
40
+ translateTime: `SYS:HH:MM:ss`,
41
+ },
42
+ }),
43
+ })
44
+ }
45
+
46
+ const logger =
47
+ streams.length > 0
48
+ ? pino(
49
+ {
50
+ base: undefined,
51
+ level: LOG_LEVEL,
52
+ },
53
+ pino.multistream(streams)
54
+ )
55
+ : pino({
56
+ base: undefined,
57
+ enabled: false,
58
+ level: LOG_LEVEL,
59
+ })
60
+
61
+ function formatArgs(args: Array<unknown>): { err?: Error; msg: string } {
62
+ const errors: Array<Error> = []
63
+ const parts: Array<string> = []
64
+ for (const a of args) {
65
+ if (a instanceof Error) errors.push(a)
66
+ else parts.push(typeof a === `string` ? a : JSON.stringify(a))
67
+ }
68
+ return {
69
+ err: errors[0],
70
+ msg: parts.join(` `),
71
+ }
72
+ }
73
+
74
+ export const serverLog = {
75
+ info(...args: Array<unknown>): void {
76
+ const { msg } = formatArgs(args)
77
+ logger.info(msg)
78
+ },
79
+
80
+ warn(...args: Array<unknown>): void {
81
+ const { err, msg } = formatArgs(args)
82
+ if (err) logger.warn({ err }, msg)
83
+ else logger.warn(msg)
84
+ },
85
+
86
+ error(...args: Array<unknown>): void {
87
+ const { err, msg } = formatArgs(args)
88
+ if (err) logger.error({ err }, msg)
89
+ else logger.error(msg)
90
+ },
91
+
92
+ event(obj: Record<string, unknown>, msg: string): void {
93
+ logger.info(obj, msg)
94
+ },
95
+ }
@@ -0,0 +1,245 @@
1
+ import { access } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import {
4
+ applyElectricUrlQueryParams,
5
+ electricUrlWithPath,
6
+ } from './electric-url.js'
7
+ import { resolveDurableStreamsRoutingAdapter } from '../routing/durable-streams-routing-adapter.js'
8
+ import { applyDurableStreamsBearer } from '../stream-client.js'
9
+ import type { Agent } from 'undici'
10
+ import type { DurableStreamsRoutingAdapter } from '../routing/durable-streams-routing-adapter.js'
11
+ import type { DurableStreamsBearerProvider } from '../stream-client.js'
12
+
13
+ export function contentTypeForStaticFile(filePath: string): string {
14
+ const ext = path.extname(filePath).toLowerCase()
15
+ switch (ext) {
16
+ case `.html`:
17
+ return `text/html; charset=utf-8`
18
+ case `.js`:
19
+ return `text/javascript; charset=utf-8`
20
+ case `.css`:
21
+ return `text/css; charset=utf-8`
22
+ case `.json`:
23
+ return `application/json; charset=utf-8`
24
+ case `.svg`:
25
+ return `image/svg+xml`
26
+ case `.png`:
27
+ return `image/png`
28
+ case `.jpg`:
29
+ case `.jpeg`:
30
+ return `image/jpeg`
31
+ case `.ico`:
32
+ return `image/x-icon`
33
+ case `.map`:
34
+ return `application/json; charset=utf-8`
35
+ default:
36
+ return `application/octet-stream`
37
+ }
38
+ }
39
+
40
+ export function cacheControlForAgentUiFile(filePath: string): string {
41
+ return filePath.includes(`${path.sep}assets${path.sep}`)
42
+ ? `public, max-age=31536000, immutable`
43
+ : `no-cache`
44
+ }
45
+
46
+ export function resolveAgentUiPath(
47
+ agentUiDistDir: string,
48
+ relativePath: string
49
+ ): string {
50
+ const normalized = relativePath.replace(/^\/+/, ``)
51
+ return path.resolve(agentUiDistDir, normalized)
52
+ }
53
+
54
+ export async function pickAgentUiFile(
55
+ agentUiDistDir: string,
56
+ filePath: string,
57
+ fallbackToIndex: boolean
58
+ ): Promise<string | null> {
59
+ if (isAgentUiPath(agentUiDistDir, filePath) && (await fileExists(filePath))) {
60
+ return filePath
61
+ }
62
+
63
+ if (!fallbackToIndex) {
64
+ return null
65
+ }
66
+
67
+ const indexPath = path.join(agentUiDistDir, `index.html`)
68
+ if (!(await fileExists(indexPath))) {
69
+ return null
70
+ }
71
+ return indexPath
72
+ }
73
+
74
+ export function isAgentUiPath(
75
+ agentUiDistDir: string,
76
+ filePath: string
77
+ ): boolean {
78
+ return (
79
+ filePath === agentUiDistDir ||
80
+ filePath.startsWith(`${agentUiDistDir}${path.sep}`)
81
+ )
82
+ }
83
+
84
+ export async function fileExists(filePath: string): Promise<boolean> {
85
+ try {
86
+ await access(filePath)
87
+ return true
88
+ } catch {
89
+ return false
90
+ }
91
+ }
92
+
93
+ export function buildElectricProxyTarget(options: {
94
+ incomingUrl: URL
95
+ electricUrl: string
96
+ electricSecret?: string
97
+ tenantId: string
98
+ }): URL {
99
+ const targetPath = options.incomingUrl.pathname.replace(
100
+ `/_electric/electric`,
101
+ ``
102
+ )
103
+ const target = electricUrlWithPath(options.electricUrl, targetPath)
104
+ options.incomingUrl.searchParams.forEach((value, key) => {
105
+ target.searchParams.append(key, value)
106
+ })
107
+ applyElectricUrlQueryParams(target, options.electricUrl)
108
+
109
+ if (targetPath !== `/v1/shape`) {
110
+ return target
111
+ }
112
+
113
+ if (options.electricSecret) {
114
+ target.searchParams.set(`secret`, options.electricSecret)
115
+ }
116
+
117
+ const table = options.incomingUrl.searchParams.get(`table`)
118
+ if (table === `entities`) {
119
+ target.searchParams.set(
120
+ `columns`,
121
+ `"tenant_id","url","type","status","dispatch_policy","tags","spawn_args","parent","type_revision","inbox_schemas","state_schemas","created_at","updated_at"`
122
+ )
123
+ applyTenantShapeWhere(target, options.tenantId)
124
+ } else if (table === `entity_types`) {
125
+ target.searchParams.set(
126
+ `columns`,
127
+ `"tenant_id","name","description","creation_schema","inbox_schemas","state_schemas","serve_endpoint","default_dispatch_policy","revision","created_at","updated_at"`
128
+ )
129
+ applyTenantShapeWhere(target, options.tenantId)
130
+ } else if (table === `runners`) {
131
+ target.searchParams.set(
132
+ `columns`,
133
+ `"tenant_id","id","owner_user_id","label","kind","admin_status","wake_stream","wake_stream_offset","last_seen_at","liveness_lease_expires_at","created_at","updated_at"`
134
+ )
135
+ applyTenantShapeWhere(target, options.tenantId)
136
+ } else if (table === `entity_dispatch_state`) {
137
+ target.searchParams.set(
138
+ `columns`,
139
+ `"tenant_id","entity_url","pending_source_streams","pending_reason","pending_since","outstanding_wake_id","outstanding_wake_target","outstanding_wake_created_at","active_consumer_id","active_runner_id","active_epoch","active_claimed_at","active_lease_expires_at","last_wake_id","last_claimed_at","last_released_at","last_completed_at","last_error","updated_at"`
140
+ )
141
+ applyTenantShapeWhere(target, options.tenantId)
142
+ } else if (table === `wake_notifications`) {
143
+ target.searchParams.set(
144
+ `columns`,
145
+ `"tenant_id","wake_id","entity_url","target_type","target_runner_id","target_webhook_url","target_worker_pool_id","runner_wake_stream","runner_wake_stream_offset","notification_public","delivery_status","claim_status","created_at","delivered_at","claimed_at","resolved_at"`
146
+ )
147
+ applyTenantShapeWhere(target, options.tenantId)
148
+ } else if (table === `consumer_claims`) {
149
+ target.searchParams.set(
150
+ `columns`,
151
+ `"tenant_id","consumer_id","epoch","wake_id","entity_url","stream_path","runner_id","status","claimed_at","last_heartbeat_at","lease_expires_at","released_at","acked_streams","updated_at"`
152
+ )
153
+ applyTenantShapeWhere(target, options.tenantId)
154
+ }
155
+
156
+ return target
157
+ }
158
+
159
+ export async function forwardFetchRequest(options: {
160
+ request: {
161
+ method: string
162
+ url: string
163
+ headers: Headers
164
+ }
165
+ durableStreamsUrl: string
166
+ durableStreamsRouting?: DurableStreamsRoutingAdapter
167
+ serviceId: string
168
+ body?: Uint8Array
169
+ dispatcher?: Agent
170
+ route?: `stream` | `stream-meta`
171
+ durableStreamsBearer?: DurableStreamsBearerProvider
172
+ durableStreamsBearerMode?: `overwrite` | `if-missing` | `none`
173
+ }): Promise<Response> {
174
+ const routingAdapter = resolveDurableStreamsRoutingAdapter(
175
+ options.durableStreamsRouting
176
+ )
177
+ const routingInput = {
178
+ durableStreamsUrl: options.durableStreamsUrl,
179
+ serviceId: options.serviceId,
180
+ requestUrl: options.request.url,
181
+ }
182
+ const upstreamUrl =
183
+ options.route === `stream-meta`
184
+ ? routingAdapter.streamMetaUrl(routingInput)
185
+ : routingAdapter.streamUrl(routingInput)
186
+
187
+ const headers = new Headers(options.request.headers)
188
+ if (options.durableStreamsBearerMode !== `none`) {
189
+ await applyDurableStreamsBearer(headers, options.durableStreamsBearer, {
190
+ overwrite: options.durableStreamsBearerMode !== `if-missing`,
191
+ })
192
+ }
193
+
194
+ const init: RequestInit & { duplex?: `half`; dispatcher?: Agent } = {
195
+ method: options.request.method,
196
+ headers,
197
+ }
198
+ if (options.body !== undefined) {
199
+ headers.delete(`content-length`)
200
+ init.body = bodyFromBytes(options.body)
201
+ init.duplex = `half`
202
+ }
203
+ if (options.dispatcher) {
204
+ init.dispatcher = options.dispatcher
205
+ }
206
+
207
+ return await fetch(upstreamUrl, init as RequestInit)
208
+ }
209
+
210
+ function bodyFromBytes(body: Uint8Array): ArrayBuffer {
211
+ return body.buffer.slice(
212
+ body.byteOffset,
213
+ body.byteOffset + body.byteLength
214
+ ) as ArrayBuffer
215
+ }
216
+
217
+ export function decodeJsonObject(
218
+ body: Uint8Array
219
+ ): Record<string, unknown> | null {
220
+ if (body.length === 0) return null
221
+
222
+ try {
223
+ const parsed = JSON.parse(new TextDecoder().decode(body)) as unknown
224
+ if (parsed && typeof parsed === `object` && !Array.isArray(parsed)) {
225
+ return parsed as Record<string, unknown>
226
+ }
227
+ } catch {
228
+ // Not JSON; callers can fall back to raw bytes.
229
+ }
230
+
231
+ return null
232
+ }
233
+
234
+ function applyTenantShapeWhere(target: URL, tenantId: string): void {
235
+ const tenantWhere = `tenant_id = ${sqlStringLiteral(tenantId)}`
236
+ const existingWhere = target.searchParams.get(`where`)
237
+ target.searchParams.set(
238
+ `where`,
239
+ existingWhere ? `${tenantWhere} AND (${existingWhere})` : tenantWhere
240
+ )
241
+ }
242
+
243
+ function sqlStringLiteral(value: string): string {
244
+ return `'${value.replace(/'/g, `''`)}'`
245
+ }
@@ -0,0 +1,33 @@
1
+ export function rewriteLoopbackWebhookUrl(
2
+ value: string | undefined
3
+ ): string | undefined {
4
+ if (!value) return undefined
5
+
6
+ const rewriteTarget =
7
+ process.env.ELECTRIC_AGENTS_REWRITE_LOOPBACK_WEBHOOKS_TO?.trim()
8
+ if (!rewriteTarget) return value
9
+
10
+ const url = new URL(value)
11
+ if (!isLoopbackHostname(url.hostname)) {
12
+ return value
13
+ }
14
+
15
+ if (rewriteTarget.includes(`://`)) {
16
+ const target = new URL(rewriteTarget)
17
+ url.protocol = target.protocol
18
+ url.username = target.username
19
+ url.password = target.password
20
+ url.hostname = target.hostname
21
+ url.port = target.port
22
+ return url.toString()
23
+ }
24
+
25
+ url.host = rewriteTarget
26
+ return url.toString()
27
+ }
28
+
29
+ function isLoopbackHostname(hostname: string): boolean {
30
+ return (
31
+ hostname === `localhost` || hostname === `127.0.0.1` || hostname === `::1`
32
+ )
33
+ }