@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,741 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sub-router for /_electric/* control-plane routes.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { appendPathToUrl } from '@electric-ax/agents-runtime'
|
|
6
|
+
import { Type, type Static } from '@sinclair/typebox'
|
|
7
|
+
import { and, eq } from 'drizzle-orm'
|
|
8
|
+
import { Router, json, status } from 'itty-router'
|
|
9
|
+
import {
|
|
10
|
+
apiError,
|
|
11
|
+
readRequestBody,
|
|
12
|
+
responseHeaders,
|
|
13
|
+
} from '../electric-agents-http.js'
|
|
14
|
+
import { consumerCallbacks, subscriptionWebhooks } from '../db/schema.js'
|
|
15
|
+
import {
|
|
16
|
+
ErrCodeCallbackNotFound,
|
|
17
|
+
ErrCodeForkInProgress,
|
|
18
|
+
ErrCodeSubscriptionNotFound,
|
|
19
|
+
} from '../electric-agents-types.js'
|
|
20
|
+
import { ATTR, tracer } from '../tracing.js'
|
|
21
|
+
import { decodeJsonObject } from '../utils/server-utils.js'
|
|
22
|
+
import { serverLog } from '../utils/log.js'
|
|
23
|
+
import { cronRouter } from './cron-router.js'
|
|
24
|
+
import { resolveDurableStreamsRoutingAdapter } from './durable-streams-routing-adapter.js'
|
|
25
|
+
import { electricProxyRouter } from './electric-proxy-router.js'
|
|
26
|
+
import { entitiesRouter } from './entities-router.js'
|
|
27
|
+
import { entityTypesRouter } from './entity-types-router.js'
|
|
28
|
+
import { getRequestSpan } from './hooks.js'
|
|
29
|
+
import { runnersRouter } from './runners-router.js'
|
|
30
|
+
import { routeBody, validateOptionalJsonBody, withSchema } from './schema.js'
|
|
31
|
+
import { withLeadingSlash } from './tenant-stream-paths.js'
|
|
32
|
+
import type { IRequest, RouterType } from 'itty-router'
|
|
33
|
+
import type { TenantContext } from './context.js'
|
|
34
|
+
import type { DurableStreamsRoutingAdapter } from './durable-streams-routing-adapter.js'
|
|
35
|
+
|
|
36
|
+
const wakeRegistrationBodySchema = Type.Object({
|
|
37
|
+
subscriberUrl: Type.String(),
|
|
38
|
+
sourceUrl: Type.String(),
|
|
39
|
+
condition: Type.Union([
|
|
40
|
+
Type.Literal(`runFinished`),
|
|
41
|
+
Type.Object({
|
|
42
|
+
on: Type.Literal(`change`),
|
|
43
|
+
collections: Type.Optional(Type.Array(Type.String())),
|
|
44
|
+
ops: Type.Optional(
|
|
45
|
+
Type.Array(
|
|
46
|
+
Type.Union([
|
|
47
|
+
Type.Literal(`insert`),
|
|
48
|
+
Type.Literal(`update`),
|
|
49
|
+
Type.Literal(`delete`),
|
|
50
|
+
])
|
|
51
|
+
)
|
|
52
|
+
),
|
|
53
|
+
}),
|
|
54
|
+
]),
|
|
55
|
+
debounceMs: Type.Optional(Type.Number()),
|
|
56
|
+
timeoutMs: Type.Optional(Type.Number()),
|
|
57
|
+
includeResponse: Type.Optional(Type.Boolean()),
|
|
58
|
+
manifestKey: Type.Optional(Type.String()),
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
const webhookForwardBodySchema = Type.Object(
|
|
62
|
+
{
|
|
63
|
+
subscription_id: Type.Optional(Type.String()),
|
|
64
|
+
wake_id: Type.Optional(Type.String()),
|
|
65
|
+
generation: Type.Optional(Type.Number()),
|
|
66
|
+
streams: Type.Optional(Type.Array(Type.Record(Type.String(), Type.Any()))),
|
|
67
|
+
callback_url: Type.Optional(Type.String()),
|
|
68
|
+
callback_token: Type.Optional(Type.String()),
|
|
69
|
+
primary_stream: Type.Optional(Type.String()),
|
|
70
|
+
primaryStream: Type.Optional(Type.String()),
|
|
71
|
+
streamPath: Type.Optional(Type.String()),
|
|
72
|
+
consumerId: Type.Optional(Type.String()),
|
|
73
|
+
consumer_id: Type.Optional(Type.String()),
|
|
74
|
+
callback: Type.Optional(Type.String()),
|
|
75
|
+
},
|
|
76
|
+
{ additionalProperties: true }
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
const callbackForwardBodySchema = Type.Object(
|
|
80
|
+
{
|
|
81
|
+
epoch: Type.Optional(Type.Number()),
|
|
82
|
+
generation: Type.Optional(Type.Number()),
|
|
83
|
+
wakeId: Type.Optional(Type.String()),
|
|
84
|
+
wake_id: Type.Optional(Type.String()),
|
|
85
|
+
acks: Type.Optional(Type.Array(Type.Record(Type.String(), Type.Any()))),
|
|
86
|
+
done: Type.Optional(Type.Boolean()),
|
|
87
|
+
},
|
|
88
|
+
{ additionalProperties: true }
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
type WakeRegistrationBody = Static<typeof wakeRegistrationBodySchema>
|
|
92
|
+
type WebhookForwardBody = Static<typeof webhookForwardBodySchema>
|
|
93
|
+
type CallbackForwardBody = Static<typeof callbackForwardBodySchema>
|
|
94
|
+
|
|
95
|
+
const DS_SUBSCRIPTION_CALLBACK_PREFIX = `ds-subscription:`
|
|
96
|
+
|
|
97
|
+
export type InternalRoutes = RouterType<
|
|
98
|
+
IRequest,
|
|
99
|
+
[TenantContext],
|
|
100
|
+
Response | undefined
|
|
101
|
+
>
|
|
102
|
+
|
|
103
|
+
export const internalRouter: InternalRoutes = Router<
|
|
104
|
+
IRequest,
|
|
105
|
+
[TenantContext],
|
|
106
|
+
Response | undefined
|
|
107
|
+
>({
|
|
108
|
+
base: `/_electric`,
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
internalRouter.get(`/health`, () => json({ status: `ok` }))
|
|
112
|
+
internalRouter.post(
|
|
113
|
+
`/wake`,
|
|
114
|
+
withSchema(wakeRegistrationBodySchema),
|
|
115
|
+
registerWake
|
|
116
|
+
)
|
|
117
|
+
internalRouter.post(`/webhook-forward/:subscriptionId`, webhookForward)
|
|
118
|
+
internalRouter.post(`/callback-forward/:consumerId`, callbackForward)
|
|
119
|
+
internalRouter.all(`/runners`, runnersRouter.fetch)
|
|
120
|
+
internalRouter.all(`/runners/*`, runnersRouter.fetch)
|
|
121
|
+
internalRouter.all(`/entities/*`, entitiesRouter.fetch)
|
|
122
|
+
internalRouter.all(`/entity-types/*`, entityTypesRouter.fetch)
|
|
123
|
+
internalRouter.all(`/cron/*`, cronRouter.fetch)
|
|
124
|
+
internalRouter.get(`/electric/*`, electricProxyRouter.fetch)
|
|
125
|
+
internalRouter.all(`*`, () => status(404))
|
|
126
|
+
|
|
127
|
+
function routeParam(request: IRequest, name: string): string {
|
|
128
|
+
const value = request.params[name]
|
|
129
|
+
return decodeURIComponent(Array.isArray(value) ? value[0]! : value)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function bodyFromBytes(body: Uint8Array): ArrayBuffer {
|
|
133
|
+
return body.buffer.slice(
|
|
134
|
+
body.byteOffset,
|
|
135
|
+
body.byteOffset + body.byteLength
|
|
136
|
+
) as ArrayBuffer
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function responseFromUpstream(response: Response, body?: Uint8Array): Response {
|
|
140
|
+
return new Response(body ? bodyFromBytes(body) : response.body, {
|
|
141
|
+
status: response.status,
|
|
142
|
+
statusText: response.statusText,
|
|
143
|
+
headers: responseHeaders(response),
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function forwardHeadersFromRequest(request: IRequest): Headers {
|
|
148
|
+
const headers = new Headers(request.headers)
|
|
149
|
+
headers.delete(`host`)
|
|
150
|
+
return headers
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function durableStreamsSubscriptionCallback(value: string): string | null {
|
|
154
|
+
return value.startsWith(DS_SUBSCRIPTION_CALLBACK_PREFIX)
|
|
155
|
+
? value.slice(DS_SUBSCRIPTION_CALLBACK_PREFIX.length)
|
|
156
|
+
: null
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function claimTokenFromRequest(request: IRequest): string | undefined {
|
|
160
|
+
const electricClaimToken = request.headers.get(`electric-claim-token`)?.trim()
|
|
161
|
+
if (electricClaimToken) return electricClaimToken
|
|
162
|
+
return (
|
|
163
|
+
request.headers
|
|
164
|
+
.get(`authorization`)
|
|
165
|
+
?.replace(/^Bearer\s+/i, ``)
|
|
166
|
+
.trim() || undefined
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function newWebhookPayload(body: WebhookForwardBody | undefined): {
|
|
171
|
+
wakeId: string
|
|
172
|
+
generation: number
|
|
173
|
+
primaryStream: string
|
|
174
|
+
tailOffset: string
|
|
175
|
+
callbackUrl: string
|
|
176
|
+
callbackToken: string
|
|
177
|
+
} | null {
|
|
178
|
+
if (
|
|
179
|
+
!body ||
|
|
180
|
+
typeof body.subscription_id !== `string` ||
|
|
181
|
+
typeof body.wake_id !== `string` ||
|
|
182
|
+
typeof body.generation !== `number` ||
|
|
183
|
+
typeof body.callback_url !== `string` ||
|
|
184
|
+
typeof body.callback_token !== `string` ||
|
|
185
|
+
!Array.isArray(body.streams)
|
|
186
|
+
) {
|
|
187
|
+
return null
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const streamInfos = body.streams as Array<
|
|
191
|
+
| {
|
|
192
|
+
path?: unknown
|
|
193
|
+
tail_offset?: unknown
|
|
194
|
+
has_pending?: unknown
|
|
195
|
+
}
|
|
196
|
+
| undefined
|
|
197
|
+
>
|
|
198
|
+
const firstStream =
|
|
199
|
+
streamInfos.find((stream) => stream?.has_pending === true) ?? streamInfos[0]
|
|
200
|
+
const selectedStream = firstStream as
|
|
201
|
+
| {
|
|
202
|
+
path?: unknown
|
|
203
|
+
tail_offset?: unknown
|
|
204
|
+
}
|
|
205
|
+
| undefined
|
|
206
|
+
if (
|
|
207
|
+
typeof selectedStream?.path !== `string` ||
|
|
208
|
+
typeof selectedStream.tail_offset !== `string`
|
|
209
|
+
) {
|
|
210
|
+
return null
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
wakeId: body.wake_id,
|
|
215
|
+
generation: body.generation,
|
|
216
|
+
primaryStream: withLeadingSlash(selectedStream.path),
|
|
217
|
+
tailOffset: selectedStream.tail_offset,
|
|
218
|
+
callbackUrl: body.callback_url,
|
|
219
|
+
callbackToken: body.callback_token,
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function toRuntimeStreamPath(
|
|
224
|
+
path: string,
|
|
225
|
+
service: string,
|
|
226
|
+
routingAdapter: DurableStreamsRoutingAdapter
|
|
227
|
+
): string {
|
|
228
|
+
return withLeadingSlash(routingAdapter.toRuntimeStreamPath(service, path))
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function registerWake(
|
|
232
|
+
request: IRequest,
|
|
233
|
+
ctx: TenantContext
|
|
234
|
+
): Promise<Response> {
|
|
235
|
+
const opts = routeBody<WakeRegistrationBody>(request)
|
|
236
|
+
await ctx.entityManager.registerWake(opts)
|
|
237
|
+
return status(204)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function webhookForward(
|
|
241
|
+
request: IRequest,
|
|
242
|
+
ctx: TenantContext
|
|
243
|
+
): Promise<Response> {
|
|
244
|
+
const subscriptionId = routeParam(request, `subscriptionId`)
|
|
245
|
+
const rootSpan = getRequestSpan(request)
|
|
246
|
+
rootSpan?.updateName(`webhook-forward`)
|
|
247
|
+
rootSpan?.setAttribute(
|
|
248
|
+
`electric_agents.webhook.subscription_id`,
|
|
249
|
+
subscriptionId
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
const lookupPromise: Promise<string | null> = tracer.startActiveSpan(
|
|
253
|
+
`db.lookupSubscription`,
|
|
254
|
+
async (span) => {
|
|
255
|
+
try {
|
|
256
|
+
const rows = await ctx.pgDb
|
|
257
|
+
.select()
|
|
258
|
+
.from(subscriptionWebhooks)
|
|
259
|
+
.where(
|
|
260
|
+
and(
|
|
261
|
+
eq(subscriptionWebhooks.tenantId, ctx.service),
|
|
262
|
+
eq(subscriptionWebhooks.subscriptionId, subscriptionId)
|
|
263
|
+
)
|
|
264
|
+
)
|
|
265
|
+
.limit(1)
|
|
266
|
+
return rows[0]?.webhookUrl ?? null
|
|
267
|
+
} finally {
|
|
268
|
+
span.end()
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
const [targetWebhookUrl, body] = await Promise.all([
|
|
274
|
+
lookupPromise,
|
|
275
|
+
readRequestBody(request as Request),
|
|
276
|
+
])
|
|
277
|
+
|
|
278
|
+
if (!targetWebhookUrl) {
|
|
279
|
+
return apiError(
|
|
280
|
+
404,
|
|
281
|
+
ErrCodeSubscriptionNotFound,
|
|
282
|
+
`Unknown webhook subscription`
|
|
283
|
+
)
|
|
284
|
+
}
|
|
285
|
+
const parsedBodyResult = validateOptionalJsonBody(
|
|
286
|
+
webhookForwardBodySchema,
|
|
287
|
+
body,
|
|
288
|
+
request.headers.get(`content-type`)
|
|
289
|
+
)
|
|
290
|
+
if (!parsedBodyResult.ok) return parsedBodyResult.response
|
|
291
|
+
|
|
292
|
+
let forwardBody = body
|
|
293
|
+
let runningEntityUrl: string | null = null
|
|
294
|
+
const parsedBody = parsedBodyResult.value as WebhookForwardBody | undefined
|
|
295
|
+
const newWebhook = newWebhookPayload(parsedBody)
|
|
296
|
+
const routingAdapter = resolveDurableStreamsRoutingAdapter(
|
|
297
|
+
ctx.durableStreamsRouting
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
if (parsedBody) {
|
|
301
|
+
const rawPrimaryStream =
|
|
302
|
+
newWebhook?.primaryStream ??
|
|
303
|
+
parsedBody.primary_stream ??
|
|
304
|
+
parsedBody.primaryStream ??
|
|
305
|
+
parsedBody.streamPath ??
|
|
306
|
+
null
|
|
307
|
+
const primaryStream =
|
|
308
|
+
typeof rawPrimaryStream === `string`
|
|
309
|
+
? toRuntimeStreamPath(rawPrimaryStream, ctx.service, routingAdapter)
|
|
310
|
+
: null
|
|
311
|
+
const consumerId =
|
|
312
|
+
newWebhook?.wakeId ??
|
|
313
|
+
parsedBody.consumerId ??
|
|
314
|
+
parsedBody.consumer_id ??
|
|
315
|
+
null
|
|
316
|
+
const callbackUrl = newWebhook?.callbackUrl ?? parsedBody.callback ?? null
|
|
317
|
+
|
|
318
|
+
if (primaryStream) {
|
|
319
|
+
rootSpan?.setAttribute(ATTR.STREAM_PATH, primaryStream)
|
|
320
|
+
|
|
321
|
+
const entityPromise = tracer.startActiveSpan(
|
|
322
|
+
`db.getEntityByStream`,
|
|
323
|
+
async (span) => {
|
|
324
|
+
try {
|
|
325
|
+
return await ctx.entityManager.registry.getEntityByStream(
|
|
326
|
+
primaryStream
|
|
327
|
+
)
|
|
328
|
+
} finally {
|
|
329
|
+
span.end()
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
)
|
|
333
|
+
const enrichPromise = tracer.startActiveSpan(
|
|
334
|
+
`electric_agents.enrichPayload`,
|
|
335
|
+
async (span) => {
|
|
336
|
+
try {
|
|
337
|
+
return await ctx.entityManager.enrichPayload(parsedBody, {
|
|
338
|
+
primary_stream: primaryStream,
|
|
339
|
+
})
|
|
340
|
+
} finally {
|
|
341
|
+
span.end()
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
const upsertPromise =
|
|
347
|
+
consumerId && callbackUrl
|
|
348
|
+
? tracer
|
|
349
|
+
.startActiveSpan(`db.upsertConsumerCallback`, async (span) => {
|
|
350
|
+
try {
|
|
351
|
+
await ctx.pgDb
|
|
352
|
+
.insert(consumerCallbacks)
|
|
353
|
+
.values({
|
|
354
|
+
tenantId: ctx.service,
|
|
355
|
+
consumerId,
|
|
356
|
+
callbackUrl,
|
|
357
|
+
primaryStream,
|
|
358
|
+
})
|
|
359
|
+
.onConflictDoUpdate({
|
|
360
|
+
target: [
|
|
361
|
+
consumerCallbacks.tenantId,
|
|
362
|
+
consumerCallbacks.consumerId,
|
|
363
|
+
],
|
|
364
|
+
set: { callbackUrl, primaryStream },
|
|
365
|
+
})
|
|
366
|
+
} finally {
|
|
367
|
+
span.end()
|
|
368
|
+
}
|
|
369
|
+
})
|
|
370
|
+
.catch((err) => {
|
|
371
|
+
serverLog.warn(
|
|
372
|
+
`[webhook-forward] consumerCallbacks upsert failed (non-fatal): ${
|
|
373
|
+
err instanceof Error ? err.message : String(err)
|
|
374
|
+
}`
|
|
375
|
+
)
|
|
376
|
+
})
|
|
377
|
+
: undefined
|
|
378
|
+
|
|
379
|
+
const [entity, enriched] = await Promise.all([
|
|
380
|
+
entityPromise,
|
|
381
|
+
enrichPromise,
|
|
382
|
+
])
|
|
383
|
+
|
|
384
|
+
if (entity?.status === `stopped`) {
|
|
385
|
+
if (upsertPromise) await upsertPromise
|
|
386
|
+
return json({ done: true })
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (upsertPromise) await upsertPromise
|
|
390
|
+
|
|
391
|
+
if (entity && ctx.entityManager.isForkWorkLockedEntity(entity.url)) {
|
|
392
|
+
return apiError(
|
|
393
|
+
409,
|
|
394
|
+
ErrCodeForkInProgress,
|
|
395
|
+
`Entity subtree is being forked`
|
|
396
|
+
)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (entity) {
|
|
400
|
+
rootSpan?.setAttribute(ATTR.ENTITY_URL, entity.url)
|
|
401
|
+
await tracer.startActiveSpan(
|
|
402
|
+
`db.updateStatus.running`,
|
|
403
|
+
async (span) => {
|
|
404
|
+
try {
|
|
405
|
+
await ctx.entityManager.registry.updateStatus(
|
|
406
|
+
entity.url,
|
|
407
|
+
`running`
|
|
408
|
+
)
|
|
409
|
+
} finally {
|
|
410
|
+
span.end()
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
)
|
|
414
|
+
runningEntityUrl = entity.url
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (consumerId && callbackUrl) {
|
|
418
|
+
const callback = appendPathToUrl(
|
|
419
|
+
ctx.publicUrl,
|
|
420
|
+
`/_electric/callback-forward/${encodeURIComponent(consumerId)}`
|
|
421
|
+
)
|
|
422
|
+
enriched.callback = callback
|
|
423
|
+
if (newWebhook) {
|
|
424
|
+
enriched.consumerId = newWebhook.wakeId
|
|
425
|
+
enriched.epoch = newWebhook.generation
|
|
426
|
+
enriched.wakeId = newWebhook.wakeId
|
|
427
|
+
enriched.streamPath = primaryStream
|
|
428
|
+
enriched.streams = [
|
|
429
|
+
{ path: primaryStream, offset: newWebhook.tailOffset },
|
|
430
|
+
]
|
|
431
|
+
enriched.claimToken = newWebhook.callbackToken
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
forwardBody = new TextEncoder().encode(JSON.stringify(enriched))
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const headers = forwardHeadersFromRequest(request)
|
|
439
|
+
headers.set(`content-type`, `application/json`)
|
|
440
|
+
headers.delete(`content-length`)
|
|
441
|
+
|
|
442
|
+
let upstream: Response
|
|
443
|
+
try {
|
|
444
|
+
upstream = await tracer.startActiveSpan(
|
|
445
|
+
`fetch.agent-handler`,
|
|
446
|
+
async (span) => {
|
|
447
|
+
span.setAttribute(`http.url`, targetWebhookUrl)
|
|
448
|
+
try {
|
|
449
|
+
return await fetch(targetWebhookUrl, {
|
|
450
|
+
method: request.method,
|
|
451
|
+
headers,
|
|
452
|
+
body: bodyFromBytes(forwardBody),
|
|
453
|
+
})
|
|
454
|
+
} finally {
|
|
455
|
+
span.end()
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
)
|
|
459
|
+
} catch (err) {
|
|
460
|
+
if (runningEntityUrl) {
|
|
461
|
+
await ctx.entityManager.registry.updateStatus(runningEntityUrl, `idle`)
|
|
462
|
+
}
|
|
463
|
+
return apiError(
|
|
464
|
+
502,
|
|
465
|
+
`WEBHOOK_FORWARD_FAILED`,
|
|
466
|
+
err instanceof Error ? err.message : String(err)
|
|
467
|
+
)
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const responseBytes = upstream.body
|
|
471
|
+
? new Uint8Array(await upstream.arrayBuffer())
|
|
472
|
+
: new Uint8Array()
|
|
473
|
+
return responseFromUpstream(upstream, responseBytes)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async function callbackForward(
|
|
477
|
+
request: IRequest,
|
|
478
|
+
ctx: TenantContext
|
|
479
|
+
): Promise<Response> {
|
|
480
|
+
const consumerId = routeParam(request, `consumerId`)
|
|
481
|
+
const rows = await ctx.pgDb
|
|
482
|
+
.select()
|
|
483
|
+
.from(consumerCallbacks)
|
|
484
|
+
.where(
|
|
485
|
+
and(
|
|
486
|
+
eq(consumerCallbacks.tenantId, ctx.service),
|
|
487
|
+
eq(consumerCallbacks.consumerId, consumerId)
|
|
488
|
+
)
|
|
489
|
+
)
|
|
490
|
+
.limit(1)
|
|
491
|
+
const target = rows[0]
|
|
492
|
+
? {
|
|
493
|
+
callbackUrl: rows[0].callbackUrl,
|
|
494
|
+
primaryStream: rows[0].primaryStream,
|
|
495
|
+
}
|
|
496
|
+
: undefined
|
|
497
|
+
|
|
498
|
+
if (!target) {
|
|
499
|
+
return apiError(
|
|
500
|
+
404,
|
|
501
|
+
ErrCodeCallbackNotFound,
|
|
502
|
+
`Unknown callback-forward consumer`
|
|
503
|
+
)
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const body = await readRequestBody(request as Request)
|
|
507
|
+
const parsedBodyResult = validateOptionalJsonBody(
|
|
508
|
+
callbackForwardBodySchema,
|
|
509
|
+
body,
|
|
510
|
+
request.headers.get(`content-type`)
|
|
511
|
+
)
|
|
512
|
+
if (!parsedBodyResult.ok) return parsedBodyResult.response
|
|
513
|
+
const requestBody = parsedBodyResult.value as CallbackForwardBody | undefined
|
|
514
|
+
const isClaimRequest =
|
|
515
|
+
requestBody?.wakeId !== undefined || requestBody?.wake_id !== undefined
|
|
516
|
+
const isDoneRequest = requestBody?.done === true
|
|
517
|
+
|
|
518
|
+
const headers = forwardHeadersFromRequest(request)
|
|
519
|
+
headers.delete(`content-length`)
|
|
520
|
+
|
|
521
|
+
if (isClaimRequest && !isDoneRequest) {
|
|
522
|
+
let responseBody: Record<string, unknown> = { ok: true }
|
|
523
|
+
if (target.primaryStream) {
|
|
524
|
+
const writeToken = await mintClaimWriteToken(
|
|
525
|
+
ctx,
|
|
526
|
+
target.primaryStream,
|
|
527
|
+
consumerId
|
|
528
|
+
)
|
|
529
|
+
if (writeToken) {
|
|
530
|
+
responseBody = { ...responseBody, writeToken }
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return json(responseBody)
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const upstreamBody = encodeCallbackForwardBody(
|
|
537
|
+
ctx.service,
|
|
538
|
+
consumerId,
|
|
539
|
+
requestBody,
|
|
540
|
+
resolveDurableStreamsRoutingAdapter(ctx.durableStreamsRouting)
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
let upstream: Response
|
|
544
|
+
try {
|
|
545
|
+
const subscriptionId = durableStreamsSubscriptionCallback(
|
|
546
|
+
target.callbackUrl
|
|
547
|
+
)
|
|
548
|
+
if (subscriptionId) {
|
|
549
|
+
const token = claimTokenFromRequest(request)
|
|
550
|
+
if (!token) {
|
|
551
|
+
return apiError(401, `UNAUTHORIZED`, `Missing claim token`)
|
|
552
|
+
}
|
|
553
|
+
const upstreamPayload = encodeCallbackForwardPayload(
|
|
554
|
+
consumerId,
|
|
555
|
+
requestBody,
|
|
556
|
+
(stream) => stream.replace(/^\/+/, ``)
|
|
557
|
+
)
|
|
558
|
+
const result = await ctx.streamClient.ackSubscription(
|
|
559
|
+
subscriptionId,
|
|
560
|
+
token,
|
|
561
|
+
upstreamPayload
|
|
562
|
+
)
|
|
563
|
+
upstream = json(result)
|
|
564
|
+
} else {
|
|
565
|
+
upstream = await fetch(target.callbackUrl, {
|
|
566
|
+
method: request.method,
|
|
567
|
+
headers,
|
|
568
|
+
body: bodyFromBytes(upstreamBody),
|
|
569
|
+
})
|
|
570
|
+
}
|
|
571
|
+
} catch (err) {
|
|
572
|
+
return apiError(
|
|
573
|
+
502,
|
|
574
|
+
`CALLBACK_FORWARD_FAILED`,
|
|
575
|
+
err instanceof Error ? err.message : String(err)
|
|
576
|
+
)
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
let responseBytes: Uint8Array = upstream.body
|
|
580
|
+
? new Uint8Array(await upstream.arrayBuffer())
|
|
581
|
+
: new Uint8Array()
|
|
582
|
+
|
|
583
|
+
if (isClaimRequest && upstream.ok && target.primaryStream) {
|
|
584
|
+
const responseBody = decodeJsonObject(responseBytes)
|
|
585
|
+
if (responseBody?.ok === true) {
|
|
586
|
+
const writeToken = await mintClaimWriteToken(
|
|
587
|
+
ctx,
|
|
588
|
+
target.primaryStream,
|
|
589
|
+
consumerId
|
|
590
|
+
)
|
|
591
|
+
if (writeToken) {
|
|
592
|
+
responseBody.writeToken = writeToken
|
|
593
|
+
responseBytes = new TextEncoder().encode(JSON.stringify(responseBody))
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
try {
|
|
599
|
+
const epoch = requestBody?.generation ?? requestBody?.epoch
|
|
600
|
+
if (
|
|
601
|
+
upstream.ok &&
|
|
602
|
+
!isDoneRequest &&
|
|
603
|
+
epoch !== undefined &&
|
|
604
|
+
target.primaryStream
|
|
605
|
+
) {
|
|
606
|
+
await ctx.entityManager.registry.materializeHeartbeatClaim?.({
|
|
607
|
+
consumerId,
|
|
608
|
+
epoch,
|
|
609
|
+
})
|
|
610
|
+
}
|
|
611
|
+
if (upstream.ok && isDoneRequest && target.primaryStream) {
|
|
612
|
+
serverLog.info(
|
|
613
|
+
`[callback-forward] done received for stream=${target.primaryStream} consumer=${consumerId}`
|
|
614
|
+
)
|
|
615
|
+
const stillOwnsClaim = ctx.runtime.claimWriteTokens.owns(
|
|
616
|
+
ctx.service,
|
|
617
|
+
target.primaryStream,
|
|
618
|
+
consumerId
|
|
619
|
+
)
|
|
620
|
+
const entity = await ctx.entityManager.registry.getEntityByStream(
|
|
621
|
+
target.primaryStream
|
|
622
|
+
)
|
|
623
|
+
if (entity && stillOwnsClaim) {
|
|
624
|
+
if (epoch !== undefined) {
|
|
625
|
+
await ctx.entityManager.registry.materializeReleasedClaim?.({
|
|
626
|
+
consumerId,
|
|
627
|
+
epoch,
|
|
628
|
+
ackedStreams: Array.isArray(requestBody?.acks)
|
|
629
|
+
? requestBody.acks.flatMap((ack) => {
|
|
630
|
+
const stream =
|
|
631
|
+
typeof ack.stream === `string`
|
|
632
|
+
? ack.stream
|
|
633
|
+
: typeof ack.path === `string`
|
|
634
|
+
? ack.path
|
|
635
|
+
: undefined
|
|
636
|
+
const offset =
|
|
637
|
+
typeof ack.offset === `string` ? ack.offset : undefined
|
|
638
|
+
return stream && offset ? [{ path: stream, offset }] : []
|
|
639
|
+
})
|
|
640
|
+
: undefined,
|
|
641
|
+
})
|
|
642
|
+
}
|
|
643
|
+
await ctx.entityManager.registry.updateStatus(entity.url, `idle`)
|
|
644
|
+
ctx.runtime.claimWriteTokens.clearStream(
|
|
645
|
+
ctx.service,
|
|
646
|
+
target.primaryStream
|
|
647
|
+
)
|
|
648
|
+
await ctx.entityBridgeManager.onEntityChanged(entity.url)
|
|
649
|
+
serverLog.info(
|
|
650
|
+
`[callback-forward] status updated to idle for ${entity.url}`
|
|
651
|
+
)
|
|
652
|
+
} else if (stillOwnsClaim) {
|
|
653
|
+
ctx.runtime.claimWriteTokens.clearStream(
|
|
654
|
+
ctx.service,
|
|
655
|
+
target.primaryStream
|
|
656
|
+
)
|
|
657
|
+
} else if (entity) {
|
|
658
|
+
serverLog.info(
|
|
659
|
+
`[callback-forward] done ignored for stale claim stream=${target.primaryStream} consumer=${consumerId}`
|
|
660
|
+
)
|
|
661
|
+
} else {
|
|
662
|
+
serverLog.warn(
|
|
663
|
+
`[callback-forward] done received but no entity found for stream=${target.primaryStream}`
|
|
664
|
+
)
|
|
665
|
+
}
|
|
666
|
+
} else if (requestBody?.done === true) {
|
|
667
|
+
serverLog.warn(
|
|
668
|
+
`[callback-forward] done received but skipped: upstream.ok=${upstream.ok} primaryStream=${
|
|
669
|
+
target.primaryStream ?? `null`
|
|
670
|
+
} consumer=${consumerId}`
|
|
671
|
+
)
|
|
672
|
+
}
|
|
673
|
+
} catch (err) {
|
|
674
|
+
serverLog.error(
|
|
675
|
+
`[callback-forward] error processing done for consumer=${consumerId}: ${
|
|
676
|
+
err instanceof Error ? err.message : String(err)
|
|
677
|
+
}`
|
|
678
|
+
)
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
return responseFromUpstream(upstream, responseBytes)
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
async function mintClaimWriteToken(
|
|
685
|
+
ctx: TenantContext,
|
|
686
|
+
streamPath: string,
|
|
687
|
+
consumerId: string
|
|
688
|
+
): Promise<string | undefined> {
|
|
689
|
+
const entity = await ctx.entityManager.registry.getEntityByStream(streamPath)
|
|
690
|
+
if (!entity) return undefined
|
|
691
|
+
|
|
692
|
+
return ctx.runtime.claimWriteTokens.mint(ctx.service, streamPath, consumerId)
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function encodeCallbackForwardBody(
|
|
696
|
+
service: string,
|
|
697
|
+
consumerId: string,
|
|
698
|
+
body: CallbackForwardBody | undefined,
|
|
699
|
+
routingAdapter: DurableStreamsRoutingAdapter
|
|
700
|
+
): Uint8Array {
|
|
701
|
+
const payload = encodeCallbackForwardPayload(consumerId, body, (stream) =>
|
|
702
|
+
routingAdapter.toBackendStreamPath(service, stream)
|
|
703
|
+
)
|
|
704
|
+
return new TextEncoder().encode(JSON.stringify(payload))
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function encodeCallbackForwardPayload(
|
|
708
|
+
consumerId: string,
|
|
709
|
+
body: CallbackForwardBody | undefined,
|
|
710
|
+
mapStream: (stream: string) => string
|
|
711
|
+
): Record<string, unknown> {
|
|
712
|
+
if (!body) return {}
|
|
713
|
+
const generation = body.generation ?? body.epoch
|
|
714
|
+
const wakeId = body.wake_id ?? body.wakeId ?? consumerId
|
|
715
|
+
const acks = Array.isArray(body.acks)
|
|
716
|
+
? body.acks.map((ack) => {
|
|
717
|
+
const input = ack as {
|
|
718
|
+
path?: unknown
|
|
719
|
+
stream?: unknown
|
|
720
|
+
offset?: unknown
|
|
721
|
+
}
|
|
722
|
+
const stream =
|
|
723
|
+
typeof input.stream === `string`
|
|
724
|
+
? input.stream
|
|
725
|
+
: typeof input.path === `string`
|
|
726
|
+
? input.path
|
|
727
|
+
: ``
|
|
728
|
+
return {
|
|
729
|
+
stream: mapStream(stream),
|
|
730
|
+
offset: typeof input.offset === `string` ? input.offset : ``,
|
|
731
|
+
}
|
|
732
|
+
})
|
|
733
|
+
: []
|
|
734
|
+
|
|
735
|
+
return {
|
|
736
|
+
wake_id: wakeId,
|
|
737
|
+
...(generation !== undefined ? { generation } : {}),
|
|
738
|
+
acks,
|
|
739
|
+
...(body.done !== undefined ? { done: body.done } : {}),
|
|
740
|
+
}
|
|
741
|
+
}
|