@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
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
|
+
);
|
package/src/triggers.ts
ADDED
|
@@ -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
|
+
}
|