@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,694 @@
|
|
|
1
|
+
import { DurableStream, IdempotentProducer } from '@durable-streams/client'
|
|
2
|
+
import {
|
|
3
|
+
assertTags,
|
|
4
|
+
buildTagsIndex,
|
|
5
|
+
getEntitiesStreamPath,
|
|
6
|
+
normalizeTags,
|
|
7
|
+
sourceRefForTags,
|
|
8
|
+
} from '@electric-ax/agents-runtime'
|
|
9
|
+
import {
|
|
10
|
+
ShapeStream,
|
|
11
|
+
isChangeMessage,
|
|
12
|
+
isControlMessage,
|
|
13
|
+
} from '@electric-sql/client'
|
|
14
|
+
import { serverLog } from './utils/log.js'
|
|
15
|
+
import { electricUrlWithPath } from './utils/electric-url.js'
|
|
16
|
+
import { DEFAULT_TENANT_ID } from './tenant.js'
|
|
17
|
+
import type { EntityBridgeRow, PostgresRegistry } from './entity-registry.js'
|
|
18
|
+
import type { StreamClient } from './stream-client.js'
|
|
19
|
+
import type {
|
|
20
|
+
ChangeMessage,
|
|
21
|
+
Offset,
|
|
22
|
+
Row,
|
|
23
|
+
ShapeStreamInterface,
|
|
24
|
+
} from '@electric-sql/client'
|
|
25
|
+
import type {
|
|
26
|
+
EntityMembershipRow,
|
|
27
|
+
EntityTags,
|
|
28
|
+
} from '@electric-ax/agents-runtime'
|
|
29
|
+
|
|
30
|
+
export interface EntityBridgeCoordinator {
|
|
31
|
+
start(): Promise<void>
|
|
32
|
+
stop(): Promise<void>
|
|
33
|
+
register(tagsInput: unknown): Promise<{
|
|
34
|
+
sourceRef: string
|
|
35
|
+
streamUrl: string
|
|
36
|
+
}>
|
|
37
|
+
onEntityChanged(entityUrl: string): Promise<void>
|
|
38
|
+
touchByStreamPath(streamPath: string): Promise<void>
|
|
39
|
+
beginClientRead(streamPath: string): Promise<(() => Promise<void>) | null>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface EntityShapeRow extends Row<unknown> {
|
|
43
|
+
tenant_id: string
|
|
44
|
+
url: string
|
|
45
|
+
type: string
|
|
46
|
+
status: `spawning` | `running` | `idle` | `stopped`
|
|
47
|
+
tags: EntityTags
|
|
48
|
+
spawn_args?: Record<string, unknown> | null
|
|
49
|
+
parent?: string | null
|
|
50
|
+
type_revision?: number | null
|
|
51
|
+
inbox_schemas?: Record<string, Record<string, unknown>> | null
|
|
52
|
+
state_schemas?: Record<string, Record<string, unknown>> | null
|
|
53
|
+
created_at: number
|
|
54
|
+
updated_at: number
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const ENTITY_SHAPE_COLUMNS = [
|
|
58
|
+
`tenant_id`,
|
|
59
|
+
`url`,
|
|
60
|
+
`type`,
|
|
61
|
+
`status`,
|
|
62
|
+
`tags`,
|
|
63
|
+
`spawn_args`,
|
|
64
|
+
`parent`,
|
|
65
|
+
`type_revision`,
|
|
66
|
+
`inbox_schemas`,
|
|
67
|
+
`state_schemas`,
|
|
68
|
+
`created_at`,
|
|
69
|
+
`updated_at`,
|
|
70
|
+
] as const
|
|
71
|
+
|
|
72
|
+
function parseElectricOffset(offset: string): Offset | null {
|
|
73
|
+
if (offset === `-1` || offset === `now`) {
|
|
74
|
+
return offset
|
|
75
|
+
}
|
|
76
|
+
return /^\d+_\d+$/.test(offset) ? (offset as Offset) : null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function sameMember(
|
|
80
|
+
left: EntityMembershipRow | undefined,
|
|
81
|
+
right: EntityMembershipRow
|
|
82
|
+
): boolean {
|
|
83
|
+
return JSON.stringify(left) === JSON.stringify(right)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function toMemberRow(entity: EntityShapeRow): EntityMembershipRow {
|
|
87
|
+
return {
|
|
88
|
+
url: entity.url,
|
|
89
|
+
type: entity.type,
|
|
90
|
+
status: entity.status,
|
|
91
|
+
tags: entity.tags,
|
|
92
|
+
spawn_args: entity.spawn_args ?? {},
|
|
93
|
+
parent: entity.parent ?? null,
|
|
94
|
+
type_revision: entity.type_revision ?? null,
|
|
95
|
+
inbox_schemas: entity.inbox_schemas ?? null,
|
|
96
|
+
state_schemas: entity.state_schemas ?? null,
|
|
97
|
+
created_at: entity.created_at,
|
|
98
|
+
updated_at: entity.updated_at,
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function buildTagsWhereClause(tags: EntityTags): string {
|
|
103
|
+
const encoded = buildTagsIndex(tags).map(
|
|
104
|
+
(entry) => `'${entry.replace(/'/g, `''`)}'`
|
|
105
|
+
)
|
|
106
|
+
if (encoded.length === 0) {
|
|
107
|
+
return `TRUE`
|
|
108
|
+
}
|
|
109
|
+
return `tags_index @> ARRAY[${encoded.join(`, `)}]::text[]`
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function sqlStringLiteral(value: string): string {
|
|
113
|
+
return `'${value.replace(/'/g, `''`)}'`
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function buildTenantTagsWhereClause(
|
|
117
|
+
tenantId: string,
|
|
118
|
+
tags: EntityTags
|
|
119
|
+
): string {
|
|
120
|
+
return `tenant_id = ${sqlStringLiteral(tenantId)} AND (${buildTagsWhereClause(tags)})`
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function shapeEntityKey(message: ChangeMessage<EntityShapeRow>): string {
|
|
124
|
+
return message.value.url
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
class EntityBridge {
|
|
128
|
+
readonly sourceRef: string
|
|
129
|
+
readonly tags: EntityTags
|
|
130
|
+
readonly streamUrl: string
|
|
131
|
+
|
|
132
|
+
private currentMembers = new Map<string, EntityMembershipRow>()
|
|
133
|
+
private producer: IdempotentProducer | null = null
|
|
134
|
+
private liveAbortController: AbortController | null = null
|
|
135
|
+
private liveUnsubscribe: (() => void) | null = null
|
|
136
|
+
private stopped = false
|
|
137
|
+
private resyncPromise: Promise<void> | null = null
|
|
138
|
+
private bootstrapState: {
|
|
139
|
+
staleMembers: Map<string, EntityMembershipRow>
|
|
140
|
+
resolve: (result: `up-to-date` | `must-refetch`) => void
|
|
141
|
+
reject: (error: Error) => void
|
|
142
|
+
} | null = null
|
|
143
|
+
|
|
144
|
+
constructor(
|
|
145
|
+
row: EntityBridgeRow,
|
|
146
|
+
private registry: PostgresRegistry,
|
|
147
|
+
private streamClient: StreamClient,
|
|
148
|
+
private electricUrl: string,
|
|
149
|
+
private tenantId: string,
|
|
150
|
+
private electricSecret?: string
|
|
151
|
+
) {
|
|
152
|
+
this.sourceRef = row.sourceRef
|
|
153
|
+
this.tags = normalizeTags(row.tags)
|
|
154
|
+
this.streamUrl = row.streamUrl
|
|
155
|
+
this.initialShapeHandle = row.shapeHandle
|
|
156
|
+
this.initialShapeOffset = row.shapeOffset
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private initialShapeHandle?: string
|
|
160
|
+
private initialShapeOffset?: string
|
|
161
|
+
|
|
162
|
+
async start(): Promise<void> {
|
|
163
|
+
await this.ensureStream()
|
|
164
|
+
this.producer = new IdempotentProducer(
|
|
165
|
+
new DurableStream({
|
|
166
|
+
url: `${this.streamClient.baseUrl}${this.streamUrl}`,
|
|
167
|
+
contentType: `application/json`,
|
|
168
|
+
}),
|
|
169
|
+
`entity-bridge-${this.sourceRef}`,
|
|
170
|
+
{
|
|
171
|
+
autoClaim: true,
|
|
172
|
+
onError: (error) => {
|
|
173
|
+
serverLog.warn(
|
|
174
|
+
`[entity-bridge] producer write failed for ${this.sourceRef}:`,
|
|
175
|
+
error
|
|
176
|
+
)
|
|
177
|
+
},
|
|
178
|
+
}
|
|
179
|
+
)
|
|
180
|
+
await this.loadCurrentMembers()
|
|
181
|
+
if (this.initialShapeHandle && this.initialShapeOffset) {
|
|
182
|
+
const initialOffset = parseElectricOffset(this.initialShapeOffset)
|
|
183
|
+
if (initialOffset) {
|
|
184
|
+
this.startLiveStream(initialOffset, this.initialShapeHandle)
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
await this.resync(`startup`)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async stop(): Promise<void> {
|
|
192
|
+
this.stopped = true
|
|
193
|
+
this.stopLiveStream()
|
|
194
|
+
this.clearBootstrapState()?.resolve(`up-to-date`)
|
|
195
|
+
if (this.producer) {
|
|
196
|
+
try {
|
|
197
|
+
await this.producer.flush()
|
|
198
|
+
} catch {
|
|
199
|
+
// Reconcile repairs missed writes on next startup.
|
|
200
|
+
}
|
|
201
|
+
await this.producer.detach()
|
|
202
|
+
this.producer = null
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async requestResync(reason: string): Promise<void> {
|
|
207
|
+
if (this.stopped) return
|
|
208
|
+
if (this.resyncPromise) {
|
|
209
|
+
await this.resyncPromise
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
this.resyncPromise = this.resync(reason).finally(() => {
|
|
214
|
+
this.resyncPromise = null
|
|
215
|
+
})
|
|
216
|
+
await this.resyncPromise
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private async resync(reason: string): Promise<void> {
|
|
220
|
+
if (this.stopped) return
|
|
221
|
+
|
|
222
|
+
serverLog.info(
|
|
223
|
+
`[entity-bridge] resyncing ${this.sourceRef} from shape log bootstrap (${reason})`
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
this.stopLiveStream()
|
|
227
|
+
|
|
228
|
+
if (this.producer) {
|
|
229
|
+
try {
|
|
230
|
+
await this.producer.flush()
|
|
231
|
+
} catch {
|
|
232
|
+
// A later reconcile will repair any dropped writes.
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
for (;;) {
|
|
237
|
+
await this.loadCurrentMembers()
|
|
238
|
+
const result = await this.startBootstrapStream()
|
|
239
|
+
if (result === `up-to-date`) {
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private async ensureStream(): Promise<void> {
|
|
246
|
+
if (!(await this.streamClient.exists(this.streamUrl))) {
|
|
247
|
+
await this.streamClient.create(this.streamUrl, {
|
|
248
|
+
contentType: `application/json`,
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private startBootstrapStream(): Promise<`up-to-date` | `must-refetch`> {
|
|
254
|
+
return new Promise((resolve, reject) => {
|
|
255
|
+
this.bootstrapState = {
|
|
256
|
+
staleMembers: new Map(this.currentMembers),
|
|
257
|
+
resolve,
|
|
258
|
+
reject,
|
|
259
|
+
}
|
|
260
|
+
this.startLiveStream(`-1`)
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private finalizeBootstrap(): void {
|
|
265
|
+
if (!this.bootstrapState) {
|
|
266
|
+
return
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
for (const [url, existing] of this.bootstrapState.staleMembers) {
|
|
270
|
+
this.append(`delete`, existing)
|
|
271
|
+
this.currentMembers.delete(url)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private clearBootstrapState(): {
|
|
276
|
+
staleMembers: Map<string, EntityMembershipRow>
|
|
277
|
+
resolve: (result: `up-to-date` | `must-refetch`) => void
|
|
278
|
+
reject: (error: Error) => void
|
|
279
|
+
} | null {
|
|
280
|
+
const state = this.bootstrapState
|
|
281
|
+
this.bootstrapState = null
|
|
282
|
+
return state
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private async loadCurrentMembers(): Promise<void> {
|
|
286
|
+
this.currentMembers.clear()
|
|
287
|
+
const events = await this.streamClient.readJson<Record<string, unknown>>(
|
|
288
|
+
this.streamUrl
|
|
289
|
+
)
|
|
290
|
+
for (const event of events) {
|
|
291
|
+
if (event.type !== `members` || typeof event.key !== `string`) {
|
|
292
|
+
continue
|
|
293
|
+
}
|
|
294
|
+
const headers =
|
|
295
|
+
typeof event.headers === `object` && event.headers !== null
|
|
296
|
+
? (event.headers as Record<string, unknown>)
|
|
297
|
+
: undefined
|
|
298
|
+
const operation = headers?.operation
|
|
299
|
+
if (operation === `delete`) {
|
|
300
|
+
this.currentMembers.delete(event.key)
|
|
301
|
+
continue
|
|
302
|
+
}
|
|
303
|
+
const value = event.value as EntityMembershipRow | undefined
|
|
304
|
+
if (value) {
|
|
305
|
+
this.currentMembers.set(event.key, value)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private createShapeStream(opts?: {
|
|
311
|
+
offset?: Offset
|
|
312
|
+
handle?: string
|
|
313
|
+
signal?: AbortSignal
|
|
314
|
+
}): ShapeStreamInterface<EntityShapeRow> {
|
|
315
|
+
return new ShapeStream<EntityShapeRow>({
|
|
316
|
+
url: electricUrlWithPath(this.electricUrl, `/v1/shape`).toString(),
|
|
317
|
+
params: {
|
|
318
|
+
table: `entities`,
|
|
319
|
+
where: buildTenantTagsWhereClause(this.tenantId, this.tags),
|
|
320
|
+
...(this.electricSecret ? { secret: this.electricSecret } : {}),
|
|
321
|
+
columns: [...ENTITY_SHAPE_COLUMNS],
|
|
322
|
+
replica: `full`,
|
|
323
|
+
},
|
|
324
|
+
parser: {
|
|
325
|
+
int8: (value: string) => Number.parseInt(value, 10),
|
|
326
|
+
},
|
|
327
|
+
...(opts?.offset ? { offset: opts.offset } : {}),
|
|
328
|
+
...(opts?.handle ? { handle: opts.handle } : {}),
|
|
329
|
+
...(opts?.signal ? { signal: opts.signal } : {}),
|
|
330
|
+
onError: (error) => {
|
|
331
|
+
if (opts?.signal?.aborted) {
|
|
332
|
+
return {}
|
|
333
|
+
}
|
|
334
|
+
serverLog.warn(
|
|
335
|
+
`[entity-bridge] live shape error for ${this.sourceRef}:`,
|
|
336
|
+
error
|
|
337
|
+
)
|
|
338
|
+
return {}
|
|
339
|
+
},
|
|
340
|
+
})
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
private startLiveStream(offset: Offset, handle?: string): void {
|
|
344
|
+
if (this.stopped) return
|
|
345
|
+
|
|
346
|
+
const abortController = new AbortController()
|
|
347
|
+
const stream = this.createShapeStream({
|
|
348
|
+
offset,
|
|
349
|
+
handle,
|
|
350
|
+
signal: abortController.signal,
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
this.liveAbortController = abortController
|
|
354
|
+
this.liveUnsubscribe = stream.subscribe(
|
|
355
|
+
async (messages) => {
|
|
356
|
+
let shouldPersistCursor = false
|
|
357
|
+
let bootstrapResult: `up-to-date` | `must-refetch` | null = null
|
|
358
|
+
for (const message of messages) {
|
|
359
|
+
if (isControlMessage(message)) {
|
|
360
|
+
if (message.headers.control === `must-refetch`) {
|
|
361
|
+
await this.registry.clearEntityBridgeCursor(this.sourceRef)
|
|
362
|
+
const bootstrapState = this.clearBootstrapState()
|
|
363
|
+
if (bootstrapState) {
|
|
364
|
+
this.stopLiveStream()
|
|
365
|
+
bootstrapResult = `must-refetch`
|
|
366
|
+
bootstrapState.resolve(`must-refetch`)
|
|
367
|
+
return
|
|
368
|
+
}
|
|
369
|
+
await this.requestResync(`shape-reset`)
|
|
370
|
+
return
|
|
371
|
+
}
|
|
372
|
+
if (
|
|
373
|
+
message.headers.control === `up-to-date` &&
|
|
374
|
+
this.bootstrapState
|
|
375
|
+
) {
|
|
376
|
+
this.finalizeBootstrap()
|
|
377
|
+
bootstrapResult = `up-to-date`
|
|
378
|
+
}
|
|
379
|
+
shouldPersistCursor = true
|
|
380
|
+
continue
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (!isChangeMessage(message)) {
|
|
384
|
+
continue
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
this.bootstrapState?.staleMembers.delete(shapeEntityKey(message))
|
|
388
|
+
this.applyChange(message)
|
|
389
|
+
shouldPersistCursor = true
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (shouldPersistCursor) {
|
|
393
|
+
await this.persistCursor(stream)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (bootstrapResult === `up-to-date`) {
|
|
397
|
+
const bootstrapState = this.clearBootstrapState()
|
|
398
|
+
bootstrapState?.resolve(`up-to-date`)
|
|
399
|
+
}
|
|
400
|
+
},
|
|
401
|
+
(error) => {
|
|
402
|
+
const bootstrapState = this.clearBootstrapState()
|
|
403
|
+
if (bootstrapState) {
|
|
404
|
+
bootstrapState.reject(
|
|
405
|
+
error instanceof Error ? error : new Error(String(error))
|
|
406
|
+
)
|
|
407
|
+
}
|
|
408
|
+
if (abortController.signal.aborted) {
|
|
409
|
+
return
|
|
410
|
+
}
|
|
411
|
+
serverLog.warn(
|
|
412
|
+
`[entity-bridge] live subscription failed for ${this.sourceRef}:`,
|
|
413
|
+
error
|
|
414
|
+
)
|
|
415
|
+
void this.requestResync(`subscription-error`)
|
|
416
|
+
}
|
|
417
|
+
)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
private async persistCursor(
|
|
421
|
+
stream: ShapeStreamInterface<EntityShapeRow>
|
|
422
|
+
): Promise<void> {
|
|
423
|
+
const shapeHandle = stream.shapeHandle
|
|
424
|
+
const shapeOffset = stream.lastOffset
|
|
425
|
+
if (!shapeHandle || shapeOffset === `-1`) {
|
|
426
|
+
return
|
|
427
|
+
}
|
|
428
|
+
await this.registry.updateEntityBridgeCursor(
|
|
429
|
+
this.sourceRef,
|
|
430
|
+
shapeHandle,
|
|
431
|
+
shapeOffset
|
|
432
|
+
)
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private stopLiveStream(): void {
|
|
436
|
+
this.liveUnsubscribe?.()
|
|
437
|
+
this.liveUnsubscribe = null
|
|
438
|
+
this.liveAbortController?.abort()
|
|
439
|
+
this.liveAbortController = null
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
private applyChange(message: ChangeMessage<EntityShapeRow>): void {
|
|
443
|
+
const next = toMemberRow(message.value)
|
|
444
|
+
const key = shapeEntityKey(message)
|
|
445
|
+
const existing = this.currentMembers.get(key)
|
|
446
|
+
const operation = message.headers.operation
|
|
447
|
+
|
|
448
|
+
if (operation === `delete`) {
|
|
449
|
+
if (!existing) return
|
|
450
|
+
this.append(`delete`, existing)
|
|
451
|
+
this.currentMembers.delete(key)
|
|
452
|
+
return
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (!existing) {
|
|
456
|
+
this.append(`insert`, next)
|
|
457
|
+
this.currentMembers.set(key, next)
|
|
458
|
+
return
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (!sameMember(existing, next)) {
|
|
462
|
+
this.append(`update`, next)
|
|
463
|
+
this.currentMembers.set(key, next)
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
private append(
|
|
468
|
+
operation: `insert` | `update` | `delete`,
|
|
469
|
+
row: EntityMembershipRow
|
|
470
|
+
): void {
|
|
471
|
+
if (!this.producer) {
|
|
472
|
+
throw new Error(
|
|
473
|
+
`[entity-bridge] producer is not initialized for ${this.sourceRef}`
|
|
474
|
+
)
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const event =
|
|
478
|
+
operation === `delete`
|
|
479
|
+
? {
|
|
480
|
+
type: `members`,
|
|
481
|
+
key: row.url,
|
|
482
|
+
old_value: row,
|
|
483
|
+
headers: {
|
|
484
|
+
operation,
|
|
485
|
+
timestamp: new Date().toISOString(),
|
|
486
|
+
},
|
|
487
|
+
}
|
|
488
|
+
: {
|
|
489
|
+
type: `members`,
|
|
490
|
+
key: row.url,
|
|
491
|
+
value: row,
|
|
492
|
+
headers: {
|
|
493
|
+
operation,
|
|
494
|
+
timestamp: new Date().toISOString(),
|
|
495
|
+
},
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
this.producer.append(JSON.stringify(event))
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
export class EntityBridgeManager implements EntityBridgeCoordinator {
|
|
503
|
+
private bridges = new Map<string, EntityBridge>()
|
|
504
|
+
private startingBridges = new Map<string, Promise<void>>()
|
|
505
|
+
private activeReaders = new Map<string, number>()
|
|
506
|
+
private gcTimer: NodeJS.Timeout | null = null
|
|
507
|
+
|
|
508
|
+
constructor(
|
|
509
|
+
private registry: PostgresRegistry,
|
|
510
|
+
private streamClient: StreamClient,
|
|
511
|
+
private electricUrl?: string,
|
|
512
|
+
private electricSecret?: string,
|
|
513
|
+
private tenantId: string = DEFAULT_TENANT_ID
|
|
514
|
+
) {}
|
|
515
|
+
|
|
516
|
+
async start(): Promise<void> {
|
|
517
|
+
if (
|
|
518
|
+
!this.electricUrl ||
|
|
519
|
+
typeof this.registry.listEntityBridges !== `function`
|
|
520
|
+
) {
|
|
521
|
+
return
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const rows = await this.registry.listEntityBridges()
|
|
525
|
+
await Promise.all(
|
|
526
|
+
rows.map(async (row) => {
|
|
527
|
+
try {
|
|
528
|
+
await this.ensureBridge(row)
|
|
529
|
+
} catch (error) {
|
|
530
|
+
serverLog.warn(
|
|
531
|
+
`[entity-bridge] failed to start ${row.sourceRef}:`,
|
|
532
|
+
error
|
|
533
|
+
)
|
|
534
|
+
}
|
|
535
|
+
})
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
// 5-minute sweep / 15-minute idle TTL (see sweepIdleBridges). The idle
|
|
539
|
+
// grace absorbs client flapping (reloads, brief disconnects) without
|
|
540
|
+
// triggering a full reconcile on each reconnect; the sweep cadence is
|
|
541
|
+
// fast enough to release bridges soon after observers go away.
|
|
542
|
+
this.gcTimer = setInterval(() => {
|
|
543
|
+
void this.sweepIdleBridges().catch((error) => {
|
|
544
|
+
serverLog.warn(`[entity-bridge] idle sweep failed:`, error)
|
|
545
|
+
})
|
|
546
|
+
}, 5 * 60_000)
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async stop(): Promise<void> {
|
|
550
|
+
if (this.gcTimer) {
|
|
551
|
+
clearInterval(this.gcTimer)
|
|
552
|
+
this.gcTimer = null
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const bridges = [...this.bridges.values()]
|
|
556
|
+
this.bridges.clear()
|
|
557
|
+
this.startingBridges.clear()
|
|
558
|
+
this.activeReaders.clear()
|
|
559
|
+
|
|
560
|
+
await Promise.all(
|
|
561
|
+
bridges.map(async (bridge) => {
|
|
562
|
+
await bridge.stop()
|
|
563
|
+
})
|
|
564
|
+
)
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
async register(tagsInput: unknown): Promise<{
|
|
568
|
+
sourceRef: string
|
|
569
|
+
streamUrl: string
|
|
570
|
+
}> {
|
|
571
|
+
if (!this.electricUrl) {
|
|
572
|
+
throw new Error(`[entity-bridge] Electric URL is required for entities()`)
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const tags = normalizeTags(assertTags(tagsInput))
|
|
576
|
+
const sourceRef = sourceRefForTags(tags)
|
|
577
|
+
const streamUrl = getEntitiesStreamPath(sourceRef)
|
|
578
|
+
|
|
579
|
+
const row = await this.registry.upsertEntityBridge({
|
|
580
|
+
sourceRef,
|
|
581
|
+
tags,
|
|
582
|
+
streamUrl,
|
|
583
|
+
})
|
|
584
|
+
await this.registry.touchEntityBridge(sourceRef)
|
|
585
|
+
await this.ensureBridge(row)
|
|
586
|
+
|
|
587
|
+
return { sourceRef, streamUrl }
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
async onEntityChanged(_entityUrl: string): Promise<void> {
|
|
591
|
+
// Membership updates come from the Electric shape. This hook remains only
|
|
592
|
+
// to preserve existing call sites until they are cleaned up.
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
async touchByStreamPath(streamPath: string): Promise<void> {
|
|
596
|
+
const sourceRef = this.sourceRefFromStreamPath(streamPath)
|
|
597
|
+
if (!sourceRef) return
|
|
598
|
+
await this.touchSourceRef(sourceRef, `head`)
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
async beginClientRead(
|
|
602
|
+
streamPath: string
|
|
603
|
+
): Promise<(() => Promise<void>) | null> {
|
|
604
|
+
const sourceRef = this.sourceRefFromStreamPath(streamPath)
|
|
605
|
+
if (!sourceRef) return null
|
|
606
|
+
|
|
607
|
+
const current = this.activeReaders.get(sourceRef) ?? 0
|
|
608
|
+
this.activeReaders.set(sourceRef, current + 1)
|
|
609
|
+
await this.touchSourceRef(sourceRef, `read-open`)
|
|
610
|
+
|
|
611
|
+
return async () => {
|
|
612
|
+
const remaining = (this.activeReaders.get(sourceRef) ?? 1) - 1
|
|
613
|
+
if (remaining <= 0) {
|
|
614
|
+
this.activeReaders.delete(sourceRef)
|
|
615
|
+
} else {
|
|
616
|
+
this.activeReaders.set(sourceRef, remaining)
|
|
617
|
+
}
|
|
618
|
+
await this.touchSourceRef(sourceRef, `read-close`)
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
private async ensureBridge(row: EntityBridgeRow): Promise<void> {
|
|
623
|
+
if (this.bridges.has(row.sourceRef)) return
|
|
624
|
+
const starting = this.startingBridges.get(row.sourceRef)
|
|
625
|
+
if (starting) {
|
|
626
|
+
await starting
|
|
627
|
+
return
|
|
628
|
+
}
|
|
629
|
+
if (!this.electricUrl) {
|
|
630
|
+
throw new Error(`[entity-bridge] Electric URL is required for entities()`)
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const startPromise = (async () => {
|
|
634
|
+
const bridge = new EntityBridge(
|
|
635
|
+
row,
|
|
636
|
+
this.registry,
|
|
637
|
+
this.streamClient,
|
|
638
|
+
this.electricUrl!,
|
|
639
|
+
this.tenantId,
|
|
640
|
+
this.electricSecret
|
|
641
|
+
)
|
|
642
|
+
await bridge.start()
|
|
643
|
+
this.bridges.set(row.sourceRef, bridge)
|
|
644
|
+
})().finally(() => {
|
|
645
|
+
this.startingBridges.delete(row.sourceRef)
|
|
646
|
+
})
|
|
647
|
+
|
|
648
|
+
this.startingBridges.set(row.sourceRef, startPromise)
|
|
649
|
+
await startPromise
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
private async sweepIdleBridges(): Promise<void> {
|
|
653
|
+
const activeSourceRefs = await this.collectReferencedSourceRefs()
|
|
654
|
+
for (const sourceRef of activeSourceRefs) {
|
|
655
|
+
await this.registry.touchEntityBridge(sourceRef)
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const stale = await this.registry.listStaleEntityBridges(
|
|
659
|
+
new Date(Date.now() - 15 * 60_000)
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
for (const row of stale) {
|
|
663
|
+
if (activeSourceRefs.has(row.sourceRef)) continue
|
|
664
|
+
if ((this.activeReaders.get(row.sourceRef) ?? 0) > 0) continue
|
|
665
|
+
const bridge = this.bridges.get(row.sourceRef)
|
|
666
|
+
this.bridges.delete(row.sourceRef)
|
|
667
|
+
await bridge?.stop()
|
|
668
|
+
await this.registry.deleteEntityBridge(row.sourceRef)
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
private async collectReferencedSourceRefs(): Promise<Set<string>> {
|
|
673
|
+
return new Set(await this.registry.listReferencedEntitySourceRefs())
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
private sourceRefFromStreamPath(streamPath: string): string | null {
|
|
677
|
+
const match = streamPath.match(/^\/_entities\/([^/]+)$/)
|
|
678
|
+
return match?.[1] ?? null
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
private async touchSourceRef(
|
|
682
|
+
sourceRef: string,
|
|
683
|
+
reason: string
|
|
684
|
+
): Promise<void> {
|
|
685
|
+
try {
|
|
686
|
+
await this.registry.touchEntityBridge(sourceRef)
|
|
687
|
+
} catch (error) {
|
|
688
|
+
serverLog.warn(
|
|
689
|
+
`[entity-bridge] failed to touch ${sourceRef} during ${reason}:`,
|
|
690
|
+
error
|
|
691
|
+
)
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|