@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,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
|
+
}
|