@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 ADDED
@@ -0,0 +1,36 @@
1
+ # @everystack/events
2
+
3
+ PostgreSQL LISTEN/NOTIFY bridge for serverless — WebSocket fan-out, Lambda invocation, and real-time subscriptions for Expo apps.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pnpm add @everystack/events
9
+ ```
10
+
11
+ ## Entry Points
12
+
13
+ | Import | Description |
14
+ |--------|-------------|
15
+ | `@everystack/events` | Server: listener, event router |
16
+ | `@everystack/events/triggers` | SQL trigger generation |
17
+ | `@everystack/events/handler` | Lambda handler for WebSocket + event dispatch |
18
+ | `@everystack/events/schema` | Drizzle tables (ws_connections) |
19
+ | `@everystack/events/client` | React hooks (useSubscription) |
20
+ | `@everystack/events/actions` | Server-side event actions |
21
+
22
+ ## Peer Dependencies
23
+
24
+ - `drizzle-orm` — Database ORM
25
+ - `@aws-sdk/client-apigatewaymanagementapi` — WebSocket push (optional)
26
+ - `@aws-sdk/client-lambda` — Lambda invocation (optional)
27
+ - `@tanstack/react-query` — Client hooks (optional)
28
+ - `react` — Client hooks (optional)
29
+
30
+ ---
31
+
32
+ Part of [everystack](https://github.com/scalable-technology/everystack) — a self-hosted application stack for Expo apps on AWS.
33
+
34
+ ## License
35
+
36
+ MIT
package/package.json ADDED
@@ -0,0 +1,79 @@
1
+ {
2
+ "name": "@everystack/events",
3
+ "version": "0.1.0",
4
+ "description": "PostgreSQL LISTEN/NOTIFY bridge for serverless — WebSocket fan-out + Lambda invocation",
5
+ "license": "MIT",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "files": [
10
+ "src",
11
+ "README.md"
12
+ ],
13
+ "type": "module",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./src/index.ts",
17
+ "default": "./src/index.ts"
18
+ },
19
+ "./triggers": {
20
+ "types": "./src/triggers.ts",
21
+ "default": "./src/triggers.ts"
22
+ },
23
+ "./handler": {
24
+ "types": "./src/handler/index.ts",
25
+ "default": "./src/handler/index.ts"
26
+ },
27
+ "./schema": {
28
+ "types": "./src/schema.ts",
29
+ "default": "./src/schema.ts"
30
+ },
31
+ "./client": {
32
+ "types": "./src/client/index.ts",
33
+ "default": "./src/client/index.ts"
34
+ },
35
+ "./actions": {
36
+ "types": "./src/actions.ts",
37
+ "default": "./src/actions.ts"
38
+ }
39
+ },
40
+ "peerDependencies": {
41
+ "@aws-sdk/client-apigatewaymanagementapi": ">=3.0.0",
42
+ "@aws-sdk/client-lambda": ">=3.0.0",
43
+ "@tanstack/react-query": ">=5.0.0",
44
+ "drizzle-orm": ">=0.30.0",
45
+ "react": ">=18.0.0"
46
+ },
47
+ "peerDependenciesMeta": {
48
+ "@aws-sdk/client-lambda": {
49
+ "optional": true
50
+ },
51
+ "@aws-sdk/client-apigatewaymanagementapi": {
52
+ "optional": true
53
+ },
54
+ "react": {
55
+ "optional": true
56
+ },
57
+ "@tanstack/react-query": {
58
+ "optional": true
59
+ }
60
+ },
61
+ "devDependencies": {
62
+ "@aws-sdk/client-apigatewaymanagementapi": "^3.700.0",
63
+ "@aws-sdk/client-lambda": "^3.700.0",
64
+ "@types/jest": "^29.5.14",
65
+ "@types/node": "^22.0.0",
66
+ "@types/react": "^19.2.14",
67
+ "drizzle-orm": "^0.41.0",
68
+ "jest": "^29.7.0",
69
+ "react": "^19.2.6",
70
+ "ts-jest": "^29.3.0",
71
+ "typescript": "^5.7.0",
72
+ "@everystack/auth": "0.1.0"
73
+ },
74
+ "scripts": {
75
+ "test": "NODE_OPTIONS='--experimental-vm-modules' jest",
76
+ "build": "tsc --build",
77
+ "lint": "tsc --noEmit"
78
+ }
79
+ }
package/src/actions.ts ADDED
@@ -0,0 +1,133 @@
1
+ import type { EventPayload, EventAction } from './types';
2
+
3
+ /**
4
+ * Match event_actions cache against an incoming event.
5
+ * Returns matched actions sorted by priority DESC.
6
+ */
7
+ export function matchEventActions(
8
+ actions: EventAction[],
9
+ event: EventPayload,
10
+ ): EventAction[] {
11
+ const eventSchema = event.schema || 'public';
12
+
13
+ return actions
14
+ .filter((a) =>
15
+ a.enabled &&
16
+ a.schemaName === eventSchema &&
17
+ a.tableName === event.table &&
18
+ (a.operation === '*' || a.operation === event.op)
19
+ )
20
+ .sort((a, b) => b.priority - a.priority);
21
+ }
22
+
23
+ /**
24
+ * Check if any matched actions need a row fetch.
25
+ * Row fetch is needed when:
26
+ * 1. Any config contains {{row.xxx}} template variables
27
+ * 2. Any action has a non-null tenant_id (for tenant matching)
28
+ * 3. Any action has a non-null filter
29
+ */
30
+ export function needsRowFetch(actions: EventAction[]): boolean {
31
+ for (const action of actions) {
32
+ if (action.tenantId !== null) return true;
33
+ if (action.filter !== null) return true;
34
+ if (configContainsRowTemplate(action.config)) return true;
35
+ }
36
+ return false;
37
+ }
38
+
39
+ function configContainsRowTemplate(obj: unknown): boolean {
40
+ if (typeof obj === 'string') return obj.includes('{{row.');
41
+ if (Array.isArray(obj)) return obj.some(configContainsRowTemplate);
42
+ if (obj && typeof obj === 'object') {
43
+ return Object.values(obj).some(configContainsRowTemplate);
44
+ }
45
+ return false;
46
+ }
47
+
48
+ /**
49
+ * Filter actions by tenant_id matching against the fetched row.
50
+ * Platform-wide actions (tenant_id=null) always pass.
51
+ * Tenant-scoped actions require row.tenant_id to match.
52
+ */
53
+ export function filterByTenant(
54
+ actions: EventAction[],
55
+ row: Record<string, unknown> | null,
56
+ ): EventAction[] {
57
+ return actions.filter((action) => {
58
+ if (action.tenantId === null) return true;
59
+ if (!row) return false;
60
+ return row.tenant_id === action.tenantId;
61
+ });
62
+ }
63
+
64
+ /**
65
+ * Apply a simple key-value filter against a row.
66
+ * All keys in the filter must equal the corresponding row value.
67
+ * Returns true if filter is null (no filtering).
68
+ */
69
+ export function applyFilter(
70
+ action: EventAction,
71
+ row: Record<string, unknown> | null,
72
+ ): boolean {
73
+ if (action.filter === null) return true;
74
+ if (!row) return false;
75
+ for (const [key, value] of Object.entries(action.filter)) {
76
+ if (row[key] !== value) return false;
77
+ }
78
+ return true;
79
+ }
80
+
81
+ /**
82
+ * Resolve {{...}} template variables in a config value.
83
+ * Event-level: {{table}}, {{op}}, {{id}}, {{schema}}
84
+ * Row-level: {{row.fieldName}} — only if row was fetched
85
+ */
86
+ export function resolveTemplate(
87
+ value: unknown,
88
+ event: EventPayload,
89
+ row: Record<string, unknown> | null,
90
+ ): unknown {
91
+ if (typeof value === 'string') {
92
+ return value.replace(/\{\{(\w+(?:\.\w+)?)\}\}/g, (match, key: string) => {
93
+ if (key === 'table') return event.table;
94
+ if (key === 'op') return event.op;
95
+ if (key === 'id') return event.id;
96
+ if (key === 'schema') return event.schema || 'public';
97
+ if (key.startsWith('row.')) {
98
+ const field = key.slice(4);
99
+ return row?.[field] != null ? String(row[field]) : '';
100
+ }
101
+ return match;
102
+ });
103
+ }
104
+ if (Array.isArray(value)) {
105
+ return value.map((v) => resolveTemplate(v, event, row));
106
+ }
107
+ if (value && typeof value === 'object') {
108
+ const result: Record<string, unknown> = {};
109
+ for (const [k, v] of Object.entries(value)) {
110
+ result[k] = resolveTemplate(v, event, row);
111
+ }
112
+ return result;
113
+ }
114
+ return value;
115
+ }
116
+
117
+ /**
118
+ * In-memory cache for event_actions.
119
+ * Loaded on startup, reloaded on _event_actions NOTIFY.
120
+ */
121
+ export function createActionCache(): {
122
+ get(): EventAction[];
123
+ set(actions: EventAction[]): void;
124
+ clear(): void;
125
+ } {
126
+ let actions: EventAction[] = [];
127
+
128
+ return {
129
+ get(): EventAction[] { return actions; },
130
+ set(newActions: EventAction[]): void { actions = newActions; },
131
+ clear(): void { actions = []; },
132
+ };
133
+ }
@@ -0,0 +1,198 @@
1
+ import { createContext, createElement, useContext, useEffect, useRef } from 'react';
2
+ import type { EventsClient, EventsClientOptions, EventSignal } from '../types';
3
+
4
+ // --- Core WebSocket client (framework-agnostic) ---
5
+
6
+ export function createEventsClient(options: EventsClientOptions): EventsClient {
7
+ const {
8
+ url,
9
+ getToken,
10
+ reconnectDelay = 1000,
11
+ pingInterval = 300000, // 5 minutes
12
+ } = options;
13
+
14
+ // channel → Set<callback>
15
+ const listeners = new Map<string, Set<(signal: EventSignal) => void>>();
16
+ let ws: WebSocket | null = null;
17
+ let intentionalClose = false;
18
+ let pingTimer: ReturnType<typeof setInterval> | null = null;
19
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
20
+
21
+ function getChannels(): string[] {
22
+ return [...listeners.keys()];
23
+ }
24
+
25
+ function handleMessage(event: { data: string }) {
26
+ try {
27
+ const signal = JSON.parse(event.data) as EventSignal;
28
+ const callbacks = listeners.get(signal.channel);
29
+ if (callbacks) {
30
+ for (const cb of callbacks) {
31
+ cb(signal);
32
+ }
33
+ }
34
+ } catch {
35
+ // Malformed message — skip
36
+ }
37
+ }
38
+
39
+ function startPing() {
40
+ if (pingTimer) clearInterval(pingTimer);
41
+ pingTimer = setInterval(() => {
42
+ if (ws?.readyState === WebSocket.OPEN) {
43
+ ws.send(JSON.stringify({ action: 'ping' }));
44
+ }
45
+ }, pingInterval);
46
+ }
47
+
48
+ function stopPing() {
49
+ if (pingTimer) {
50
+ clearInterval(pingTimer);
51
+ pingTimer = null;
52
+ }
53
+ }
54
+
55
+ function scheduleReconnect() {
56
+ if (intentionalClose) return;
57
+ if (reconnectTimer) clearTimeout(reconnectTimer);
58
+ reconnectTimer = setTimeout(() => {
59
+ reconnectTimer = null;
60
+ connect();
61
+ }, reconnectDelay);
62
+ }
63
+
64
+ function connect() {
65
+ const token = getToken();
66
+ if (!token) return;
67
+
68
+ intentionalClose = false;
69
+
70
+ const channels = getChannels();
71
+ const params = new URLSearchParams({ token });
72
+ if (channels.length > 0) {
73
+ params.set('subscribe', channels.join(','));
74
+ }
75
+
76
+ ws = new WebSocket(`${url}?${params.toString()}`);
77
+
78
+ ws.onopen = () => {
79
+ startPing();
80
+ };
81
+
82
+ ws.onmessage = handleMessage;
83
+
84
+ ws.onclose = () => {
85
+ stopPing();
86
+ scheduleReconnect();
87
+ };
88
+
89
+ ws.onerror = () => {
90
+ // onclose will fire after onerror
91
+ };
92
+ }
93
+
94
+ function disconnect() {
95
+ intentionalClose = true;
96
+ stopPing();
97
+ if (reconnectTimer) {
98
+ clearTimeout(reconnectTimer);
99
+ reconnectTimer = null;
100
+ }
101
+ ws?.close();
102
+ }
103
+
104
+ function on(channel: string, callback: (signal: EventSignal) => void): () => void {
105
+ let callbacks = listeners.get(channel);
106
+ if (!callbacks) {
107
+ callbacks = new Set();
108
+ listeners.set(channel, callbacks);
109
+ }
110
+ callbacks.add(callback);
111
+
112
+ return () => {
113
+ callbacks!.delete(callback);
114
+ if (callbacks!.size === 0) {
115
+ listeners.delete(channel);
116
+ }
117
+ };
118
+ }
119
+
120
+ function subscribe(channels: string[]) {
121
+ if (ws?.readyState === WebSocket.OPEN) {
122
+ ws.send(JSON.stringify({ action: 'subscribe', channels }));
123
+ }
124
+ }
125
+
126
+ function unsubscribe(channels: string[]) {
127
+ if (ws?.readyState === WebSocket.OPEN) {
128
+ ws.send(JSON.stringify({ action: 'unsubscribe', channels }));
129
+ }
130
+ }
131
+
132
+ return { connect, disconnect, on, subscribe, unsubscribe };
133
+ }
134
+
135
+ // --- React Context ---
136
+
137
+ const EventsContext = createContext<EventsClient | null>(null);
138
+
139
+ export function EventsProvider(props: {
140
+ client: EventsClient;
141
+ children: React.ReactNode;
142
+ }): React.ReactElement {
143
+ return createElement(EventsContext.Provider, { value: props.client }, props.children);
144
+ }
145
+
146
+ export function useEventsClient(): EventsClient | null {
147
+ return useContext(EventsContext);
148
+ }
149
+
150
+ // --- useSignal: call a callback when a channel signal arrives ---
151
+
152
+ export function useSignal(
153
+ channel: string,
154
+ callback: (signal: EventSignal) => void,
155
+ ): void {
156
+ const client = useContext(EventsContext);
157
+ const callbackRef = useRef(callback);
158
+ callbackRef.current = callback;
159
+
160
+ useEffect(() => {
161
+ if (!client) return;
162
+
163
+ const unsub = client.on(channel, (signal) => {
164
+ callbackRef.current(signal);
165
+ });
166
+
167
+ return unsub;
168
+ }, [client, channel]);
169
+ }
170
+
171
+ // --- useSubscription: React Query cache invalidation on signal ---
172
+
173
+ export function useSubscription(channel: string, queryKeys: string[]): void {
174
+ const client = useContext(EventsContext);
175
+
176
+ useEffect(() => {
177
+ if (!client) return;
178
+
179
+ // Lazy import — only loaded when useSubscription is actually called
180
+ let unsub: (() => void) | undefined;
181
+
182
+ try {
183
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
184
+ const { useQueryClient } = require('@tanstack/react-query');
185
+ const queryClient = useQueryClient();
186
+
187
+ unsub = client.on(channel, () => {
188
+ for (const key of queryKeys) {
189
+ queryClient.invalidateQueries({ queryKey: [key] });
190
+ }
191
+ });
192
+ } catch {
193
+ // @tanstack/react-query not available — no-op
194
+ }
195
+
196
+ return () => unsub?.();
197
+ }, [client, channel, ...queryKeys]);
198
+ }
@@ -0,0 +1,176 @@
1
+ import type {
2
+ EventPayload, EventAction,
3
+ RpcActionConfig, JobActionConfig, CachePurgeActionConfig,
4
+ } from './types';
5
+ import { resolveTemplate, filterByTenant, needsRowFetch, applyFilter } from './actions';
6
+
7
+ // --- Interfaces for injectable dependencies ---
8
+
9
+ export interface PgClient {
10
+ query(text: string, values?: unknown[]): Promise<{ rows: Record<string, unknown>[] }>;
11
+ }
12
+
13
+ export interface LambdaClient {
14
+ invoke(params: unknown): Promise<unknown>;
15
+ }
16
+
17
+ export interface KvsClient {
18
+ describe(kvsArn: string): Promise<{ ETag: string }>;
19
+ put(kvsArn: string, key: string, value: string, etag: string): Promise<void>;
20
+ }
21
+
22
+ export interface DispatchOptions {
23
+ workerFunctionName?: string;
24
+ defaultKvsArn?: string | null;
25
+ }
26
+
27
+ // --- Individual dispatchers ---
28
+
29
+ /**
30
+ * Dispatch an RPC action: invoke worker Lambda with the action payload.
31
+ */
32
+ export async function dispatchRpc(
33
+ action: EventAction,
34
+ event: EventPayload,
35
+ row: Record<string, unknown> | null,
36
+ lambdaClient: LambdaClient,
37
+ workerFunctionName: string,
38
+ ): Promise<void> {
39
+ const resolved = resolveTemplate(action.config, event, row) as RpcActionConfig;
40
+ await lambdaClient.invoke({
41
+ FunctionName: workerFunctionName,
42
+ InvocationType: 'Event',
43
+ Payload: JSON.stringify({
44
+ action_type: 'rpc',
45
+ function: resolved.function,
46
+ args: resolved.args || {},
47
+ event,
48
+ event_action_id: action.id,
49
+ }),
50
+ });
51
+ }
52
+
53
+ /**
54
+ * Dispatch a Job action: insert into ops.runs with type='job'.
55
+ */
56
+ export async function dispatchJob(
57
+ action: EventAction,
58
+ event: EventPayload,
59
+ row: Record<string, unknown> | null,
60
+ pgClient: PgClient,
61
+ ): Promise<void> {
62
+ const resolved = resolveTemplate(action.config, event, row) as JobActionConfig;
63
+ await pgClient.query(
64
+ `INSERT INTO ops.runs (type, name, status, priority, queued_at, data)
65
+ VALUES ('job', $1, 'pending', $2, NOW(), $3)`,
66
+ [
67
+ resolved.jobType,
68
+ resolved.priority ?? 0,
69
+ JSON.stringify({
70
+ payload: resolved.payload || {},
71
+ attempts: 0,
72
+ maxAttempts: resolved.maxAttempts ?? 3,
73
+ event_action_id: action.id,
74
+ source_event: event,
75
+ }),
76
+ ]
77
+ );
78
+ }
79
+
80
+ /**
81
+ * Dispatch a cache_purge action: invalidate CloudFront KVS paths.
82
+ * Lightweight — runs directly in the listener, no Lambda invocation.
83
+ */
84
+ export async function dispatchCachePurge(
85
+ action: EventAction,
86
+ event: EventPayload,
87
+ row: Record<string, unknown> | null,
88
+ kvsClient: KvsClient | null,
89
+ defaultKvsArn: string | null,
90
+ ): Promise<void> {
91
+ const resolved = resolveTemplate(action.config, event, row) as CachePurgeActionConfig;
92
+ const kvsArn = resolved.kvsArn || defaultKvsArn;
93
+ if (!kvsArn || !kvsClient) return;
94
+
95
+ const desc = await kvsClient.describe(kvsArn);
96
+ let etag = desc.ETag;
97
+
98
+ for (const path of resolved.paths) {
99
+ await kvsClient.put(kvsArn, path, String(Math.floor(Date.now() / 1000)), etag);
100
+ // KVS etag changes after each put — re-describe for next iteration
101
+ const newDesc = await kvsClient.describe(kvsArn);
102
+ etag = newDesc.ETag;
103
+ }
104
+ }
105
+
106
+ // --- Orchestrator ---
107
+
108
+ /**
109
+ * Orchestrator: fetch row if needed, filter by tenant + row filter,
110
+ * dispatch all matched actions.
111
+ *
112
+ * Actions are already sorted by priority DESC from matchEventActions.
113
+ */
114
+ export async function dispatchActions(
115
+ matched: EventAction[],
116
+ event: EventPayload,
117
+ pgClient: PgClient,
118
+ lambdaClient: LambdaClient,
119
+ kvsClient: KvsClient | null,
120
+ options: DispatchOptions,
121
+ ): Promise<void> {
122
+ if (matched.length === 0) return;
123
+
124
+ // Lazy row fetch — only if any action needs it
125
+ let row: Record<string, unknown> | null = null;
126
+ if (needsRowFetch(matched) && event.op !== 'delete') {
127
+ const schema = event.schema || 'public';
128
+ try {
129
+ const { rows } = await pgClient.query(
130
+ `SELECT * FROM "${schema}"."${event.table}" WHERE id = $1 LIMIT 1`,
131
+ [event.id]
132
+ );
133
+ row = (rows[0] as Record<string, unknown>) ?? null;
134
+ } catch {
135
+ // Table might not have standard id column — skip row fetch
136
+ }
137
+ }
138
+
139
+ // Tenant filtering
140
+ let filtered = filterByTenant(matched, row);
141
+
142
+ // Row filter
143
+ filtered = filtered.filter((action) => applyFilter(action, row));
144
+
145
+ // Dispatch each action (already sorted by priority)
146
+ const tasks: Promise<void>[] = [];
147
+ for (const action of filtered) {
148
+ switch (action.actionType) {
149
+ case 'rpc':
150
+ if (options.workerFunctionName) {
151
+ tasks.push(
152
+ dispatchRpc(action, event, row, lambdaClient, options.workerFunctionName)
153
+ .catch(() => {})
154
+ );
155
+ }
156
+ break;
157
+ case 'job':
158
+ tasks.push(
159
+ dispatchJob(action, event, row, pgClient)
160
+ .catch(() => {})
161
+ );
162
+ break;
163
+ case 'cache_purge':
164
+ tasks.push(
165
+ dispatchCachePurge(action, event, row, kvsClient, options.defaultKvsArn ?? null)
166
+ .catch(() => {})
167
+ );
168
+ break;
169
+ case 'webhook':
170
+ // Future: not implemented yet
171
+ break;
172
+ }
173
+ }
174
+
175
+ await Promise.allSettled(tasks);
176
+ }