@electric-ax/agents-server 0.4.18 → 0.4.20
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 +590 -40
- package/dist/index.cjs +576 -36
- package/dist/index.d.cts +290 -40
- package/dist/index.d.ts +290 -40
- package/dist/index.js +577 -37
- package/drizzle/0015_pg_sync_bridges.sql +14 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +6 -6
- package/src/db/schema.ts +28 -0
- package/src/entity-manager.ts +34 -29
- package/src/entity-registry.ts +144 -3
- package/src/manifest-side-effects.ts +10 -0
- package/src/pg-sync-bridge-manager.ts +552 -0
- package/src/routing/context.ts +2 -0
- package/src/routing/durable-streams-router.ts +13 -0
- package/src/routing/entities-router.ts +28 -16
- package/src/routing/global-router.ts +3 -0
- package/src/routing/hooks.ts +7 -0
- package/src/routing/internal-router.ts +2 -0
- package/src/routing/pg-sync-router.ts +113 -0
- package/src/runtime.ts +20 -1
- package/src/scheduler.ts +26 -0
- package/src/server.ts +4 -0
- package/src/standalone-runtime.ts +16 -0
- package/src/utils/server-utils.ts +97 -1
- package/src/wake-registry.ts +27 -2
package/src/routing/hooks.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { SpanKind, SpanStatusCode } from '@opentelemetry/api'
|
|
2
2
|
import { apiError } from '../electric-agents-http.js'
|
|
3
3
|
import { ElectricAgentsError } from '../entity-manager.js'
|
|
4
|
+
import { ElectricProxyError } from '../utils/server-utils.js'
|
|
4
5
|
import { ELECTRIC_PRINCIPAL_HEADER } from '../principal.js'
|
|
5
6
|
import { ATTR, extractTraceContext, tracer } from '../tracing.js'
|
|
6
7
|
import { serverLog } from '../utils/log.js'
|
|
@@ -112,6 +113,12 @@ export function errorMapper(err: unknown, req: IRequest): Response {
|
|
|
112
113
|
if (err instanceof ElectricAgentsError) {
|
|
113
114
|
return apiError(err.status, err.code, err.message, err.details)
|
|
114
115
|
}
|
|
116
|
+
if (err instanceof ElectricProxyError) {
|
|
117
|
+
serverLog.warn(
|
|
118
|
+
`[agent-server] Electric proxy rejected request (${err.code}): ${req.url}`
|
|
119
|
+
)
|
|
120
|
+
return apiError(err.status, err.code, err.message)
|
|
121
|
+
}
|
|
115
122
|
serverLog.error(`[agent-server] Unhandled error:`, err)
|
|
116
123
|
return apiError(500, `INTERNAL_SERVER_ERROR`, `Internal server error`)
|
|
117
124
|
}
|
|
@@ -30,6 +30,7 @@ import { resolveDurableStreamsRoutingAdapter } from './durable-streams-routing-a
|
|
|
30
30
|
import { electricProxyRouter } from './electric-proxy-router.js'
|
|
31
31
|
import { entitiesRouter } from './entities-router.js'
|
|
32
32
|
import { entityTypesRouter } from './entity-types-router.js'
|
|
33
|
+
import { pgSyncRouter } from './pg-sync-router.js'
|
|
33
34
|
import { getRequestSpan } from './hooks.js'
|
|
34
35
|
import { observationsRouter } from './observations-router.js'
|
|
35
36
|
import { runnersRouter } from './runners-router.js'
|
|
@@ -135,6 +136,7 @@ internalRouter.all(`/runners`, runnersRouter.fetch)
|
|
|
135
136
|
internalRouter.all(`/runners/*`, runnersRouter.fetch)
|
|
136
137
|
internalRouter.all(`/entities/*`, entitiesRouter.fetch)
|
|
137
138
|
internalRouter.all(`/entity-types/*`, entityTypesRouter.fetch)
|
|
139
|
+
internalRouter.all(`/pg-sync/*`, pgSyncRouter.fetch)
|
|
138
140
|
internalRouter.all(`/observations/*`, observationsRouter.fetch)
|
|
139
141
|
internalRouter.get(`/electric/*`, electricProxyRouter.fetch)
|
|
140
142
|
internalRouter.all(`*`, () => status(404))
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP routes for pg-sync observation source registration.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
PgSyncOptions,
|
|
7
|
+
PgSyncRequestMetadata,
|
|
8
|
+
} from '@electric-ax/agents-runtime'
|
|
9
|
+
import { Type, type Static } from '@sinclair/typebox'
|
|
10
|
+
import { Router, json } from 'itty-router'
|
|
11
|
+
import { apiError } from '../electric-agents-http.js'
|
|
12
|
+
import { ErrCodeInvalidRequest } from '../electric-agents-types.js'
|
|
13
|
+
import { routeBody, withSchema } from './schema.js'
|
|
14
|
+
import type { JsonRouteRequest } from './schema.js'
|
|
15
|
+
import type { RouterType } from 'itty-router'
|
|
16
|
+
import type { TenantContext } from './context.js'
|
|
17
|
+
|
|
18
|
+
const pgSyncOptionsSchema = Type.Object({
|
|
19
|
+
url: Type.Optional(Type.String()),
|
|
20
|
+
table: Type.String(),
|
|
21
|
+
columns: Type.Optional(Type.Array(Type.String())),
|
|
22
|
+
where: Type.Optional(Type.String()),
|
|
23
|
+
params: Type.Optional(
|
|
24
|
+
Type.Union([
|
|
25
|
+
Type.Array(Type.String()),
|
|
26
|
+
Type.Record(Type.String(), Type.String()),
|
|
27
|
+
])
|
|
28
|
+
),
|
|
29
|
+
replica: Type.Optional(
|
|
30
|
+
Type.Union([Type.Literal(`default`), Type.Literal(`full`)])
|
|
31
|
+
),
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const pgSyncRequestMetadataSchema = Type.Object({
|
|
35
|
+
entityUrl: Type.Optional(Type.String()),
|
|
36
|
+
entityType: Type.Optional(Type.String()),
|
|
37
|
+
streamPath: Type.Optional(Type.String()),
|
|
38
|
+
runtimeConsumerId: Type.Optional(Type.String()),
|
|
39
|
+
wakeId: Type.Optional(Type.String()),
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const pgSyncRegisterBodySchema = Type.Object({
|
|
43
|
+
options: pgSyncOptionsSchema,
|
|
44
|
+
metadata: Type.Optional(pgSyncRequestMetadataSchema),
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
type PgSyncRegisterBody = Static<typeof pgSyncRegisterBodySchema>
|
|
48
|
+
|
|
49
|
+
export type PgSyncRoutes = RouterType<
|
|
50
|
+
JsonRouteRequest,
|
|
51
|
+
[TenantContext],
|
|
52
|
+
Response | undefined
|
|
53
|
+
>
|
|
54
|
+
|
|
55
|
+
export const pgSyncRouter: PgSyncRoutes = Router<
|
|
56
|
+
JsonRouteRequest,
|
|
57
|
+
[TenantContext],
|
|
58
|
+
Response | undefined
|
|
59
|
+
>({
|
|
60
|
+
base: `/_electric/pg-sync`,
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
pgSyncRouter.post(
|
|
64
|
+
`/register`,
|
|
65
|
+
withSchema(pgSyncRegisterBodySchema),
|
|
66
|
+
registerPgSync
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
async function registerPgSync(
|
|
70
|
+
request: JsonRouteRequest,
|
|
71
|
+
ctx: TenantContext
|
|
72
|
+
): Promise<Response> {
|
|
73
|
+
const { options, metadata } = routeBody<PgSyncRegisterBody>(request)
|
|
74
|
+
|
|
75
|
+
if (options.table.trim() === ``) {
|
|
76
|
+
return apiError(
|
|
77
|
+
400,
|
|
78
|
+
ErrCodeInvalidRequest,
|
|
79
|
+
`pgSync table must be non-empty`
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!ctx.pgSyncBridgeManager) {
|
|
84
|
+
return apiError(
|
|
85
|
+
503,
|
|
86
|
+
ErrCodeInvalidRequest,
|
|
87
|
+
`pgSync bridge manager is not configured`
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const requestMetadata: PgSyncRequestMetadata = {
|
|
93
|
+
tenantId: ctx.service,
|
|
94
|
+
principalKind: ctx.principal.kind,
|
|
95
|
+
principalId: ctx.principal.id,
|
|
96
|
+
principalKey: ctx.principal.key,
|
|
97
|
+
principalUrl: ctx.principal.url,
|
|
98
|
+
...(metadata ?? {}),
|
|
99
|
+
}
|
|
100
|
+
const result = await ctx.pgSyncBridgeManager.register(
|
|
101
|
+
options as PgSyncOptions,
|
|
102
|
+
requestMetadata
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
return json(result)
|
|
106
|
+
} catch (error) {
|
|
107
|
+
return apiError(
|
|
108
|
+
500,
|
|
109
|
+
ErrCodeInvalidRequest,
|
|
110
|
+
`pgSync registration failed: ${error instanceof Error ? error.message : String(error)}`
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
}
|
package/src/runtime.ts
CHANGED
|
@@ -14,7 +14,12 @@ import { isPermanentElectricAgentsError } from './scheduler.js'
|
|
|
14
14
|
import { StreamClient } from './stream-client.js'
|
|
15
15
|
import { DEFAULT_TENANT_ID } from './tenant.js'
|
|
16
16
|
import type { DrizzleDB } from './db/index.js'
|
|
17
|
+
import { PgSyncBridgeManager } from './pg-sync-bridge-manager.js'
|
|
17
18
|
import type { EntityBridgeCoordinator } from './entity-bridge-manager.js'
|
|
19
|
+
import type {
|
|
20
|
+
PgSyncBridgeCoordinator,
|
|
21
|
+
PgSyncBridgeManagerOptions,
|
|
22
|
+
} from './pg-sync-bridge-manager.js'
|
|
18
23
|
import type { DurableStreamsBearerProvider } from './stream-client.js'
|
|
19
24
|
import type {
|
|
20
25
|
CronTickPayload,
|
|
@@ -40,6 +45,8 @@ export interface ElectricAgentsTenantRuntimeOptions {
|
|
|
40
45
|
wakeRegistry: WakeRegistry
|
|
41
46
|
scheduler: SchedulerClient
|
|
42
47
|
entityBridgeManager: EntityBridgeCoordinator
|
|
48
|
+
pgSyncBridgeManager?: PgSyncBridgeCoordinator
|
|
49
|
+
pgSync?: PgSyncBridgeManagerOptions
|
|
43
50
|
claimWriteTokens?: ClaimWriteTokenStore
|
|
44
51
|
stopWakeRegistryOnShutdown?: boolean
|
|
45
52
|
}
|
|
@@ -53,6 +60,7 @@ export class ElectricAgentsTenantRuntime {
|
|
|
53
60
|
readonly wakeRegistry: WakeRegistry
|
|
54
61
|
readonly scheduler: SchedulerClient
|
|
55
62
|
readonly entityBridgeManager: EntityBridgeCoordinator
|
|
63
|
+
readonly pgSyncBridgeManager: PgSyncBridgeCoordinator
|
|
56
64
|
readonly claimWriteTokens: ClaimWriteTokenStore
|
|
57
65
|
readonly manager: EntityManager
|
|
58
66
|
|
|
@@ -92,10 +100,21 @@ export class ElectricAgentsTenantRuntime {
|
|
|
92
100
|
),
|
|
93
101
|
stopWakeRegistryOnShutdown: options.stopWakeRegistryOnShutdown ?? false,
|
|
94
102
|
})
|
|
103
|
+
this.pgSyncBridgeManager =
|
|
104
|
+
options.pgSyncBridgeManager ??
|
|
105
|
+
new PgSyncBridgeManager(
|
|
106
|
+
this.streamClient,
|
|
107
|
+
(sourceUrl, event) => this.manager.evaluateWakes(sourceUrl, event),
|
|
108
|
+
this.registry,
|
|
109
|
+
options.pgSync
|
|
110
|
+
)
|
|
95
111
|
}
|
|
96
112
|
|
|
97
113
|
async stop(): Promise<void> {
|
|
98
|
-
await
|
|
114
|
+
await Promise.all([
|
|
115
|
+
this.manager.shutdown(),
|
|
116
|
+
this.pgSyncBridgeManager.stop(),
|
|
117
|
+
])
|
|
99
118
|
}
|
|
100
119
|
|
|
101
120
|
async rehydrateCronSchedules(): Promise<void> {
|
package/src/scheduler.ts
CHANGED
|
@@ -228,6 +228,14 @@ export function isPermanentElectricAgentsError(err: unknown): boolean {
|
|
|
228
228
|
)
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
+
function cronTaskStreamPath(
|
|
232
|
+
payload: DelayedSendPayload | CronTickPayload
|
|
233
|
+
): string | null {
|
|
234
|
+
return typeof (payload as { streamPath?: unknown }).streamPath === `string`
|
|
235
|
+
? (payload as { streamPath: string }).streamPath
|
|
236
|
+
: null
|
|
237
|
+
}
|
|
238
|
+
|
|
231
239
|
function normalizeTask(row: ScheduledTaskRow): {
|
|
232
240
|
id: number
|
|
233
241
|
tenantId: string
|
|
@@ -680,6 +688,24 @@ export class Scheduler implements SchedulerClient {
|
|
|
680
688
|
task.fireAt
|
|
681
689
|
)
|
|
682
690
|
|
|
691
|
+
const streamPath = cronTaskStreamPath(task.payload)
|
|
692
|
+
const subscriberRows = streamPath
|
|
693
|
+
? await sql<Array<{ exists: number }>>`
|
|
694
|
+
select 1 as exists
|
|
695
|
+
from wake_registrations
|
|
696
|
+
where tenant_id = ${tenantId}
|
|
697
|
+
and source_url = ${streamPath}
|
|
698
|
+
limit 1
|
|
699
|
+
`
|
|
700
|
+
: []
|
|
701
|
+
|
|
702
|
+
// Cron streams are virtual shared sources. If no wake registrations
|
|
703
|
+
// still point at this cron stream (e.g. the owning manifest schedule was
|
|
704
|
+
// deleted), stop the chain here instead of keeping a forever-global tick
|
|
705
|
+
// alive. Rehydration/getOrCreateCronStream will seed a fresh tick when a
|
|
706
|
+
// subscription is recreated.
|
|
707
|
+
if (subscriberRows.length === 0) return
|
|
708
|
+
|
|
683
709
|
await sql`
|
|
684
710
|
insert into scheduled_tasks (
|
|
685
711
|
tenant_id,
|
package/src/server.ts
CHANGED
|
@@ -36,6 +36,7 @@ import type { EntityBridgeCoordinator } from './entity-bridge-manager.js'
|
|
|
36
36
|
import type { DurableStreamsRoutingAdapter } from './routing/durable-streams-routing-adapter.js'
|
|
37
37
|
import type { OssServerContext } from './routing/oss-server-router.js'
|
|
38
38
|
import type { EventSourceCatalog } from './routing/context.js'
|
|
39
|
+
import type { PgSyncBridgeManagerOptions } from './pg-sync-bridge-manager.js'
|
|
39
40
|
import type { StartedStandaloneAgentsRuntime } from './standalone-runtime.js'
|
|
40
41
|
import type { DurableStreamsBearerProvider } from './stream-client.js'
|
|
41
42
|
import type {
|
|
@@ -72,6 +73,7 @@ export interface ElectricAgentsServerOptions {
|
|
|
72
73
|
allowDevPrincipalFallback?: boolean
|
|
73
74
|
eventSources?: EventSourceCatalog
|
|
74
75
|
ensureEventSourceWakeSource?: (sourceUrl: string) => Promise<void> | void
|
|
76
|
+
pgSync?: PgSyncBridgeManagerOptions
|
|
75
77
|
/**
|
|
76
78
|
* Disabled by default. When set to a positive interval, periodically
|
|
77
79
|
* recovers expired dispatch claims and stale outstanding wakes.
|
|
@@ -242,6 +244,7 @@ export class ElectricAgentsServer {
|
|
|
242
244
|
streamClient: this.streamClient,
|
|
243
245
|
electricUrl: this.options.electricUrl,
|
|
244
246
|
electricSecret: this.options.electricSecret,
|
|
247
|
+
pgSync: this.options.pgSync,
|
|
245
248
|
})
|
|
246
249
|
this.electricAgentsManager = this.standaloneRuntime.manager
|
|
247
250
|
this.entityBridgeManager = this.standaloneRuntime.entityBridgeManager
|
|
@@ -446,6 +449,7 @@ export class ElectricAgentsServer {
|
|
|
446
449
|
streamClient: this.streamClient,
|
|
447
450
|
runtime: this.standaloneRuntime.runtime,
|
|
448
451
|
entityBridgeManager: this.entityBridgeManager,
|
|
452
|
+
pgSyncBridgeManager: this.standaloneRuntime.runtime.pgSyncBridgeManager,
|
|
449
453
|
...(this.options.eventSources
|
|
450
454
|
? { eventSources: this.options.eventSources }
|
|
451
455
|
: {}),
|
|
@@ -11,6 +11,10 @@ import { WakeRegistry } from './wake-registry.js'
|
|
|
11
11
|
import type { DrizzleDB, PgClient } from './db/index.js'
|
|
12
12
|
import type { EntityManager } from './entity-manager.js'
|
|
13
13
|
import type { EntityBridgeCoordinator } from './entity-bridge-manager.js'
|
|
14
|
+
import type {
|
|
15
|
+
PgSyncBridgeCoordinator,
|
|
16
|
+
PgSyncBridgeManagerOptions,
|
|
17
|
+
} from './pg-sync-bridge-manager.js'
|
|
14
18
|
import type { CronTickPayload, DelayedSendPayload } from './scheduler.js'
|
|
15
19
|
import type { DurableStreamsBearerProvider } from './stream-client.js'
|
|
16
20
|
|
|
@@ -30,8 +34,11 @@ export interface StandaloneAgentsRuntimeOptions {
|
|
|
30
34
|
startScheduler?: boolean
|
|
31
35
|
startTagStreamOutboxDrainer?: boolean
|
|
32
36
|
startEntityBridgeManager?: boolean
|
|
37
|
+
startPgSyncBridgeManager?: boolean
|
|
33
38
|
rehydrateOnStart?: boolean
|
|
34
39
|
entityBridgeManager?: EntityBridgeCoordinator
|
|
40
|
+
pgSyncBridgeManager?: PgSyncBridgeCoordinator
|
|
41
|
+
pgSync?: PgSyncBridgeManagerOptions
|
|
35
42
|
}
|
|
36
43
|
|
|
37
44
|
export interface StartedStandaloneAgentsRuntime {
|
|
@@ -46,6 +53,7 @@ export interface StartedStandaloneAgentsRuntime {
|
|
|
46
53
|
manager: EntityManager
|
|
47
54
|
scheduler: Scheduler
|
|
48
55
|
entityBridgeManager: EntityBridgeCoordinator
|
|
56
|
+
pgSyncBridgeManager: PgSyncBridgeCoordinator
|
|
49
57
|
tagStreamOutboxDrainer: TagStreamOutboxDrainer
|
|
50
58
|
stop: () => Promise<void>
|
|
51
59
|
}
|
|
@@ -104,6 +112,8 @@ export async function startStandaloneAgentsRuntime(
|
|
|
104
112
|
wakeRegistry,
|
|
105
113
|
scheduler,
|
|
106
114
|
entityBridgeManager,
|
|
115
|
+
pgSyncBridgeManager: options.pgSyncBridgeManager,
|
|
116
|
+
pgSync: options.pgSync,
|
|
107
117
|
stopWakeRegistryOnShutdown: options.wakeRegistry ? false : true,
|
|
108
118
|
})
|
|
109
119
|
|
|
@@ -112,6 +122,7 @@ export async function startStandaloneAgentsRuntime(
|
|
|
112
122
|
const startTagStreamOutboxDrainer =
|
|
113
123
|
options.startTagStreamOutboxDrainer ?? true
|
|
114
124
|
const startEntityBridgeManager = options.startEntityBridgeManager ?? true
|
|
125
|
+
const startPgSyncBridgeManager = options.startPgSyncBridgeManager ?? true
|
|
115
126
|
const rehydrateOnStart = options.rehydrateOnStart ?? true
|
|
116
127
|
let entityBridgeManagerStarted = false
|
|
117
128
|
let tagStreamOutboxDrainerStarted = false
|
|
@@ -153,6 +164,10 @@ export async function startStandaloneAgentsRuntime(
|
|
|
153
164
|
await entityBridgeManager.start()
|
|
154
165
|
entityBridgeManagerStarted = true
|
|
155
166
|
}
|
|
167
|
+
if (startPgSyncBridgeManager) {
|
|
168
|
+
serverLog.info(`[agent-server] starting pg-sync bridge manager...`)
|
|
169
|
+
await runtime.pgSyncBridgeManager.start?.()
|
|
170
|
+
}
|
|
156
171
|
if (startTagStreamOutboxDrainer) {
|
|
157
172
|
serverLog.info(`[agent-server] starting tag stream outbox drainer...`)
|
|
158
173
|
tagStreamOutboxDrainer.start()
|
|
@@ -181,6 +196,7 @@ export async function startStandaloneAgentsRuntime(
|
|
|
181
196
|
manager: runtime.manager,
|
|
182
197
|
scheduler,
|
|
183
198
|
entityBridgeManager,
|
|
199
|
+
pgSyncBridgeManager: runtime.pgSyncBridgeManager,
|
|
184
200
|
tagStreamOutboxDrainer,
|
|
185
201
|
stop,
|
|
186
202
|
}
|
|
@@ -90,6 +90,25 @@ export async function fileExists(filePath: string): Promise<boolean> {
|
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Raised when an Electric shape proxy request must be rejected for security
|
|
95
|
+
* reasons (an un-scoped table, or a client `where` clause that could escape the
|
|
96
|
+
* enforced per-tenant/per-principal scoping). The global `errorMapper` hook
|
|
97
|
+
* maps this to an HTTP error response. Defined here (rather than reusing
|
|
98
|
+
* `ElectricAgentsError`) to keep this module free of the heavy entity-manager
|
|
99
|
+
* import graph.
|
|
100
|
+
*/
|
|
101
|
+
export class ElectricProxyError extends Error {
|
|
102
|
+
constructor(
|
|
103
|
+
readonly code: `INVALID_WHERE` | `TABLE_NOT_ALLOWED`,
|
|
104
|
+
message: string,
|
|
105
|
+
readonly status: number
|
|
106
|
+
) {
|
|
107
|
+
super(message)
|
|
108
|
+
this.name = `ElectricProxyError`
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
93
112
|
export function buildElectricProxyTarget(options: {
|
|
94
113
|
incomingUrl: URL
|
|
95
114
|
electricUrl: string
|
|
@@ -117,7 +136,29 @@ export function buildElectricProxyTarget(options: {
|
|
|
117
136
|
target.searchParams.set(`secret`, options.electricSecret)
|
|
118
137
|
}
|
|
119
138
|
|
|
120
|
-
|
|
139
|
+
// The enforced scoping `where` is AND-combined with the client's own `where`.
|
|
140
|
+
// A client clause that is not self-contained (e.g. `1=1) OR (1=1`) could
|
|
141
|
+
// break out of its parenthesised group and neutralise the scoping under SQL
|
|
142
|
+
// operator precedence, so reject anything that isn't balanced.
|
|
143
|
+
const clientWhere = options.incomingUrl.searchParams.get(`where`)
|
|
144
|
+
if (clientWhere !== null && !isSelfContainedWhereClause(clientWhere)) {
|
|
145
|
+
throw new ElectricProxyError(`INVALID_WHERE`, `Invalid where clause`, 400)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const tableParams = options.incomingUrl.searchParams.getAll(`table`)
|
|
149
|
+
if (tableParams.length !== 1) {
|
|
150
|
+
throw new ElectricProxyError(
|
|
151
|
+
`TABLE_NOT_ALLOWED`,
|
|
152
|
+
`Table is not available through the Electric proxy`,
|
|
153
|
+
403
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const table = tableParams[0]
|
|
158
|
+
// Canonicalise the upstream table after validation so duplicate client query
|
|
159
|
+
// params cannot be interpreted differently by Electric or intermediaries.
|
|
160
|
+
target.searchParams.set(`table`, table)
|
|
161
|
+
|
|
121
162
|
if (table === `entities`) {
|
|
122
163
|
target.searchParams.set(
|
|
123
164
|
`columns`,
|
|
@@ -224,11 +265,66 @@ export function buildElectricProxyTarget(options: {
|
|
|
224
265
|
permissionBypass: options.permissionBypass,
|
|
225
266
|
})
|
|
226
267
|
)
|
|
268
|
+
} else {
|
|
269
|
+
// Default-deny: every shape request gets the privileged Electric secret
|
|
270
|
+
// (when configured) injected above, so only tables with explicit column +
|
|
271
|
+
// row scoping may be proxied. Any other table (or a missing `table` param)
|
|
272
|
+
// is rejected.
|
|
273
|
+
throw new ElectricProxyError(
|
|
274
|
+
`TABLE_NOT_ALLOWED`,
|
|
275
|
+
`Table is not available through the Electric proxy`,
|
|
276
|
+
403
|
|
277
|
+
)
|
|
227
278
|
}
|
|
228
279
|
|
|
229
280
|
return target
|
|
230
281
|
}
|
|
231
282
|
|
|
283
|
+
/**
|
|
284
|
+
* Returns true when a client-supplied Electric `where` clause is self-contained:
|
|
285
|
+
* its parentheses are balanced, never close below the top level, all string
|
|
286
|
+
* (`'`) and identifier (`"`) literals are terminated, and it contains no SQL
|
|
287
|
+
* comment markers. Such a clause cannot break out of the `(...)` group it is
|
|
288
|
+
* wrapped in when AND-combined with the enforced scoping predicate, nor comment
|
|
289
|
+
* out the trailing paren the proxy appends. Characters inside string/identifier
|
|
290
|
+
* literals are ignored. Comment markers are rejected unconditionally (even where
|
|
291
|
+
* harmless) as a conservative defensive measure; dollar-quoted and `E''` strings
|
|
292
|
+
* are not modeled and only ever cause fail-safe over-rejection, never a bypass.
|
|
293
|
+
*/
|
|
294
|
+
function isSelfContainedWhereClause(where: string): boolean {
|
|
295
|
+
let depth = 0
|
|
296
|
+
let quote: `'` | `"` | null = null
|
|
297
|
+
for (let i = 0; i < where.length; i++) {
|
|
298
|
+
const ch = where[i]
|
|
299
|
+
if (quote !== null) {
|
|
300
|
+
if (ch === quote) {
|
|
301
|
+
if (where[i + 1] === quote) {
|
|
302
|
+
i++ // doubled quote is an escaped literal quote
|
|
303
|
+
} else {
|
|
304
|
+
quote = null
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
continue
|
|
308
|
+
}
|
|
309
|
+
if (ch === `'` || ch === `"`) {
|
|
310
|
+
quote = ch
|
|
311
|
+
} else if (ch === `(`) {
|
|
312
|
+
depth++
|
|
313
|
+
} else if (ch === `)`) {
|
|
314
|
+
depth--
|
|
315
|
+
if (depth < 0) {
|
|
316
|
+
return false
|
|
317
|
+
}
|
|
318
|
+
} else if (
|
|
319
|
+
(ch === `-` && where[i + 1] === `-`) ||
|
|
320
|
+
(ch === `/` && where[i + 1] === `*`)
|
|
321
|
+
) {
|
|
322
|
+
return false // SQL comment marker
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return depth === 0 && quote === null
|
|
326
|
+
}
|
|
327
|
+
|
|
232
328
|
export function buildReadableEntitiesWhere(options: {
|
|
233
329
|
tenantId: string
|
|
234
330
|
principalUrl: string
|
package/src/wake-registry.ts
CHANGED
|
@@ -41,6 +41,8 @@ export interface WakeEvalResult {
|
|
|
41
41
|
collection: string
|
|
42
42
|
kind: `insert` | `update` | `delete`
|
|
43
43
|
key: string
|
|
44
|
+
value?: unknown
|
|
45
|
+
oldValue?: unknown
|
|
44
46
|
from?: string
|
|
45
47
|
from_principal?: string
|
|
46
48
|
from_agent?: string
|
|
@@ -737,7 +739,23 @@ export class WakeRegistry {
|
|
|
737
739
|
}
|
|
738
740
|
|
|
739
741
|
if (message.headers.operation === `delete`) {
|
|
740
|
-
|
|
742
|
+
// Shape keys are protocol-level identifiers and are not guaranteed to be
|
|
743
|
+
// the table primary key. The wake_registrations shape uses
|
|
744
|
+
// `replica: full`, so deletes should carry the deleted row in old_value;
|
|
745
|
+
// use that row id to remove the matching in-memory registration. If the
|
|
746
|
+
// id is unavailable, reset the cache so we fail closed rather than
|
|
747
|
+
// keeping a stale wake registration alive.
|
|
748
|
+
const oldValue = (
|
|
749
|
+
message as unknown as {
|
|
750
|
+
old_value?: { id?: unknown }
|
|
751
|
+
}
|
|
752
|
+
).old_value
|
|
753
|
+
const oldId = Number(oldValue?.id)
|
|
754
|
+
if (Number.isFinite(oldId)) {
|
|
755
|
+
this.removeCachedRegistrationByDbId(oldId)
|
|
756
|
+
} else {
|
|
757
|
+
this.resetCachedRegistrations()
|
|
758
|
+
}
|
|
741
759
|
return
|
|
742
760
|
}
|
|
743
761
|
|
|
@@ -937,14 +955,21 @@ export class WakeRegistry {
|
|
|
937
955
|
return null
|
|
938
956
|
}
|
|
939
957
|
|
|
958
|
+
const value = event.value as Record<string, unknown> | undefined
|
|
940
959
|
const change: WakeEvalResult[`wakeMessage`][`changes`][number] = {
|
|
941
960
|
collection: eventType,
|
|
942
961
|
kind,
|
|
943
962
|
key: (event.key as string) || ``,
|
|
944
963
|
}
|
|
945
964
|
|
|
965
|
+
if (value && `value` in value) {
|
|
966
|
+
change.value = value.value
|
|
967
|
+
}
|
|
968
|
+
if (value && `oldValue` in value) {
|
|
969
|
+
change.oldValue = value.oldValue
|
|
970
|
+
}
|
|
971
|
+
|
|
946
972
|
if (eventType === `inbox`) {
|
|
947
|
-
const value = event.value as Record<string, unknown> | undefined
|
|
948
973
|
if (typeof value?.from === `string`) change.from = value.from
|
|
949
974
|
if (typeof value?.from_principal === `string`) {
|
|
950
975
|
change.from_principal = value.from_principal
|