@everystack/events 0.2.2 → 0.3.1
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/package.json +9 -2
- package/src/models/event-action.ts +46 -0
- package/src/models/index.ts +26 -0
- package/src/schema.ts +6 -36
- package/src/triggers.ts +11 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/events",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "PostgreSQL LISTEN/NOTIFY bridge for serverless — WebSocket fan-out + Lambda invocation",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
@@ -37,6 +37,10 @@
|
|
|
37
37
|
"types": "./src/schema.ts",
|
|
38
38
|
"default": "./src/schema.ts"
|
|
39
39
|
},
|
|
40
|
+
"./models": {
|
|
41
|
+
"types": "./src/models/index.ts",
|
|
42
|
+
"default": "./src/models/index.ts"
|
|
43
|
+
},
|
|
40
44
|
"./client": {
|
|
41
45
|
"types": "./src/client/index.ts",
|
|
42
46
|
"default": "./src/client/index.ts"
|
|
@@ -50,6 +54,9 @@
|
|
|
50
54
|
"default": "./src/listener.ts"
|
|
51
55
|
}
|
|
52
56
|
},
|
|
57
|
+
"dependencies": {
|
|
58
|
+
"@everystack/model": "0.3.3"
|
|
59
|
+
},
|
|
53
60
|
"peerDependencies": {
|
|
54
61
|
"@aws-sdk/client-apigatewaymanagementapi": "3.1053.0",
|
|
55
62
|
"@aws-sdk/client-cloudfront-keyvaluestore": "3.1053.0",
|
|
@@ -92,7 +99,7 @@
|
|
|
92
99
|
"react": "19.2.6",
|
|
93
100
|
"ts-jest": "29.4.9",
|
|
94
101
|
"typescript": "5.9.3",
|
|
95
|
-
"@everystack/auth": "0.
|
|
102
|
+
"@everystack/auth": "0.3.1"
|
|
96
103
|
},
|
|
97
104
|
"scripts": {
|
|
98
105
|
"test": "jest",
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ops.event_actions — data-driven event routing. Rules the listener loads (and caches,
|
|
3
|
+
* reloading on a `_event_actions` NOTIFY) to dispatch actions when a DB mutation fires.
|
|
4
|
+
* Admin-configurable, no redeploy.
|
|
5
|
+
*
|
|
6
|
+
* The v3 Model that owns this table's schema AND its authorization. The drizzle runtime
|
|
7
|
+
* table (`../schema`) derives from it via `toDrizzleTable`, and `db:generate` reads it to
|
|
8
|
+
* emit the DDL + RLS + grants — the events package owns its table end to end. The notify
|
|
9
|
+
* functions + triggers it pairs with are package SQL (`../triggers`), contributed through the
|
|
10
|
+
* events Module's `functions`, not modeled.
|
|
11
|
+
*
|
|
12
|
+
* Authz:
|
|
13
|
+
* - `writtenBy: 'worker'` — the listener reads it on the owner connection, so it must keep
|
|
14
|
+
* the owner's RLS bypass: ENABLE-not-FORCE. (Admin configures it through the admin path.)
|
|
15
|
+
* - `can('manage', { role: 'admin' })` — admin gets CRUD + the `event_actions_admin` policy.
|
|
16
|
+
* - `private: true` — reached through the listener + the admin path, never the generic API.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { defineModel, field, can, index } from '@everystack/model';
|
|
20
|
+
|
|
21
|
+
export const EventAction = defineModel('event_actions', {
|
|
22
|
+
schema: 'ops',
|
|
23
|
+
writtenBy: 'worker',
|
|
24
|
+
private: true,
|
|
25
|
+
fields: {
|
|
26
|
+
id: field.uuid().primaryKey().defaultRandom(),
|
|
27
|
+
tenantId: field.uuid().nullable(), // NULL = platform-wide, non-NULL = tenant-scoped
|
|
28
|
+
name: field.text().notNull(),
|
|
29
|
+
schemaName: field.text().notNull().default('public'),
|
|
30
|
+
tableName: field.text().notNull(),
|
|
31
|
+
operation: field.text().notNull(), // 'insert' | 'update' | 'delete' | '*'
|
|
32
|
+
actionType: field.text().notNull(), // 'rpc' | 'job' | 'cache_purge' | 'webhook'
|
|
33
|
+
config: field.jsonb().notNull().default('{}'),
|
|
34
|
+
filter: field.jsonb().nullable(),
|
|
35
|
+
enabled: field.boolean().notNull().default(true),
|
|
36
|
+
priority: field.integer().notNull().default(0),
|
|
37
|
+
createdAt: field.timestamptz().defaultNow(),
|
|
38
|
+
updatedAt: field.timestamptz().defaultNow(),
|
|
39
|
+
},
|
|
40
|
+
constraints: [
|
|
41
|
+
index('schemaName', 'tableName', 'operation', 'enabled'),
|
|
42
|
+
index('tenantId'),
|
|
43
|
+
index('enabled'),
|
|
44
|
+
],
|
|
45
|
+
abilities: [can('manage', { role: 'admin' })],
|
|
46
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Events' package-owned models + Module. A consuming app composes `eventModule` so
|
|
3
|
+
* `db:generate` emits ops.event_actions (schema + authz) plus the notify functions/triggers.
|
|
4
|
+
*
|
|
5
|
+
* The trigger ATTACHMENT (which app tables emit events) is the app's choice, so it is NOT in
|
|
6
|
+
* this module — the app contributes `generateTriggerSQL([...its tables])` in its own module.
|
|
7
|
+
* This module ships the table + the `event_actions` self-notify function/trigger.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { defineModule } from '@everystack/model';
|
|
11
|
+
import { EventAction } from './event-action';
|
|
12
|
+
import { generateEventActionsTriggerSQL } from '../triggers';
|
|
13
|
+
|
|
14
|
+
export { EventAction } from './event-action';
|
|
15
|
+
|
|
16
|
+
/** All events-owned models, for the app to spread into its db:generate model list. */
|
|
17
|
+
export const eventModels = [EventAction];
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The events Module: the event_actions table + its self-notify function/trigger. The
|
|
21
|
+
* per-app-table event triggers are the app's contribution (`generateTriggerSQL(tables)`).
|
|
22
|
+
*/
|
|
23
|
+
export const eventModule = defineModule({
|
|
24
|
+
models: eventModels,
|
|
25
|
+
functions: generateEventActionsTriggerSQL,
|
|
26
|
+
});
|
package/src/schema.ts
CHANGED
|
@@ -21,8 +21,9 @@ import {
|
|
|
21
21
|
index,
|
|
22
22
|
uniqueIndex,
|
|
23
23
|
jsonb,
|
|
24
|
-
boolean,
|
|
25
24
|
} from 'drizzle-orm/pg-core';
|
|
25
|
+
import { toDrizzleTable } from '@everystack/model/drizzle';
|
|
26
|
+
import { EventAction } from './models/event-action';
|
|
26
27
|
|
|
27
28
|
const authSchema = pgSchema('auth');
|
|
28
29
|
|
|
@@ -101,40 +102,9 @@ export const sessions = authSchema.table(
|
|
|
101
102
|
// EVENT ACTIONS (ops.event_actions — data-driven event routing)
|
|
102
103
|
// =============================================================================
|
|
103
104
|
|
|
104
|
-
const opsSchema = pgSchema('ops');
|
|
105
|
-
|
|
106
105
|
/**
|
|
107
|
-
* Event actions define what happens when a database event occurs.
|
|
108
|
-
*
|
|
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
|
|
106
|
+
* Event actions define what happens when a database event occurs. DERIVED from the
|
|
107
|
+
* package-owned Model (`./models/event-action`) — edit the Model, not this. The runtime
|
|
108
|
+
* drizzle table the listener queries.
|
|
115
109
|
*/
|
|
116
|
-
export const eventActions =
|
|
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
|
-
);
|
|
110
|
+
export const eventActions = toDrizzleTable(EventAction);
|
package/src/triggers.ts
CHANGED
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
const NOTIFY_FUNCTION = `\
|
|
2
2
|
CREATE OR REPLACE FUNCTION everystack_notify() RETURNS trigger AS $$
|
|
3
|
+
DECLARE
|
|
4
|
+
row_id text;
|
|
3
5
|
BEGIN
|
|
6
|
+
-- A composite-key table (e.g. likes (user_id, post_id), follows (follower_id, following_id))
|
|
7
|
+
-- has no \`id\` column, so NEW.id raises undefined_column — emit a NULL id rather than fail
|
|
8
|
+
-- the whole write. Without this guard, any INSERT/UPDATE/DELETE on such a table errors.
|
|
9
|
+
BEGIN
|
|
10
|
+
row_id := COALESCE(NEW.id, OLD.id);
|
|
11
|
+
EXCEPTION WHEN undefined_column THEN
|
|
12
|
+
row_id := NULL;
|
|
13
|
+
END;
|
|
4
14
|
PERFORM pg_notify(
|
|
5
15
|
'events',
|
|
6
16
|
json_build_object(
|
|
7
17
|
'schema', TG_TABLE_SCHEMA,
|
|
8
18
|
'table', TG_TABLE_NAME,
|
|
9
19
|
'op', lower(TG_OP),
|
|
10
|
-
'id',
|
|
20
|
+
'id', row_id
|
|
11
21
|
)::text
|
|
12
22
|
);
|
|
13
23
|
RETURN COALESCE(NEW, OLD);
|