@electric-ax/agents-server 0.4.3 → 0.4.5

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.
@@ -8,6 +8,7 @@ import {
8
8
  ErrCodeUnauthorized,
9
9
  } from '../electric-agents-types.js'
10
10
  import { runnerWakeStream } from '../entity-registry.js'
11
+ import { DurableStreamsSubscriptionError } from '../stream-client.js'
11
12
  import { rewriteLoopbackWebhookUrl } from '../utils/webhook-url.js'
12
13
  import { serverLog } from '../utils/log.js'
13
14
  import type {
@@ -16,6 +17,9 @@ import type {
16
17
  ElectricAgentsEntity,
17
18
  } from '../electric-agents-types.js'
18
19
  import type { TenantContext } from './context.js'
20
+ import type { SubscriptionCreateInput } from '../stream-client.js'
21
+
22
+ const linkedDispatchSubscriptions = new WeakMap<object, Set<string>>()
19
23
 
20
24
  export function subscriptionIdForDispatchTarget(
21
25
  target: DispatchTarget
@@ -31,7 +35,7 @@ function subscriptionIdForEntityDispatchTarget(
31
35
  entityUrl: string
32
36
  ): string {
33
37
  const base = subscriptionIdForDispatchTarget(target)
34
- if (!target.subscription_id) return base
38
+ if (!target.subscription_id && target.type !== `runner`) return base
35
39
  const digest = createHash(`sha256`).update(entityUrl).digest(`hex`)
36
40
  return `${base}:${digest.slice(0, 16)}`
37
41
  }
@@ -109,12 +113,92 @@ function sameDispatchDestination(
109
113
  return false
110
114
  }
111
115
 
116
+ function subscriptionHasStream(
117
+ ctx: TenantContext,
118
+ existing: { streams?: Array<string | { path?: string }> },
119
+ streamPath: string
120
+ ): boolean {
121
+ const normalizedStream = streamPath.replace(/^\/+/, ``)
122
+ const backendStream = `${ctx.service}/${normalizedStream}`
123
+ return (
124
+ existing.streams?.some((stream) => {
125
+ const path = typeof stream === `string` ? stream : stream.path
126
+ if (!path) return false
127
+ const normalized = path.replace(/^\/+/, ``)
128
+ return normalized === normalizedStream || normalized === backendStream
129
+ }) ?? false
130
+ )
131
+ }
132
+
133
+ function dispatchLinkCacheKey(
134
+ ctx: TenantContext,
135
+ subscriptionId: string,
136
+ streamPath: string
137
+ ): string {
138
+ return `${ctx.service}:${subscriptionId}:${streamPath}`
139
+ }
140
+
141
+ function getDispatchLinkCache(ctx: TenantContext): Set<string> {
142
+ let cache = linkedDispatchSubscriptions.get(ctx.streamClient)
143
+ if (!cache) {
144
+ cache = new Set()
145
+ linkedDispatchSubscriptions.set(ctx.streamClient, cache)
146
+ }
147
+ return cache
148
+ }
149
+
150
+ function isSubscriptionAlreadyExistsError(err: unknown): boolean {
151
+ if (!(err instanceof DurableStreamsSubscriptionError)) return false
152
+ if (err.status === 409) return true
153
+ return (
154
+ err.code === `SUBSCRIPTION_ALREADY_EXISTS` ||
155
+ err.code === `ALREADY_EXISTS` ||
156
+ /already exists/i.test(err.errorMessage ?? err.body ?? err.message)
157
+ )
158
+ }
159
+
160
+ async function ensureSubscriptionIncludesStream(
161
+ ctx: TenantContext,
162
+ subscriptionId: string,
163
+ streamPath: string,
164
+ input: SubscriptionCreateInput,
165
+ existing: { streams?: Array<string | { path?: string }> } | null
166
+ ): Promise<void> {
167
+ if (!existing) {
168
+ try {
169
+ await ctx.streamClient.putSubscription(subscriptionId, input)
170
+ return
171
+ } catch (err) {
172
+ if (!isSubscriptionAlreadyExistsError(err)) throw err
173
+ existing = await ctx.streamClient.getSubscription(subscriptionId)
174
+ if (!existing) {
175
+ serverLog.warn(
176
+ `[dispatch-policy] subscription create raced with existing subscription but it could not be read`,
177
+ { subscriptionId, stream: streamPath }
178
+ )
179
+ return
180
+ }
181
+ }
182
+ }
183
+
184
+ if (!subscriptionHasStream(ctx, existing, streamPath)) {
185
+ await ctx.streamClient.addSubscriptionStreams(subscriptionId, [streamPath])
186
+ }
187
+ }
188
+
112
189
  export async function assertDispatchPolicyAllowed(
113
190
  ctx: TenantContext,
114
191
  policy: DispatchPolicy | undefined
115
192
  ): Promise<void> {
116
193
  const target = policy?.targets[0]
117
194
  if (!target || target.type !== `runner`) return
195
+ if (!ctx.principal) {
196
+ throw new ElectricAgentsError(
197
+ ErrCodeUnauthorized,
198
+ `Runner dispatch requires an authenticated owner`,
199
+ 401
200
+ )
201
+ }
118
202
 
119
203
  const runner = await ctx.entityManager.registry.getRunner(target.runnerId)
120
204
  if (!runner) {
@@ -124,7 +208,7 @@ export async function assertDispatchPolicyAllowed(
124
208
  404
125
209
  )
126
210
  }
127
- if (ctx.principal && runner.owner_user_id !== ctx.principal.key) {
211
+ if (runner.owner_principal !== ctx.principal.url) {
128
212
  throw new ElectricAgentsError(
129
213
  ErrCodeUnauthorized,
130
214
  `Runner dispatch requires the authenticated owner`,
@@ -143,7 +227,19 @@ export async function linkEntityDispatchSubscription(
143
227
  )
144
228
  const target = dispatchPolicy?.targets[0]
145
229
  if (!target) return
146
- await linkStreamToTargetSubscription(ctx, target, entity)
230
+ const subscriptionId = subscriptionIdForEntityDispatchTarget(
231
+ target,
232
+ entity.url
233
+ )
234
+ const cacheKey = dispatchLinkCacheKey(
235
+ ctx,
236
+ subscriptionId,
237
+ entity.streams.main
238
+ )
239
+ const cache = getDispatchLinkCache(ctx)
240
+ if (cache.has(cacheKey)) return
241
+ await linkStreamToTargetSubscription(ctx, target, entity, subscriptionId)
242
+ cache.add(cacheKey)
147
243
  }
148
244
 
149
245
  export async function unlinkEntityDispatchSubscription(
@@ -160,6 +256,9 @@ export async function unlinkEntityDispatchSubscription(
160
256
  target,
161
257
  entity.url
162
258
  )
259
+ getDispatchLinkCache(ctx).delete(
260
+ dispatchLinkCacheKey(ctx, subscriptionId, entity.streams.main)
261
+ )
163
262
  await ctx.streamClient
164
263
  .removeSubscriptionStream(subscriptionId, entity.streams.main)
165
264
  .catch((err) => {
@@ -174,13 +273,13 @@ export async function unlinkEntityDispatchSubscription(
174
273
  async function linkStreamToTargetSubscription(
175
274
  ctx: TenantContext,
176
275
  target: DispatchTarget,
177
- entity: ElectricAgentsEntity
276
+ entity: ElectricAgentsEntity,
277
+ subscriptionId: string
178
278
  ): Promise<void> {
179
279
  const streamPath = entity.streams.main
180
- const subscriptionId = subscriptionIdForEntityDispatchTarget(
181
- target,
182
- entity.url
183
- )
280
+ await ctx.streamClient.ensure(streamPath, {
281
+ contentType: `application/json`,
282
+ })
184
283
  const existing = await ctx.streamClient.getSubscription(subscriptionId)
185
284
 
186
285
  if (target.type === `runner`) {
@@ -196,16 +295,18 @@ async function linkStreamToTargetSubscription(
196
295
  await ctx.streamClient.ensure(wakeStream, {
197
296
  contentType: `application/json`,
198
297
  })
199
- if (!existing) {
200
- await ctx.streamClient.putSubscription(subscriptionId, {
298
+ await ensureSubscriptionIncludesStream(
299
+ ctx,
300
+ subscriptionId,
301
+ streamPath,
302
+ {
201
303
  type: `pull-wake`,
202
304
  streams: [streamPath],
203
305
  wake_stream: wakeStream,
204
306
  description: `Electric Agents runner ${target.runnerId}`,
205
- })
206
- return
207
- }
208
- await ctx.streamClient.addSubscriptionStreams(subscriptionId, [streamPath])
307
+ },
308
+ existing
309
+ )
209
310
  return
210
311
  }
211
312
 
@@ -221,16 +322,18 @@ async function linkStreamToTargetSubscription(
221
322
  ctx.publicUrl,
222
323
  `/_electric/webhook-forward/${encodeURIComponent(subscriptionId)}`
223
324
  )
224
- if (!existing) {
225
- await ctx.streamClient.putSubscription(subscriptionId, {
325
+ await ensureSubscriptionIncludesStream(
326
+ ctx,
327
+ subscriptionId,
328
+ streamPath,
329
+ {
226
330
  type: `webhook`,
227
331
  streams: [streamPath],
228
332
  webhook: { url: forwardUrl },
229
333
  description: `Electric Agents webhook ${subscriptionId}`,
230
- })
231
- } else {
232
- await ctx.streamClient.addSubscriptionStreams(subscriptionId, [streamPath])
233
- }
334
+ },
335
+ existing
336
+ )
234
337
  await ctx.pgDb
235
338
  .insert(subscriptionWebhooks)
236
339
  .values({
@@ -12,12 +12,11 @@ export interface DurableStreamsRoutingAdapter {
12
12
  }
13
13
 
14
14
  function appendSearch(target: URL, source: URL): URL {
15
- target.search = source.search
16
- return target
17
- }
18
-
19
- function removeServiceQuery(target: URL): URL {
20
- target.searchParams.delete(`service`)
15
+ source.searchParams.forEach((value, key) => {
16
+ if (key !== `service`) {
17
+ target.searchParams.append(key, value)
18
+ }
19
+ })
21
20
  return target
22
21
  }
23
22
 
@@ -32,7 +31,7 @@ function appendRequestPathToStreamRoot(input: DurableStreamsRoutingInput): URL {
32
31
  target.pathname = path
33
32
  ? `${withoutTrailingSlash(target.pathname)}/${path}`
34
33
  : withoutTrailingSlash(target.pathname)
35
- return removeServiceQuery(appendSearch(target, incomingUrl))
34
+ return appendSearch(target, incomingUrl)
36
35
  }
37
36
 
38
37
  export const streamRootDurableStreamsRoutingAdapter: DurableStreamsRoutingAdapter =
@@ -38,6 +38,7 @@ async function proxyElectric(
38
38
  electricUrl: ctx.electricUrl,
39
39
  electricSecret: ctx.electricSecret,
40
40
  tenantId: ctx.service,
41
+ principalUrl: ctx.principal.url,
41
42
  })
42
43
  const headers = new Headers(request.headers)
43
44
  headers.delete(`host`)
@@ -530,10 +530,10 @@ async function sendEntity(
530
530
  await ctx.entityManager.ensurePrincipal(principal)
531
531
  const { entityUrl, entity } = requireExistingEntityRoute(request)
532
532
 
533
- if (!entity.dispatch_policy) {
534
- const updatedEntity = await backfillEntityDispatchPolicy(ctx, entity)
535
- await linkEntityDispatchSubscription(ctx, updatedEntity)
536
- }
533
+ const dispatchEntity = entity.dispatch_policy
534
+ ? entity
535
+ : await backfillEntityDispatchPolicy(ctx, entity)
536
+ await linkEntityDispatchSubscription(ctx, dispatchEntity)
537
537
 
538
538
  if (parsed.afterMs && parsed.afterMs > 0) {
539
539
  await ctx.entityManager.enqueueDelayedSend(
@@ -1,6 +1,7 @@
1
1
  import { SpanKind, SpanStatusCode } from '@opentelemetry/api'
2
2
  import { apiError } from '../electric-agents-http.js'
3
3
  import { ElectricAgentsError } from '../entity-manager.js'
4
+ import { ELECTRIC_PRINCIPAL_HEADER } from '../principal.js'
4
5
  import { ATTR, extractTraceContext, tracer } from '../tracing.js'
5
6
  import { serverLog } from '../utils/log.js'
6
7
  import type { Span } from '@opentelemetry/api'
@@ -80,7 +81,13 @@ export function applyCors(
80
81
  )
81
82
  headers.set(
82
83
  `access-control-allow-headers`,
83
- `content-type, authorization, electric-claim-token, ngrok-skip-browser-warning`
84
+ [
85
+ `content-type`,
86
+ `authorization`,
87
+ `electric-claim-token`,
88
+ ELECTRIC_PRINCIPAL_HEADER,
89
+ `ngrok-skip-browser-warning`,
90
+ ].join(`, `)
84
91
  )
85
92
  headers.set(`access-control-expose-headers`, `*`)
86
93
  return new Response(response.body, {