@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,765 @@
|
|
|
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 { PostgresRegistry } from './entity-registry.js'
|
|
15
|
+
import { electricUrlWithPath } from './utils/electric-url.js'
|
|
16
|
+
import { serverLog } from './utils/log.js'
|
|
17
|
+
import { isUnregisteredTenantError } from './tenant.js'
|
|
18
|
+
import type { DrizzleDB } from './db/index.js'
|
|
19
|
+
import type { EntityBridgeCoordinator } from './entity-bridge-manager.js'
|
|
20
|
+
import type { EntityBridgeRow } from './entity-registry.js'
|
|
21
|
+
import type { StreamClient } from './stream-client.js'
|
|
22
|
+
import type {
|
|
23
|
+
ChangeMessage,
|
|
24
|
+
Message,
|
|
25
|
+
Offset,
|
|
26
|
+
Row,
|
|
27
|
+
ShapeStreamInterface,
|
|
28
|
+
} from '@electric-sql/client'
|
|
29
|
+
import type {
|
|
30
|
+
EntityMembershipRow,
|
|
31
|
+
EntityTags,
|
|
32
|
+
} from '@electric-ax/agents-runtime'
|
|
33
|
+
|
|
34
|
+
interface EntityShapeRow extends Row<unknown> {
|
|
35
|
+
tenant_id: string
|
|
36
|
+
url: string
|
|
37
|
+
type: string
|
|
38
|
+
status: `spawning` | `running` | `idle` | `stopped`
|
|
39
|
+
tags: EntityTags
|
|
40
|
+
spawn_args?: Record<string, unknown> | null
|
|
41
|
+
parent?: string | null
|
|
42
|
+
type_revision?: number | null
|
|
43
|
+
inbox_schemas?: Record<string, Record<string, unknown>> | null
|
|
44
|
+
state_schemas?: Record<string, Record<string, unknown>> | null
|
|
45
|
+
created_at: number
|
|
46
|
+
updated_at: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const ENTITY_SHAPE_COLUMNS = [
|
|
50
|
+
`tenant_id`,
|
|
51
|
+
`url`,
|
|
52
|
+
`type`,
|
|
53
|
+
`status`,
|
|
54
|
+
`tags`,
|
|
55
|
+
`spawn_args`,
|
|
56
|
+
`parent`,
|
|
57
|
+
`type_revision`,
|
|
58
|
+
`inbox_schemas`,
|
|
59
|
+
`state_schemas`,
|
|
60
|
+
`created_at`,
|
|
61
|
+
`updated_at`,
|
|
62
|
+
] as const
|
|
63
|
+
|
|
64
|
+
type StreamClientResolver = (
|
|
65
|
+
tenantId: string
|
|
66
|
+
) => StreamClient | Promise<StreamClient>
|
|
67
|
+
type TenantIdsProvider = () => Iterable<string>
|
|
68
|
+
|
|
69
|
+
export interface EntityProjectorOptions {
|
|
70
|
+
db: DrizzleDB
|
|
71
|
+
electricUrl?: string
|
|
72
|
+
electricSecret?: string
|
|
73
|
+
streamClientForTenant: StreamClientResolver
|
|
74
|
+
tenantIds?: TenantIdsProvider
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function entityKey(tenantId: string, url: string): string {
|
|
78
|
+
return `${tenantId}:${url}`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function projectionKey(tenantId: string, sourceRef: string): string {
|
|
82
|
+
return `${tenantId}:${sourceRef}`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function sourceRefFromStreamPath(streamPath: string): string | null {
|
|
86
|
+
const match = streamPath.match(/^\/_entities\/([^/]+)$/)
|
|
87
|
+
return match?.[1] ?? null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function sameMember(
|
|
91
|
+
left: EntityMembershipRow | undefined,
|
|
92
|
+
right: EntityMembershipRow
|
|
93
|
+
): boolean {
|
|
94
|
+
return JSON.stringify(left) === JSON.stringify(right)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function entityMatchesTags(entity: EntityShapeRow, tags: EntityTags): boolean {
|
|
98
|
+
const required = buildTagsIndex(tags)
|
|
99
|
+
if (required.length === 0) return true
|
|
100
|
+
const entityTags = new Set(buildTagsIndex(entity.tags))
|
|
101
|
+
return required.every((tag) => entityTags.has(tag))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function toMemberRow(entity: EntityShapeRow): EntityMembershipRow {
|
|
105
|
+
return {
|
|
106
|
+
url: entity.url,
|
|
107
|
+
type: entity.type,
|
|
108
|
+
status: entity.status,
|
|
109
|
+
tags: entity.tags,
|
|
110
|
+
spawn_args: entity.spawn_args ?? {},
|
|
111
|
+
parent: entity.parent ?? null,
|
|
112
|
+
type_revision: entity.type_revision ?? null,
|
|
113
|
+
inbox_schemas: entity.inbox_schemas ?? null,
|
|
114
|
+
state_schemas: entity.state_schemas ?? null,
|
|
115
|
+
created_at: entity.created_at,
|
|
116
|
+
updated_at: entity.updated_at,
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
class ProjectedEntityBridge {
|
|
121
|
+
readonly tenantId: string
|
|
122
|
+
readonly sourceRef: string
|
|
123
|
+
readonly tags: EntityTags
|
|
124
|
+
readonly streamUrl: string
|
|
125
|
+
|
|
126
|
+
private currentMembers = new Map<string, EntityMembershipRow>()
|
|
127
|
+
private producer: IdempotentProducer | null = null
|
|
128
|
+
private stopped = false
|
|
129
|
+
|
|
130
|
+
constructor(
|
|
131
|
+
row: EntityBridgeRow,
|
|
132
|
+
private streamClient: StreamClient
|
|
133
|
+
) {
|
|
134
|
+
this.tenantId = row.tenantId
|
|
135
|
+
this.sourceRef = row.sourceRef
|
|
136
|
+
this.tags = normalizeTags(row.tags)
|
|
137
|
+
this.streamUrl = row.streamUrl
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async start(initialEntities: Iterable<EntityShapeRow>): Promise<void> {
|
|
141
|
+
await this.ensureStream()
|
|
142
|
+
this.producer = new IdempotentProducer(
|
|
143
|
+
new DurableStream({
|
|
144
|
+
url: `${this.streamClient.baseUrl}${this.streamUrl}`,
|
|
145
|
+
contentType: `application/json`,
|
|
146
|
+
}),
|
|
147
|
+
`entity-bridge-${this.sourceRef}`,
|
|
148
|
+
{
|
|
149
|
+
autoClaim: true,
|
|
150
|
+
onError: (error) => {
|
|
151
|
+
serverLog.warn(
|
|
152
|
+
`[entity-projector] producer write failed for ${this.tenantId}/${this.sourceRef}:`,
|
|
153
|
+
error
|
|
154
|
+
)
|
|
155
|
+
},
|
|
156
|
+
}
|
|
157
|
+
)
|
|
158
|
+
await this.loadCurrentMembers()
|
|
159
|
+
this.reconcile(initialEntities)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async stop(): Promise<void> {
|
|
163
|
+
this.stopped = true
|
|
164
|
+
if (this.producer) {
|
|
165
|
+
try {
|
|
166
|
+
await this.producer.flush()
|
|
167
|
+
} catch {
|
|
168
|
+
// Reconcile repairs missed writes on next startup.
|
|
169
|
+
}
|
|
170
|
+
await this.producer.detach()
|
|
171
|
+
this.producer = null
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
reconcile(entities: Iterable<EntityShapeRow>): void {
|
|
176
|
+
if (this.stopped) return
|
|
177
|
+
|
|
178
|
+
const staleMembers = new Map(this.currentMembers)
|
|
179
|
+
for (const entity of entities) {
|
|
180
|
+
if (entity.tenant_id !== this.tenantId) continue
|
|
181
|
+
if (!entityMatchesTags(entity, this.tags)) continue
|
|
182
|
+
staleMembers.delete(entity.url)
|
|
183
|
+
this.upsertEntity(entity)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
for (const [url, row] of staleMembers) {
|
|
187
|
+
this.append(`delete`, row)
|
|
188
|
+
this.currentMembers.delete(url)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
applyEntity(entity: EntityShapeRow): void {
|
|
193
|
+
if (this.stopped) return
|
|
194
|
+
if (entity.tenant_id !== this.tenantId) return
|
|
195
|
+
|
|
196
|
+
if (!entityMatchesTags(entity, this.tags)) {
|
|
197
|
+
const existing = this.currentMembers.get(entity.url)
|
|
198
|
+
if (!existing) return
|
|
199
|
+
this.append(`delete`, existing)
|
|
200
|
+
this.currentMembers.delete(entity.url)
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
this.upsertEntity(entity)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
deleteEntity(entity: EntityShapeRow): void {
|
|
208
|
+
if (this.stopped) return
|
|
209
|
+
const existing = this.currentMembers.get(entity.url)
|
|
210
|
+
if (!existing) return
|
|
211
|
+
this.append(`delete`, existing)
|
|
212
|
+
this.currentMembers.delete(entity.url)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private upsertEntity(entity: EntityShapeRow): void {
|
|
216
|
+
const next = toMemberRow(entity)
|
|
217
|
+
const existing = this.currentMembers.get(entity.url)
|
|
218
|
+
|
|
219
|
+
if (!existing) {
|
|
220
|
+
this.append(`insert`, next)
|
|
221
|
+
this.currentMembers.set(entity.url, next)
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (!sameMember(existing, next)) {
|
|
226
|
+
this.append(`update`, next)
|
|
227
|
+
this.currentMembers.set(entity.url, next)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private async ensureStream(): Promise<void> {
|
|
232
|
+
if (!(await this.streamClient.exists(this.streamUrl))) {
|
|
233
|
+
await this.streamClient.create(this.streamUrl, {
|
|
234
|
+
contentType: `application/json`,
|
|
235
|
+
})
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private async loadCurrentMembers(): Promise<void> {
|
|
240
|
+
this.currentMembers.clear()
|
|
241
|
+
const events = await this.streamClient.readJson<Record<string, unknown>>(
|
|
242
|
+
this.streamUrl
|
|
243
|
+
)
|
|
244
|
+
for (const event of events) {
|
|
245
|
+
if (event.type !== `members` || typeof event.key !== `string`) {
|
|
246
|
+
continue
|
|
247
|
+
}
|
|
248
|
+
const headers =
|
|
249
|
+
typeof event.headers === `object` && event.headers !== null
|
|
250
|
+
? (event.headers as Record<string, unknown>)
|
|
251
|
+
: undefined
|
|
252
|
+
const operation = headers?.operation
|
|
253
|
+
if (operation === `delete`) {
|
|
254
|
+
this.currentMembers.delete(event.key)
|
|
255
|
+
continue
|
|
256
|
+
}
|
|
257
|
+
const value = event.value as EntityMembershipRow | undefined
|
|
258
|
+
if (value) {
|
|
259
|
+
this.currentMembers.set(event.key, value)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private append(
|
|
265
|
+
operation: `insert` | `update` | `delete`,
|
|
266
|
+
row: EntityMembershipRow
|
|
267
|
+
): void {
|
|
268
|
+
if (!this.producer) {
|
|
269
|
+
throw new Error(
|
|
270
|
+
`[entity-projector] producer is not initialized for ${this.tenantId}/${this.sourceRef}`
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const event =
|
|
275
|
+
operation === `delete`
|
|
276
|
+
? {
|
|
277
|
+
type: `members`,
|
|
278
|
+
key: row.url,
|
|
279
|
+
old_value: row,
|
|
280
|
+
headers: {
|
|
281
|
+
operation,
|
|
282
|
+
timestamp: new Date().toISOString(),
|
|
283
|
+
},
|
|
284
|
+
}
|
|
285
|
+
: {
|
|
286
|
+
type: `members`,
|
|
287
|
+
key: row.url,
|
|
288
|
+
value: row,
|
|
289
|
+
headers: {
|
|
290
|
+
operation,
|
|
291
|
+
timestamp: new Date().toISOString(),
|
|
292
|
+
},
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
this.producer.append(JSON.stringify(event))
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export class EntityProjector {
|
|
300
|
+
private readonly db: DrizzleDB
|
|
301
|
+
private readonly electricUrl?: string
|
|
302
|
+
private readonly electricSecret?: string
|
|
303
|
+
private readonly streamClientForTenant: StreamClientResolver
|
|
304
|
+
private readonly tenantIds?: TenantIdsProvider
|
|
305
|
+
private readonly projections = new Map<string, ProjectedEntityBridge>()
|
|
306
|
+
private readonly startingProjections = new Map<string, Promise<void>>()
|
|
307
|
+
private readonly registries = new Map<string, PostgresRegistry>()
|
|
308
|
+
private readonly activeReaders = new Map<string, number>()
|
|
309
|
+
private readonly entities = new Map<string, EntityShapeRow>()
|
|
310
|
+
private abortController: AbortController | null = null
|
|
311
|
+
private unsubscribe: (() => void) | null = null
|
|
312
|
+
private gcTimer: NodeJS.Timeout | null = null
|
|
313
|
+
private started = false
|
|
314
|
+
private upToDate = false
|
|
315
|
+
private readyPromise: Promise<void> = Promise.resolve()
|
|
316
|
+
private readyResolve: (() => void) | null = null
|
|
317
|
+
private readyReject: ((error: Error) => void) | null = null
|
|
318
|
+
|
|
319
|
+
constructor(options: EntityProjectorOptions) {
|
|
320
|
+
this.db = options.db
|
|
321
|
+
this.electricUrl = options.electricUrl
|
|
322
|
+
this.electricSecret = options.electricSecret
|
|
323
|
+
this.streamClientForTenant = options.streamClientForTenant
|
|
324
|
+
this.tenantIds = options.tenantIds
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
forTenant(
|
|
328
|
+
tenantId: string,
|
|
329
|
+
registry = new PostgresRegistry(this.db, tenantId)
|
|
330
|
+
): EntityProjectorTenantFacade {
|
|
331
|
+
this.registries.set(tenantId, registry)
|
|
332
|
+
return new EntityProjectorTenantFacade(this, tenantId, registry)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async start(): Promise<void> {
|
|
336
|
+
if (!this.electricUrl) return
|
|
337
|
+
if (this.started) {
|
|
338
|
+
await this.waitUntilReady()
|
|
339
|
+
return
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
this.started = true
|
|
343
|
+
this.resetReady()
|
|
344
|
+
this.startShapeStream(`-1`)
|
|
345
|
+
await this.waitUntilReady()
|
|
346
|
+
await this.loadPersistedBridges()
|
|
347
|
+
|
|
348
|
+
this.gcTimer = setInterval(() => {
|
|
349
|
+
void this.sweepIdleBridges().catch((error) => {
|
|
350
|
+
serverLog.warn(`[entity-projector] idle sweep failed:`, error)
|
|
351
|
+
})
|
|
352
|
+
}, 5 * 60_000)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async stop(): Promise<void> {
|
|
356
|
+
this.started = false
|
|
357
|
+
this.upToDate = false
|
|
358
|
+
this.unsubscribe?.()
|
|
359
|
+
this.unsubscribe = null
|
|
360
|
+
this.abortController?.abort()
|
|
361
|
+
this.abortController = null
|
|
362
|
+
if (this.gcTimer) {
|
|
363
|
+
clearInterval(this.gcTimer)
|
|
364
|
+
this.gcTimer = null
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const projections = [...this.projections.values()]
|
|
368
|
+
this.projections.clear()
|
|
369
|
+
this.startingProjections.clear()
|
|
370
|
+
this.activeReaders.clear()
|
|
371
|
+
await Promise.all(projections.map((projection) => projection.stop()))
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async register(
|
|
375
|
+
tenantId: string,
|
|
376
|
+
registry: PostgresRegistry,
|
|
377
|
+
tagsInput: unknown
|
|
378
|
+
): Promise<{ sourceRef: string; streamUrl: string }> {
|
|
379
|
+
if (!this.electricUrl) {
|
|
380
|
+
throw new Error(
|
|
381
|
+
`[entity-projector] Electric URL is required for entities()`
|
|
382
|
+
)
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
await this.start()
|
|
386
|
+
this.registries.set(tenantId, registry)
|
|
387
|
+
const tags = normalizeTags(assertTags(tagsInput))
|
|
388
|
+
const sourceRef = sourceRefForTags(tags)
|
|
389
|
+
const streamUrl = getEntitiesStreamPath(sourceRef)
|
|
390
|
+
const row = await registry.upsertEntityBridge({
|
|
391
|
+
sourceRef,
|
|
392
|
+
tags,
|
|
393
|
+
streamUrl,
|
|
394
|
+
})
|
|
395
|
+
await registry.touchEntityBridge(sourceRef)
|
|
396
|
+
await this.ensureProjection(row)
|
|
397
|
+
|
|
398
|
+
return { sourceRef, streamUrl }
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async touchByStreamPath(
|
|
402
|
+
tenantId: string,
|
|
403
|
+
registry: PostgresRegistry,
|
|
404
|
+
streamPath: string
|
|
405
|
+
): Promise<void> {
|
|
406
|
+
const sourceRef = sourceRefFromStreamPath(streamPath)
|
|
407
|
+
if (!sourceRef) return
|
|
408
|
+
await this.touchSourceRef(tenantId, registry, sourceRef, `head`)
|
|
409
|
+
await this.ensureProjectionForSourceRef(tenantId, registry, sourceRef)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async beginClientRead(
|
|
413
|
+
tenantId: string,
|
|
414
|
+
registry: PostgresRegistry,
|
|
415
|
+
streamPath: string
|
|
416
|
+
): Promise<(() => Promise<void>) | null> {
|
|
417
|
+
const sourceRef = sourceRefFromStreamPath(streamPath)
|
|
418
|
+
if (!sourceRef) return null
|
|
419
|
+
|
|
420
|
+
const key = projectionKey(tenantId, sourceRef)
|
|
421
|
+
this.activeReaders.set(key, (this.activeReaders.get(key) ?? 0) + 1)
|
|
422
|
+
await this.touchSourceRef(tenantId, registry, sourceRef, `read-open`)
|
|
423
|
+
await this.ensureProjectionForSourceRef(tenantId, registry, sourceRef)
|
|
424
|
+
|
|
425
|
+
return async () => {
|
|
426
|
+
const remaining = (this.activeReaders.get(key) ?? 1) - 1
|
|
427
|
+
if (remaining <= 0) {
|
|
428
|
+
this.activeReaders.delete(key)
|
|
429
|
+
} else {
|
|
430
|
+
this.activeReaders.set(key, remaining)
|
|
431
|
+
}
|
|
432
|
+
await this.touchSourceRef(tenantId, registry, sourceRef, `read-close`)
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async onEntityChanged(_tenantId: string, _entityUrl: string): Promise<void> {
|
|
437
|
+
// Membership updates come from the shared Electric entities shape.
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async loadTenantBridges(
|
|
441
|
+
tenantId: string,
|
|
442
|
+
registry = this.registryForTenant(tenantId)
|
|
443
|
+
): Promise<void> {
|
|
444
|
+
if (!this.started || !this.electricUrl) return
|
|
445
|
+
await this.loadPersistedBridgesForTenant(tenantId, registry)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
private resetReady(): void {
|
|
449
|
+
this.upToDate = false
|
|
450
|
+
this.readyPromise = new Promise<void>((resolve, reject) => {
|
|
451
|
+
this.readyResolve = resolve
|
|
452
|
+
this.readyReject = reject
|
|
453
|
+
})
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
private async waitUntilReady(): Promise<void> {
|
|
457
|
+
await this.readyPromise
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
private createShapeStream(
|
|
461
|
+
offset: Offset,
|
|
462
|
+
signal: AbortSignal
|
|
463
|
+
): ShapeStreamInterface<EntityShapeRow> {
|
|
464
|
+
return new ShapeStream<EntityShapeRow>({
|
|
465
|
+
url: electricUrlWithPath(this.electricUrl!, `/v1/shape`).toString(),
|
|
466
|
+
params: {
|
|
467
|
+
table: `entities`,
|
|
468
|
+
...(this.electricSecret ? { secret: this.electricSecret } : {}),
|
|
469
|
+
columns: [...ENTITY_SHAPE_COLUMNS],
|
|
470
|
+
replica: `full`,
|
|
471
|
+
},
|
|
472
|
+
parser: {
|
|
473
|
+
int8: (value: string) => Number.parseInt(value, 10),
|
|
474
|
+
},
|
|
475
|
+
offset,
|
|
476
|
+
signal,
|
|
477
|
+
onError: (error) => {
|
|
478
|
+
if (signal.aborted) {
|
|
479
|
+
return {}
|
|
480
|
+
}
|
|
481
|
+
serverLog.warn(`[entity-projector] shared shape error:`, error)
|
|
482
|
+
return {}
|
|
483
|
+
},
|
|
484
|
+
})
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
private startShapeStream(offset: Offset): void {
|
|
488
|
+
if (!this.electricUrl) return
|
|
489
|
+
|
|
490
|
+
this.unsubscribe?.()
|
|
491
|
+
this.abortController?.abort()
|
|
492
|
+
const abortController = new AbortController()
|
|
493
|
+
const stream = this.createShapeStream(offset, abortController.signal)
|
|
494
|
+
this.abortController = abortController
|
|
495
|
+
this.unsubscribe = stream.subscribe(
|
|
496
|
+
async (messages) => {
|
|
497
|
+
await this.applyShapeMessages(messages)
|
|
498
|
+
},
|
|
499
|
+
(error) => {
|
|
500
|
+
if (abortController.signal.aborted) {
|
|
501
|
+
return
|
|
502
|
+
}
|
|
503
|
+
const err = error instanceof Error ? error : new Error(String(error))
|
|
504
|
+
this.readyReject?.(err)
|
|
505
|
+
serverLog.warn(`[entity-projector] shared subscription failed:`, error)
|
|
506
|
+
}
|
|
507
|
+
)
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
private async applyShapeMessages(
|
|
511
|
+
messages: Array<Message<EntityShapeRow>>
|
|
512
|
+
): Promise<void> {
|
|
513
|
+
for (const message of messages) {
|
|
514
|
+
if (isControlMessage(message)) {
|
|
515
|
+
if (message.headers.control === `must-refetch`) {
|
|
516
|
+
this.entities.clear()
|
|
517
|
+
this.resetReady()
|
|
518
|
+
this.startShapeStream(`-1`)
|
|
519
|
+
return
|
|
520
|
+
}
|
|
521
|
+
if (message.headers.control === `up-to-date`) {
|
|
522
|
+
this.upToDate = true
|
|
523
|
+
this.reconcileAll()
|
|
524
|
+
this.readyResolve?.()
|
|
525
|
+
}
|
|
526
|
+
continue
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (!isChangeMessage(message)) continue
|
|
530
|
+
this.applyChangeMessage(message)
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
private applyChangeMessage(message: ChangeMessage<EntityShapeRow>): void {
|
|
535
|
+
const entity = message.value
|
|
536
|
+
const key = entityKey(entity.tenant_id, entity.url)
|
|
537
|
+
if (message.headers.operation === `delete`) {
|
|
538
|
+
this.entities.delete(key)
|
|
539
|
+
if (this.upToDate) {
|
|
540
|
+
for (const projection of this.projectionsForTenant(entity.tenant_id)) {
|
|
541
|
+
projection.deleteEntity(entity)
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
this.entities.set(key, entity)
|
|
548
|
+
if (this.upToDate) {
|
|
549
|
+
for (const projection of this.projectionsForTenant(entity.tenant_id)) {
|
|
550
|
+
projection.applyEntity(entity)
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
private async loadPersistedBridges(): Promise<void> {
|
|
556
|
+
const registry = new PostgresRegistry(this.db)
|
|
557
|
+
const rows = await registry.listEntityBridges(null)
|
|
558
|
+
const tenantIds = this.sharedTenantIds()
|
|
559
|
+
const filteredRows = tenantIds
|
|
560
|
+
? rows.filter((row) => tenantIds.has(row.tenantId))
|
|
561
|
+
: rows
|
|
562
|
+
await Promise.all(
|
|
563
|
+
filteredRows.map(async (row) => {
|
|
564
|
+
try {
|
|
565
|
+
this.registryForTenant(row.tenantId)
|
|
566
|
+
await this.ensureProjection(row)
|
|
567
|
+
} catch (error) {
|
|
568
|
+
serverLog.warn(
|
|
569
|
+
`[entity-projector] failed to start ${row.tenantId}/${row.sourceRef}:`,
|
|
570
|
+
error
|
|
571
|
+
)
|
|
572
|
+
}
|
|
573
|
+
})
|
|
574
|
+
)
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
private async loadPersistedBridgesForTenant(
|
|
578
|
+
tenantId: string,
|
|
579
|
+
registry: PostgresRegistry
|
|
580
|
+
): Promise<void> {
|
|
581
|
+
await this.waitUntilReady()
|
|
582
|
+
this.registries.set(tenantId, registry)
|
|
583
|
+
const rows = await registry.listEntityBridges(tenantId)
|
|
584
|
+
await Promise.all(
|
|
585
|
+
rows.map(async (row) => {
|
|
586
|
+
try {
|
|
587
|
+
await this.ensureProjection(row)
|
|
588
|
+
} catch (error) {
|
|
589
|
+
serverLog.warn(
|
|
590
|
+
`[entity-projector] failed to start ${row.tenantId}/${row.sourceRef}:`,
|
|
591
|
+
error
|
|
592
|
+
)
|
|
593
|
+
}
|
|
594
|
+
})
|
|
595
|
+
)
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
private registryForTenant(tenantId: string): PostgresRegistry {
|
|
599
|
+
const existing = this.registries.get(tenantId)
|
|
600
|
+
if (existing) return existing
|
|
601
|
+
const registry = new PostgresRegistry(this.db, tenantId)
|
|
602
|
+
this.registries.set(tenantId, registry)
|
|
603
|
+
return registry
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
private async ensureProjectionForSourceRef(
|
|
607
|
+
tenantId: string,
|
|
608
|
+
registry: PostgresRegistry,
|
|
609
|
+
sourceRef: string
|
|
610
|
+
): Promise<void> {
|
|
611
|
+
await this.start()
|
|
612
|
+
const row = await registry.getEntityBridge(sourceRef)
|
|
613
|
+
if (!row) return
|
|
614
|
+
if (row.tenantId !== tenantId) return
|
|
615
|
+
await this.ensureProjection(row)
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
private async ensureProjection(row: EntityBridgeRow): Promise<void> {
|
|
619
|
+
await this.waitUntilReady()
|
|
620
|
+
const key = projectionKey(row.tenantId, row.sourceRef)
|
|
621
|
+
if (this.projections.has(key)) return
|
|
622
|
+
const starting = this.startingProjections.get(key)
|
|
623
|
+
if (starting) {
|
|
624
|
+
await starting
|
|
625
|
+
return
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const startPromise = (async () => {
|
|
629
|
+
let streamClient: StreamClient
|
|
630
|
+
try {
|
|
631
|
+
streamClient = await this.streamClientForTenant(row.tenantId)
|
|
632
|
+
} catch (error) {
|
|
633
|
+
if (isUnregisteredTenantError(error)) {
|
|
634
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
635
|
+
serverLog.warn(
|
|
636
|
+
`[entity-projector] skipped ${row.tenantId}/${row.sourceRef} for unregistered tenant: ${message}`
|
|
637
|
+
)
|
|
638
|
+
return
|
|
639
|
+
}
|
|
640
|
+
throw error
|
|
641
|
+
}
|
|
642
|
+
const projection = new ProjectedEntityBridge(row, streamClient)
|
|
643
|
+
await projection.start(this.entitiesForTenant(row.tenantId))
|
|
644
|
+
this.projections.set(key, projection)
|
|
645
|
+
})().finally(() => {
|
|
646
|
+
this.startingProjections.delete(key)
|
|
647
|
+
})
|
|
648
|
+
|
|
649
|
+
this.startingProjections.set(key, startPromise)
|
|
650
|
+
await startPromise
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
private entitiesForTenant(tenantId: string): Iterable<EntityShapeRow> {
|
|
654
|
+
return [...this.entities.values()].filter(
|
|
655
|
+
(entity) => entity.tenant_id === tenantId
|
|
656
|
+
)
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
private projectionsForTenant(tenantId: string): Array<ProjectedEntityBridge> {
|
|
660
|
+
return [...this.projections.values()].filter(
|
|
661
|
+
(projection) => projection.tenantId === tenantId
|
|
662
|
+
)
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
private reconcileAll(): void {
|
|
666
|
+
for (const projection of this.projections.values()) {
|
|
667
|
+
projection.reconcile(this.entitiesForTenant(projection.tenantId))
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
private async touchSourceRef(
|
|
672
|
+
tenantId: string,
|
|
673
|
+
registry: PostgresRegistry,
|
|
674
|
+
sourceRef: string,
|
|
675
|
+
reason: string
|
|
676
|
+
): Promise<void> {
|
|
677
|
+
try {
|
|
678
|
+
await registry.touchEntityBridge(sourceRef)
|
|
679
|
+
} catch (error) {
|
|
680
|
+
serverLog.warn(
|
|
681
|
+
`[entity-projector] failed to touch ${tenantId}/${sourceRef} during ${reason}:`,
|
|
682
|
+
error
|
|
683
|
+
)
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
private async sweepIdleBridges(): Promise<void> {
|
|
688
|
+
const tenantIds = this.sharedTenantIds()
|
|
689
|
+
for (const [tenantId, registry] of this.registries.entries()) {
|
|
690
|
+
if (tenantIds && !tenantIds.has(tenantId)) continue
|
|
691
|
+
const activeSourceRefs = new Set(
|
|
692
|
+
await registry.listReferencedEntitySourceRefs()
|
|
693
|
+
)
|
|
694
|
+
for (const sourceRef of activeSourceRefs) {
|
|
695
|
+
await registry.touchEntityBridge(sourceRef)
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const stale = await registry.listStaleEntityBridges(
|
|
699
|
+
new Date(Date.now() - 15 * 60_000)
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
for (const row of stale) {
|
|
703
|
+
const key = projectionKey(tenantId, row.sourceRef)
|
|
704
|
+
if (activeSourceRefs.has(row.sourceRef)) continue
|
|
705
|
+
if ((this.activeReaders.get(key) ?? 0) > 0) continue
|
|
706
|
+
const projection = this.projections.get(key)
|
|
707
|
+
this.projections.delete(key)
|
|
708
|
+
await projection?.stop()
|
|
709
|
+
await registry.deleteEntityBridge(row.sourceRef)
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
private sharedTenantIds(): Set<string> | null {
|
|
715
|
+
if (!this.tenantIds) return null
|
|
716
|
+
return new Set(this.tenantIds())
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
export class EntityProjectorTenantFacade implements EntityBridgeCoordinator {
|
|
721
|
+
constructor(
|
|
722
|
+
private readonly projector: EntityProjector,
|
|
723
|
+
private readonly tenantId: string,
|
|
724
|
+
private readonly registry: PostgresRegistry
|
|
725
|
+
) {}
|
|
726
|
+
|
|
727
|
+
async start(): Promise<void> {
|
|
728
|
+
await this.projector.start()
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
async stop(): Promise<void> {}
|
|
732
|
+
|
|
733
|
+
async register(tagsInput: unknown): Promise<{
|
|
734
|
+
sourceRef: string
|
|
735
|
+
streamUrl: string
|
|
736
|
+
}> {
|
|
737
|
+
return await this.projector.register(
|
|
738
|
+
this.tenantId,
|
|
739
|
+
this.registry,
|
|
740
|
+
tagsInput
|
|
741
|
+
)
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
async onEntityChanged(entityUrl: string): Promise<void> {
|
|
745
|
+
await this.projector.onEntityChanged(this.tenantId, entityUrl)
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
async touchByStreamPath(streamPath: string): Promise<void> {
|
|
749
|
+
await this.projector.touchByStreamPath(
|
|
750
|
+
this.tenantId,
|
|
751
|
+
this.registry,
|
|
752
|
+
streamPath
|
|
753
|
+
)
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
async beginClientRead(
|
|
757
|
+
streamPath: string
|
|
758
|
+
): Promise<(() => Promise<void>) | null> {
|
|
759
|
+
return await this.projector.beginClientRead(
|
|
760
|
+
this.tenantId,
|
|
761
|
+
this.registry,
|
|
762
|
+
streamPath
|
|
763
|
+
)
|
|
764
|
+
}
|
|
765
|
+
}
|