@electric-ax/agents-server 0.4.20 → 0.5.1
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/dist/entrypoint.js +1003 -834
- package/dist/index.cjs +241 -72
- package/dist/index.d.cts +2507 -2440
- package/dist/index.d.ts +2506 -2441
- package/dist/index.js +242 -73
- package/drizzle/0016_entity_type_externally_writable_collections.sql +1 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +5 -5
- package/src/db/schema.ts +4 -0
- package/src/electric-agents-types.ts +23 -0
- package/src/entity-manager.ts +157 -7
- package/src/entity-registry.ts +25 -1
- package/src/index.ts +6 -6
- package/src/manifest-side-effects.ts +2 -6
- package/src/pg-sync-bridge-manager.ts +147 -47
- package/src/routing/context.ts +11 -11
- package/src/routing/entities-router.ts +112 -30
- package/src/routing/entity-types-router.ts +56 -0
- package/src/routing/internal-router.ts +9 -7
- package/src/routing/pg-sync-router.ts +14 -1
- package/src/server.ts +8 -8
- package/src/wake-registry.ts +2 -0
|
@@ -21,10 +21,6 @@ import type {
|
|
|
21
21
|
ShapeStreamInterface,
|
|
22
22
|
} from '@electric-sql/client'
|
|
23
23
|
|
|
24
|
-
export const PG_SYNC_ELECTRIC_SHAPE_URL =
|
|
25
|
-
process.env.ELECTRIC_AGENTS_PG_SYNC_ELECTRIC_URL ??
|
|
26
|
-
`http://localhost:3000/v1/shape`
|
|
27
|
-
|
|
28
24
|
type PgSyncOperation = `insert` | `update` | `delete`
|
|
29
25
|
type WakeEvaluator = (
|
|
30
26
|
sourceUrl: string,
|
|
@@ -36,22 +32,28 @@ export type PgSyncResolvedSource = {
|
|
|
36
32
|
}
|
|
37
33
|
|
|
38
34
|
export interface PgSyncBridgeManagerOptions {
|
|
39
|
-
url?: string
|
|
40
35
|
retry?: {
|
|
41
36
|
initialDelayMs?: number
|
|
42
37
|
maxDelayMs?: number
|
|
43
38
|
random?: () => number
|
|
44
39
|
sleep?: (ms: number) => Promise<void>
|
|
45
40
|
}
|
|
41
|
+
fetchFn?: typeof fetch
|
|
42
|
+
probeTimeoutMs?: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Registration was rejected because the source itself is invalid — map to a 4xx. */
|
|
46
|
+
export class PgSyncSourceValidationError extends Error {
|
|
47
|
+
override name = `PgSyncSourceValidationError`
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
const DEFAULT_RETRY_INITIAL_DELAY_MS = 1_000
|
|
49
51
|
const DEFAULT_RETRY_MAX_DELAY_MS = 30_000
|
|
52
|
+
const DEFAULT_PROBE_TIMEOUT_MS = 10_000
|
|
50
53
|
|
|
51
54
|
type PgSyncChangeMessage = {
|
|
52
55
|
headers: Record<string, unknown> & {
|
|
53
56
|
operation?: PgSyncOperation | string
|
|
54
|
-
offset?: unknown
|
|
55
57
|
key?: unknown
|
|
56
58
|
rowKey?: unknown
|
|
57
59
|
}
|
|
@@ -126,6 +128,54 @@ export function buildElectricShapeParams(
|
|
|
126
128
|
}
|
|
127
129
|
}
|
|
128
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Build the one-shot URL used to validate a shape source at registration
|
|
133
|
+
* time. Approximates the query-param encoding of the Electric TS client
|
|
134
|
+
* (arrays comma-joined, where-clause params as `params[n]`) — unlike the
|
|
135
|
+
* client it does not quote column identifiers, so probe and stream encoding
|
|
136
|
+
* can diverge for exotic column names.
|
|
137
|
+
*/
|
|
138
|
+
export function buildShapeProbeUrl(
|
|
139
|
+
sourceUrl: string,
|
|
140
|
+
options: PgSyncOptions
|
|
141
|
+
): URL {
|
|
142
|
+
let url: URL
|
|
143
|
+
try {
|
|
144
|
+
url = new URL(sourceUrl)
|
|
145
|
+
} catch {
|
|
146
|
+
throw new PgSyncSourceValidationError(
|
|
147
|
+
`pgSync url "${sourceUrl}" is not a valid URL`
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
if (url.protocol !== `http:` && url.protocol !== `https:`) {
|
|
151
|
+
throw new PgSyncSourceValidationError(
|
|
152
|
+
`pgSync url "${sourceUrl}" must be an HTTP(S) Electric shape endpoint, not a database connection string`
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
for (const [key, value] of Object.entries(
|
|
156
|
+
buildElectricShapeParams(options)
|
|
157
|
+
)) {
|
|
158
|
+
if (value === undefined || value === null) continue
|
|
159
|
+
if (Array.isArray(value)) {
|
|
160
|
+
if (key === `params`) {
|
|
161
|
+
value.forEach((item, index) =>
|
|
162
|
+
url.searchParams.set(`params[${index + 1}]`, String(item))
|
|
163
|
+
)
|
|
164
|
+
} else {
|
|
165
|
+
url.searchParams.set(key, value.join(`,`))
|
|
166
|
+
}
|
|
167
|
+
} else if (typeof value === `object`) {
|
|
168
|
+
for (const [k, v] of Object.entries(value)) {
|
|
169
|
+
url.searchParams.set(`${key}[${k}]`, String(v))
|
|
170
|
+
}
|
|
171
|
+
} else {
|
|
172
|
+
url.searchParams.set(key, String(value))
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
url.searchParams.set(`offset`, `now`)
|
|
176
|
+
return url
|
|
177
|
+
}
|
|
178
|
+
|
|
129
179
|
function jsonSafe(value: unknown): unknown {
|
|
130
180
|
if (typeof value === `bigint`) return value.toString()
|
|
131
181
|
if (value === null || typeof value !== `object`) return value
|
|
@@ -168,56 +218,41 @@ function rowKeyForMessage(message: PgSyncChangeMessage): string | undefined {
|
|
|
168
218
|
return candidate === undefined ? undefined : stableJson(candidate)
|
|
169
219
|
}
|
|
170
220
|
|
|
171
|
-
export function pgSyncMessageToDurableEvent(
|
|
172
|
-
message: PgSyncChangeMessage,
|
|
173
|
-
optionsOrSourceRef: PgSyncOptions | string
|
|
174
|
-
): {
|
|
221
|
+
export function pgSyncMessageToDurableEvent(message: PgSyncChangeMessage): {
|
|
175
222
|
type: `pg_sync_change`
|
|
176
223
|
key: string
|
|
177
224
|
value: Record<string, unknown>
|
|
178
|
-
headers: { operation: PgSyncOperation
|
|
225
|
+
headers: Record<string, unknown> & { operation: PgSyncOperation }
|
|
179
226
|
} | null {
|
|
180
227
|
const operation = message.headers.operation
|
|
181
228
|
if (
|
|
182
229
|
operation !== `insert` &&
|
|
183
230
|
operation !== `update` &&
|
|
184
231
|
operation !== `delete`
|
|
185
|
-
)
|
|
232
|
+
) {
|
|
233
|
+
return null
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const key =
|
|
237
|
+
message.key ??
|
|
238
|
+
(typeof message.headers.key === `string`
|
|
239
|
+
? message.headers.key
|
|
240
|
+
: undefined) ??
|
|
241
|
+
rowKeyForMessage(message)
|
|
242
|
+
if (!key) {
|
|
186
243
|
return null
|
|
244
|
+
}
|
|
187
245
|
|
|
188
|
-
const
|
|
189
|
-
typeof optionsOrSourceRef === `string`
|
|
190
|
-
? optionsOrSourceRef
|
|
191
|
-
: sourceRefForPgSync(optionsOrSourceRef)
|
|
192
|
-
const rowKey = rowKeyForMessage(message)
|
|
193
|
-
const offset = message.headers.offset
|
|
194
|
-
if (typeof offset !== `string` || offset.length === 0) return null
|
|
195
|
-
const messageKeyPart = offset
|
|
196
|
-
const messageKey = `${sourceRef}:${operation}:${messageKeyPart}`
|
|
197
|
-
const timestamp = new Date().toISOString()
|
|
198
|
-
const oldValue = message.old_value
|
|
199
|
-
const safeValue = jsonSafe(message.value)
|
|
200
|
-
const safeOldValue = jsonSafe(oldValue)
|
|
201
|
-
const safeHeaders = jsonSafe(message.headers)
|
|
246
|
+
const safeMessage = jsonSafe(message) as Record<string, unknown>
|
|
202
247
|
|
|
203
248
|
return {
|
|
204
249
|
type: `pg_sync_change`,
|
|
205
|
-
key
|
|
206
|
-
value:
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
typeof optionsOrSourceRef === `string`
|
|
210
|
-
? undefined
|
|
211
|
-
: optionsOrSourceRef.table,
|
|
250
|
+
key,
|
|
251
|
+
value: safeMessage,
|
|
252
|
+
headers: {
|
|
253
|
+
...(jsonSafe(message.headers) as Record<string, unknown>),
|
|
212
254
|
operation,
|
|
213
|
-
...(rowKey !== undefined ? { rowKey } : {}),
|
|
214
|
-
...(message.value !== undefined ? { value: safeValue } : {}),
|
|
215
|
-
...(oldValue !== undefined ? { oldValue: safeOldValue } : {}),
|
|
216
|
-
headers: safeHeaders,
|
|
217
|
-
...(typeof offset === `string` ? { offset } : {}),
|
|
218
|
-
receivedAt: timestamp,
|
|
219
255
|
},
|
|
220
|
-
headers: { operation, timestamp },
|
|
221
256
|
}
|
|
222
257
|
}
|
|
223
258
|
|
|
@@ -338,13 +373,18 @@ class PgSyncBridge {
|
|
|
338
373
|
}
|
|
339
374
|
if (!isChangeMessage(message)) continue
|
|
340
375
|
if (!this.skipChangesUntilUpToDate) {
|
|
341
|
-
const event = pgSyncMessageToDurableEvent(message
|
|
376
|
+
const event = pgSyncMessageToDurableEvent(message)
|
|
342
377
|
if (event) {
|
|
343
378
|
if (!this.producer)
|
|
344
379
|
throw new Error(`pg-sync producer is not started`)
|
|
345
380
|
await this.producer.append(JSON.stringify(event))
|
|
346
381
|
await this.producer.flush?.()
|
|
347
382
|
await this.evaluateWakes?.(this.streamUrl, event)
|
|
383
|
+
} else {
|
|
384
|
+
serverLog.warn(
|
|
385
|
+
`[pg-sync-bridge] dropped change message for ${this.sourceRef} (unknown operation or missing row key):`,
|
|
386
|
+
message.headers
|
|
387
|
+
)
|
|
348
388
|
}
|
|
349
389
|
}
|
|
350
390
|
await this.persistCursor(stream)
|
|
@@ -425,10 +465,11 @@ export class PgSyncBridgeManager implements PgSyncBridgeCoordinator {
|
|
|
425
465
|
private bridges = new Map<string, PgSyncBridge>()
|
|
426
466
|
private starting = new Map<string, Promise<void>>()
|
|
427
467
|
|
|
428
|
-
private readonly url: string
|
|
429
468
|
private readonly retry: Required<
|
|
430
469
|
NonNullable<PgSyncBridgeManagerOptions[`retry`]>
|
|
431
470
|
>
|
|
471
|
+
private readonly fetchFn?: typeof fetch
|
|
472
|
+
private readonly probeTimeoutMs: number
|
|
432
473
|
|
|
433
474
|
constructor(
|
|
434
475
|
private streamClient: StreamClient,
|
|
@@ -436,7 +477,8 @@ export class PgSyncBridgeManager implements PgSyncBridgeCoordinator {
|
|
|
436
477
|
private registry?: PostgresRegistry,
|
|
437
478
|
options: PgSyncBridgeManagerOptions = {}
|
|
438
479
|
) {
|
|
439
|
-
this.
|
|
480
|
+
this.fetchFn = options.fetchFn
|
|
481
|
+
this.probeTimeoutMs = options.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS
|
|
440
482
|
this.retry = {
|
|
441
483
|
initialDelayMs:
|
|
442
484
|
options.retry?.initialDelayMs ?? DEFAULT_RETRY_INITIAL_DELAY_MS,
|
|
@@ -453,14 +495,23 @@ export class PgSyncBridgeManager implements PgSyncBridgeCoordinator {
|
|
|
453
495
|
const rows = await this.registry?.listPgSyncBridges?.()
|
|
454
496
|
if (!rows) return
|
|
455
497
|
await Promise.all(
|
|
456
|
-
rows.map((row) =>
|
|
457
|
-
|
|
498
|
+
rows.map(async (row) => {
|
|
499
|
+
if (!row.options.url) {
|
|
500
|
+
// Rows persisted before source URLs were required can never
|
|
501
|
+
// resume — remove them instead of warning on every boot.
|
|
502
|
+
serverLog.warn(
|
|
503
|
+
`[pg-sync-bridge] deleting registration ${row.sourceRef}: it predates required source URLs; re-register the observation with an explicit Electric shape URL`
|
|
504
|
+
)
|
|
505
|
+
await this.registry?.deletePgSyncBridge?.(row.sourceRef)
|
|
506
|
+
return
|
|
507
|
+
}
|
|
508
|
+
await this.ensureBridge(row).catch((error) => {
|
|
458
509
|
serverLog.warn(
|
|
459
510
|
`[pg-sync-bridge] failed to start ${row.sourceRef}:`,
|
|
460
511
|
error
|
|
461
512
|
)
|
|
462
513
|
})
|
|
463
|
-
)
|
|
514
|
+
})
|
|
464
515
|
)
|
|
465
516
|
}
|
|
466
517
|
|
|
@@ -478,6 +529,9 @@ export class PgSyncBridgeManager implements PgSyncBridgeCoordinator {
|
|
|
478
529
|
const resolvedSource = this.resolveSource(canonicalOptions)
|
|
479
530
|
const sourceRef = sourceRefForPgSync(canonicalOptions)
|
|
480
531
|
const streamUrl = getPgSyncStreamPath(sourceRef, this.registry?.tenantId)
|
|
532
|
+
if (!this.bridges.has(sourceRef) && !this.starting.has(sourceRef)) {
|
|
533
|
+
await this.probeSource(resolvedSource, canonicalOptions)
|
|
534
|
+
}
|
|
481
535
|
const row = await this.registry?.upsertPgSyncBridge({
|
|
482
536
|
sourceRef,
|
|
483
537
|
options: canonicalOptions,
|
|
@@ -541,7 +595,53 @@ export class PgSyncBridgeManager implements PgSyncBridgeCoordinator {
|
|
|
541
595
|
}
|
|
542
596
|
|
|
543
597
|
private resolveSource(options: CanonicalPgSyncConfig): PgSyncResolvedSource {
|
|
544
|
-
|
|
598
|
+
if (!options.url) {
|
|
599
|
+
throw new PgSyncSourceValidationError(
|
|
600
|
+
`pgSync source url is required; no server default is configured`
|
|
601
|
+
)
|
|
602
|
+
}
|
|
603
|
+
return { url: options.url }
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* One-shot fetch of the shape log before a bridge is created, so a bad
|
|
608
|
+
* URL or rejected shape fails the registration instead of dying silently
|
|
609
|
+
* in the bridge's retry loop.
|
|
610
|
+
*/
|
|
611
|
+
private async probeSource(
|
|
612
|
+
source: PgSyncResolvedSource,
|
|
613
|
+
options: CanonicalPgSyncConfig
|
|
614
|
+
): Promise<void> {
|
|
615
|
+
const probeUrl = buildShapeProbeUrl(source.url, options)
|
|
616
|
+
const fetchFn = this.fetchFn ?? globalThis.fetch
|
|
617
|
+
let response: Response
|
|
618
|
+
try {
|
|
619
|
+
response = await fetchFn(probeUrl, {
|
|
620
|
+
signal: AbortSignal.timeout(this.probeTimeoutMs),
|
|
621
|
+
})
|
|
622
|
+
} catch (error) {
|
|
623
|
+
throw new PgSyncSourceValidationError(
|
|
624
|
+
`pgSync source at ${source.url} is unreachable: ${error instanceof Error ? error.message : String(error)}`
|
|
625
|
+
)
|
|
626
|
+
}
|
|
627
|
+
if (!response.ok) {
|
|
628
|
+
const body = (
|
|
629
|
+
await response.text().catch(() => `<failed to read body>`)
|
|
630
|
+
).slice(0, 500)
|
|
631
|
+
throw new PgSyncSourceValidationError(
|
|
632
|
+
`pgSync source at ${source.url} rejected the shape request (${response.status})${body ? `: ${body}` : ``}`
|
|
633
|
+
)
|
|
634
|
+
}
|
|
635
|
+
// Electric answers 200 on paths that aren't the shape API (e.g. its
|
|
636
|
+
// root), so an ok status alone doesn't prove the URL is right. Real
|
|
637
|
+
// shape responses always carry the electric-handle header.
|
|
638
|
+
if (!response.headers.get(`electric-handle`)) {
|
|
639
|
+
const suggestion = new URL(source.url)
|
|
640
|
+
suggestion.pathname = `/v1/shape`
|
|
641
|
+
throw new PgSyncSourceValidationError(
|
|
642
|
+
`pgSync source at ${source.url} responded but is not a shape log (missing electric-handle header) — the Electric shape API is usually served at ${suggestion.origin}/v1/shape`
|
|
643
|
+
)
|
|
644
|
+
}
|
|
545
645
|
}
|
|
546
646
|
|
|
547
647
|
async stop(): Promise<void> {
|
package/src/routing/context.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Agent } from 'undici'
|
|
2
2
|
import type {
|
|
3
|
-
|
|
3
|
+
WebhookSourceContract,
|
|
4
4
|
WebhookSignatureVerifierConfig,
|
|
5
5
|
} from '@electric-ax/agents-runtime'
|
|
6
6
|
import type { DrizzleDB } from '../db/index.js'
|
|
@@ -15,16 +15,16 @@ import type { DurableStreamsBearerProvider } from '../stream-client.js'
|
|
|
15
15
|
import type { WebhookSigner } from '../webhook-signing.js'
|
|
16
16
|
import type { AuthorizeRequest } from '../electric-agents-types.js'
|
|
17
17
|
|
|
18
|
-
export interface
|
|
19
|
-
|
|
20
|
-
| Array<
|
|
21
|
-
| Promise<Array<
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
export interface WebhookSourceCatalog {
|
|
19
|
+
listWebhookSources: () =>
|
|
20
|
+
| Array<WebhookSourceContract>
|
|
21
|
+
| Promise<Array<WebhookSourceContract>>
|
|
22
|
+
getWebhookSource: (
|
|
23
|
+
webhookKey: string
|
|
24
24
|
) =>
|
|
25
|
-
|
|
|
25
|
+
| WebhookSourceContract
|
|
26
26
|
| undefined
|
|
27
|
-
| Promise<
|
|
27
|
+
| Promise<WebhookSourceContract | undefined>
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
/**
|
|
@@ -56,8 +56,8 @@ export interface TenantContext {
|
|
|
56
56
|
runtime: ElectricAgentsTenantRuntime
|
|
57
57
|
entityBridgeManager: EntityBridgeCoordinator
|
|
58
58
|
pgSyncBridgeManager?: PgSyncBridgeCoordinator
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
webhookSources?: WebhookSourceCatalog
|
|
60
|
+
ensureWebhookSourceWakeSource?: (sourceUrl: string) => Promise<void> | void
|
|
61
61
|
authorizeRequest?: AuthorizeRequest
|
|
62
62
|
isShuttingDown: () => boolean
|
|
63
63
|
}
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
import { Type, type Static } from '@sinclair/typebox'
|
|
6
6
|
import {
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
buildWebhookSourceManifestEntry,
|
|
8
|
+
resolveWebhookSourceSubscription,
|
|
9
9
|
} from '@electric-ax/agents-runtime'
|
|
10
10
|
import { Router, json, status } from 'itty-router'
|
|
11
11
|
import { apiError } from '../electric-agents-http.js'
|
|
@@ -45,7 +45,7 @@ import type {
|
|
|
45
45
|
import type { JsonRouteRequest } from './schema.js'
|
|
46
46
|
import type { RouterType } from 'itty-router'
|
|
47
47
|
import type { TenantContext } from './context.js'
|
|
48
|
-
import type {
|
|
48
|
+
import type { WebhookSourceSubscriptionInput } from '@electric-ax/agents-runtime'
|
|
49
49
|
|
|
50
50
|
interface AgentsRouteRequest extends JsonRouteRequest {
|
|
51
51
|
entityRoute?: ExistingEntityRoute
|
|
@@ -149,6 +149,19 @@ const spawnBodySchema = Type.Object({
|
|
|
149
149
|
),
|
|
150
150
|
})
|
|
151
151
|
|
|
152
|
+
const writeCollectionBodySchema = Type.Object(
|
|
153
|
+
{
|
|
154
|
+
operation: Type.Union([
|
|
155
|
+
Type.Literal(`insert`),
|
|
156
|
+
Type.Literal(`update`),
|
|
157
|
+
Type.Literal(`delete`),
|
|
158
|
+
]),
|
|
159
|
+
key: Type.Optional(Type.String()),
|
|
160
|
+
value: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
161
|
+
},
|
|
162
|
+
{ additionalProperties: false }
|
|
163
|
+
)
|
|
164
|
+
|
|
152
165
|
const sendBodySchema = Type.Object({
|
|
153
166
|
payload: Type.Optional(Type.Unknown()),
|
|
154
167
|
key: Type.Optional(Type.String()),
|
|
@@ -318,8 +331,8 @@ const subscriptionLifetimeSchema = Type.Union([
|
|
|
318
331
|
Type.Object({ kind: Type.Literal(`manual`) }),
|
|
319
332
|
])
|
|
320
333
|
|
|
321
|
-
const
|
|
322
|
-
|
|
334
|
+
const webhookSourceSubscriptionBodySchema = Type.Object({
|
|
335
|
+
webhookKey: Type.String(),
|
|
323
336
|
bucketKey: Type.Optional(Type.String()),
|
|
324
337
|
params: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
325
338
|
filterKey: Type.Optional(Type.String()),
|
|
@@ -328,14 +341,15 @@ const eventSourceSubscriptionBodySchema = Type.Object({
|
|
|
328
341
|
})
|
|
329
342
|
|
|
330
343
|
type SpawnBody = Static<typeof spawnBodySchema>
|
|
344
|
+
type WriteCollectionBody = Static<typeof writeCollectionBodySchema>
|
|
331
345
|
type SendBody = Static<typeof sendBodySchema>
|
|
332
346
|
type InboxMessageBody = Static<typeof inboxMessageBodySchema>
|
|
333
347
|
type ForkBody = Static<typeof forkBodySchema>
|
|
334
348
|
type SetTagBody = Static<typeof setTagBodySchema>
|
|
335
349
|
type SignalBody = Static<typeof signalBodySchema>
|
|
336
350
|
type ScheduleBody = Static<typeof scheduleBodySchema>
|
|
337
|
-
type
|
|
338
|
-
typeof
|
|
351
|
+
type WebhookSourceSubscriptionBody = Static<
|
|
352
|
+
typeof webhookSourceSubscriptionBodySchema
|
|
339
353
|
>
|
|
340
354
|
type EntityPermissionGrantInput = Static<
|
|
341
355
|
typeof entityPermissionGrantInputSchema
|
|
@@ -408,6 +422,13 @@ entitiesRouter.post(
|
|
|
408
422
|
withEntityPermission(`write`),
|
|
409
423
|
sendEntity
|
|
410
424
|
)
|
|
425
|
+
entitiesRouter.post(
|
|
426
|
+
`/:type/:instanceId/collections/:collection`,
|
|
427
|
+
withExistingEntity,
|
|
428
|
+
withSchema(writeCollectionBodySchema),
|
|
429
|
+
withEntityPermission(`write`),
|
|
430
|
+
writeCollection
|
|
431
|
+
)
|
|
411
432
|
entitiesRouter.post(
|
|
412
433
|
`/:type/:instanceId/attachments`,
|
|
413
434
|
withExistingEntity,
|
|
@@ -473,17 +494,23 @@ entitiesRouter.delete(
|
|
|
473
494
|
deleteSchedule
|
|
474
495
|
)
|
|
475
496
|
entitiesRouter.put(
|
|
476
|
-
`/:type/:instanceId/
|
|
497
|
+
`/:type/:instanceId/webhook-source-subscriptions/:subscriptionId`,
|
|
498
|
+
withExistingEntity,
|
|
499
|
+
withSchema(webhookSourceSubscriptionBodySchema),
|
|
500
|
+
withEntityPermission(`write`),
|
|
501
|
+
upsertWebhookSourceSubscription
|
|
502
|
+
)
|
|
503
|
+
entitiesRouter.delete(
|
|
504
|
+
`/:type/:instanceId/webhook-source-subscriptions/:subscriptionId`,
|
|
477
505
|
withExistingEntity,
|
|
478
|
-
withSchema(eventSourceSubscriptionBodySchema),
|
|
479
506
|
withEntityPermission(`write`),
|
|
480
|
-
|
|
507
|
+
deleteWebhookSourceSubscription
|
|
481
508
|
)
|
|
482
509
|
entitiesRouter.delete(
|
|
483
|
-
`/:type/:instanceId/
|
|
510
|
+
`/:type/:instanceId/pg-sync-observations/:sourceRef`,
|
|
484
511
|
withExistingEntity,
|
|
485
512
|
withEntityPermission(`write`),
|
|
486
|
-
|
|
513
|
+
deletePgSyncObservation
|
|
487
514
|
)
|
|
488
515
|
entitiesRouter.get(
|
|
489
516
|
`/:type/:instanceId/grants`,
|
|
@@ -997,33 +1024,33 @@ async function deleteSchedule(
|
|
|
997
1024
|
return json(result)
|
|
998
1025
|
}
|
|
999
1026
|
|
|
1000
|
-
async function
|
|
1027
|
+
async function upsertWebhookSourceSubscription(
|
|
1001
1028
|
request: AgentsRouteRequest,
|
|
1002
1029
|
ctx: TenantContext
|
|
1003
1030
|
): Promise<Response> {
|
|
1004
1031
|
const principalMutationError = rejectPrincipalEntityMutation(
|
|
1005
1032
|
request,
|
|
1006
|
-
`subscribed to
|
|
1033
|
+
`subscribed to webhook sources`
|
|
1007
1034
|
)
|
|
1008
1035
|
if (principalMutationError) return principalMutationError
|
|
1009
1036
|
|
|
1010
|
-
const catalog = ctx.
|
|
1037
|
+
const catalog = ctx.webhookSources
|
|
1011
1038
|
if (!catalog) {
|
|
1012
1039
|
return apiError(
|
|
1013
1040
|
404,
|
|
1014
1041
|
ErrCodeNotFound,
|
|
1015
|
-
`No
|
|
1042
|
+
`No webhook source catalog is configured`
|
|
1016
1043
|
)
|
|
1017
1044
|
}
|
|
1018
1045
|
|
|
1019
1046
|
const { entityUrl } = requireExistingEntityRoute(request)
|
|
1020
|
-
const parsed = routeBody<
|
|
1021
|
-
const source = await catalog.
|
|
1047
|
+
const parsed = routeBody<WebhookSourceSubscriptionBody>(request)
|
|
1048
|
+
const source = await catalog.getWebhookSource(parsed.webhookKey)
|
|
1022
1049
|
if (!source) {
|
|
1023
1050
|
return apiError(
|
|
1024
1051
|
404,
|
|
1025
1052
|
ErrCodeNotFound,
|
|
1026
|
-
`
|
|
1053
|
+
`Webhook source "${parsed.webhookKey}" not found`
|
|
1027
1054
|
)
|
|
1028
1055
|
}
|
|
1029
1056
|
|
|
@@ -1038,13 +1065,13 @@ async function upsertEventSourceSubscription(
|
|
|
1038
1065
|
}
|
|
1039
1066
|
}
|
|
1040
1067
|
|
|
1041
|
-
let resolved: ReturnType<typeof
|
|
1068
|
+
let resolved: ReturnType<typeof resolveWebhookSourceSubscription>
|
|
1042
1069
|
try {
|
|
1043
|
-
resolved =
|
|
1070
|
+
resolved = resolveWebhookSourceSubscription({
|
|
1044
1071
|
contract: source,
|
|
1045
1072
|
entityUrl,
|
|
1046
1073
|
request: {
|
|
1047
|
-
...(parsed as
|
|
1074
|
+
...(parsed as WebhookSourceSubscriptionInput),
|
|
1048
1075
|
id: decodeURIComponent(request.params.subscriptionId),
|
|
1049
1076
|
},
|
|
1050
1077
|
createdBy: `tool`,
|
|
@@ -1057,30 +1084,30 @@ async function upsertEventSourceSubscription(
|
|
|
1057
1084
|
)
|
|
1058
1085
|
}
|
|
1059
1086
|
|
|
1060
|
-
await ctx.
|
|
1087
|
+
await ctx.ensureWebhookSourceWakeSource?.(resolved.subscription.sourceUrl)
|
|
1061
1088
|
|
|
1062
|
-
const result = await ctx.entityManager.
|
|
1089
|
+
const result = await ctx.entityManager.upsertWebhookSourceSubscription(
|
|
1063
1090
|
entityUrl,
|
|
1064
1091
|
{
|
|
1065
1092
|
subscription: resolved.subscription,
|
|
1066
|
-
manifest:
|
|
1093
|
+
manifest: buildWebhookSourceManifestEntry(resolved),
|
|
1067
1094
|
}
|
|
1068
1095
|
)
|
|
1069
1096
|
return json(result)
|
|
1070
1097
|
}
|
|
1071
1098
|
|
|
1072
|
-
async function
|
|
1099
|
+
async function deleteWebhookSourceSubscription(
|
|
1073
1100
|
request: AgentsRouteRequest,
|
|
1074
1101
|
ctx: TenantContext
|
|
1075
1102
|
): Promise<Response> {
|
|
1076
1103
|
const principalMutationError = rejectPrincipalEntityMutation(
|
|
1077
1104
|
request,
|
|
1078
|
-
`unsubscribed from
|
|
1105
|
+
`unsubscribed from webhook sources`
|
|
1079
1106
|
)
|
|
1080
1107
|
if (principalMutationError) return principalMutationError
|
|
1081
1108
|
|
|
1082
1109
|
const { entityUrl } = requireExistingEntityRoute(request)
|
|
1083
|
-
const result = await ctx.entityManager.
|
|
1110
|
+
const result = await ctx.entityManager.deleteWebhookSourceSubscription(
|
|
1084
1111
|
entityUrl,
|
|
1085
1112
|
{
|
|
1086
1113
|
id: decodeURIComponent(request.params.subscriptionId),
|
|
@@ -1089,6 +1116,23 @@ async function deleteEventSourceSubscription(
|
|
|
1089
1116
|
return json(result)
|
|
1090
1117
|
}
|
|
1091
1118
|
|
|
1119
|
+
async function deletePgSyncObservation(
|
|
1120
|
+
request: AgentsRouteRequest,
|
|
1121
|
+
ctx: TenantContext
|
|
1122
|
+
): Promise<Response> {
|
|
1123
|
+
const principalMutationError = rejectPrincipalEntityMutation(
|
|
1124
|
+
request,
|
|
1125
|
+
`unobserved a pg-sync source`
|
|
1126
|
+
)
|
|
1127
|
+
if (principalMutationError) return principalMutationError
|
|
1128
|
+
|
|
1129
|
+
const { entityUrl } = requireExistingEntityRoute(request)
|
|
1130
|
+
const result = await ctx.entityManager.deletePgSyncObservation(entityUrl, {
|
|
1131
|
+
sourceRef: decodeURIComponent(request.params.sourceRef),
|
|
1132
|
+
})
|
|
1133
|
+
return json(result)
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1092
1136
|
function tagResponseBody(
|
|
1093
1137
|
entity: ElectricAgentsEntity & { txid?: number }
|
|
1094
1138
|
): PublicElectricAgentsEntity & { txid?: number } {
|
|
@@ -1308,6 +1352,31 @@ async function sendEntity(
|
|
|
1308
1352
|
return json(result)
|
|
1309
1353
|
}
|
|
1310
1354
|
|
|
1355
|
+
async function writeCollection(
|
|
1356
|
+
request: AgentsRouteRequest,
|
|
1357
|
+
ctx: TenantContext
|
|
1358
|
+
): Promise<Response> {
|
|
1359
|
+
const parsed = routeBody<WriteCollectionBody>(request)
|
|
1360
|
+
await ctx.entityManager.ensurePrincipal(ctx.principal)
|
|
1361
|
+
const { entityUrl } = requireExistingEntityRoute(request)
|
|
1362
|
+
const collection = request.params.collection
|
|
1363
|
+
const result = await ctx.entityManager.writeCollection(
|
|
1364
|
+
entityUrl,
|
|
1365
|
+
collection,
|
|
1366
|
+
{
|
|
1367
|
+
operation: parsed.operation,
|
|
1368
|
+
key: parsed.key,
|
|
1369
|
+
value: parsed.value,
|
|
1370
|
+
principal: {
|
|
1371
|
+
url: ctx.principal.url,
|
|
1372
|
+
kind: ctx.principal.kind,
|
|
1373
|
+
id: ctx.principal.id,
|
|
1374
|
+
},
|
|
1375
|
+
}
|
|
1376
|
+
)
|
|
1377
|
+
return json(result, { status: parsed.operation === `insert` ? 201 : 200 })
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1311
1380
|
async function createAttachment(
|
|
1312
1381
|
request: AgentsRouteRequest,
|
|
1313
1382
|
ctx: TenantContext
|
|
@@ -1473,8 +1542,21 @@ async function spawnEntity(
|
|
|
1473
1542
|
)
|
|
1474
1543
|
}
|
|
1475
1544
|
|
|
1476
|
-
function getEntity(
|
|
1477
|
-
|
|
1545
|
+
async function getEntity(
|
|
1546
|
+
request: AgentsRouteRequest,
|
|
1547
|
+
ctx: TenantContext
|
|
1548
|
+
): Promise<Response> {
|
|
1549
|
+
const { entity } = requireExistingEntityRoute(request)
|
|
1550
|
+
const entityType = entity.type
|
|
1551
|
+
? await ctx.entityManager.registry.getEntityType(entity.type)
|
|
1552
|
+
: null
|
|
1553
|
+
return json({
|
|
1554
|
+
...toPublicEntity(entity),
|
|
1555
|
+
...(entityType?.externally_writable_collections && {
|
|
1556
|
+
externally_writable_collections:
|
|
1557
|
+
entityType.externally_writable_collections,
|
|
1558
|
+
}),
|
|
1559
|
+
})
|
|
1478
1560
|
}
|
|
1479
1561
|
|
|
1480
1562
|
function headEntity(): Response {
|