@everystack/events 0.1.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/README.md +36 -0
- package/package.json +79 -0
- package/src/actions.ts +133 -0
- package/src/client/index.ts +198 -0
- package/src/dispatchers.ts +176 -0
- package/src/handler/index.ts +160 -0
- package/src/index.ts +28 -0
- package/src/listener.ts +473 -0
- package/src/schema.ts +140 -0
- package/src/triggers.ts +54 -0
- package/src/types.ts +129 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import type { WebSocketHandlerOptions } from '../types';
|
|
2
|
+
|
|
3
|
+
interface WebSocketEvent {
|
|
4
|
+
requestContext: {
|
|
5
|
+
connectionId: string;
|
|
6
|
+
routeKey: string;
|
|
7
|
+
};
|
|
8
|
+
queryStringParameters?: Record<string, string> | null;
|
|
9
|
+
body?: string | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface WebSocketResult {
|
|
13
|
+
statusCode: number;
|
|
14
|
+
body?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// --- SQL helpers ---
|
|
18
|
+
// These create tagged template objects compatible with Drizzle's db.execute(sql`...`)
|
|
19
|
+
// We use raw string queries passed to execute() for simplicity
|
|
20
|
+
|
|
21
|
+
interface DbLike {
|
|
22
|
+
execute(query: unknown): Promise<unknown>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function rawSql(strings: TemplateStringsArray, ...values: unknown[]) {
|
|
26
|
+
// Build a { sql, params } object that Drizzle's execute can handle
|
|
27
|
+
// However, Drizzle's sql tagged template is the proper way.
|
|
28
|
+
// For handler use, we'll pass pre-built query objects.
|
|
29
|
+
let text = '';
|
|
30
|
+
const params: unknown[] = [];
|
|
31
|
+
for (let i = 0; i < strings.length; i++) {
|
|
32
|
+
text += strings[i];
|
|
33
|
+
if (i < values.length) {
|
|
34
|
+
params.push(values[i]);
|
|
35
|
+
text += `$${params.length}`;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return { sql: text, params };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// --- $connect ---
|
|
42
|
+
|
|
43
|
+
export function createConnectHandler(options: {
|
|
44
|
+
db: DbLike;
|
|
45
|
+
verifyToken: (token: string) => Promise<Record<string, unknown> | null>;
|
|
46
|
+
}) {
|
|
47
|
+
const { db, verifyToken } = options;
|
|
48
|
+
|
|
49
|
+
return async function handler(event: WebSocketEvent): Promise<WebSocketResult> {
|
|
50
|
+
const connectionId = event.requestContext.connectionId;
|
|
51
|
+
const token = event.queryStringParameters?.token;
|
|
52
|
+
|
|
53
|
+
if (!token) {
|
|
54
|
+
return { statusCode: 401 };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const payload = await verifyToken(token);
|
|
58
|
+
if (!payload) {
|
|
59
|
+
return { statusCode: 401 };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const channels = event.queryStringParameters?.subscribe?.split(',') ?? [];
|
|
63
|
+
|
|
64
|
+
// Persist for cold path recovery
|
|
65
|
+
await db.execute(
|
|
66
|
+
rawSql`INSERT INTO ws_connections (connection_id, channels) VALUES (${connectionId}, ${channels}) ON CONFLICT (connection_id) DO UPDATE SET channels = EXCLUDED.channels, last_ping_at = now()`
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
// Notify running listener (hot path)
|
|
70
|
+
await db.execute(
|
|
71
|
+
rawSql`SELECT pg_notify('_subs', ${JSON.stringify({
|
|
72
|
+
action: 'connect',
|
|
73
|
+
connectionId,
|
|
74
|
+
channels,
|
|
75
|
+
})})`
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
return { statusCode: 200 };
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// --- $disconnect ---
|
|
83
|
+
|
|
84
|
+
export function createDisconnectHandler(options: { db: DbLike }) {
|
|
85
|
+
const { db } = options;
|
|
86
|
+
|
|
87
|
+
return async function handler(event: WebSocketEvent): Promise<WebSocketResult> {
|
|
88
|
+
const connectionId = event.requestContext.connectionId;
|
|
89
|
+
|
|
90
|
+
await db.execute(
|
|
91
|
+
rawSql`DELETE FROM ws_connections WHERE connection_id = ${connectionId}`
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
await db.execute(
|
|
95
|
+
rawSql`SELECT pg_notify('_subs', ${JSON.stringify({
|
|
96
|
+
action: 'disconnect',
|
|
97
|
+
connectionId,
|
|
98
|
+
})})`
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
return { statusCode: 200 };
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// --- $default ---
|
|
106
|
+
|
|
107
|
+
export function createDefaultHandler(options: { db: DbLike }) {
|
|
108
|
+
const { db } = options;
|
|
109
|
+
|
|
110
|
+
return async function handler(event: WebSocketEvent): Promise<WebSocketResult> {
|
|
111
|
+
const connectionId = event.requestContext.connectionId;
|
|
112
|
+
const body = event.body ? JSON.parse(event.body) : {};
|
|
113
|
+
|
|
114
|
+
switch (body.action) {
|
|
115
|
+
case 'subscribe': {
|
|
116
|
+
const channels: string[] = body.channels ?? [];
|
|
117
|
+
await db.execute(
|
|
118
|
+
rawSql`UPDATE ws_connections SET channels = channels || ${channels}::text[], last_ping_at = now() WHERE connection_id = ${connectionId}`
|
|
119
|
+
);
|
|
120
|
+
await db.execute(
|
|
121
|
+
rawSql`SELECT pg_notify('_subs', ${JSON.stringify({
|
|
122
|
+
action: 'subscribe',
|
|
123
|
+
connectionId,
|
|
124
|
+
channels,
|
|
125
|
+
})})`
|
|
126
|
+
);
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
case 'unsubscribe': {
|
|
131
|
+
const channels: string[] = body.channels ?? [];
|
|
132
|
+
// Remove all channels in a single UPDATE using array subtraction
|
|
133
|
+
await db.execute(
|
|
134
|
+
rawSql`UPDATE ws_connections SET channels = (SELECT array_agg(elem) FROM unnest(channels) AS elem WHERE elem != ALL(${channels}::text[])) WHERE connection_id = ${connectionId}`
|
|
135
|
+
);
|
|
136
|
+
await db.execute(
|
|
137
|
+
rawSql`SELECT pg_notify('_subs', ${JSON.stringify({
|
|
138
|
+
action: 'unsubscribe',
|
|
139
|
+
connectionId,
|
|
140
|
+
channels,
|
|
141
|
+
})})`
|
|
142
|
+
);
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
case 'ping': {
|
|
147
|
+
await db.execute(
|
|
148
|
+
rawSql`UPDATE ws_connections SET last_ping_at = now() WHERE connection_id = ${connectionId}`
|
|
149
|
+
);
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
default:
|
|
154
|
+
// Unknown action — no-op
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return { statusCode: 200 };
|
|
159
|
+
};
|
|
160
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export { createListener, createListenerInternal } from './listener';
|
|
2
|
+
export { generateTriggerSQL, generateEventActionsTriggerSQL } from './triggers';
|
|
3
|
+
export { matchEventActions, needsRowFetch, filterByTenant, applyFilter, resolveTemplate, createActionCache } from './actions';
|
|
4
|
+
export { dispatchActions, dispatchRpc, dispatchJob, dispatchCachePurge } from './dispatchers';
|
|
5
|
+
export { createConnectHandler, createDisconnectHandler, createDefaultHandler } from './handler/index';
|
|
6
|
+
export { createEventsClient, EventsProvider, useEventsClient, useSignal, useSubscription } from './client/index';
|
|
7
|
+
export type {
|
|
8
|
+
EventPayload,
|
|
9
|
+
EventSignal,
|
|
10
|
+
EventsClient,
|
|
11
|
+
EventsClientOptions,
|
|
12
|
+
ListenerOptions,
|
|
13
|
+
ListenerRoutes,
|
|
14
|
+
WebSocketRoute,
|
|
15
|
+
LambdaRoute,
|
|
16
|
+
SubAction,
|
|
17
|
+
WebSocketHandlerOptions,
|
|
18
|
+
JobHandler,
|
|
19
|
+
JobHandlerOptions,
|
|
20
|
+
EventAction,
|
|
21
|
+
EventActionType,
|
|
22
|
+
EventOperation,
|
|
23
|
+
EventActionsOptions,
|
|
24
|
+
RpcActionConfig,
|
|
25
|
+
JobActionConfig,
|
|
26
|
+
CachePurgeActionConfig,
|
|
27
|
+
WebhookActionConfig,
|
|
28
|
+
} from './types';
|
package/src/listener.ts
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
import type { EventPayload, EventAction, EventActionsOptions, ListenerOptions, ListenerRoutes, SubAction } from './types';
|
|
2
|
+
import { matchEventActions, createActionCache } from './actions';
|
|
3
|
+
import { dispatchActions } from './dispatchers';
|
|
4
|
+
import type { KvsClient } from './dispatchers';
|
|
5
|
+
|
|
6
|
+
// --- Subscription state (in-memory) ---
|
|
7
|
+
|
|
8
|
+
interface SubscriptionState {
|
|
9
|
+
// channel → Set<connectionId>
|
|
10
|
+
channels: Map<string, Set<string>>;
|
|
11
|
+
// connectionId → Set<channel> (reverse map for cleanup)
|
|
12
|
+
connections: Map<string, Set<string>>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function createSubscriptionState(): SubscriptionState {
|
|
16
|
+
return {
|
|
17
|
+
channels: new Map(),
|
|
18
|
+
connections: new Map(),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function addSubscription(state: SubscriptionState, channel: string, connectionId: string): void {
|
|
23
|
+
let conns = state.channels.get(channel);
|
|
24
|
+
if (!conns) {
|
|
25
|
+
conns = new Set();
|
|
26
|
+
state.channels.set(channel, conns);
|
|
27
|
+
}
|
|
28
|
+
conns.add(connectionId);
|
|
29
|
+
|
|
30
|
+
let chans = state.connections.get(connectionId);
|
|
31
|
+
if (!chans) {
|
|
32
|
+
chans = new Set();
|
|
33
|
+
state.connections.set(connectionId, chans);
|
|
34
|
+
}
|
|
35
|
+
chans.add(channel);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function removeConnection(state: SubscriptionState, connectionId: string): void {
|
|
39
|
+
const chans = state.connections.get(connectionId);
|
|
40
|
+
if (chans) {
|
|
41
|
+
for (const channel of chans) {
|
|
42
|
+
const conns = state.channels.get(channel);
|
|
43
|
+
if (conns) {
|
|
44
|
+
conns.delete(connectionId);
|
|
45
|
+
if (conns.size === 0) state.channels.delete(channel);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
state.connections.delete(connectionId);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function removeSubscription(state: SubscriptionState, channel: string, connectionId: string): void {
|
|
53
|
+
const conns = state.channels.get(channel);
|
|
54
|
+
if (conns) {
|
|
55
|
+
conns.delete(connectionId);
|
|
56
|
+
if (conns.size === 0) state.channels.delete(channel);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const chans = state.connections.get(connectionId);
|
|
60
|
+
if (chans) {
|
|
61
|
+
chans.delete(channel);
|
|
62
|
+
if (chans.size === 0) state.connections.delete(connectionId);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// --- Event processing ---
|
|
67
|
+
|
|
68
|
+
async function fanOut(
|
|
69
|
+
state: SubscriptionState,
|
|
70
|
+
event: EventPayload,
|
|
71
|
+
apigw: { postToConnection(params: { ConnectionId: string; Data: Buffer }): Promise<unknown> },
|
|
72
|
+
): Promise<void> {
|
|
73
|
+
const channel = `table:${event.table}`;
|
|
74
|
+
const connectionIds = state.channels.get(channel);
|
|
75
|
+
if (!connectionIds?.size) return;
|
|
76
|
+
|
|
77
|
+
const signal = JSON.stringify({
|
|
78
|
+
channel,
|
|
79
|
+
table: event.table,
|
|
80
|
+
op: event.op,
|
|
81
|
+
id: event.id,
|
|
82
|
+
ts: Date.now(),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const ids = [...connectionIds];
|
|
86
|
+
const results = await Promise.allSettled(
|
|
87
|
+
ids.map((id) =>
|
|
88
|
+
apigw.postToConnection({
|
|
89
|
+
ConnectionId: id,
|
|
90
|
+
Data: Buffer.from(signal),
|
|
91
|
+
})
|
|
92
|
+
)
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
for (let i = 0; i < results.length; i++) {
|
|
96
|
+
const r = results[i];
|
|
97
|
+
if (r.status === 'rejected' && r.reason?.$metadata?.httpStatusCode === 410) {
|
|
98
|
+
removeConnection(state, ids[i]);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function invokeLambda(
|
|
104
|
+
lambdaClient: { invoke(params: unknown): Promise<unknown> },
|
|
105
|
+
functionName: string,
|
|
106
|
+
event: EventPayload,
|
|
107
|
+
): Promise<void> {
|
|
108
|
+
await lambdaClient.invoke({
|
|
109
|
+
FunctionName: functionName,
|
|
110
|
+
InvocationType: 'Event',
|
|
111
|
+
Payload: JSON.stringify(event),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function routeEvent(
|
|
116
|
+
state: SubscriptionState,
|
|
117
|
+
event: EventPayload,
|
|
118
|
+
routes: ListenerRoutes,
|
|
119
|
+
apigw: { postToConnection(params: { ConnectionId: string; Data: Buffer }): Promise<unknown> },
|
|
120
|
+
lambdaClient: { invoke(params: unknown): Promise<unknown> },
|
|
121
|
+
): Promise<void> {
|
|
122
|
+
const tasks: Promise<void>[] = [];
|
|
123
|
+
|
|
124
|
+
// WebSocket fan-out
|
|
125
|
+
if (routes.websocket) {
|
|
126
|
+
tasks.push(fanOut(state, event, apigw));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Lambda invocation
|
|
130
|
+
if (routes.lambda?.[event.table]) {
|
|
131
|
+
const route = routes.lambda[event.table];
|
|
132
|
+
const fnName = route.functionName || route.functionArn;
|
|
133
|
+
if (fnName) {
|
|
134
|
+
tasks.push(invokeLambda(lambdaClient, fnName, event));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Callbacks
|
|
139
|
+
if (routes.callbacks) {
|
|
140
|
+
// Table-specific callback
|
|
141
|
+
if (routes.callbacks[event.table]) {
|
|
142
|
+
tasks.push(routes.callbacks[event.table](event));
|
|
143
|
+
}
|
|
144
|
+
// Wildcard callback
|
|
145
|
+
if (routes.callbacks['*']) {
|
|
146
|
+
tasks.push(routes.callbacks['*'](event));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
await Promise.allSettled(tasks);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function handleSubAction(state: SubscriptionState, action: SubAction): void {
|
|
154
|
+
switch (action.action) {
|
|
155
|
+
case 'connect':
|
|
156
|
+
for (const channel of action.channels) {
|
|
157
|
+
addSubscription(state, channel, action.connectionId);
|
|
158
|
+
}
|
|
159
|
+
break;
|
|
160
|
+
case 'disconnect':
|
|
161
|
+
removeConnection(state, action.connectionId);
|
|
162
|
+
break;
|
|
163
|
+
case 'subscribe':
|
|
164
|
+
for (const channel of action.channels) {
|
|
165
|
+
addSubscription(state, channel, action.connectionId);
|
|
166
|
+
}
|
|
167
|
+
break;
|
|
168
|
+
case 'unsubscribe':
|
|
169
|
+
for (const channel of action.channels) {
|
|
170
|
+
removeSubscription(state, channel, action.connectionId);
|
|
171
|
+
}
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// --- Event actions helpers ---
|
|
177
|
+
|
|
178
|
+
function rowToEventAction(row: Record<string, unknown>): EventAction {
|
|
179
|
+
const config = row.config;
|
|
180
|
+
const filter = row.filter;
|
|
181
|
+
return {
|
|
182
|
+
id: row.id as string,
|
|
183
|
+
tenantId: (row.tenant_id as string) ?? null,
|
|
184
|
+
name: row.name as string,
|
|
185
|
+
schemaName: row.schema_name as string,
|
|
186
|
+
tableName: row.table_name as string,
|
|
187
|
+
operation: row.operation as EventAction['operation'],
|
|
188
|
+
actionType: row.action_type as EventAction['actionType'],
|
|
189
|
+
config: typeof config === 'string' ? JSON.parse(config) : (config as Record<string, unknown>) ?? {},
|
|
190
|
+
filter: filter ? (typeof filter === 'string' ? JSON.parse(filter) : filter as Record<string, unknown>) : null,
|
|
191
|
+
enabled: row.enabled as boolean,
|
|
192
|
+
priority: row.priority as number,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function loadEventActions(
|
|
197
|
+
pgClient: { query(text: string, values?: unknown[]): Promise<{ rows: Record<string, unknown>[] }> },
|
|
198
|
+
cache: ReturnType<typeof createActionCache>,
|
|
199
|
+
): Promise<void> {
|
|
200
|
+
try {
|
|
201
|
+
const { rows } = await pgClient.query(
|
|
202
|
+
`SELECT id, tenant_id, name, schema_name, table_name, operation,
|
|
203
|
+
action_type, config, filter, enabled, priority
|
|
204
|
+
FROM ops.event_actions
|
|
205
|
+
WHERE enabled = true
|
|
206
|
+
ORDER BY priority DESC`
|
|
207
|
+
);
|
|
208
|
+
cache.set(rows.map(rowToEventAction));
|
|
209
|
+
} catch {
|
|
210
|
+
// Table might not exist yet — keep existing cache (empty on first load)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// --- Cold path reconciliation ---
|
|
215
|
+
|
|
216
|
+
async function reconcile(
|
|
217
|
+
pgClient: { query(text: string, values?: unknown[]): Promise<{ rows: Record<string, unknown>[] }> },
|
|
218
|
+
state: SubscriptionState,
|
|
219
|
+
routes: ListenerRoutes,
|
|
220
|
+
lambdaClient: { invoke(params: unknown): Promise<unknown> },
|
|
221
|
+
actionCache: ReturnType<typeof createActionCache>,
|
|
222
|
+
): Promise<void> {
|
|
223
|
+
// 1. Rebuild subscription map from auth.sessions (domain='ws')
|
|
224
|
+
try {
|
|
225
|
+
const { rows: connections } = await pgClient.query(
|
|
226
|
+
`SELECT data->>'connectionId' AS connection_id, data->'channels' AS channels
|
|
227
|
+
FROM auth.sessions
|
|
228
|
+
WHERE domain = 'ws' AND status = 'active' AND last_used_at > now() - interval '15 minutes'`
|
|
229
|
+
);
|
|
230
|
+
for (const conn of connections) {
|
|
231
|
+
const channels = conn.channels as string[];
|
|
232
|
+
const connectionId = conn.connection_id as string;
|
|
233
|
+
if (connectionId && channels) {
|
|
234
|
+
for (const channel of channels) {
|
|
235
|
+
addSubscription(state, channel, connectionId);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
} catch {
|
|
240
|
+
// Table might not exist yet — skip subscription rebuild
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// 2. Process pending jobs
|
|
244
|
+
if (routes.lambda?.jobs) {
|
|
245
|
+
try {
|
|
246
|
+
const { rows: pendingJobs } = await pgClient.query(
|
|
247
|
+
`SELECT id FROM jobs WHERE status = 'pending' AND run_at <= now() ORDER BY priority DESC, run_at ASC`
|
|
248
|
+
);
|
|
249
|
+
const fnName = routes.lambda.jobs.functionName || routes.lambda.jobs.functionArn;
|
|
250
|
+
if (fnName) {
|
|
251
|
+
for (const job of pendingJobs) {
|
|
252
|
+
await invokeLambda(lambdaClient, fnName, {
|
|
253
|
+
table: 'jobs',
|
|
254
|
+
op: 'insert',
|
|
255
|
+
id: job.id as string,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
} catch {
|
|
260
|
+
// jobs table might not exist
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// 3. Purge stale WS sessions
|
|
265
|
+
try {
|
|
266
|
+
await pgClient.query(
|
|
267
|
+
`UPDATE auth.sessions SET status = 'disconnected', ended_at = now()
|
|
268
|
+
WHERE domain = 'ws' AND status = 'active' AND last_used_at < now() - interval '15 minutes'`
|
|
269
|
+
);
|
|
270
|
+
} catch {
|
|
271
|
+
// Table might not exist yet
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// 4. Load event_actions cache
|
|
275
|
+
await loadEventActions(pgClient, actionCache);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// --- Internal (testable) listener factory ---
|
|
279
|
+
|
|
280
|
+
interface InternalListenerOptions {
|
|
281
|
+
pgClient: {
|
|
282
|
+
connect(): Promise<void>;
|
|
283
|
+
query(text: string, values?: unknown[]): Promise<{ rows: Record<string, unknown>[] }>;
|
|
284
|
+
on(event: string, cb: (msg: { channel: string; payload: string }) => void): void;
|
|
285
|
+
end(): Promise<void>;
|
|
286
|
+
};
|
|
287
|
+
channels: string[];
|
|
288
|
+
lockId: number;
|
|
289
|
+
routes: ListenerRoutes;
|
|
290
|
+
apigw: { postToConnection(params: { ConnectionId: string; Data: Buffer }): Promise<unknown> };
|
|
291
|
+
lambdaClient: { invoke(params: unknown): Promise<unknown> };
|
|
292
|
+
reconcile?: (db: unknown) => Promise<void>;
|
|
293
|
+
heartbeatInterval?: number;
|
|
294
|
+
eventActions?: EventActionsOptions;
|
|
295
|
+
kvsClient?: KvsClient | null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
interface HandlerEvent {
|
|
299
|
+
_testMaxMs?: number;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function createListenerInternal(options: InternalListenerOptions) {
|
|
303
|
+
const {
|
|
304
|
+
pgClient,
|
|
305
|
+
channels,
|
|
306
|
+
lockId,
|
|
307
|
+
routes,
|
|
308
|
+
apigw,
|
|
309
|
+
lambdaClient,
|
|
310
|
+
heartbeatInterval = 30000,
|
|
311
|
+
eventActions,
|
|
312
|
+
kvsClient = null,
|
|
313
|
+
} = options;
|
|
314
|
+
|
|
315
|
+
return async function handler(event: HandlerEvent = {}): Promise<{ status: string; reason?: string }> {
|
|
316
|
+
// 1. Connect
|
|
317
|
+
await pgClient.connect();
|
|
318
|
+
|
|
319
|
+
// 2. Acquire advisory lock
|
|
320
|
+
const { rows } = await pgClient.query(
|
|
321
|
+
'SELECT pg_try_advisory_lock($1)',
|
|
322
|
+
[lockId]
|
|
323
|
+
);
|
|
324
|
+
const lockAcquired = rows[0]?.pg_try_advisory_lock;
|
|
325
|
+
if (!lockAcquired) {
|
|
326
|
+
await pgClient.end();
|
|
327
|
+
return { status: 'skipped', reason: 'lock_held' };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// 3. LISTEN on channels
|
|
331
|
+
for (const channel of channels) {
|
|
332
|
+
await pgClient.query(`LISTEN "${channel}"`);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// 4. Set up subscription state + action cache
|
|
336
|
+
const state = createSubscriptionState();
|
|
337
|
+
const actionCache = createActionCache();
|
|
338
|
+
|
|
339
|
+
// 5. Cold path reconciliation (includes loading event_actions)
|
|
340
|
+
await reconcile(pgClient, state, routes, lambdaClient, actionCache);
|
|
341
|
+
|
|
342
|
+
// 6. Set up notification handler
|
|
343
|
+
pgClient.on('notification', (msg: { channel: string; payload: string }) => {
|
|
344
|
+
try {
|
|
345
|
+
if (msg.channel === '_subs') {
|
|
346
|
+
const action = JSON.parse(msg.payload) as SubAction;
|
|
347
|
+
handleSubAction(state, action);
|
|
348
|
+
} else if (msg.channel === '_event_actions') {
|
|
349
|
+
// Reload event_actions cache
|
|
350
|
+
loadEventActions(pgClient, actionCache);
|
|
351
|
+
} else if (msg.channel === 'events') {
|
|
352
|
+
const ev = JSON.parse(msg.payload) as EventPayload;
|
|
353
|
+
// Existing hardcoded routing (backwards compatible)
|
|
354
|
+
routeEvent(state, ev, routes, apigw, lambdaClient);
|
|
355
|
+
// Data-driven routing via event_actions
|
|
356
|
+
const matched = matchEventActions(actionCache.get(), ev);
|
|
357
|
+
if (matched.length > 0) {
|
|
358
|
+
dispatchActions(matched, ev, pgClient, lambdaClient, kvsClient, {
|
|
359
|
+
workerFunctionName: eventActions?.workerFunctionName,
|
|
360
|
+
defaultKvsArn: eventActions?.defaultKvsArn,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
} catch {
|
|
365
|
+
// Malformed payload — skip
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// 7. Run event loop
|
|
370
|
+
const maxMs = event._testMaxMs ?? 14 * 60 * 1000; // 14 minutes default
|
|
371
|
+
const heartbeat = setInterval(async () => {
|
|
372
|
+
try {
|
|
373
|
+
await pgClient.query('SELECT 1');
|
|
374
|
+
} catch {
|
|
375
|
+
// Connection lost — exit will happen naturally
|
|
376
|
+
}
|
|
377
|
+
}, heartbeatInterval);
|
|
378
|
+
|
|
379
|
+
await new Promise<void>((resolve) => setTimeout(resolve, maxMs));
|
|
380
|
+
|
|
381
|
+
clearInterval(heartbeat);
|
|
382
|
+
|
|
383
|
+
// 8. Cleanup
|
|
384
|
+
await pgClient.end();
|
|
385
|
+
|
|
386
|
+
return { status: 'completed' };
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// --- Public API ---
|
|
391
|
+
|
|
392
|
+
export function createListener(options: ListenerOptions) {
|
|
393
|
+
return async function handler(_event?: unknown): Promise<{ status: string; reason?: string }> {
|
|
394
|
+
// Dynamic imports to avoid bundling AWS SDK when not needed
|
|
395
|
+
const { default: pg } = await import('pg' as string);
|
|
396
|
+
const pgClient = new pg.Client({ connectionString: options.connectionString });
|
|
397
|
+
|
|
398
|
+
let apigw: { postToConnection(params: { ConnectionId: string; Data: Buffer }): Promise<unknown> } = {
|
|
399
|
+
postToConnection: async () => {},
|
|
400
|
+
};
|
|
401
|
+
let lambdaClient: { invoke(params: unknown): Promise<unknown> } = {
|
|
402
|
+
invoke: async () => {},
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
if (options.routes.websocket) {
|
|
406
|
+
const { ApiGatewayManagementApiClient, PostToConnectionCommand } = await import(
|
|
407
|
+
'@aws-sdk/client-apigatewaymanagementapi' as string
|
|
408
|
+
);
|
|
409
|
+
const client = new ApiGatewayManagementApiClient({
|
|
410
|
+
endpoint: options.routes.websocket.endpoint,
|
|
411
|
+
region: options.routes.websocket.region,
|
|
412
|
+
});
|
|
413
|
+
apigw = {
|
|
414
|
+
postToConnection: (params) =>
|
|
415
|
+
client.send(new PostToConnectionCommand(params)),
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (options.routes.lambda || options.eventActions?.workerFunctionName) {
|
|
420
|
+
const { LambdaClient, InvokeCommand } = await import(
|
|
421
|
+
'@aws-sdk/client-lambda' as string
|
|
422
|
+
);
|
|
423
|
+
const client = new LambdaClient({});
|
|
424
|
+
lambdaClient = {
|
|
425
|
+
invoke: (params) => client.send(new InvokeCommand(params as any)),
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// KVS client for cache_purge event actions
|
|
430
|
+
let kvsClient: KvsClient | null = null;
|
|
431
|
+
if (options.eventActions?.defaultKvsArn) {
|
|
432
|
+
try {
|
|
433
|
+
await import('@aws-sdk/signature-v4a' as string);
|
|
434
|
+
const {
|
|
435
|
+
CloudFrontKeyValueStoreClient,
|
|
436
|
+
DescribeKeyValueStoreCommand,
|
|
437
|
+
PutKeyCommand,
|
|
438
|
+
} = await import('@aws-sdk/client-cloudfront-keyvaluestore' as string);
|
|
439
|
+
const client = new CloudFrontKeyValueStoreClient({});
|
|
440
|
+
kvsClient = {
|
|
441
|
+
describe: async (kvsArn: string) => {
|
|
442
|
+
const desc = await client.send(new DescribeKeyValueStoreCommand({ KvsARN: kvsArn }));
|
|
443
|
+
return { ETag: desc.ETag! };
|
|
444
|
+
},
|
|
445
|
+
put: async (kvsArn: string, key: string, value: string, etag: string) => {
|
|
446
|
+
await client.send(new PutKeyCommand({
|
|
447
|
+
KvsARN: kvsArn,
|
|
448
|
+
Key: key,
|
|
449
|
+
Value: value,
|
|
450
|
+
IfMatch: etag,
|
|
451
|
+
}));
|
|
452
|
+
},
|
|
453
|
+
};
|
|
454
|
+
} catch {
|
|
455
|
+
// KVS SDK not available — skip cache_purge actions
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const internal = createListenerInternal({
|
|
460
|
+
pgClient: pgClient as any,
|
|
461
|
+
channels: options.channels ?? ['events', '_subs', '_event_actions'],
|
|
462
|
+
lockId: options.lockId ?? 74736,
|
|
463
|
+
routes: options.routes,
|
|
464
|
+
apigw,
|
|
465
|
+
lambdaClient,
|
|
466
|
+
heartbeatInterval: options.heartbeatInterval,
|
|
467
|
+
eventActions: options.eventActions,
|
|
468
|
+
kvsClient,
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
return internal({});
|
|
472
|
+
};
|
|
473
|
+
}
|