@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,842 @@
1
+ import {
2
+ DurableStream,
3
+ DurableStreamError,
4
+ FetchError,
5
+ IdempotentProducer,
6
+ } from '@durable-streams/client'
7
+ import { ErrCodeNotFound } from './electric-agents-types.js'
8
+ import { ATTR, injectTraceHeaders, withSpan } from './tracing.js'
9
+ import type { HeadersRecord, MaybePromise } from '@durable-streams/client'
10
+
11
+ export type DurableStreamsBearerProvider = string | (() => MaybePromise<string>)
12
+
13
+ export interface StreamClientOptions {
14
+ bearer?: DurableStreamsBearerProvider
15
+ }
16
+
17
+ export interface StreamAppendResult {
18
+ offset: string
19
+ }
20
+
21
+ export interface StreamMessage {
22
+ data: Uint8Array
23
+ offset: string
24
+ }
25
+
26
+ export interface StreamReadResult {
27
+ messages: Array<StreamMessage>
28
+ }
29
+
30
+ export interface WaitForMessagesResult {
31
+ messages: Array<StreamMessage>
32
+ timedOut: boolean
33
+ }
34
+
35
+ export interface ConsumerStateResponse {
36
+ state: string
37
+ wake_id?: string | null
38
+ webhook?: {
39
+ wake_id?: string | null
40
+ subscription_id?: string
41
+ }
42
+ }
43
+
44
+ export interface SubscriptionStreamInfo {
45
+ path: string
46
+ tail_offset?: string
47
+ has_pending?: boolean
48
+ }
49
+
50
+ export interface SubscriptionResponse {
51
+ subscription_id?: string
52
+ id?: string
53
+ type?: `webhook` | `pull-wake`
54
+ pattern?: string
55
+ streams?: Array<string | SubscriptionStreamInfo>
56
+ webhook?: { url?: string }
57
+ wake_stream?: string
58
+ callback_url?: string
59
+ callback_token?: string
60
+ webhook_secret?: string
61
+ }
62
+
63
+ export interface SubscriptionCreateInput {
64
+ type: `webhook` | `pull-wake`
65
+ pattern?: string
66
+ streams?: Array<string>
67
+ webhook?: { url: string }
68
+ wake_stream?: string
69
+ lease_ttl_ms?: number
70
+ description?: string
71
+ }
72
+
73
+ export interface SubscriptionClaimResponse {
74
+ wake_id: string
75
+ generation: number
76
+ token: string
77
+ streams: Array<SubscriptionStreamInfo>
78
+ lease_ttl_ms?: number
79
+ }
80
+
81
+ export class DurableStreamsSubscriptionError extends Error {
82
+ readonly code?: string
83
+ readonly errorMessage?: string
84
+
85
+ constructor(
86
+ message: string,
87
+ readonly status: number,
88
+ readonly body: string
89
+ ) {
90
+ super(`${message}: ${status} ${body}`)
91
+ this.name = `DurableStreamsSubscriptionError`
92
+
93
+ try {
94
+ const parsed = JSON.parse(body) as {
95
+ error?: { code?: unknown; message?: unknown }
96
+ }
97
+ if (typeof parsed.error?.code === `string`) {
98
+ this.code = parsed.error.code
99
+ }
100
+ if (typeof parsed.error?.message === `string`) {
101
+ this.errorMessage = parsed.error.message
102
+ }
103
+ } catch {
104
+ // Preserve the raw body in the error message when DS returns non-JSON.
105
+ }
106
+ }
107
+ }
108
+
109
+ async function resolveDurableStreamsBearer(
110
+ bearer: DurableStreamsBearerProvider | undefined
111
+ ): Promise<string | undefined> {
112
+ if (!bearer) return undefined
113
+ const value = typeof bearer === `function` ? await bearer() : bearer
114
+ const trimmed = value.trim()
115
+ if (!trimmed) return undefined
116
+ return /^Bearer\s+/i.test(trimmed) ? trimmed : `Bearer ${trimmed}`
117
+ }
118
+
119
+ export async function applyDurableStreamsBearer(
120
+ headers: Headers,
121
+ bearer: DurableStreamsBearerProvider | undefined,
122
+ opts: { overwrite?: boolean } = {}
123
+ ): Promise<void> {
124
+ if (!bearer) return
125
+ if (!opts.overwrite && headers.has(`authorization`)) return
126
+ const value = await resolveDurableStreamsBearer(bearer)
127
+ if (value) {
128
+ headers.set(`authorization`, value)
129
+ }
130
+ }
131
+
132
+ function durableStreamsBearerHeaders(
133
+ bearer: DurableStreamsBearerProvider | undefined
134
+ ): HeadersRecord | undefined {
135
+ if (!bearer) return undefined
136
+ return {
137
+ authorization: async () =>
138
+ (await resolveDurableStreamsBearer(bearer)) ?? ``,
139
+ }
140
+ }
141
+
142
+ export function durableStreamsServiceUrl(
143
+ baseUrl: string,
144
+ serviceId: string
145
+ ): string {
146
+ const url = new URL(baseUrl)
147
+ if (/^\/v1\/stream\/[^/]+\/?$/.test(url.pathname)) {
148
+ return baseUrl.replace(/\/+$/, ``)
149
+ }
150
+ const base = baseUrl.replace(/\/+$/, ``)
151
+ return `${base}/v1/stream/${encodeURIComponent(serviceId)}`
152
+ }
153
+
154
+ function isNotFoundError(err: unknown): boolean {
155
+ return (
156
+ (err instanceof DurableStreamError && err.code === ErrCodeNotFound) ||
157
+ (err instanceof FetchError && err.status === 404)
158
+ )
159
+ }
160
+
161
+ function isAbortLikeError(err: unknown): boolean {
162
+ return (
163
+ err instanceof Error &&
164
+ (err.name === `AbortError` || err.message === `Stream request was aborted`)
165
+ )
166
+ }
167
+
168
+ function normalizeSubscriptionPattern(pattern: string): string {
169
+ return pattern.replace(/^\/+/, ``)
170
+ }
171
+
172
+ function normalizeSubscriptionStreamPath(path: string): string {
173
+ return path.replace(/^\/+/, ``)
174
+ }
175
+
176
+ function normalizeSubscriptionPath(path: string): string {
177
+ return path.replace(/^\/+/, ``).replace(/\/+$/, ``)
178
+ }
179
+
180
+ export class StreamClient {
181
+ constructor(
182
+ readonly baseUrl: string,
183
+ readonly options: StreamClientOptions = {}
184
+ ) {}
185
+
186
+ private streamUrl(path: string): string {
187
+ return `${this.baseUrl}${path}`
188
+ }
189
+
190
+ private streamHeaders(): HeadersRecord | undefined {
191
+ return durableStreamsBearerHeaders(this.options.bearer)
192
+ }
193
+
194
+ private async requestHeaders(
195
+ init?: HeadersInit,
196
+ opts: { overwriteBearer?: boolean } = {}
197
+ ): Promise<Headers> {
198
+ const headers = new Headers(init)
199
+ await applyDurableStreamsBearer(headers, this.options.bearer, {
200
+ overwrite: opts.overwriteBearer,
201
+ })
202
+ return headers
203
+ }
204
+
205
+ private subscriptionServiceId(): string | null {
206
+ const url = new URL(this.baseUrl)
207
+ const match = /^(.*)\/v1\/stream\/([^/]+)\/?$/.exec(url.pathname)
208
+ return match ? decodeURIComponent(match[2]!) : null
209
+ }
210
+
211
+ private backendSubscriptionPath(path: string): string {
212
+ const normalized = normalizeSubscriptionPath(path)
213
+ const serviceId = this.subscriptionServiceId()
214
+ if (!serviceId) return normalized
215
+ if (normalized === serviceId || normalized.startsWith(`${serviceId}/`)) {
216
+ return normalized
217
+ }
218
+ return `${serviceId}/${normalized}`
219
+ }
220
+
221
+ private runtimeSubscriptionPath(path: string): string {
222
+ const normalized = normalizeSubscriptionPath(path)
223
+ const serviceId = this.subscriptionServiceId()
224
+ if (!serviceId) return normalized
225
+ return normalized.startsWith(`${serviceId}/`)
226
+ ? normalized.slice(serviceId.length + 1)
227
+ : normalized
228
+ }
229
+
230
+ private subscriptionUrl(subscriptionId: string): string {
231
+ const url = new URL(this.baseUrl)
232
+ const match = /^(.*)\/v1\/stream\/([^/]+)\/?$/.exec(url.pathname)
233
+ if (match) {
234
+ const [, prefix = ``, serviceId] = match
235
+ url.pathname = `${prefix}/v1/stream-meta/subscriptions/${encodeURIComponent(subscriptionId)}`
236
+ url.searchParams.set(`service`, decodeURIComponent(serviceId!))
237
+ return url.toString()
238
+ }
239
+
240
+ url.pathname = `${url.pathname.replace(/\/+$/, ``)}/v1/stream-meta/subscriptions/${encodeURIComponent(subscriptionId)}`
241
+ return url.toString()
242
+ }
243
+
244
+ private subscriptionChildUrl(
245
+ subscriptionId: string,
246
+ ...segments: Array<string>
247
+ ): string {
248
+ const url = new URL(this.subscriptionUrl(subscriptionId))
249
+ url.pathname = `${url.pathname.replace(/\/+$/, ``)}/${segments
250
+ .map((segment) => encodeURIComponent(segment))
251
+ .join(`/`)}`
252
+ return url.toString()
253
+ }
254
+
255
+ async create(
256
+ path: string,
257
+ opts: { contentType: string; body?: Uint8Array | string }
258
+ ): Promise<void> {
259
+ return await withSpan(`stream.create`, async (span) => {
260
+ span.setAttributes({
261
+ [ATTR.STREAM_PATH]: path,
262
+ [ATTR.STREAM_OP]: `create`,
263
+ })
264
+ await DurableStream.create({
265
+ url: this.streamUrl(path),
266
+ headers: this.streamHeaders(),
267
+ contentType: opts.contentType,
268
+ body: opts.body,
269
+ })
270
+ })
271
+ }
272
+
273
+ async fork(path: string, sourcePath: string): Promise<void> {
274
+ return await withSpan(`stream.fork`, async (span) => {
275
+ span.setAttributes({
276
+ [ATTR.STREAM_PATH]: path,
277
+ [ATTR.STREAM_OP]: `fork`,
278
+ })
279
+ const headers: Record<string, string> = {
280
+ 'content-type': `application/json`,
281
+ 'Stream-Forked-From': sourcePath,
282
+ }
283
+ injectTraceHeaders(headers)
284
+
285
+ const response = await fetch(this.streamUrl(path), {
286
+ method: `PUT`,
287
+ headers: await this.requestHeaders(headers),
288
+ })
289
+
290
+ if (response.ok) return
291
+
292
+ throw new Error(
293
+ `Stream fork failed: ${response.status} ${await response.text()}`
294
+ )
295
+ })
296
+ }
297
+
298
+ async append(
299
+ path: string,
300
+ data: Uint8Array | string,
301
+ opts?: { close?: boolean }
302
+ ): Promise<StreamAppendResult> {
303
+ return await withSpan(`stream.append`, async (span) => {
304
+ span.setAttributes({
305
+ [ATTR.STREAM_PATH]: path,
306
+ [ATTR.STREAM_OP]: opts?.close ? `append+close` : `append`,
307
+ })
308
+ const handle = new DurableStream({
309
+ url: this.streamUrl(path),
310
+ headers: this.streamHeaders(),
311
+ contentType: `application/json`,
312
+ batching: false,
313
+ })
314
+ if (opts?.close) {
315
+ const result = await handle.close({ body: data })
316
+ return { offset: result.finalOffset }
317
+ }
318
+
319
+ await handle.append(data)
320
+ const head = await handle.head()
321
+ return { offset: (head.exists && head.offset) || `` }
322
+ })
323
+ }
324
+
325
+ async appendIdempotent(
326
+ path: string,
327
+ data: Uint8Array | string,
328
+ opts: { producerId: string; epoch?: number }
329
+ ): Promise<void> {
330
+ return await withSpan(`stream.appendIdempotent`, async (span) => {
331
+ span.setAttributes({
332
+ [ATTR.STREAM_PATH]: path,
333
+ [ATTR.STREAM_OP]: `appendIdempotent`,
334
+ })
335
+ const stream = new DurableStream({
336
+ url: this.streamUrl(path),
337
+ headers: this.streamHeaders(),
338
+ contentType: `application/json`,
339
+ })
340
+ const producer = new IdempotentProducer(stream, opts.producerId, {
341
+ epoch: opts.epoch ?? 0,
342
+ })
343
+
344
+ try {
345
+ producer.append(data)
346
+ await producer.flush()
347
+ } finally {
348
+ await producer.detach()
349
+ }
350
+ })
351
+ }
352
+
353
+ async appendWithProducerHeaders(
354
+ path: string,
355
+ data: Uint8Array | string,
356
+ opts: { producerId: string; epoch: number; seq: number }
357
+ ): Promise<void> {
358
+ return await withSpan(`stream.appendWithProducerHeaders`, async (span) => {
359
+ span.setAttributes({
360
+ [ATTR.STREAM_PATH]: path,
361
+ [ATTR.STREAM_OP]: `appendWithProducerHeaders`,
362
+ })
363
+ const headers: Record<string, string> = {
364
+ 'content-type': `application/json`,
365
+ 'Producer-Id': opts.producerId,
366
+ 'Producer-Epoch': String(opts.epoch),
367
+ 'Producer-Seq': String(opts.seq),
368
+ }
369
+ injectTraceHeaders(headers)
370
+ const response = await fetch(this.streamUrl(path), {
371
+ method: `POST`,
372
+ headers: await this.requestHeaders(headers),
373
+ body: typeof data === `string` ? data : Buffer.from(data),
374
+ })
375
+
376
+ if (response.ok || response.status === 204) {
377
+ return
378
+ }
379
+
380
+ throw new Error(
381
+ `Stream append failed: ${response.status} ${await response.text()}`
382
+ )
383
+ })
384
+ }
385
+
386
+ async read(path: string, fromOffset?: string): Promise<StreamReadResult> {
387
+ return await withSpan(`stream.read`, async (span) => {
388
+ span.setAttributes({
389
+ [ATTR.STREAM_PATH]: path,
390
+ [ATTR.STREAM_OP]: `read`,
391
+ })
392
+ const handle = new DurableStream({
393
+ url: this.streamUrl(path),
394
+ headers: this.streamHeaders(),
395
+ })
396
+ const response = await handle.stream({
397
+ offset: fromOffset ?? `-1`,
398
+ live: false,
399
+ })
400
+ const messages: Array<StreamMessage> = []
401
+
402
+ return await new Promise<StreamReadResult>((resolve, reject) => {
403
+ let settled = false
404
+ let unsub = () => {}
405
+
406
+ const finish = (r: StreamReadResult) => {
407
+ if (settled) return
408
+ settled = true
409
+ unsub()
410
+ resolve(r)
411
+ }
412
+
413
+ unsub = response.subscribeBytes((chunk) => {
414
+ messages.push({
415
+ data: chunk.data,
416
+ offset: chunk.offset,
417
+ })
418
+ if (chunk.upToDate || chunk.streamClosed) {
419
+ finish({ messages })
420
+ }
421
+ })
422
+
423
+ response.closed
424
+ .then(() => finish({ messages }))
425
+ .catch((err) => {
426
+ if (settled) return
427
+ settled = true
428
+ unsub()
429
+ reject(err)
430
+ })
431
+ })
432
+ })
433
+ }
434
+
435
+ async readJson<T = unknown>(
436
+ path: string,
437
+ fromOffset?: string
438
+ ): Promise<Array<T>> {
439
+ return await withSpan(`stream.readJson`, async (span) => {
440
+ span.setAttributes({
441
+ [ATTR.STREAM_PATH]: path,
442
+ [ATTR.STREAM_OP]: `readJson`,
443
+ })
444
+ const handle = new DurableStream({
445
+ url: this.streamUrl(path),
446
+ headers: this.streamHeaders(),
447
+ })
448
+ const response = await handle.stream<T>({
449
+ offset: fromOffset ?? `-1`,
450
+ live: false,
451
+ })
452
+ return await response.json<T>()
453
+ })
454
+ }
455
+
456
+ async waitForMessages(
457
+ path: string,
458
+ fromOffset: string,
459
+ timeoutMs: number
460
+ ): Promise<WaitForMessagesResult> {
461
+ return await withSpan(`stream.waitForMessages`, async (span) => {
462
+ span.setAttributes({
463
+ [ATTR.STREAM_PATH]: path,
464
+ [ATTR.STREAM_OP]: `waitForMessages`,
465
+ })
466
+ const handle = new DurableStream({
467
+ url: this.streamUrl(path),
468
+ headers: this.streamHeaders(),
469
+ })
470
+ const controller = new AbortController()
471
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
472
+
473
+ try {
474
+ const response = await handle.stream({
475
+ offset: fromOffset,
476
+ live: `long-poll`,
477
+ signal: controller.signal,
478
+ })
479
+
480
+ const messages: Array<StreamMessage> = []
481
+ return await new Promise<WaitForMessagesResult>((resolve, reject) => {
482
+ let settled = false
483
+ let unsub = () => {}
484
+
485
+ const finish = (result: WaitForMessagesResult) => {
486
+ if (settled) return
487
+ settled = true
488
+ clearTimeout(timer)
489
+ unsub()
490
+ resolve(result)
491
+ }
492
+
493
+ unsub = response.subscribeBytes((chunk) => {
494
+ messages.push({
495
+ data: chunk.data,
496
+ offset: chunk.offset,
497
+ })
498
+ if (chunk.upToDate) {
499
+ finish({ messages, timedOut: false })
500
+ }
501
+ })
502
+
503
+ response.closed
504
+ .then(() => finish({ messages, timedOut: false }))
505
+ .catch((err) => {
506
+ if (settled) return
507
+ clearTimeout(timer)
508
+ if (isAbortLikeError(err)) {
509
+ settled = true
510
+ unsub()
511
+ resolve({ messages: [], timedOut: true })
512
+ return
513
+ }
514
+ settled = true
515
+ unsub()
516
+ reject(err)
517
+ })
518
+ })
519
+ } catch (err) {
520
+ clearTimeout(timer)
521
+ if (isAbortLikeError(err)) {
522
+ return { messages: [], timedOut: true }
523
+ }
524
+ throw err
525
+ }
526
+ })
527
+ }
528
+
529
+ async delete(path: string): Promise<void> {
530
+ await DurableStream.delete({
531
+ url: this.streamUrl(path),
532
+ headers: this.streamHeaders(),
533
+ })
534
+ }
535
+
536
+ async ensure(path: string, opts: { contentType: string }): Promise<void> {
537
+ if (await this.exists(path)) return
538
+ try {
539
+ await this.create(path, opts)
540
+ } catch (err) {
541
+ if (
542
+ err &&
543
+ typeof err === `object` &&
544
+ `status` in err &&
545
+ (err as { status?: unknown }).status === 409
546
+ ) {
547
+ return
548
+ }
549
+ throw err
550
+ }
551
+ }
552
+
553
+ async exists(path: string): Promise<boolean> {
554
+ try {
555
+ const result = await DurableStream.head({
556
+ url: this.streamUrl(path),
557
+ headers: this.streamHeaders(),
558
+ })
559
+ return result.exists
560
+ } catch (err) {
561
+ if (isNotFoundError(err)) {
562
+ return false
563
+ }
564
+ throw err
565
+ }
566
+ }
567
+
568
+ async createSubscription(
569
+ pattern: string,
570
+ subscriptionId: string,
571
+ webhookUrl: string,
572
+ description?: string
573
+ ): Promise<{ subscription_id: string; webhook_secret?: string }> {
574
+ const res = await this.putSubscription(subscriptionId, {
575
+ type: `webhook`,
576
+ pattern: normalizeSubscriptionPattern(pattern),
577
+ webhook: { url: webhookUrl },
578
+ ...(description ? { description } : {}),
579
+ })
580
+ return res as { subscription_id: string; webhook_secret?: string }
581
+ }
582
+
583
+ async putSubscription(
584
+ subscriptionId: string,
585
+ input: SubscriptionCreateInput
586
+ ): Promise<SubscriptionResponse> {
587
+ const res = await fetch(this.subscriptionUrl(subscriptionId), {
588
+ method: `PUT`,
589
+ headers: await this.requestHeaders({
590
+ 'content-type': `application/json`,
591
+ }),
592
+ body: JSON.stringify({
593
+ ...input,
594
+ pattern:
595
+ typeof input.pattern === `string`
596
+ ? this.backendSubscriptionPath(
597
+ normalizeSubscriptionPattern(input.pattern)
598
+ )
599
+ : undefined,
600
+ streams: input.streams?.map((stream) =>
601
+ this.backendSubscriptionPath(normalizeSubscriptionStreamPath(stream))
602
+ ),
603
+ wake_stream:
604
+ typeof input.wake_stream === `string`
605
+ ? this.backendSubscriptionPath(
606
+ normalizeSubscriptionStreamPath(input.wake_stream)
607
+ )
608
+ : undefined,
609
+ }),
610
+ })
611
+ return await this.subscriptionJson(res, `Subscription creation failed`)
612
+ }
613
+
614
+ async getSubscription(
615
+ subscriptionId: string
616
+ ): Promise<SubscriptionResponse | null> {
617
+ const res = await fetch(this.subscriptionUrl(subscriptionId), {
618
+ method: `GET`,
619
+ headers: await this.requestHeaders(),
620
+ })
621
+ if (res.status === 404) return null
622
+ return await this.subscriptionJson(res, `Subscription query failed`)
623
+ }
624
+
625
+ async deleteSubscription(subscriptionId: string): Promise<void> {
626
+ const res = await fetch(this.subscriptionUrl(subscriptionId), {
627
+ method: `DELETE`,
628
+ headers: await this.requestHeaders(),
629
+ })
630
+ if (res.status === 404 || res.status === 204) return
631
+ if (!res.ok) {
632
+ throw new Error(
633
+ `Subscription delete failed: ${res.status} ${await res.text()}`
634
+ )
635
+ }
636
+ }
637
+
638
+ async addSubscriptionStreams(
639
+ subscriptionId: string,
640
+ streams: Array<string>
641
+ ): Promise<SubscriptionResponse> {
642
+ const res = await fetch(
643
+ this.subscriptionChildUrl(subscriptionId, `streams`),
644
+ {
645
+ method: `POST`,
646
+ headers: await this.requestHeaders({
647
+ 'content-type': `application/json`,
648
+ }),
649
+ body: JSON.stringify({
650
+ streams: streams.map((stream) =>
651
+ this.backendSubscriptionPath(
652
+ normalizeSubscriptionStreamPath(stream)
653
+ )
654
+ ),
655
+ }),
656
+ }
657
+ )
658
+ return await this.subscriptionJson(res, `Subscription stream add failed`)
659
+ }
660
+
661
+ async removeSubscriptionStream(
662
+ subscriptionId: string,
663
+ streamPath: string
664
+ ): Promise<void> {
665
+ const res = await fetch(
666
+ this.subscriptionChildUrl(
667
+ subscriptionId,
668
+ `streams`,
669
+ this.backendSubscriptionPath(
670
+ normalizeSubscriptionStreamPath(streamPath)
671
+ )
672
+ ),
673
+ { method: `DELETE`, headers: await this.requestHeaders() }
674
+ )
675
+ if (res.status === 404 || res.status === 204) return
676
+ if (!res.ok) {
677
+ throw new Error(
678
+ `Subscription stream remove failed: ${res.status} ${await res.text()}`
679
+ )
680
+ }
681
+ }
682
+
683
+ async claimSubscription(
684
+ subscriptionId: string,
685
+ worker: string
686
+ ): Promise<SubscriptionClaimResponse | null> {
687
+ const res = await fetch(
688
+ this.subscriptionChildUrl(subscriptionId, `claim`),
689
+ {
690
+ method: `POST`,
691
+ headers: await this.requestHeaders({
692
+ 'content-type': `application/json`,
693
+ }),
694
+ body: JSON.stringify({ worker }),
695
+ }
696
+ )
697
+ if (res.status === 204 || res.status === 404) return null
698
+ return (await this.subscriptionJson(
699
+ res,
700
+ `Subscription claim failed`
701
+ )) as SubscriptionClaimResponse
702
+ }
703
+
704
+ async ackSubscription(
705
+ subscriptionId: string,
706
+ token: string,
707
+ body: Record<string, unknown>
708
+ ): Promise<SubscriptionResponse> {
709
+ const res = await fetch(this.subscriptionChildUrl(subscriptionId, `ack`), {
710
+ method: `POST`,
711
+ headers: await this.requestHeaders({
712
+ 'content-type': `application/json`,
713
+ authorization: `Bearer ${token}`,
714
+ }),
715
+ body: JSON.stringify(this.subscriptionRequestBody(body)),
716
+ })
717
+ return await this.subscriptionJson(res, `Subscription ack failed`)
718
+ }
719
+
720
+ async releaseSubscription(
721
+ subscriptionId: string,
722
+ token: string,
723
+ body: Record<string, unknown>
724
+ ): Promise<SubscriptionResponse> {
725
+ const res = await fetch(
726
+ this.subscriptionChildUrl(subscriptionId, `release`),
727
+ {
728
+ method: `POST`,
729
+ headers: await this.requestHeaders({
730
+ 'content-type': `application/json`,
731
+ authorization: `Bearer ${token}`,
732
+ }),
733
+ body: JSON.stringify(this.subscriptionRequestBody(body)),
734
+ }
735
+ )
736
+ return await this.subscriptionJson(res, `Subscription release failed`)
737
+ }
738
+
739
+ private subscriptionRequestBody(
740
+ body: Record<string, unknown>
741
+ ): Record<string, unknown> {
742
+ const next = { ...body }
743
+ if (typeof next.stream === `string`) {
744
+ next.stream = this.backendSubscriptionPath(next.stream)
745
+ }
746
+ if (typeof next.path === `string`) {
747
+ next.path = this.backendSubscriptionPath(next.path)
748
+ }
749
+ if (Array.isArray(next.acks)) {
750
+ next.acks = next.acks.map((ack) => {
751
+ if (!ack || typeof ack !== `object`) return ack
752
+ const mapped = { ...(ack as Record<string, unknown>) }
753
+ if (typeof mapped.stream === `string`) {
754
+ mapped.stream = this.backendSubscriptionPath(mapped.stream)
755
+ }
756
+ if (typeof mapped.path === `string`) {
757
+ mapped.path = this.backendSubscriptionPath(mapped.path)
758
+ }
759
+ return mapped
760
+ })
761
+ }
762
+ return next
763
+ }
764
+
765
+ private subscriptionResponseBody(
766
+ body: SubscriptionResponse
767
+ ): SubscriptionResponse {
768
+ const next = { ...body }
769
+ if (typeof next.pattern === `string`) {
770
+ next.pattern = this.runtimeSubscriptionPath(next.pattern)
771
+ }
772
+ if (typeof next.wake_stream === `string`) {
773
+ next.wake_stream = this.runtimeSubscriptionPath(next.wake_stream)
774
+ }
775
+ if (Array.isArray(next.streams)) {
776
+ next.streams = next.streams.map((stream) => {
777
+ if (typeof stream === `string`)
778
+ return this.runtimeSubscriptionPath(stream)
779
+ return {
780
+ ...stream,
781
+ path: this.runtimeSubscriptionPath(stream.path),
782
+ }
783
+ })
784
+ }
785
+ if (Array.isArray((next as { acks?: unknown }).acks)) {
786
+ ;(next as { acks?: Array<Record<string, unknown>> }).acks = (
787
+ next as { acks: Array<Record<string, unknown>> }
788
+ ).acks.map((ack) => {
789
+ if (!ack || typeof ack !== `object`) return ack
790
+ const mapped = { ...ack }
791
+ if (typeof mapped.stream === `string`) {
792
+ mapped.stream = this.runtimeSubscriptionPath(mapped.stream)
793
+ }
794
+ if (typeof mapped.path === `string`) {
795
+ mapped.path = this.runtimeSubscriptionPath(mapped.path)
796
+ }
797
+ return mapped
798
+ })
799
+ }
800
+ if (typeof (next as { stream?: unknown }).stream === `string`) {
801
+ ;(next as { stream: string }).stream = this.runtimeSubscriptionPath(
802
+ (next as { stream: string }).stream
803
+ )
804
+ }
805
+ return next
806
+ }
807
+
808
+ private async subscriptionJson(
809
+ res: Response,
810
+ message: string
811
+ ): Promise<SubscriptionResponse> {
812
+ if (!res.ok) {
813
+ throw new DurableStreamsSubscriptionError(
814
+ message,
815
+ res.status,
816
+ await res.text()
817
+ )
818
+ }
819
+ if (res.status === 204) return {}
820
+ const text = await res.text()
821
+ if (!text.trim()) return {}
822
+ return this.subscriptionResponseBody(
823
+ JSON.parse(text) as SubscriptionResponse
824
+ )
825
+ }
826
+
827
+ async getConsumerState(
828
+ consumerId: string
829
+ ): Promise<ConsumerStateResponse | null> {
830
+ const res = await fetch(
831
+ `${this.baseUrl}/consumers/${encodeURIComponent(consumerId)}`,
832
+ { method: `GET`, headers: await this.requestHeaders() }
833
+ )
834
+ if (res.status === 404) return null
835
+ if (!res.ok) {
836
+ throw new Error(
837
+ `Consumer query failed: ${res.status} ${await res.text()}`
838
+ )
839
+ }
840
+ return res.json() as Promise<ConsumerStateResponse>
841
+ }
842
+ }