@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,946 @@
1
+ import {
2
+ ShapeStream,
3
+ isChangeMessage,
4
+ isControlMessage,
5
+ } from '@electric-sql/client'
6
+ import { and, eq } from 'drizzle-orm'
7
+ import { wakeRegistrations } from './db/schema.js'
8
+ import { serverLog } from './utils/log.js'
9
+ import { electricUrlWithPath } from './utils/electric-url.js'
10
+ import { DEFAULT_TENANT_ID } from './tenant.js'
11
+ import type { DrizzleDB } from './db/index.js'
12
+ import type { Message, Row, Value } from '@electric-sql/client'
13
+
14
+ export interface WakeRegistration {
15
+ tenantId?: string
16
+ subscriberUrl: string
17
+ sourceUrl: string
18
+ condition:
19
+ | `runFinished`
20
+ | {
21
+ on: `change`
22
+ collections?: Array<string>
23
+ ops?: Array<`insert` | `update` | `delete`>
24
+ }
25
+ debounceMs?: number
26
+ timeoutMs?: number
27
+ oneShot: boolean
28
+ includeResponse?: boolean
29
+ manifestKey?: string
30
+ }
31
+
32
+ export interface WakeEvalResult {
33
+ tenantId: string
34
+ subscriberUrl: string
35
+ registrationDbId: number
36
+ sourceEventKey: string
37
+ wakeMessage: {
38
+ source: string
39
+ timeout: boolean
40
+ changes: Array<{
41
+ collection: string
42
+ kind: `insert` | `update` | `delete`
43
+ key: string
44
+ }>
45
+ }
46
+ runFinishedStatus?: `completed` | `failed`
47
+ includeResponse?: boolean
48
+ }
49
+
50
+ export type WakeTimeoutCallback = (result: WakeEvalResult) => void
51
+ export type WakeDebounceCallback = (result: WakeEvalResult) => void
52
+
53
+ interface CachedWakeRegistration extends WakeRegistration {
54
+ tenantId: string
55
+ dbId: number
56
+ createdAt?: Date
57
+ timeoutConsumed?: boolean
58
+ }
59
+
60
+ interface WakeRegistrationShapeRow extends Row<Date> {
61
+ id: number
62
+ tenant_id: string
63
+ subscriber_url: string
64
+ source_url: string
65
+ condition: WakeRegistration[`condition`] & Value<Date>
66
+ debounce_ms: number
67
+ timeout_ms: number
68
+ one_shot: boolean
69
+ timeout_consumed: boolean
70
+ include_response: boolean
71
+ manifest_key: string | null
72
+ created_at: Date
73
+ }
74
+
75
+ function wakeSourceEventId(event: Record<string, unknown>): string {
76
+ const headers =
77
+ typeof event.headers === `object` && event.headers !== null
78
+ ? (event.headers as Record<string, unknown>)
79
+ : undefined
80
+ const offset = headers?.offset
81
+ if (typeof offset === `string` && offset.length > 0) {
82
+ return offset
83
+ }
84
+
85
+ const operation = headers?.operation
86
+ const key = event.key
87
+ if (typeof operation === `string` && typeof key === `string`) {
88
+ return `${operation}:${key}`
89
+ }
90
+ if (typeof key === `string`) {
91
+ return key
92
+ }
93
+ return crypto.randomUUID()
94
+ }
95
+
96
+ function sqlStringLiteral(value: string): string {
97
+ return `'${value.replace(/'/g, `''`)}'`
98
+ }
99
+
100
+ export class WakeRegistry {
101
+ private db: DrizzleDB
102
+ private registrationCache = new Map<string, Array<CachedWakeRegistration>>()
103
+ private debounceTimers = new Map<string, NodeJS.Timeout>()
104
+ private debounceBuffers = new Map<
105
+ string,
106
+ Array<WakeEvalResult[`wakeMessage`][`changes`][number]>
107
+ >()
108
+ private debounceRunStatus = new Map<string, `completed` | `failed`>()
109
+ private timeoutTimers = new Map<string, NodeJS.Timeout>()
110
+ private timeoutDelivered = new Set<number>()
111
+ private timeoutCallbacks = new Map<string, WakeTimeoutCallback>()
112
+ private debounceCallbacks = new Map<string, WakeDebounceCallback>()
113
+ private syncElectricUrl: string | null = null
114
+ private syncElectricSecret: string | undefined
115
+ private syncAbortController: AbortController | null = null
116
+ private syncUnsubscribe: (() => void) | null = null
117
+ private syncReadyPromise: Promise<void> | null = null
118
+ private syncRecoveryPromise: Promise<void> | null = null
119
+
120
+ constructor(
121
+ db: DrizzleDB,
122
+ readonly tenantId: string | null = DEFAULT_TENANT_ID
123
+ ) {
124
+ this.db = db
125
+ }
126
+
127
+ setTimeoutCallback(cb: WakeTimeoutCallback, tenantId?: string): void {
128
+ const resolvedTenantId = this.resolveTenantId(tenantId)
129
+ this.timeoutCallbacks.set(resolvedTenantId, cb)
130
+ this.syncTenantTimeoutTimers(resolvedTenantId)
131
+ }
132
+
133
+ setDebounceCallback(cb: WakeDebounceCallback, tenantId?: string): void {
134
+ this.debounceCallbacks.set(this.resolveTenantId(tenantId), cb)
135
+ }
136
+
137
+ private resolveTenantId(tenantId?: string): string {
138
+ if (tenantId) return tenantId
139
+ if (this.tenantId) return this.tenantId
140
+ throw new Error(`WakeRegistry tenantId is required in shared mode`)
141
+ }
142
+
143
+ private cacheKey(tenantId: string, sourceUrl: string): string {
144
+ return `${tenantId}:${sourceUrl}`
145
+ }
146
+
147
+ private registrationKey(reg: CachedWakeRegistration): string {
148
+ return [
149
+ reg.tenantId,
150
+ reg.subscriberUrl,
151
+ reg.sourceUrl,
152
+ reg.manifestKey ?? ``,
153
+ reg.oneShot ? `1` : `0`,
154
+ reg.debounceMs ?? ``,
155
+ reg.timeoutMs ?? ``,
156
+ JSON.stringify(reg.condition),
157
+ reg.includeResponse === false ? `0` : `1`,
158
+ ].join(`:`)
159
+ }
160
+
161
+ private deliverTimeout(result: WakeEvalResult): boolean {
162
+ const callback = this.timeoutCallbacks.get(result.tenantId)
163
+ if (!callback) return false
164
+ callback(result)
165
+ return true
166
+ }
167
+
168
+ private deliverDebounce(result: WakeEvalResult): void {
169
+ this.debounceCallbacks.get(result.tenantId)?.(result)
170
+ }
171
+
172
+ async startSync(electricUrl: string, electricSecret?: string): Promise<void> {
173
+ if (this.syncReadyPromise) {
174
+ await this.syncReadyPromise
175
+ return
176
+ }
177
+
178
+ this.syncElectricUrl = electricUrl
179
+ this.syncElectricSecret = electricSecret
180
+
181
+ const abortController = new AbortController()
182
+ const stream = new ShapeStream<WakeRegistrationShapeRow>({
183
+ url: electricUrlWithPath(electricUrl, `/v1/shape`).toString(),
184
+ params: {
185
+ table: `wake_registrations`,
186
+ ...(this.tenantId
187
+ ? { where: `tenant_id = ${sqlStringLiteral(this.tenantId)}` }
188
+ : {}),
189
+ ...(electricSecret ? { secret: electricSecret } : {}),
190
+ columns: [
191
+ `id`,
192
+ `tenant_id`,
193
+ `subscriber_url`,
194
+ `source_url`,
195
+ `condition`,
196
+ `debounce_ms`,
197
+ `timeout_ms`,
198
+ `one_shot`,
199
+ `timeout_consumed`,
200
+ `include_response`,
201
+ `manifest_key`,
202
+ `created_at`,
203
+ ],
204
+ replica: `full`,
205
+ },
206
+ parser: {
207
+ timestamptz: (value: string) => new Date(value),
208
+ },
209
+ signal: abortController.signal,
210
+ onError: (error) => {
211
+ if (abortController.signal.aborted) {
212
+ return {}
213
+ }
214
+ if (this.syncReadyPromise) {
215
+ void this.recoverSync(error, `shape stream error`)
216
+ }
217
+ return {}
218
+ },
219
+ })
220
+
221
+ this.syncAbortController = abortController
222
+ this.syncReadyPromise = new Promise<void>((resolve, reject) => {
223
+ let settled = false
224
+
225
+ this.syncUnsubscribe = stream.subscribe(
226
+ async (messages) => {
227
+ try {
228
+ for (const message of messages) {
229
+ await this.applyShapeMessage(message)
230
+ if (
231
+ !settled &&
232
+ `control` in message.headers &&
233
+ message.headers.control === `up-to-date`
234
+ ) {
235
+ settled = true
236
+ resolve()
237
+ }
238
+ }
239
+ } catch (error) {
240
+ if (!settled) {
241
+ settled = true
242
+ reject(error)
243
+ return
244
+ }
245
+ serverLog.error(
246
+ `[wake-registry] failed to apply shape change:`,
247
+ error
248
+ )
249
+ }
250
+ },
251
+ (error) => {
252
+ if (!settled) {
253
+ settled = true
254
+ reject(error)
255
+ return
256
+ }
257
+ void this.recoverSync(error, `subscription error`)
258
+ }
259
+ )
260
+ })
261
+
262
+ try {
263
+ await this.syncReadyPromise
264
+ } catch (error) {
265
+ await this.stopSync()
266
+ throw error
267
+ }
268
+ }
269
+
270
+ async stopSync(): Promise<void> {
271
+ this.syncUnsubscribe?.()
272
+ this.syncUnsubscribe = null
273
+ this.syncAbortController?.abort()
274
+ this.syncAbortController = null
275
+ this.syncReadyPromise = null
276
+ }
277
+
278
+ private async recoverSync(
279
+ error: unknown,
280
+ source: `shape stream error` | `subscription error`
281
+ ): Promise<void> {
282
+ if (this.syncRecoveryPromise) {
283
+ return this.syncRecoveryPromise
284
+ }
285
+
286
+ const electricUrl = this.syncElectricUrl
287
+ if (!electricUrl) {
288
+ serverLog.error(
289
+ `[wake-registry] Electric sync failed (${source}):`,
290
+ error
291
+ )
292
+ return
293
+ }
294
+
295
+ this.syncRecoveryPromise = (async () => {
296
+ serverLog.error(
297
+ `[wake-registry] Electric sync failed (${source}):`,
298
+ error
299
+ )
300
+
301
+ await this.stopSync()
302
+ await this.loadRegistrations()
303
+
304
+ try {
305
+ await this.startSync(electricUrl, this.syncElectricSecret)
306
+ serverLog.info(`[wake-registry] Electric sync recovered`)
307
+ } catch (recoveryError) {
308
+ serverLog.error(
309
+ `[wake-registry] Electric sync recovery failed:`,
310
+ recoveryError
311
+ )
312
+ } finally {
313
+ this.syncRecoveryPromise = null
314
+ }
315
+ })()
316
+
317
+ return this.syncRecoveryPromise
318
+ }
319
+
320
+ async register(reg: WakeRegistration): Promise<void> {
321
+ const tenantId = this.resolveTenantId(reg.tenantId)
322
+ const result = await this.db
323
+ .insert(wakeRegistrations)
324
+ .values({
325
+ tenantId,
326
+ subscriberUrl: reg.subscriberUrl,
327
+ sourceUrl: reg.sourceUrl,
328
+ condition: reg.condition,
329
+ debounceMs: reg.debounceMs ?? 0,
330
+ timeoutMs: reg.timeoutMs ?? 0,
331
+ oneShot: reg.oneShot,
332
+ includeResponse: reg.includeResponse !== false,
333
+ manifestKey: reg.manifestKey ?? null,
334
+ })
335
+ .onConflictDoNothing()
336
+ .returning({ id: wakeRegistrations.id })
337
+
338
+ if (result.length === 0) {
339
+ // Another path (e.g. manifest-sync) may have created the row first.
340
+ // Refresh the cache so this process still sees the effective registration.
341
+ await this.loadRegistrations()
342
+ return
343
+ }
344
+
345
+ const dbId = result[0]!.id
346
+ this.upsertCachedRegistration({
347
+ ...reg,
348
+ tenantId,
349
+ dbId,
350
+ createdAt: new Date(),
351
+ timeoutConsumed: false,
352
+ })
353
+ }
354
+
355
+ private startTimeoutTimer(reg: CachedWakeRegistration, dbId: number): void {
356
+ if (reg.timeoutMs == null || reg.timeoutMs <= 0) return
357
+ this.startTimeoutTimerWithDuration(reg, dbId, reg.timeoutMs)
358
+ }
359
+
360
+ private async markTimeoutConsumed(
361
+ dbId: number,
362
+ tenantId: string
363
+ ): Promise<void> {
364
+ await this.db
365
+ .update(wakeRegistrations)
366
+ .set({ timeoutConsumed: true })
367
+ .where(
368
+ and(
369
+ eq(wakeRegistrations.tenantId, tenantId),
370
+ eq(wakeRegistrations.id, dbId)
371
+ )
372
+ )
373
+ }
374
+
375
+ async unregisterByManifestKey(
376
+ subscriberUrl: string,
377
+ manifestKey: string,
378
+ tenantId?: string
379
+ ): Promise<void> {
380
+ const resolvedTenantId = this.resolveTenantId(tenantId)
381
+ await this.db
382
+ .delete(wakeRegistrations)
383
+ .where(
384
+ and(
385
+ eq(wakeRegistrations.tenantId, resolvedTenantId),
386
+ eq(wakeRegistrations.subscriberUrl, subscriberUrl),
387
+ eq(wakeRegistrations.manifestKey, manifestKey)
388
+ )
389
+ )
390
+
391
+ const toRemove = Array.from(this.registrationCache.values()).flatMap(
392
+ (regs) =>
393
+ regs
394
+ .filter(
395
+ (r) =>
396
+ r.tenantId === resolvedTenantId &&
397
+ r.subscriberUrl === subscriberUrl &&
398
+ r.manifestKey === manifestKey
399
+ )
400
+ .map((r) => r.dbId)
401
+ )
402
+
403
+ for (const dbId of toRemove) {
404
+ this.removeCachedRegistrationByDbId(dbId)
405
+ }
406
+ }
407
+
408
+ async unregisterBySubscriber(
409
+ subscriberUrl: string,
410
+ tenantId?: string
411
+ ): Promise<void> {
412
+ const resolvedTenantId = this.resolveTenantId(tenantId)
413
+ await this.db
414
+ .delete(wakeRegistrations)
415
+ .where(
416
+ and(
417
+ eq(wakeRegistrations.tenantId, resolvedTenantId),
418
+ eq(wakeRegistrations.subscriberUrl, subscriberUrl)
419
+ )
420
+ )
421
+
422
+ const toRemove = Array.from(this.registrationCache.values()).flatMap(
423
+ (regs) =>
424
+ regs
425
+ .filter(
426
+ (r) =>
427
+ r.tenantId === resolvedTenantId &&
428
+ r.subscriberUrl === subscriberUrl
429
+ )
430
+ .map((r) => r.dbId)
431
+ )
432
+ for (const dbId of toRemove) {
433
+ this.removeCachedRegistrationByDbId(dbId)
434
+ }
435
+ }
436
+
437
+ async unregisterBySource(
438
+ sourceUrl: string,
439
+ tenantId?: string
440
+ ): Promise<void> {
441
+ const resolvedTenantId = this.resolveTenantId(tenantId)
442
+ await this.db
443
+ .delete(wakeRegistrations)
444
+ .where(
445
+ and(
446
+ eq(wakeRegistrations.tenantId, resolvedTenantId),
447
+ eq(wakeRegistrations.sourceUrl, sourceUrl)
448
+ )
449
+ )
450
+
451
+ const key = this.cacheKey(resolvedTenantId, sourceUrl)
452
+ const regs = this.registrationCache.get(key)
453
+ if (regs) {
454
+ for (const reg of [...regs]) {
455
+ this.removeCachedRegistrationByDbId(reg.dbId)
456
+ }
457
+ this.registrationCache.delete(key)
458
+ }
459
+ }
460
+
461
+ async unregisterBySubscriberAndSource(
462
+ subscriberUrl: string,
463
+ sourceUrl: string,
464
+ tenantId?: string
465
+ ): Promise<void> {
466
+ const resolvedTenantId = this.resolveTenantId(tenantId)
467
+ await this.db
468
+ .delete(wakeRegistrations)
469
+ .where(
470
+ and(
471
+ eq(wakeRegistrations.tenantId, resolvedTenantId),
472
+ eq(wakeRegistrations.subscriberUrl, subscriberUrl),
473
+ eq(wakeRegistrations.sourceUrl, sourceUrl)
474
+ )
475
+ )
476
+
477
+ const regs = this.registrationCache.get(
478
+ this.cacheKey(resolvedTenantId, sourceUrl)
479
+ )
480
+ if (regs) {
481
+ const toRemove = regs
482
+ .filter(
483
+ (r) =>
484
+ r.tenantId === resolvedTenantId && r.subscriberUrl === subscriberUrl
485
+ )
486
+ .map((r) => r.dbId)
487
+ for (const dbId of toRemove) {
488
+ this.removeCachedRegistrationByDbId(dbId)
489
+ }
490
+ }
491
+ }
492
+
493
+ async loadRegistrations(): Promise<void> {
494
+ const rows =
495
+ this.tenantId === null
496
+ ? await this.db.select().from(wakeRegistrations)
497
+ : await this.db
498
+ .select()
499
+ .from(wakeRegistrations)
500
+ .where(eq(wakeRegistrations.tenantId, this.tenantId))
501
+
502
+ this.resetCachedRegistrations()
503
+
504
+ for (const row of rows) {
505
+ const reg: CachedWakeRegistration = {
506
+ tenantId: row.tenantId,
507
+ subscriberUrl: row.subscriberUrl,
508
+ sourceUrl: row.sourceUrl,
509
+ condition: row.condition as WakeRegistration[`condition`],
510
+ debounceMs: row.debounceMs || undefined,
511
+ timeoutMs: row.timeoutMs || undefined,
512
+ oneShot: row.oneShot,
513
+ includeResponse: row.includeResponse === false ? false : undefined,
514
+ manifestKey: row.manifestKey ?? undefined,
515
+ dbId: row.id,
516
+ createdAt: row.createdAt,
517
+ timeoutConsumed: row.timeoutConsumed,
518
+ }
519
+ this.upsertCachedRegistration(reg)
520
+ }
521
+ }
522
+
523
+ private startTimeoutTimerWithDuration(
524
+ reg: CachedWakeRegistration,
525
+ dbId: number,
526
+ durationMs: number
527
+ ): void {
528
+ const timerKey = this.registrationKey(reg)
529
+ const timer = setTimeout(() => {
530
+ this.timeoutTimers.delete(timerKey)
531
+ this.deliverTimeoutForRegistration(reg, dbId)
532
+ }, durationMs)
533
+ this.timeoutTimers.set(timerKey, timer)
534
+ }
535
+
536
+ private clearDebounceState(timerKey: string): void {
537
+ const debounceTimer = this.debounceTimers.get(timerKey)
538
+ if (debounceTimer) {
539
+ clearTimeout(debounceTimer)
540
+ this.debounceTimers.delete(timerKey)
541
+ this.debounceBuffers.delete(timerKey)
542
+ this.debounceRunStatus.delete(timerKey)
543
+ }
544
+ }
545
+
546
+ private clearTimeoutState(timerKey: string): void {
547
+ const timeoutTimer = this.timeoutTimers.get(timerKey)
548
+ if (timeoutTimer) {
549
+ clearTimeout(timeoutTimer)
550
+ this.timeoutTimers.delete(timerKey)
551
+ }
552
+ }
553
+
554
+ private clearRegistrationState(reg: CachedWakeRegistration): void {
555
+ const timerKey = this.registrationKey(reg)
556
+ this.clearDebounceState(timerKey)
557
+ this.clearTimeoutState(timerKey)
558
+ }
559
+
560
+ private resetCachedRegistrations(): void {
561
+ for (const timer of this.debounceTimers.values()) {
562
+ clearTimeout(timer)
563
+ }
564
+ this.debounceTimers.clear()
565
+ this.debounceBuffers.clear()
566
+ this.debounceRunStatus.clear()
567
+
568
+ for (const timer of this.timeoutTimers.values()) {
569
+ clearTimeout(timer)
570
+ }
571
+ this.timeoutTimers.clear()
572
+ this.registrationCache.clear()
573
+ }
574
+
575
+ private findCachedRegistration(
576
+ dbId: number
577
+ ): { cacheKey: string; index: number; reg: CachedWakeRegistration } | null {
578
+ for (const [cacheKey, regs] of this.registrationCache) {
579
+ const index = regs.findIndex((reg) => reg.dbId === dbId)
580
+ if (index >= 0) {
581
+ return {
582
+ cacheKey,
583
+ index,
584
+ reg: regs[index]!,
585
+ }
586
+ }
587
+ }
588
+
589
+ return null
590
+ }
591
+
592
+ private upsertCachedRegistration(reg: CachedWakeRegistration): void {
593
+ const existing = this.findCachedRegistration(reg.dbId)
594
+ const nextKey = this.registrationKey(reg)
595
+
596
+ if (existing) {
597
+ const previousKey = this.registrationKey(existing.reg)
598
+ const regs = this.registrationCache.get(existing.cacheKey)
599
+ if (regs) {
600
+ regs.splice(existing.index, 1)
601
+ if (regs.length === 0) {
602
+ this.registrationCache.delete(existing.cacheKey)
603
+ }
604
+ }
605
+ if (previousKey !== nextKey) {
606
+ this.clearRegistrationState(existing.reg)
607
+ }
608
+ }
609
+
610
+ const cacheKey = this.cacheKey(reg.tenantId, reg.sourceUrl)
611
+ const cached = this.registrationCache.get(cacheKey) ?? []
612
+ cached.push(reg)
613
+ this.registrationCache.set(cacheKey, cached)
614
+ this.syncTimeoutTimer(reg)
615
+ }
616
+
617
+ private removeCachedRegistrationByDbId(dbId: number): void {
618
+ const existing = this.findCachedRegistration(dbId)
619
+ if (!existing) return
620
+
621
+ this.clearRegistrationState(existing.reg)
622
+ this.timeoutDelivered.delete(dbId)
623
+
624
+ const regs = this.registrationCache.get(existing.cacheKey)
625
+ if (!regs) return
626
+ regs.splice(existing.index, 1)
627
+ if (regs.length === 0) {
628
+ this.registrationCache.delete(existing.cacheKey)
629
+ }
630
+ }
631
+
632
+ private syncTimeoutTimer(reg: CachedWakeRegistration): void {
633
+ const timerKey = this.registrationKey(reg)
634
+
635
+ if (reg.timeoutConsumed || reg.timeoutMs == null || reg.timeoutMs <= 0) {
636
+ this.clearTimeoutState(timerKey)
637
+ return
638
+ }
639
+
640
+ if (this.timeoutTimers.has(timerKey)) {
641
+ return
642
+ }
643
+
644
+ if (!reg.createdAt) {
645
+ this.startTimeoutTimer(reg, reg.dbId)
646
+ return
647
+ }
648
+
649
+ const remaining = reg.createdAt.getTime() + reg.timeoutMs - Date.now()
650
+ if (remaining > 0) {
651
+ this.startTimeoutTimerWithDuration(reg, reg.dbId, remaining)
652
+ return
653
+ }
654
+
655
+ if (this.timeoutDelivered.has(reg.dbId)) {
656
+ return
657
+ }
658
+
659
+ this.deliverTimeoutForRegistration(reg, reg.dbId)
660
+ }
661
+
662
+ private deliverTimeoutForRegistration(
663
+ reg: CachedWakeRegistration,
664
+ dbId: number
665
+ ): void {
666
+ if (this.deliverTimeout(this.timeoutWakeResult(reg, dbId))) {
667
+ this.timeoutDelivered.add(dbId)
668
+ void this.markTimeoutConsumed(dbId, reg.tenantId)
669
+ }
670
+ }
671
+
672
+ private syncTenantTimeoutTimers(tenantId: string): void {
673
+ for (const regs of this.registrationCache.values()) {
674
+ for (const reg of regs) {
675
+ if (reg.tenantId === tenantId) {
676
+ this.syncTimeoutTimer(reg)
677
+ }
678
+ }
679
+ }
680
+ }
681
+
682
+ private timeoutWakeResult(
683
+ reg: CachedWakeRegistration,
684
+ dbId: number
685
+ ): WakeEvalResult {
686
+ return {
687
+ tenantId: reg.tenantId,
688
+ subscriberUrl: reg.subscriberUrl,
689
+ registrationDbId: dbId,
690
+ sourceEventKey: `timeout`,
691
+ wakeMessage: {
692
+ source: reg.sourceUrl,
693
+ timeout: true,
694
+ changes: [],
695
+ },
696
+ }
697
+ }
698
+
699
+ private normalizeShapeRow(
700
+ row: WakeRegistrationShapeRow
701
+ ): CachedWakeRegistration {
702
+ return {
703
+ tenantId:
704
+ (row as { tenant_id?: string }).tenant_id ?? this.resolveTenantId(),
705
+ subscriberUrl: row.subscriber_url,
706
+ sourceUrl: row.source_url,
707
+ condition: row.condition,
708
+ debounceMs: row.debounce_ms || undefined,
709
+ timeoutMs: row.timeout_ms || undefined,
710
+ oneShot: row.one_shot,
711
+ includeResponse: row.include_response === false ? false : undefined,
712
+ manifestKey: row.manifest_key ?? undefined,
713
+ dbId: row.id,
714
+ createdAt: row.created_at,
715
+ timeoutConsumed: row.timeout_consumed,
716
+ }
717
+ }
718
+
719
+ private async applyShapeMessage(
720
+ message: Message<WakeRegistrationShapeRow>
721
+ ): Promise<void> {
722
+ if (isControlMessage(message)) {
723
+ if (message.headers.control === `must-refetch`) {
724
+ this.resetCachedRegistrations()
725
+ }
726
+ return
727
+ }
728
+
729
+ if (!isChangeMessage(message)) {
730
+ return
731
+ }
732
+
733
+ if (message.headers.operation === `delete`) {
734
+ this.removeCachedRegistrationByDbId(Number(message.key))
735
+ return
736
+ }
737
+
738
+ this.upsertCachedRegistration(this.normalizeShapeRow(message.value))
739
+ }
740
+
741
+ evaluate(
742
+ sourceUrl: string,
743
+ event: Record<string, unknown>,
744
+ tenantId?: string
745
+ ): Array<WakeEvalResult> {
746
+ const resolvedTenantId = this.resolveTenantId(tenantId)
747
+ const cacheKey = this.cacheKey(resolvedTenantId, sourceUrl)
748
+ const regs = this.registrationCache.get(cacheKey)
749
+ if (!regs || regs.length === 0) return []
750
+
751
+ const results: Array<WakeEvalResult> = []
752
+ const toRemove: Array<number> = []
753
+
754
+ for (let i = 0; i < regs.length; i++) {
755
+ const reg = regs[i]!
756
+ const match = this.matchCondition(reg, event)
757
+ if (!match) continue
758
+
759
+ const timerKey = this.registrationKey(reg)
760
+ const timeoutTimer = this.timeoutTimers.get(timerKey)
761
+ if (timeoutTimer) {
762
+ clearTimeout(timeoutTimer)
763
+ this.timeoutTimers.delete(timerKey)
764
+ void this.markTimeoutConsumed(reg.dbId, reg.tenantId)
765
+ }
766
+
767
+ if (reg.debounceMs != null && reg.debounceMs > 0) {
768
+ const buffer = this.debounceBuffers.get(timerKey) ?? []
769
+ buffer.push(match.change)
770
+ this.debounceBuffers.set(timerKey, buffer)
771
+
772
+ // Preserve the latest runFinished status for debounced delivery
773
+ if (match.runFinishedStatus) {
774
+ this.debounceRunStatus.set(timerKey, match.runFinishedStatus)
775
+ }
776
+
777
+ const existing = this.debounceTimers.get(timerKey)
778
+ if (existing) clearTimeout(existing)
779
+
780
+ const timer = setTimeout(() => {
781
+ this.debounceTimers.delete(timerKey)
782
+ const flushed = this.debounceBuffers.get(timerKey)
783
+ if (flushed && flushed.length > 0) {
784
+ this.debounceBuffers.delete(timerKey)
785
+ const runStatus = this.debounceRunStatus.get(timerKey)
786
+ this.debounceRunStatus.delete(timerKey)
787
+ this.deliverDebounce({
788
+ tenantId: reg.tenantId,
789
+ subscriberUrl: reg.subscriberUrl,
790
+ registrationDbId: reg.dbId,
791
+ sourceEventKey: flushed[flushed.length - 1]!.key,
792
+ wakeMessage: {
793
+ source: sourceUrl,
794
+ timeout: false,
795
+ changes: flushed,
796
+ },
797
+ runFinishedStatus: runStatus,
798
+ includeResponse: reg.includeResponse,
799
+ })
800
+ }
801
+ }, reg.debounceMs)
802
+ this.debounceTimers.set(timerKey, timer)
803
+ } else {
804
+ results.push({
805
+ tenantId: reg.tenantId,
806
+ subscriberUrl: reg.subscriberUrl,
807
+ registrationDbId: reg.dbId,
808
+ sourceEventKey: wakeSourceEventId(event),
809
+ wakeMessage: {
810
+ source: sourceUrl,
811
+ timeout: false,
812
+ changes: [match.change],
813
+ },
814
+ runFinishedStatus: match.runFinishedStatus,
815
+ includeResponse: reg.includeResponse,
816
+ })
817
+ }
818
+
819
+ if (reg.oneShot) {
820
+ toRemove.push(i)
821
+ }
822
+ }
823
+
824
+ for (let j = toRemove.length - 1; j >= 0; j--) {
825
+ const removed = regs.splice(toRemove[j]!, 1)
826
+ if (removed[0]) {
827
+ this.clearRegistrationState(removed[0])
828
+ this.timeoutDelivered.delete(removed[0].dbId)
829
+ void this.db
830
+ .delete(wakeRegistrations)
831
+ .where(
832
+ and(
833
+ eq(wakeRegistrations.tenantId, removed[0].tenantId),
834
+ eq(wakeRegistrations.id, removed[0].dbId)
835
+ )
836
+ )
837
+ }
838
+ }
839
+ if (regs.length === 0) {
840
+ this.registrationCache.delete(cacheKey)
841
+ }
842
+
843
+ return results
844
+ }
845
+
846
+ /** Flush any pending debounce buffers for a subscriber and return them. */
847
+ flushDebounce(
848
+ subscriberUrl: string,
849
+ sourceUrl: string,
850
+ tenantId?: string
851
+ ): WakeEvalResult | null {
852
+ const resolvedTenantId = this.resolveTenantId(tenantId)
853
+ const timerKeyPrefix = `${resolvedTenantId}:${subscriberUrl}:${sourceUrl}:`
854
+ const changes: Array<WakeEvalResult[`wakeMessage`][`changes`][number]> = []
855
+
856
+ for (const [timerKey, buffer] of this.debounceBuffers.entries()) {
857
+ if (!timerKey.startsWith(timerKeyPrefix)) continue
858
+ changes.push(...buffer)
859
+ this.debounceBuffers.delete(timerKey)
860
+
861
+ const timer = this.debounceTimers.get(timerKey)
862
+ if (timer) {
863
+ clearTimeout(timer)
864
+ this.debounceTimers.delete(timerKey)
865
+ }
866
+ this.debounceRunStatus.delete(timerKey)
867
+ }
868
+
869
+ if (changes.length === 0) return null
870
+
871
+ return {
872
+ tenantId: resolvedTenantId,
873
+ subscriberUrl,
874
+ registrationDbId: -1,
875
+ sourceEventKey: changes[changes.length - 1]!.key,
876
+ wakeMessage: {
877
+ source: sourceUrl,
878
+ timeout: false,
879
+ changes,
880
+ },
881
+ }
882
+ }
883
+
884
+ private matchCondition(
885
+ reg: WakeRegistration,
886
+ event: Record<string, unknown>
887
+ ): {
888
+ change: {
889
+ collection: string
890
+ kind: `insert` | `update` | `delete`
891
+ key: string
892
+ }
893
+ runFinishedStatus?: `completed` | `failed`
894
+ } | null {
895
+ if (reg.condition === `runFinished`) {
896
+ if (event.type !== `run`) return null
897
+ const value = event.value as Record<string, unknown> | undefined
898
+ const headers = event.headers as Record<string, unknown> | undefined
899
+ const status = value?.status as string | undefined
900
+ const operation = headers?.operation as string | undefined
901
+ if (operation !== `update`) return null
902
+ if (status !== `completed` && status !== `failed`) return null
903
+ return {
904
+ change: {
905
+ collection: `runs`,
906
+ kind: `update`,
907
+ key: (event.key as string) || `run`,
908
+ },
909
+ runFinishedStatus: status,
910
+ }
911
+ }
912
+
913
+ const condition = reg.condition
914
+ const eventType = event.type as string | undefined
915
+ const headers = event.headers as Record<string, unknown> | undefined
916
+ const operation = headers?.operation as string | undefined
917
+ if (!eventType) return null
918
+
919
+ if (condition.collections && condition.collections.length > 0) {
920
+ if (!condition.collections.includes(eventType)) return null
921
+ }
922
+
923
+ const kind: `insert` | `update` | `delete` =
924
+ operation === `delete`
925
+ ? `delete`
926
+ : operation === `update`
927
+ ? `update`
928
+ : `insert`
929
+
930
+ if (
931
+ condition.ops &&
932
+ condition.ops.length > 0 &&
933
+ !condition.ops.includes(kind)
934
+ ) {
935
+ return null
936
+ }
937
+
938
+ return {
939
+ change: {
940
+ collection: eventType,
941
+ kind,
942
+ key: (event.key as string) || ``,
943
+ },
944
+ }
945
+ }
946
+ }