@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/src/schema.ts ADDED
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Events schema — WebSocket connections are now auth.sessions
3
+ *
4
+ * A WebSocket connection IS a session: domain='ws', status='active',
5
+ * data={connectionId, channels}, lastUsedAt=lastPingAt.
6
+ *
7
+ * On disconnect: status='disconnected', endedAt=now().
8
+ *
9
+ * Table definition copied locally to avoid TS6059 rootDir issues
10
+ * with cross-package re-exports during ts-jest compilation.
11
+ *
12
+ * @see everyschema — canonical schema definitions
13
+ */
14
+
15
+ import {
16
+ pgSchema,
17
+ text,
18
+ timestamp,
19
+ uuid,
20
+ integer,
21
+ index,
22
+ uniqueIndex,
23
+ jsonb,
24
+ boolean,
25
+ } from 'drizzle-orm/pg-core';
26
+
27
+ const authSchema = pgSchema('auth');
28
+
29
+ // =============================================================================
30
+ // SESSIONS (auth.sessions — unified STI)
31
+ // =============================================================================
32
+
33
+ /**
34
+ * Unified sessions table. Copied from @everystack/auth/schema.
35
+ *
36
+ * For events package: domain='ws'
37
+ * data: { connectionId, channels }
38
+ */
39
+ export const sessions = authSchema.table(
40
+ 'sessions',
41
+ {
42
+ id: uuid('id').primaryKey().defaultRandom(),
43
+
44
+ // STI discriminator
45
+ domain: text('domain').notNull(), // 'auth' | 'work' | 'agent' | 'ws'
46
+ type: text('type'), // auth sub-types: 'device' | 'web' | 'refresh' (NULL for work/agent/ws)
47
+
48
+ // Actor
49
+ userId: uuid('user_id'),
50
+ agentId: text('agent_id'),
51
+
52
+ // Context
53
+ repositoryId: uuid('repository_id'),
54
+ deviceId: uuid('device_id'),
55
+
56
+ // Auth: session token
57
+ tokenHash: text('token_hash').unique(),
58
+
59
+ // Status (shared across domains, different values per domain)
60
+ status: text('status').notNull().default('active'),
61
+
62
+ // Auth: lifecycle timestamps
63
+ expiresAt: timestamp('expires_at', { withTimezone: true }),
64
+ lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
65
+ revokedAt: timestamp('revoked_at', { withTimezone: true }),
66
+
67
+ // Auth: security context
68
+ ipAddress: text('ip_address'),
69
+ userAgent: text('user_agent'),
70
+
71
+ // Work/agent: lifecycle
72
+ startedAt: timestamp('started_at', { withTimezone: true }),
73
+ endedAt: timestamp('ended_at', { withTimezone: true }),
74
+
75
+ // Agent: counters
76
+ toolCallCount: integer('tool_call_count'),
77
+ errorCount: integer('error_count'),
78
+
79
+ // Domain-specific data
80
+ data: jsonb('data'),
81
+
82
+ // Timestamps
83
+ createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
84
+ },
85
+ (table) => ({
86
+ domainIdx: index('sessions_domain_idx').on(table.domain),
87
+ domainUserIdx: index('sessions_domain_user_idx').on(table.domain, table.userId),
88
+ domainAgentStatusIdx: index('sessions_domain_agent_status_idx').on(table.domain, table.agentId, table.status),
89
+ typeIdx: index('sessions_type_idx').on(table.type),
90
+ userIdx: index('sessions_user_idx').on(table.userId),
91
+ deviceIdx: index('sessions_device_idx').on(table.deviceId),
92
+ tokenIdx: uniqueIndex('sessions_token_idx').on(table.tokenHash),
93
+ statusIdx: index('sessions_status_idx').on(table.status),
94
+ expiresIdx: index('sessions_expires_idx').on(table.expiresAt),
95
+ repoIdx: index('sessions_repo_idx').on(table.repositoryId),
96
+ createdIdx: index('sessions_created_idx').on(table.createdAt),
97
+ })
98
+ );
99
+
100
+ // =============================================================================
101
+ // EVENT ACTIONS (ops.event_actions — data-driven event routing)
102
+ // =============================================================================
103
+
104
+ const opsSchema = pgSchema('ops');
105
+
106
+ /**
107
+ * Event actions define what happens when a database event occurs.
108
+ * Loaded by the listener on startup, cached in memory, reloaded on change.
109
+ *
110
+ * tenant_id: NULL = platform-wide, non-NULL = tenant-scoped
111
+ * operation: 'insert' | 'update' | 'delete' | '*'
112
+ * action_type: 'rpc' | 'job' | 'cache_purge' | 'webhook'
113
+ * config: action-specific JSONB (discriminated union keyed by action_type)
114
+ * filter: optional row conditions for conditional firing
115
+ */
116
+ export const eventActions = opsSchema.table(
117
+ 'event_actions',
118
+ {
119
+ id: uuid('id').primaryKey().defaultRandom(),
120
+ tenantId: uuid('tenant_id'),
121
+ name: text('name').notNull(),
122
+ schemaName: text('schema_name').notNull().default('public'),
123
+ tableName: text('table_name').notNull(),
124
+ operation: text('operation').notNull(),
125
+ actionType: text('action_type').notNull(),
126
+ config: jsonb('config').notNull().default('{}'),
127
+ filter: jsonb('filter'),
128
+ enabled: boolean('enabled').notNull().default(true),
129
+ priority: integer('priority').notNull().default(0),
130
+ createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
131
+ updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
132
+ },
133
+ (table) => ({
134
+ lookupIdx: index('event_actions_lookup_idx').on(
135
+ table.schemaName, table.tableName, table.operation, table.enabled
136
+ ),
137
+ tenantIdx: index('event_actions_tenant_idx').on(table.tenantId),
138
+ enabledIdx: index('event_actions_enabled_idx').on(table.enabled),
139
+ })
140
+ );
@@ -0,0 +1,54 @@
1
+ const NOTIFY_FUNCTION = `\
2
+ CREATE OR REPLACE FUNCTION everystack_notify() RETURNS trigger AS $$
3
+ BEGIN
4
+ PERFORM pg_notify(
5
+ 'events',
6
+ json_build_object(
7
+ 'schema', TG_TABLE_SCHEMA,
8
+ 'table', TG_TABLE_NAME,
9
+ 'op', lower(TG_OP),
10
+ 'id', COALESCE(NEW.id, OLD.id)
11
+ )::text
12
+ );
13
+ RETURN COALESCE(NEW, OLD);
14
+ END;
15
+ $$ LANGUAGE plpgsql;`;
16
+
17
+ function triggerSQL(table: string): string {
18
+ const name = `${table}_events`;
19
+ return `\
20
+ DROP TRIGGER IF EXISTS ${name} ON ${table};
21
+ CREATE TRIGGER ${name}
22
+ AFTER INSERT OR UPDATE OR DELETE ON ${table}
23
+ FOR EACH ROW EXECUTE FUNCTION everystack_notify();`;
24
+ }
25
+
26
+ export function generateTriggerSQL(tables: string[]): string {
27
+ if (tables.length === 0) {
28
+ throw new Error('generateTriggerSQL requires at least one table');
29
+ }
30
+
31
+ const parts = [NOTIFY_FUNCTION, ''];
32
+ for (const table of tables) {
33
+ parts.push(triggerSQL(table));
34
+ parts.push('');
35
+ }
36
+
37
+ return parts.join('\n').trimEnd() + '\n';
38
+ }
39
+
40
+ export function generateEventActionsTriggerSQL(): string {
41
+ return `\
42
+ CREATE OR REPLACE FUNCTION everystack_event_actions_notify() RETURNS trigger AS $$
43
+ BEGIN
44
+ PERFORM pg_notify('_event_actions', '{}');
45
+ RETURN COALESCE(NEW, OLD);
46
+ END;
47
+ $$ LANGUAGE plpgsql;
48
+
49
+ DROP TRIGGER IF EXISTS event_actions_notify ON ops.event_actions;
50
+ CREATE TRIGGER event_actions_notify
51
+ AFTER INSERT OR UPDATE OR DELETE ON ops.event_actions
52
+ FOR EACH ROW EXECUTE FUNCTION everystack_event_actions_notify();
53
+ `;
54
+ }
package/src/types.ts ADDED
@@ -0,0 +1,129 @@
1
+ // --- Core ---
2
+
3
+ export interface EventPayload {
4
+ schema?: string; // defaults to 'public' if absent (backwards compat)
5
+ table: string;
6
+ op: 'insert' | 'update' | 'delete' | string;
7
+ id: string;
8
+ }
9
+
10
+ export interface EventSignal extends EventPayload {
11
+ channel: string;
12
+ ts: number;
13
+ }
14
+
15
+ // --- Listener ---
16
+
17
+ export interface WebSocketRoute {
18
+ endpoint: string;
19
+ region: string;
20
+ }
21
+
22
+ export interface LambdaRoute {
23
+ functionName?: string;
24
+ functionArn?: string;
25
+ }
26
+
27
+ export interface ListenerRoutes {
28
+ websocket?: WebSocketRoute;
29
+ lambda?: Record<string, LambdaRoute>;
30
+ callbacks?: Record<string, (event: EventPayload) => Promise<void>>;
31
+ }
32
+
33
+ export interface EventActionsOptions {
34
+ workerFunctionName?: string;
35
+ defaultKvsArn?: string | null;
36
+ }
37
+
38
+ export interface ListenerOptions {
39
+ connectionString: string;
40
+ channels?: string[];
41
+ lockId?: number;
42
+ routes: ListenerRoutes;
43
+ reconcile?: (db: unknown) => Promise<void>;
44
+ heartbeatInterval?: number;
45
+ eventActions?: EventActionsOptions;
46
+ }
47
+
48
+ // --- Subscription (internal NOTIFY payloads) ---
49
+
50
+ export type SubAction =
51
+ | { action: 'connect'; connectionId: string; channels: string[] }
52
+ | { action: 'disconnect'; connectionId: string }
53
+ | { action: 'subscribe'; connectionId: string; channels: string[] }
54
+ | { action: 'unsubscribe'; connectionId: string; channels: string[] };
55
+
56
+ // --- Client ---
57
+
58
+ export interface EventsClientOptions {
59
+ url: string;
60
+ getToken: () => string | null;
61
+ reconnectDelay?: number;
62
+ pingInterval?: number;
63
+ }
64
+
65
+ export interface EventsClient {
66
+ connect(): void;
67
+ disconnect(): void;
68
+ on(channel: string, callback: (signal: EventSignal) => void): () => void;
69
+ subscribe(channels: string[]): void;
70
+ unsubscribe(channels: string[]): void;
71
+ }
72
+
73
+ // --- WebSocket Handler ---
74
+
75
+ export interface WebSocketHandlerOptions {
76
+ db: unknown;
77
+ verifyToken: (token: string) => Promise<Record<string, unknown> | null>;
78
+ }
79
+
80
+ // --- Job Handler ---
81
+
82
+ export type JobHandler<T = unknown> = (payload: T, job: { id: string; type: string }) => Promise<void>;
83
+
84
+ export interface JobHandlerOptions {
85
+ db: unknown;
86
+ handlers: Record<string, JobHandler>;
87
+ }
88
+
89
+ // --- Event Actions (data-driven routing) ---
90
+
91
+ export type EventActionType = 'rpc' | 'job' | 'cache_purge' | 'webhook';
92
+ export type EventOperation = 'insert' | 'update' | 'delete' | '*';
93
+
94
+ export interface EventAction {
95
+ id: string;
96
+ tenantId: string | null;
97
+ name: string;
98
+ schemaName: string;
99
+ tableName: string;
100
+ operation: EventOperation;
101
+ actionType: EventActionType;
102
+ config: Record<string, unknown>;
103
+ filter: Record<string, unknown> | null;
104
+ enabled: boolean;
105
+ priority: number;
106
+ }
107
+
108
+ export interface RpcActionConfig {
109
+ function: string;
110
+ args?: Record<string, unknown>;
111
+ }
112
+
113
+ export interface JobActionConfig {
114
+ jobType: string;
115
+ payload?: Record<string, unknown>;
116
+ priority?: number;
117
+ maxAttempts?: number;
118
+ }
119
+
120
+ export interface CachePurgeActionConfig {
121
+ paths: string[];
122
+ kvsArn?: string;
123
+ }
124
+
125
+ export interface WebhookActionConfig {
126
+ url: string;
127
+ method?: string;
128
+ headers?: Record<string, string>;
129
+ }