@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,1162 @@
|
|
|
1
|
+
import { and, desc, eq, lt, ne, sql } from 'drizzle-orm'
|
|
2
|
+
import { buildTagsIndex, normalizeTags } from '@electric-ax/agents-runtime'
|
|
3
|
+
import {
|
|
4
|
+
consumerClaims,
|
|
5
|
+
entities,
|
|
6
|
+
entityBridges,
|
|
7
|
+
entityDispatchState,
|
|
8
|
+
entityManifestSources,
|
|
9
|
+
entityTypes,
|
|
10
|
+
runners,
|
|
11
|
+
tagStreamOutbox,
|
|
12
|
+
} from './db/schema.js'
|
|
13
|
+
import {
|
|
14
|
+
assertEntityStatus,
|
|
15
|
+
assertRunnerAdminStatus,
|
|
16
|
+
assertRunnerKind,
|
|
17
|
+
} from './electric-agents-types.js'
|
|
18
|
+
import { DEFAULT_TENANT_ID } from './tenant.js'
|
|
19
|
+
import type { DrizzleDB } from './db/index.js'
|
|
20
|
+
import type {
|
|
21
|
+
ElectricAgentsEntity,
|
|
22
|
+
ElectricAgentsEntityType,
|
|
23
|
+
ElectricAgentsRunner,
|
|
24
|
+
EntityStatus,
|
|
25
|
+
RunnerAdminStatus,
|
|
26
|
+
RunnerKind,
|
|
27
|
+
SourceStreamOffset,
|
|
28
|
+
ConsumerClaim,
|
|
29
|
+
DispatchPolicy,
|
|
30
|
+
} from './electric-agents-types.js'
|
|
31
|
+
import type { EntityTags } from '@electric-ax/agents-runtime'
|
|
32
|
+
|
|
33
|
+
export class EntityAlreadyExistsError extends Error {
|
|
34
|
+
constructor(public readonly url: string) {
|
|
35
|
+
super(`Entity already exists at URL "${url}"`)
|
|
36
|
+
this.name = `EntityAlreadyExistsError`
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isDuplicateUrlError(err: unknown): boolean {
|
|
41
|
+
if (!err || typeof err !== `object`) return false
|
|
42
|
+
const e = err as { code?: string; constraint_name?: string }
|
|
43
|
+
return e.code === `23505`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface EntityBridgeRow {
|
|
47
|
+
tenantId: string
|
|
48
|
+
sourceRef: string
|
|
49
|
+
tags: EntityTags
|
|
50
|
+
streamUrl: string
|
|
51
|
+
shapeHandle?: string
|
|
52
|
+
shapeOffset?: string
|
|
53
|
+
lastObserverActivityAt: Date
|
|
54
|
+
createdAt: Date
|
|
55
|
+
updatedAt: Date
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface TagStreamOutboxRow {
|
|
59
|
+
id: number
|
|
60
|
+
tenantId: string
|
|
61
|
+
entityUrl: string
|
|
62
|
+
collection: string
|
|
63
|
+
op: `insert` | `update` | `delete`
|
|
64
|
+
key: string
|
|
65
|
+
rowData?: { key: string; value: string }
|
|
66
|
+
attemptCount: number
|
|
67
|
+
lastError?: string
|
|
68
|
+
claimedBy?: string
|
|
69
|
+
claimedAt?: Date
|
|
70
|
+
deadLetteredAt?: Date
|
|
71
|
+
createdAt: Date
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface RegisterRunnerInput {
|
|
75
|
+
id: string
|
|
76
|
+
ownerUserId: string
|
|
77
|
+
label: string
|
|
78
|
+
kind?: RunnerKind
|
|
79
|
+
adminStatus?: RunnerAdminStatus
|
|
80
|
+
wakeStream?: string
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface HeartbeatRunnerInput {
|
|
84
|
+
runnerId: string
|
|
85
|
+
heartbeatAt?: Date
|
|
86
|
+
livenessLeaseExpiresAt?: Date
|
|
87
|
+
leaseMs?: number
|
|
88
|
+
wakeStreamOffset?: string
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface MaterializeActiveClaimInput {
|
|
92
|
+
consumerId: string
|
|
93
|
+
epoch: number
|
|
94
|
+
entityUrl: string
|
|
95
|
+
streamPath: string
|
|
96
|
+
wakeId?: string
|
|
97
|
+
runnerId?: string
|
|
98
|
+
claimedAt?: Date
|
|
99
|
+
leaseExpiresAt?: Date
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface MaterializeHeartbeatClaimInput {
|
|
103
|
+
consumerId: string
|
|
104
|
+
epoch: number
|
|
105
|
+
heartbeatAt?: Date
|
|
106
|
+
leaseExpiresAt?: Date
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface MaterializeReleasedClaimInput {
|
|
110
|
+
consumerId: string
|
|
111
|
+
epoch: number
|
|
112
|
+
ackedStreams?: Array<SourceStreamOffset>
|
|
113
|
+
releasedAt?: Date
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const DEFAULT_RUNNER_LEASE_MS = 30_000
|
|
117
|
+
|
|
118
|
+
export function runnerWakeStream(runnerId: string): string {
|
|
119
|
+
return `/runners/${runnerId}/wake`
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export class PostgresRegistry {
|
|
123
|
+
constructor(
|
|
124
|
+
private db: DrizzleDB,
|
|
125
|
+
readonly tenantId: string = DEFAULT_TENANT_ID
|
|
126
|
+
) {}
|
|
127
|
+
|
|
128
|
+
async initialize(): Promise<void> {}
|
|
129
|
+
|
|
130
|
+
close(): void {}
|
|
131
|
+
|
|
132
|
+
async createRunner(
|
|
133
|
+
input: RegisterRunnerInput
|
|
134
|
+
): Promise<ElectricAgentsRunner> {
|
|
135
|
+
const now = new Date()
|
|
136
|
+
const wakeStream = input.wakeStream ?? runnerWakeStream(input.id)
|
|
137
|
+
|
|
138
|
+
await this.db
|
|
139
|
+
.insert(runners)
|
|
140
|
+
.values({
|
|
141
|
+
tenantId: this.tenantId,
|
|
142
|
+
id: input.id,
|
|
143
|
+
ownerUserId: input.ownerUserId,
|
|
144
|
+
label: input.label,
|
|
145
|
+
kind: input.kind ?? `local`,
|
|
146
|
+
adminStatus: input.adminStatus ?? `enabled`,
|
|
147
|
+
wakeStream,
|
|
148
|
+
updatedAt: now,
|
|
149
|
+
})
|
|
150
|
+
.onConflictDoUpdate({
|
|
151
|
+
target: [runners.tenantId, runners.id],
|
|
152
|
+
set: {
|
|
153
|
+
ownerUserId: input.ownerUserId,
|
|
154
|
+
label: input.label,
|
|
155
|
+
kind: input.kind ?? `local`,
|
|
156
|
+
adminStatus: input.adminStatus ?? `enabled`,
|
|
157
|
+
wakeStream,
|
|
158
|
+
updatedAt: now,
|
|
159
|
+
},
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
const runner = await this.getRunner(input.id)
|
|
163
|
+
if (!runner) {
|
|
164
|
+
throw new Error(`Failed to read back runner "${input.id}"`)
|
|
165
|
+
}
|
|
166
|
+
return runner
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async getRunner(id: string): Promise<ElectricAgentsRunner | null> {
|
|
170
|
+
const rows = await this.db
|
|
171
|
+
.select()
|
|
172
|
+
.from(runners)
|
|
173
|
+
.where(and(eq(runners.tenantId, this.tenantId), eq(runners.id, id)))
|
|
174
|
+
.limit(1)
|
|
175
|
+
return rows[0] ? this.rowToRunner(rows[0]) : null
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async listRunners(filter?: {
|
|
179
|
+
ownerUserId?: string
|
|
180
|
+
}): Promise<Array<ElectricAgentsRunner>> {
|
|
181
|
+
const conditions = [eq(runners.tenantId, this.tenantId)]
|
|
182
|
+
if (filter?.ownerUserId) {
|
|
183
|
+
conditions.push(eq(runners.ownerUserId, filter.ownerUserId))
|
|
184
|
+
}
|
|
185
|
+
const rows = await this.db
|
|
186
|
+
.select()
|
|
187
|
+
.from(runners)
|
|
188
|
+
.where(and(...conditions))
|
|
189
|
+
.orderBy(desc(runners.createdAt))
|
|
190
|
+
return rows.map((row) => this.rowToRunner(row))
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async heartbeatRunner(
|
|
194
|
+
input: HeartbeatRunnerInput
|
|
195
|
+
): Promise<ElectricAgentsRunner | null> {
|
|
196
|
+
const now = input.heartbeatAt ?? new Date()
|
|
197
|
+
const leaseExpiresAt =
|
|
198
|
+
input.livenessLeaseExpiresAt ??
|
|
199
|
+
new Date(now.getTime() + (input.leaseMs ?? DEFAULT_RUNNER_LEASE_MS))
|
|
200
|
+
|
|
201
|
+
const rows = await this.db
|
|
202
|
+
.update(runners)
|
|
203
|
+
.set({
|
|
204
|
+
lastSeenAt: now,
|
|
205
|
+
livenessLeaseExpiresAt: leaseExpiresAt,
|
|
206
|
+
...(input.wakeStreamOffset !== undefined
|
|
207
|
+
? { wakeStreamOffset: input.wakeStreamOffset }
|
|
208
|
+
: {}),
|
|
209
|
+
updatedAt: now,
|
|
210
|
+
})
|
|
211
|
+
.where(
|
|
212
|
+
and(eq(runners.tenantId, this.tenantId), eq(runners.id, input.runnerId))
|
|
213
|
+
)
|
|
214
|
+
.returning()
|
|
215
|
+
|
|
216
|
+
return rows[0] ? this.rowToRunner(rows[0]) : null
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async setRunnerAdminStatus(
|
|
220
|
+
runnerId: string,
|
|
221
|
+
adminStatus: RunnerAdminStatus
|
|
222
|
+
): Promise<ElectricAgentsRunner | null> {
|
|
223
|
+
const rows = await this.db
|
|
224
|
+
.update(runners)
|
|
225
|
+
.set({
|
|
226
|
+
adminStatus,
|
|
227
|
+
updatedAt: new Date(),
|
|
228
|
+
})
|
|
229
|
+
.where(and(eq(runners.tenantId, this.tenantId), eq(runners.id, runnerId)))
|
|
230
|
+
.returning()
|
|
231
|
+
|
|
232
|
+
return rows[0] ? this.rowToRunner(rows[0]) : null
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async materializeActiveClaim(
|
|
236
|
+
input: MaterializeActiveClaimInput
|
|
237
|
+
): Promise<void> {
|
|
238
|
+
const claimedAt = input.claimedAt ?? new Date()
|
|
239
|
+
await this.db.transaction(async (tx) => {
|
|
240
|
+
await tx
|
|
241
|
+
.insert(consumerClaims)
|
|
242
|
+
.values({
|
|
243
|
+
tenantId: this.tenantId,
|
|
244
|
+
consumerId: input.consumerId,
|
|
245
|
+
epoch: input.epoch,
|
|
246
|
+
wakeId: input.wakeId ?? null,
|
|
247
|
+
entityUrl: input.entityUrl,
|
|
248
|
+
streamPath: input.streamPath,
|
|
249
|
+
runnerId: input.runnerId ?? null,
|
|
250
|
+
status: `active`,
|
|
251
|
+
claimedAt,
|
|
252
|
+
leaseExpiresAt: input.leaseExpiresAt ?? null,
|
|
253
|
+
updatedAt: claimedAt,
|
|
254
|
+
})
|
|
255
|
+
.onConflictDoUpdate({
|
|
256
|
+
target: [
|
|
257
|
+
consumerClaims.tenantId,
|
|
258
|
+
consumerClaims.consumerId,
|
|
259
|
+
consumerClaims.epoch,
|
|
260
|
+
],
|
|
261
|
+
set: {
|
|
262
|
+
wakeId: input.wakeId ?? null,
|
|
263
|
+
entityUrl: input.entityUrl,
|
|
264
|
+
streamPath: input.streamPath,
|
|
265
|
+
runnerId: input.runnerId ?? null,
|
|
266
|
+
status: `active`,
|
|
267
|
+
claimedAt,
|
|
268
|
+
leaseExpiresAt: input.leaseExpiresAt ?? null,
|
|
269
|
+
releasedAt: null,
|
|
270
|
+
updatedAt: claimedAt,
|
|
271
|
+
},
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
await tx
|
|
275
|
+
.insert(entityDispatchState)
|
|
276
|
+
.values({
|
|
277
|
+
tenantId: this.tenantId,
|
|
278
|
+
entityUrl: input.entityUrl,
|
|
279
|
+
activeConsumerId: input.consumerId,
|
|
280
|
+
activeRunnerId: input.runnerId ?? null,
|
|
281
|
+
activeEpoch: input.epoch,
|
|
282
|
+
activeClaimedAt: claimedAt,
|
|
283
|
+
activeLeaseExpiresAt: input.leaseExpiresAt ?? null,
|
|
284
|
+
lastClaimedAt: claimedAt,
|
|
285
|
+
updatedAt: claimedAt,
|
|
286
|
+
})
|
|
287
|
+
.onConflictDoUpdate({
|
|
288
|
+
target: [entityDispatchState.tenantId, entityDispatchState.entityUrl],
|
|
289
|
+
set: {
|
|
290
|
+
activeConsumerId: input.consumerId,
|
|
291
|
+
activeRunnerId: input.runnerId ?? null,
|
|
292
|
+
activeEpoch: input.epoch,
|
|
293
|
+
activeClaimedAt: claimedAt,
|
|
294
|
+
activeLeaseExpiresAt: input.leaseExpiresAt ?? null,
|
|
295
|
+
lastClaimedAt: claimedAt,
|
|
296
|
+
updatedAt: claimedAt,
|
|
297
|
+
},
|
|
298
|
+
})
|
|
299
|
+
})
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async materializeHeartbeatClaim(
|
|
303
|
+
input: MaterializeHeartbeatClaimInput
|
|
304
|
+
): Promise<void> {
|
|
305
|
+
const heartbeatAt = input.heartbeatAt ?? new Date()
|
|
306
|
+
await this.db
|
|
307
|
+
.update(consumerClaims)
|
|
308
|
+
.set({
|
|
309
|
+
lastHeartbeatAt: heartbeatAt,
|
|
310
|
+
leaseExpiresAt: input.leaseExpiresAt ?? null,
|
|
311
|
+
updatedAt: heartbeatAt,
|
|
312
|
+
})
|
|
313
|
+
.where(
|
|
314
|
+
and(
|
|
315
|
+
eq(consumerClaims.tenantId, this.tenantId),
|
|
316
|
+
eq(consumerClaims.consumerId, input.consumerId),
|
|
317
|
+
eq(consumerClaims.epoch, input.epoch)
|
|
318
|
+
)
|
|
319
|
+
)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async materializeReleasedClaim(
|
|
323
|
+
input: MaterializeReleasedClaimInput
|
|
324
|
+
): Promise<ConsumerClaim | null> {
|
|
325
|
+
const releasedAt = input.releasedAt ?? new Date()
|
|
326
|
+
const rows = await this.db
|
|
327
|
+
.update(consumerClaims)
|
|
328
|
+
.set({
|
|
329
|
+
status: `released`,
|
|
330
|
+
releasedAt,
|
|
331
|
+
ackedStreams: input.ackedStreams ?? null,
|
|
332
|
+
updatedAt: releasedAt,
|
|
333
|
+
})
|
|
334
|
+
.where(
|
|
335
|
+
and(
|
|
336
|
+
eq(consumerClaims.tenantId, this.tenantId),
|
|
337
|
+
eq(consumerClaims.consumerId, input.consumerId),
|
|
338
|
+
eq(consumerClaims.epoch, input.epoch)
|
|
339
|
+
)
|
|
340
|
+
)
|
|
341
|
+
.returning()
|
|
342
|
+
|
|
343
|
+
const claim = rows[0] ? this.rowToConsumerClaim(rows[0]) : null
|
|
344
|
+
if (claim) {
|
|
345
|
+
await this.db
|
|
346
|
+
.update(entityDispatchState)
|
|
347
|
+
.set({
|
|
348
|
+
activeConsumerId: null,
|
|
349
|
+
activeRunnerId: null,
|
|
350
|
+
activeEpoch: null,
|
|
351
|
+
activeClaimedAt: null,
|
|
352
|
+
activeLeaseExpiresAt: null,
|
|
353
|
+
lastReleasedAt: releasedAt,
|
|
354
|
+
lastCompletedAt: releasedAt,
|
|
355
|
+
updatedAt: releasedAt,
|
|
356
|
+
})
|
|
357
|
+
.where(
|
|
358
|
+
and(
|
|
359
|
+
eq(entityDispatchState.tenantId, this.tenantId),
|
|
360
|
+
eq(entityDispatchState.entityUrl, claim.entity_url),
|
|
361
|
+
eq(entityDispatchState.activeConsumerId, input.consumerId),
|
|
362
|
+
eq(entityDispatchState.activeEpoch, input.epoch)
|
|
363
|
+
)
|
|
364
|
+
)
|
|
365
|
+
}
|
|
366
|
+
return claim
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
private entityTypeWhere(name: string) {
|
|
370
|
+
return and(
|
|
371
|
+
eq(entityTypes.tenantId, this.tenantId),
|
|
372
|
+
eq(entityTypes.name, name)
|
|
373
|
+
)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
private entityWhere(url: string) {
|
|
377
|
+
return and(eq(entities.tenantId, this.tenantId), eq(entities.url, url))
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
private entityBridgeWhere(sourceRef: string) {
|
|
381
|
+
return and(
|
|
382
|
+
eq(entityBridges.tenantId, this.tenantId),
|
|
383
|
+
eq(entityBridges.sourceRef, sourceRef)
|
|
384
|
+
)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async createEntityType(et: ElectricAgentsEntityType): Promise<void> {
|
|
388
|
+
await this.db
|
|
389
|
+
.insert(entityTypes)
|
|
390
|
+
.values({
|
|
391
|
+
tenantId: this.tenantId,
|
|
392
|
+
name: et.name,
|
|
393
|
+
description: et.description,
|
|
394
|
+
creationSchema: et.creation_schema ?? null,
|
|
395
|
+
inboxSchemas: et.inbox_schemas ?? null,
|
|
396
|
+
stateSchemas: et.state_schemas ?? null,
|
|
397
|
+
serveEndpoint: et.serve_endpoint ?? null,
|
|
398
|
+
defaultDispatchPolicy: et.default_dispatch_policy ?? null,
|
|
399
|
+
revision: et.revision,
|
|
400
|
+
createdAt: et.created_at,
|
|
401
|
+
updatedAt: et.updated_at,
|
|
402
|
+
})
|
|
403
|
+
.onConflictDoUpdate({
|
|
404
|
+
target: [entityTypes.tenantId, entityTypes.name],
|
|
405
|
+
set: {
|
|
406
|
+
description: et.description,
|
|
407
|
+
creationSchema: et.creation_schema ?? null,
|
|
408
|
+
inboxSchemas: et.inbox_schemas ?? null,
|
|
409
|
+
stateSchemas: et.state_schemas ?? null,
|
|
410
|
+
serveEndpoint: et.serve_endpoint ?? null,
|
|
411
|
+
defaultDispatchPolicy: et.default_dispatch_policy ?? null,
|
|
412
|
+
revision: et.revision,
|
|
413
|
+
updatedAt: et.updated_at,
|
|
414
|
+
},
|
|
415
|
+
})
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async getEntityType(name: string): Promise<ElectricAgentsEntityType | null> {
|
|
419
|
+
const rows = await this.db
|
|
420
|
+
.select()
|
|
421
|
+
.from(entityTypes)
|
|
422
|
+
.where(this.entityTypeWhere(name))
|
|
423
|
+
.limit(1)
|
|
424
|
+
if (rows.length === 0) return null
|
|
425
|
+
return this.rowToEntityType(rows[0]!)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async listEntityTypes(): Promise<Array<ElectricAgentsEntityType>> {
|
|
429
|
+
const rows = await this.db
|
|
430
|
+
.select()
|
|
431
|
+
.from(entityTypes)
|
|
432
|
+
.where(eq(entityTypes.tenantId, this.tenantId))
|
|
433
|
+
.orderBy(entityTypes.name)
|
|
434
|
+
return rows.map((row) => this.rowToEntityType(row))
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async deleteEntityType(name: string): Promise<void> {
|
|
438
|
+
await this.db.delete(entityTypes).where(this.entityTypeWhere(name))
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async updateEntityTypeInPlace(et: ElectricAgentsEntityType): Promise<void> {
|
|
442
|
+
await this.db
|
|
443
|
+
.update(entityTypes)
|
|
444
|
+
.set({
|
|
445
|
+
description: et.description,
|
|
446
|
+
creationSchema: et.creation_schema ?? null,
|
|
447
|
+
inboxSchemas: et.inbox_schemas ?? null,
|
|
448
|
+
stateSchemas: et.state_schemas ?? null,
|
|
449
|
+
serveEndpoint: et.serve_endpoint ?? null,
|
|
450
|
+
defaultDispatchPolicy: et.default_dispatch_policy ?? null,
|
|
451
|
+
revision: et.revision,
|
|
452
|
+
updatedAt: et.updated_at,
|
|
453
|
+
})
|
|
454
|
+
.where(this.entityTypeWhere(et.name))
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async createEntity(entity: ElectricAgentsEntity): Promise<number> {
|
|
458
|
+
try {
|
|
459
|
+
return await this.db.transaction(async (tx) => {
|
|
460
|
+
const result = await tx
|
|
461
|
+
.insert(entities)
|
|
462
|
+
.values({
|
|
463
|
+
tenantId: this.tenantId,
|
|
464
|
+
url: entity.url,
|
|
465
|
+
type: entity.type,
|
|
466
|
+
status: entity.status,
|
|
467
|
+
subscriptionId: entity.subscription_id,
|
|
468
|
+
dispatchPolicy: entity.dispatch_policy ?? null,
|
|
469
|
+
writeToken: entity.write_token,
|
|
470
|
+
tags: normalizeTags(entity.tags),
|
|
471
|
+
tagsIndex: buildTagsIndex(entity.tags),
|
|
472
|
+
spawnArgs: entity.spawn_args ?? {},
|
|
473
|
+
parent: entity.parent ?? null,
|
|
474
|
+
typeRevision: entity.type_revision ?? null,
|
|
475
|
+
inboxSchemas: entity.inbox_schemas ?? null,
|
|
476
|
+
stateSchemas: entity.state_schemas ?? null,
|
|
477
|
+
createdAt: entity.created_at,
|
|
478
|
+
updatedAt: entity.updated_at,
|
|
479
|
+
})
|
|
480
|
+
.returning({
|
|
481
|
+
txid: sql<string>`pg_current_xact_id()::xid::text`,
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
await tx
|
|
485
|
+
.insert(entityDispatchState)
|
|
486
|
+
.values({
|
|
487
|
+
tenantId: this.tenantId,
|
|
488
|
+
entityUrl: entity.url,
|
|
489
|
+
pendingSourceStreams: [],
|
|
490
|
+
updatedAt: new Date(),
|
|
491
|
+
})
|
|
492
|
+
.onConflictDoNothing()
|
|
493
|
+
|
|
494
|
+
return parseInt(result[0]!.txid)
|
|
495
|
+
})
|
|
496
|
+
} catch (err) {
|
|
497
|
+
if (isDuplicateUrlError(err)) {
|
|
498
|
+
throw new EntityAlreadyExistsError(entity.url)
|
|
499
|
+
}
|
|
500
|
+
throw err
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
async getEntity(url: string): Promise<ElectricAgentsEntity | null> {
|
|
505
|
+
const rows = await this.db
|
|
506
|
+
.select()
|
|
507
|
+
.from(entities)
|
|
508
|
+
.where(this.entityWhere(url))
|
|
509
|
+
.limit(1)
|
|
510
|
+
if (rows.length === 0) return null
|
|
511
|
+
return this.rowToEntity(rows[0]!)
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async updateEntityDispatchPolicy(
|
|
515
|
+
url: string,
|
|
516
|
+
dispatchPolicy: DispatchPolicy
|
|
517
|
+
): Promise<ElectricAgentsEntity | null> {
|
|
518
|
+
const [row] = await this.db
|
|
519
|
+
.update(entities)
|
|
520
|
+
.set({ dispatchPolicy, updatedAt: Date.now() })
|
|
521
|
+
.where(this.entityWhere(url))
|
|
522
|
+
.returning()
|
|
523
|
+
return row ? this.rowToEntity(row) : null
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
async getEntityByStream(
|
|
527
|
+
streamPath: string
|
|
528
|
+
): Promise<ElectricAgentsEntity | null> {
|
|
529
|
+
const mainSuffix = `/main`
|
|
530
|
+
const errorSuffix = `/error`
|
|
531
|
+
let entityUrl: string | null = null
|
|
532
|
+
if (streamPath.endsWith(mainSuffix)) {
|
|
533
|
+
entityUrl = streamPath.slice(0, -mainSuffix.length)
|
|
534
|
+
} else if (streamPath.endsWith(errorSuffix)) {
|
|
535
|
+
entityUrl = streamPath.slice(0, -errorSuffix.length)
|
|
536
|
+
}
|
|
537
|
+
if (!entityUrl) return null
|
|
538
|
+
return this.getEntity(entityUrl)
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
async listEntities(filter?: {
|
|
542
|
+
type?: string
|
|
543
|
+
status?: string
|
|
544
|
+
parent?: string
|
|
545
|
+
limit?: number
|
|
546
|
+
offset?: number
|
|
547
|
+
}): Promise<{ entities: Array<ElectricAgentsEntity>; total: number }> {
|
|
548
|
+
const conditions = [eq(entities.tenantId, this.tenantId)]
|
|
549
|
+
if (filter?.type) conditions.push(eq(entities.type, filter.type))
|
|
550
|
+
if (filter?.status) conditions.push(eq(entities.status, filter.status))
|
|
551
|
+
if (filter?.parent) conditions.push(eq(entities.parent, filter.parent))
|
|
552
|
+
|
|
553
|
+
const whereClause = and(...conditions)
|
|
554
|
+
|
|
555
|
+
const countResult = await this.db
|
|
556
|
+
.select({ count: sql<number>`count(*)` })
|
|
557
|
+
.from(entities)
|
|
558
|
+
.where(whereClause)
|
|
559
|
+
const total = Number(countResult[0]!.count)
|
|
560
|
+
|
|
561
|
+
let query = this.db
|
|
562
|
+
.select()
|
|
563
|
+
.from(entities)
|
|
564
|
+
.where(whereClause)
|
|
565
|
+
.orderBy(desc(entities.createdAt))
|
|
566
|
+
.$dynamic()
|
|
567
|
+
|
|
568
|
+
if (filter?.limit !== undefined) {
|
|
569
|
+
query = query.limit(filter.limit)
|
|
570
|
+
}
|
|
571
|
+
if (filter?.offset !== undefined) {
|
|
572
|
+
query = query.offset(filter.offset)
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const rows = await query
|
|
576
|
+
return {
|
|
577
|
+
entities: rows.map((row) => this.rowToEntity(row)),
|
|
578
|
+
total,
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
async updateStatus(entityUrl: string, status: EntityStatus): Promise<void> {
|
|
583
|
+
const whereClause =
|
|
584
|
+
status === `stopped`
|
|
585
|
+
? this.entityWhere(entityUrl)
|
|
586
|
+
: and(this.entityWhere(entityUrl), ne(entities.status, `stopped`))
|
|
587
|
+
|
|
588
|
+
await this.db
|
|
589
|
+
.update(entities)
|
|
590
|
+
.set({ status, updatedAt: Date.now() })
|
|
591
|
+
.where(whereClause)
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
async updateStatusWithTxid(
|
|
595
|
+
entityUrl: string,
|
|
596
|
+
status: EntityStatus
|
|
597
|
+
): Promise<number> {
|
|
598
|
+
return await this.db.transaction(async (tx) => {
|
|
599
|
+
const whereClause =
|
|
600
|
+
status === `stopped`
|
|
601
|
+
? this.entityWhere(entityUrl)
|
|
602
|
+
: and(this.entityWhere(entityUrl), ne(entities.status, `stopped`))
|
|
603
|
+
|
|
604
|
+
await tx
|
|
605
|
+
.update(entities)
|
|
606
|
+
.set({ status, updatedAt: Date.now() })
|
|
607
|
+
.where(whereClause)
|
|
608
|
+
const result = await tx.execute(
|
|
609
|
+
sql`SELECT pg_current_xact_id()::xid::text AS txid`
|
|
610
|
+
)
|
|
611
|
+
return parseInt((result[0] as { txid: string }).txid)
|
|
612
|
+
})
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
async setEntityTag(
|
|
616
|
+
url: string,
|
|
617
|
+
key: string,
|
|
618
|
+
value: string
|
|
619
|
+
): Promise<{
|
|
620
|
+
entity: ElectricAgentsEntity | null
|
|
621
|
+
changed: boolean
|
|
622
|
+
op?: `insert` | `update` | `delete`
|
|
623
|
+
}> {
|
|
624
|
+
return this.mutateEntityTags(url, (oldTags) => {
|
|
625
|
+
const previous = oldTags[key]
|
|
626
|
+
if (previous === value) return null
|
|
627
|
+
return {
|
|
628
|
+
nextTags: { ...oldTags, [key]: value },
|
|
629
|
+
outbox: {
|
|
630
|
+
op: previous === undefined ? `insert` : `update`,
|
|
631
|
+
key,
|
|
632
|
+
rowData: { key, value },
|
|
633
|
+
},
|
|
634
|
+
}
|
|
635
|
+
})
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
async removeEntityTag(
|
|
639
|
+
url: string,
|
|
640
|
+
key: string
|
|
641
|
+
): Promise<{ entity: ElectricAgentsEntity | null; changed: boolean }> {
|
|
642
|
+
return this.mutateEntityTags(url, (oldTags) => {
|
|
643
|
+
if (!(key in oldTags)) return null
|
|
644
|
+
const { [key]: _removed, ...remaining } = oldTags
|
|
645
|
+
return {
|
|
646
|
+
nextTags: remaining,
|
|
647
|
+
outbox: { op: `delete`, key },
|
|
648
|
+
}
|
|
649
|
+
})
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
private async mutateEntityTags(
|
|
653
|
+
url: string,
|
|
654
|
+
compute: (oldTags: EntityTags) => {
|
|
655
|
+
nextTags: EntityTags
|
|
656
|
+
outbox: {
|
|
657
|
+
op: `insert` | `update` | `delete`
|
|
658
|
+
key: string
|
|
659
|
+
rowData?: { key: string; value: string }
|
|
660
|
+
}
|
|
661
|
+
} | null
|
|
662
|
+
): Promise<{
|
|
663
|
+
entity: ElectricAgentsEntity | null
|
|
664
|
+
changed: boolean
|
|
665
|
+
op?: `insert` | `update`
|
|
666
|
+
}> {
|
|
667
|
+
return await this.db.transaction(async (tx) => {
|
|
668
|
+
const [row] = await tx
|
|
669
|
+
.select()
|
|
670
|
+
.from(entities)
|
|
671
|
+
.where(this.entityWhere(url))
|
|
672
|
+
.limit(1)
|
|
673
|
+
.for(`update`)
|
|
674
|
+
if (!row) {
|
|
675
|
+
return { entity: null, changed: false }
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const oldTags = (row.tags as EntityTags | null | undefined) ?? {}
|
|
679
|
+
const mutation = compute(oldTags)
|
|
680
|
+
if (!mutation) {
|
|
681
|
+
return { entity: this.rowToEntity(row), changed: false }
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const nextTags = normalizeTags(mutation.nextTags)
|
|
685
|
+
const updatedAt = Date.now()
|
|
686
|
+
await tx
|
|
687
|
+
.update(entities)
|
|
688
|
+
.set({
|
|
689
|
+
tags: nextTags,
|
|
690
|
+
tagsIndex: buildTagsIndex(nextTags),
|
|
691
|
+
updatedAt,
|
|
692
|
+
})
|
|
693
|
+
.where(this.entityWhere(url))
|
|
694
|
+
|
|
695
|
+
await tx.insert(tagStreamOutbox).values({
|
|
696
|
+
tenantId: this.tenantId,
|
|
697
|
+
entityUrl: url,
|
|
698
|
+
collection: `tags`,
|
|
699
|
+
op: mutation.outbox.op,
|
|
700
|
+
key: mutation.outbox.key,
|
|
701
|
+
rowData: mutation.outbox.rowData,
|
|
702
|
+
})
|
|
703
|
+
|
|
704
|
+
const entity = this.rowToEntity({
|
|
705
|
+
...row,
|
|
706
|
+
tags: nextTags,
|
|
707
|
+
updatedAt,
|
|
708
|
+
})
|
|
709
|
+
const op = mutation.outbox.op
|
|
710
|
+
return {
|
|
711
|
+
entity,
|
|
712
|
+
changed: true,
|
|
713
|
+
...(op === `insert` || op === `update` ? { op } : {}),
|
|
714
|
+
}
|
|
715
|
+
})
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
async upsertEntityBridge(row: {
|
|
719
|
+
sourceRef: string
|
|
720
|
+
tags: EntityTags
|
|
721
|
+
streamUrl: string
|
|
722
|
+
}): Promise<EntityBridgeRow> {
|
|
723
|
+
await this.db
|
|
724
|
+
.insert(entityBridges)
|
|
725
|
+
.values({
|
|
726
|
+
tenantId: this.tenantId,
|
|
727
|
+
sourceRef: row.sourceRef,
|
|
728
|
+
tags: normalizeTags(row.tags),
|
|
729
|
+
streamUrl: row.streamUrl,
|
|
730
|
+
})
|
|
731
|
+
.onConflictDoNothing()
|
|
732
|
+
|
|
733
|
+
const existing = await this.getEntityBridge(row.sourceRef)
|
|
734
|
+
if (!existing) {
|
|
735
|
+
throw new Error(`Failed to load entity bridge ${row.sourceRef}`)
|
|
736
|
+
}
|
|
737
|
+
return existing
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
async getEntityBridge(sourceRef: string): Promise<EntityBridgeRow | null> {
|
|
741
|
+
const rows = await this.db
|
|
742
|
+
.select()
|
|
743
|
+
.from(entityBridges)
|
|
744
|
+
.where(this.entityBridgeWhere(sourceRef))
|
|
745
|
+
.limit(1)
|
|
746
|
+
return rows[0] ? this.rowToEntityBridge(rows[0]) : null
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
async listEntityBridges(
|
|
750
|
+
tenantId: string | null = this.tenantId
|
|
751
|
+
): Promise<Array<EntityBridgeRow>> {
|
|
752
|
+
const rows =
|
|
753
|
+
tenantId === null
|
|
754
|
+
? await this.db.select().from(entityBridges)
|
|
755
|
+
: await this.db
|
|
756
|
+
.select()
|
|
757
|
+
.from(entityBridges)
|
|
758
|
+
.where(eq(entityBridges.tenantId, tenantId))
|
|
759
|
+
return rows.map((row) => this.rowToEntityBridge(row))
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
async listStaleEntityBridges(before: Date): Promise<Array<EntityBridgeRow>> {
|
|
763
|
+
const rows = await this.db
|
|
764
|
+
.select()
|
|
765
|
+
.from(entityBridges)
|
|
766
|
+
.where(
|
|
767
|
+
and(
|
|
768
|
+
eq(entityBridges.tenantId, this.tenantId),
|
|
769
|
+
lt(entityBridges.lastObserverActivityAt, before)
|
|
770
|
+
)
|
|
771
|
+
)
|
|
772
|
+
return rows.map((row) => this.rowToEntityBridge(row))
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
async replaceEntityManifestSource(
|
|
776
|
+
ownerEntityUrl: string,
|
|
777
|
+
manifestKey: string,
|
|
778
|
+
sourceRef?: string
|
|
779
|
+
): Promise<void> {
|
|
780
|
+
await this.db
|
|
781
|
+
.delete(entityManifestSources)
|
|
782
|
+
.where(
|
|
783
|
+
and(
|
|
784
|
+
eq(entityManifestSources.tenantId, this.tenantId),
|
|
785
|
+
eq(entityManifestSources.ownerEntityUrl, ownerEntityUrl),
|
|
786
|
+
eq(entityManifestSources.manifestKey, manifestKey)
|
|
787
|
+
)
|
|
788
|
+
)
|
|
789
|
+
|
|
790
|
+
if (!sourceRef) {
|
|
791
|
+
return
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
await this.db
|
|
795
|
+
.insert(entityManifestSources)
|
|
796
|
+
.values({
|
|
797
|
+
tenantId: this.tenantId,
|
|
798
|
+
ownerEntityUrl,
|
|
799
|
+
manifestKey,
|
|
800
|
+
sourceRef,
|
|
801
|
+
})
|
|
802
|
+
.onConflictDoUpdate({
|
|
803
|
+
target: [
|
|
804
|
+
entityManifestSources.tenantId,
|
|
805
|
+
entityManifestSources.ownerEntityUrl,
|
|
806
|
+
entityManifestSources.manifestKey,
|
|
807
|
+
],
|
|
808
|
+
set: {
|
|
809
|
+
sourceRef,
|
|
810
|
+
updatedAt: new Date(),
|
|
811
|
+
},
|
|
812
|
+
})
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
async clearEntityManifestSources(): Promise<void> {
|
|
816
|
+
await this.db
|
|
817
|
+
.delete(entityManifestSources)
|
|
818
|
+
.where(eq(entityManifestSources.tenantId, this.tenantId))
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
async listReferencedEntitySourceRefs(): Promise<Array<string>> {
|
|
822
|
+
const rows = await this.db
|
|
823
|
+
.selectDistinct({ sourceRef: entityManifestSources.sourceRef })
|
|
824
|
+
.from(entityManifestSources)
|
|
825
|
+
.where(eq(entityManifestSources.tenantId, this.tenantId))
|
|
826
|
+
.orderBy(entityManifestSources.sourceRef)
|
|
827
|
+
return rows.map((row) => row.sourceRef)
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
async touchEntityBridge(sourceRef: string): Promise<void> {
|
|
831
|
+
await this.db
|
|
832
|
+
.update(entityBridges)
|
|
833
|
+
.set({
|
|
834
|
+
lastObserverActivityAt: new Date(),
|
|
835
|
+
updatedAt: new Date(),
|
|
836
|
+
})
|
|
837
|
+
.where(this.entityBridgeWhere(sourceRef))
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
async updateEntityBridgeCursor(
|
|
841
|
+
sourceRef: string,
|
|
842
|
+
shapeHandle: string,
|
|
843
|
+
shapeOffset: string
|
|
844
|
+
): Promise<void> {
|
|
845
|
+
await this.db
|
|
846
|
+
.update(entityBridges)
|
|
847
|
+
.set({
|
|
848
|
+
shapeHandle,
|
|
849
|
+
shapeOffset,
|
|
850
|
+
updatedAt: new Date(),
|
|
851
|
+
})
|
|
852
|
+
.where(this.entityBridgeWhere(sourceRef))
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
async clearEntityBridgeCursor(sourceRef: string): Promise<void> {
|
|
856
|
+
await this.db
|
|
857
|
+
.update(entityBridges)
|
|
858
|
+
.set({
|
|
859
|
+
shapeHandle: null,
|
|
860
|
+
shapeOffset: null,
|
|
861
|
+
updatedAt: new Date(),
|
|
862
|
+
})
|
|
863
|
+
.where(this.entityBridgeWhere(sourceRef))
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
async deleteEntityBridge(sourceRef: string): Promise<void> {
|
|
867
|
+
await this.db.delete(entityBridges).where(this.entityBridgeWhere(sourceRef))
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// The 30-second window is the claim lease TTL: if a worker crashes mid-
|
|
871
|
+
// publish, its claim is reclaimable by another worker after 30s. Pairs
|
|
872
|
+
// with DRAIN_INTERVAL_MS=500 — short enough that recovery is fast, long
|
|
873
|
+
// enough that a healthy in-flight publish won't be stolen.
|
|
874
|
+
async claimTagOutboxRows(
|
|
875
|
+
workerId: string,
|
|
876
|
+
limit = 25,
|
|
877
|
+
tenantId: string | null = this.tenantId
|
|
878
|
+
): Promise<Array<TagStreamOutboxRow>> {
|
|
879
|
+
const tenantFilter =
|
|
880
|
+
tenantId === null
|
|
881
|
+
? sql``
|
|
882
|
+
: sql`AND ${tagStreamOutbox.tenantId} = ${tenantId}`
|
|
883
|
+
const claimed = await this.db.execute(sql`
|
|
884
|
+
WITH candidates AS (
|
|
885
|
+
SELECT id
|
|
886
|
+
FROM ${tagStreamOutbox}
|
|
887
|
+
WHERE ${tagStreamOutbox.deadLetteredAt} IS NULL
|
|
888
|
+
${tenantFilter}
|
|
889
|
+
AND (
|
|
890
|
+
${tagStreamOutbox.claimedAt} IS NULL
|
|
891
|
+
OR ${tagStreamOutbox.claimedAt} < now() - interval '30 seconds'
|
|
892
|
+
)
|
|
893
|
+
ORDER BY ${tagStreamOutbox.createdAt}
|
|
894
|
+
LIMIT ${limit}
|
|
895
|
+
FOR UPDATE SKIP LOCKED
|
|
896
|
+
)
|
|
897
|
+
UPDATE ${tagStreamOutbox}
|
|
898
|
+
SET claimed_by = ${workerId},
|
|
899
|
+
claimed_at = now()
|
|
900
|
+
WHERE ${tagStreamOutbox.id} IN (SELECT id FROM candidates)
|
|
901
|
+
RETURNING
|
|
902
|
+
id,
|
|
903
|
+
tenant_id AS "tenantId",
|
|
904
|
+
entity_url AS "entityUrl",
|
|
905
|
+
collection,
|
|
906
|
+
op,
|
|
907
|
+
key,
|
|
908
|
+
row_data AS "rowData",
|
|
909
|
+
attempt_count AS "attemptCount",
|
|
910
|
+
last_error AS "lastError",
|
|
911
|
+
claimed_by AS "claimedBy",
|
|
912
|
+
claimed_at AS "claimedAt",
|
|
913
|
+
dead_lettered_at AS "deadLetteredAt",
|
|
914
|
+
created_at AS "createdAt"
|
|
915
|
+
`)
|
|
916
|
+
|
|
917
|
+
return (
|
|
918
|
+
claimed as unknown as Array<{
|
|
919
|
+
id: number
|
|
920
|
+
tenantId: string
|
|
921
|
+
entityUrl: string
|
|
922
|
+
collection: string
|
|
923
|
+
op: `insert` | `update` | `delete`
|
|
924
|
+
key: string
|
|
925
|
+
rowData?: { key: string; value: string } | null
|
|
926
|
+
attemptCount: number
|
|
927
|
+
lastError?: string | null
|
|
928
|
+
claimedBy?: string | null
|
|
929
|
+
claimedAt?: Date | null
|
|
930
|
+
deadLetteredAt?: Date | null
|
|
931
|
+
createdAt: Date
|
|
932
|
+
}>
|
|
933
|
+
).map((row) => this.rowToTagStreamOutbox(row))
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
async failTagOutboxRow(
|
|
937
|
+
id: number,
|
|
938
|
+
workerId: string,
|
|
939
|
+
errorMessage: string,
|
|
940
|
+
maxAttempts: number,
|
|
941
|
+
tenantId: string | null = this.tenantId
|
|
942
|
+
): Promise<{ attemptCount: number; deadLettered: boolean }> {
|
|
943
|
+
const tenantFilter =
|
|
944
|
+
tenantId === null
|
|
945
|
+
? sql``
|
|
946
|
+
: sql`AND ${tagStreamOutbox.tenantId} = ${tenantId}`
|
|
947
|
+
const [row] = await this.db.execute(sql`
|
|
948
|
+
UPDATE ${tagStreamOutbox}
|
|
949
|
+
SET attempt_count = ${tagStreamOutbox.attemptCount} + 1,
|
|
950
|
+
last_error = ${errorMessage},
|
|
951
|
+
claimed_by = null,
|
|
952
|
+
claimed_at = null,
|
|
953
|
+
dead_lettered_at = CASE
|
|
954
|
+
WHEN ${tagStreamOutbox.attemptCount} + 1 >= ${maxAttempts}
|
|
955
|
+
THEN now()
|
|
956
|
+
ELSE ${tagStreamOutbox.deadLetteredAt}
|
|
957
|
+
END
|
|
958
|
+
WHERE ${tagStreamOutbox.id} = ${id}
|
|
959
|
+
${tenantFilter}
|
|
960
|
+
AND ${tagStreamOutbox.claimedBy} = ${workerId}
|
|
961
|
+
RETURNING
|
|
962
|
+
attempt_count AS "attemptCount",
|
|
963
|
+
dead_lettered_at AS "deadLetteredAt"
|
|
964
|
+
`)
|
|
965
|
+
|
|
966
|
+
if (!row) {
|
|
967
|
+
throw new Error(`Failed to mark tag outbox row ${id} as failed`)
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
const typedRow = row as {
|
|
971
|
+
attemptCount: number
|
|
972
|
+
deadLetteredAt?: Date | null
|
|
973
|
+
}
|
|
974
|
+
return {
|
|
975
|
+
attemptCount: typedRow.attemptCount,
|
|
976
|
+
deadLettered: typedRow.deadLetteredAt != null,
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
async deleteTagOutboxRow(
|
|
981
|
+
id: number,
|
|
982
|
+
tenantId: string | null = this.tenantId
|
|
983
|
+
): Promise<void> {
|
|
984
|
+
const conditions = [eq(tagStreamOutbox.id, id)]
|
|
985
|
+
if (tenantId !== null) {
|
|
986
|
+
conditions.unshift(eq(tagStreamOutbox.tenantId, tenantId))
|
|
987
|
+
}
|
|
988
|
+
await this.db.delete(tagStreamOutbox).where(and(...conditions))
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
async releaseTagOutboxClaims(
|
|
992
|
+
workerId: string,
|
|
993
|
+
tenantId: string | null = this.tenantId
|
|
994
|
+
): Promise<void> {
|
|
995
|
+
const conditions = [
|
|
996
|
+
eq(tagStreamOutbox.claimedBy, workerId),
|
|
997
|
+
sql`${tagStreamOutbox.deadLetteredAt} IS NULL`,
|
|
998
|
+
]
|
|
999
|
+
if (tenantId !== null) {
|
|
1000
|
+
conditions.unshift(eq(tagStreamOutbox.tenantId, tenantId))
|
|
1001
|
+
}
|
|
1002
|
+
await this.db
|
|
1003
|
+
.update(tagStreamOutbox)
|
|
1004
|
+
.set({
|
|
1005
|
+
claimedBy: null,
|
|
1006
|
+
claimedAt: null,
|
|
1007
|
+
})
|
|
1008
|
+
.where(and(...conditions))
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
async deleteEntity(url: string): Promise<void> {
|
|
1012
|
+
await this.db.delete(entities).where(this.entityWhere(url))
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
private rowToEntityType(
|
|
1016
|
+
row: typeof entityTypes.$inferSelect
|
|
1017
|
+
): ElectricAgentsEntityType {
|
|
1018
|
+
return {
|
|
1019
|
+
name: row.name,
|
|
1020
|
+
description: row.description,
|
|
1021
|
+
creation_schema: row.creationSchema as
|
|
1022
|
+
| Record<string, unknown>
|
|
1023
|
+
| undefined,
|
|
1024
|
+
inbox_schemas: row.inboxSchemas as
|
|
1025
|
+
| Record<string, Record<string, unknown>>
|
|
1026
|
+
| undefined,
|
|
1027
|
+
state_schemas: row.stateSchemas as
|
|
1028
|
+
| Record<string, Record<string, unknown>>
|
|
1029
|
+
| undefined,
|
|
1030
|
+
serve_endpoint: row.serveEndpoint ?? undefined,
|
|
1031
|
+
default_dispatch_policy:
|
|
1032
|
+
(row.defaultDispatchPolicy as ElectricAgentsEntityType[`default_dispatch_policy`]) ??
|
|
1033
|
+
undefined,
|
|
1034
|
+
revision: row.revision,
|
|
1035
|
+
created_at: row.createdAt,
|
|
1036
|
+
updated_at: row.updatedAt,
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
private rowToEntity(row: typeof entities.$inferSelect): ElectricAgentsEntity {
|
|
1041
|
+
return {
|
|
1042
|
+
url: row.url,
|
|
1043
|
+
type: row.type,
|
|
1044
|
+
status: assertEntityStatus(row.status),
|
|
1045
|
+
streams: {
|
|
1046
|
+
main: `${row.url}/main`,
|
|
1047
|
+
error: `${row.url}/error`,
|
|
1048
|
+
},
|
|
1049
|
+
subscription_id: row.subscriptionId,
|
|
1050
|
+
dispatch_policy:
|
|
1051
|
+
(row.dispatchPolicy as ElectricAgentsEntity[`dispatch_policy`]) ??
|
|
1052
|
+
undefined,
|
|
1053
|
+
write_token: row.writeToken,
|
|
1054
|
+
tags: (row.tags as EntityTags | null | undefined) ?? {},
|
|
1055
|
+
spawn_args: row.spawnArgs as Record<string, unknown> | undefined,
|
|
1056
|
+
parent: row.parent ?? undefined,
|
|
1057
|
+
type_revision: row.typeRevision ?? undefined,
|
|
1058
|
+
inbox_schemas: row.inboxSchemas as
|
|
1059
|
+
| Record<string, Record<string, unknown>>
|
|
1060
|
+
| undefined,
|
|
1061
|
+
state_schemas: row.stateSchemas as
|
|
1062
|
+
| Record<string, Record<string, unknown>>
|
|
1063
|
+
| undefined,
|
|
1064
|
+
created_at: row.createdAt,
|
|
1065
|
+
updated_at: row.updatedAt,
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
private rowToEntityBridge(
|
|
1070
|
+
row: typeof entityBridges.$inferSelect
|
|
1071
|
+
): EntityBridgeRow {
|
|
1072
|
+
return {
|
|
1073
|
+
tenantId: row.tenantId,
|
|
1074
|
+
sourceRef: row.sourceRef,
|
|
1075
|
+
tags: (row.tags as EntityTags | null | undefined) ?? {},
|
|
1076
|
+
streamUrl: row.streamUrl,
|
|
1077
|
+
shapeHandle: row.shapeHandle ?? undefined,
|
|
1078
|
+
shapeOffset: row.shapeOffset ?? undefined,
|
|
1079
|
+
lastObserverActivityAt: row.lastObserverActivityAt,
|
|
1080
|
+
createdAt: row.createdAt,
|
|
1081
|
+
updatedAt: row.updatedAt,
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
private rowToTagStreamOutbox(row: {
|
|
1086
|
+
id: number
|
|
1087
|
+
tenantId: string
|
|
1088
|
+
entityUrl: string
|
|
1089
|
+
collection: string
|
|
1090
|
+
op: string
|
|
1091
|
+
key: string
|
|
1092
|
+
rowData?: { key: string; value: string } | null
|
|
1093
|
+
attemptCount: number
|
|
1094
|
+
lastError?: string | null
|
|
1095
|
+
claimedBy?: string | null
|
|
1096
|
+
claimedAt?: Date | null
|
|
1097
|
+
deadLetteredAt?: Date | null
|
|
1098
|
+
createdAt: Date
|
|
1099
|
+
}): TagStreamOutboxRow {
|
|
1100
|
+
return {
|
|
1101
|
+
id: row.id,
|
|
1102
|
+
tenantId: row.tenantId,
|
|
1103
|
+
entityUrl: row.entityUrl,
|
|
1104
|
+
collection: row.collection,
|
|
1105
|
+
op: row.op as `insert` | `update` | `delete`,
|
|
1106
|
+
key: row.key,
|
|
1107
|
+
rowData:
|
|
1108
|
+
(row.rowData as { key: string; value: string } | null | undefined) ??
|
|
1109
|
+
undefined,
|
|
1110
|
+
attemptCount: row.attemptCount,
|
|
1111
|
+
lastError: row.lastError ?? undefined,
|
|
1112
|
+
claimedBy: row.claimedBy ?? undefined,
|
|
1113
|
+
claimedAt: row.claimedAt ?? undefined,
|
|
1114
|
+
deadLetteredAt: row.deadLetteredAt ?? undefined,
|
|
1115
|
+
createdAt: row.createdAt,
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
private rowToRunner(row: typeof runners.$inferSelect): ElectricAgentsRunner {
|
|
1120
|
+
const now = Date.now()
|
|
1121
|
+
const livenessExpiry = row.livenessLeaseExpiresAt?.getTime()
|
|
1122
|
+
return {
|
|
1123
|
+
id: row.id,
|
|
1124
|
+
owner_user_id: row.ownerUserId,
|
|
1125
|
+
label: row.label,
|
|
1126
|
+
kind: assertRunnerKind(row.kind),
|
|
1127
|
+
admin_status: assertRunnerAdminStatus(row.adminStatus),
|
|
1128
|
+
liveness:
|
|
1129
|
+
livenessExpiry !== undefined && livenessExpiry > now
|
|
1130
|
+
? `online`
|
|
1131
|
+
: `offline`,
|
|
1132
|
+
last_seen_at: row.lastSeenAt?.toISOString(),
|
|
1133
|
+
liveness_lease_expires_at: row.livenessLeaseExpiresAt?.toISOString(),
|
|
1134
|
+
wake_stream: row.wakeStream,
|
|
1135
|
+
wake_stream_offset: row.wakeStreamOffset ?? undefined,
|
|
1136
|
+
created_at: row.createdAt.toISOString(),
|
|
1137
|
+
updated_at: row.updatedAt.toISOString(),
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
private rowToConsumerClaim(
|
|
1142
|
+
row: typeof consumerClaims.$inferSelect
|
|
1143
|
+
): ConsumerClaim {
|
|
1144
|
+
return {
|
|
1145
|
+
consumer_id: row.consumerId,
|
|
1146
|
+
epoch: row.epoch,
|
|
1147
|
+
wake_id: row.wakeId ?? undefined,
|
|
1148
|
+
entity_url: row.entityUrl,
|
|
1149
|
+
stream_path: row.streamPath,
|
|
1150
|
+
runner_id: row.runnerId ?? undefined,
|
|
1151
|
+
status: row.status as ConsumerClaim[`status`],
|
|
1152
|
+
claimed_at: row.claimedAt.toISOString(),
|
|
1153
|
+
last_heartbeat_at: row.lastHeartbeatAt?.toISOString(),
|
|
1154
|
+
lease_expires_at: row.leaseExpiresAt?.toISOString(),
|
|
1155
|
+
released_at: row.releasedAt?.toISOString(),
|
|
1156
|
+
acked_streams:
|
|
1157
|
+
(row.ackedStreams as Array<SourceStreamOffset> | null | undefined) ??
|
|
1158
|
+
undefined,
|
|
1159
|
+
updated_at: row.updatedAt.toISOString(),
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
}
|