@checkstack/notification-backend 0.2.1 → 1.0.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/CHANGELOG.md +164 -0
- package/drizzle/0005_material_mauler.sql +3 -0
- package/drizzle/0006_chubby_gladiator.sql +46 -0
- package/drizzle/meta/0005_snapshot.json +237 -0
- package/drizzle/meta/0006_snapshot.json +532 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +10 -9
- package/src/router.ts +605 -97
- package/src/schema.ts +155 -14
- package/src/subscription-engine.ts +257 -0
package/src/schema.ts
CHANGED
|
@@ -6,23 +6,54 @@ import {
|
|
|
6
6
|
timestamp,
|
|
7
7
|
jsonb,
|
|
8
8
|
primaryKey,
|
|
9
|
+
index,
|
|
9
10
|
} from "drizzle-orm/pg-core";
|
|
10
|
-
import type {
|
|
11
|
+
import type {
|
|
12
|
+
NotificationAction,
|
|
13
|
+
NotificationSubject,
|
|
14
|
+
} from "@checkstack/notification-common";
|
|
11
15
|
|
|
12
16
|
// User notifications table
|
|
13
|
-
export const notifications = pgTable(
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
17
|
+
export const notifications = pgTable(
|
|
18
|
+
"notifications",
|
|
19
|
+
{
|
|
20
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
21
|
+
userId: text("user_id").notNull(), // No FK - cross-schema limitation
|
|
22
|
+
title: text("title").notNull(),
|
|
23
|
+
/** Notification body content (supports markdown) */
|
|
24
|
+
body: text("body").notNull(),
|
|
25
|
+
/** Single primary action button */
|
|
26
|
+
action: jsonb("action").$type<NotificationAction | null>(),
|
|
27
|
+
importance: text("importance").notNull().default("info"), // 'info' | 'warning' | 'critical'
|
|
28
|
+
isRead: boolean("is_read").notNull().default(false),
|
|
29
|
+
/**
|
|
30
|
+
* Collapse key shared by related notifications. Notifications with the
|
|
31
|
+
* same (userId, collapseKey) collapse into one card on the frontend.
|
|
32
|
+
* Examples: "incident.incident.<id>", "healthcheck.healthcheck.<id>".
|
|
33
|
+
* Distinct from `notification_groups.id` (which is a subscription target).
|
|
34
|
+
* Kept as `group_id` at the column level for backwards compatibility; the
|
|
35
|
+
* TypeScript field name reflects its true purpose.
|
|
36
|
+
*/
|
|
37
|
+
collapseKey: text("group_id"),
|
|
38
|
+
/**
|
|
39
|
+
* Affected entities. Each renders as a chip (in-app) or a link (in
|
|
40
|
+
* notification strategies). When `action` is null, these are the only
|
|
41
|
+
* navigation paths; when present, they supplement the primary CTA.
|
|
42
|
+
*/
|
|
43
|
+
subjects: jsonb("subjects").$type<NotificationSubject[] | null>(),
|
|
44
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
45
|
+
},
|
|
46
|
+
(t) => ({
|
|
47
|
+
userCollapseIdx: index("notifications_user_collapse_idx").on(
|
|
48
|
+
t.userId,
|
|
49
|
+
t.collapseKey,
|
|
50
|
+
),
|
|
51
|
+
userCreatedIdx: index("notifications_user_created_idx").on(
|
|
52
|
+
t.userId,
|
|
53
|
+
t.createdAt,
|
|
54
|
+
),
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
26
57
|
|
|
27
58
|
// Notification groups (created by plugins)
|
|
28
59
|
// ID is namespaced: "pluginId.groupName"
|
|
@@ -52,3 +83,113 @@ export const notificationSubscriptions = pgTable(
|
|
|
52
83
|
// Note: User notification preferences are now stored via ConfigService
|
|
53
84
|
// using the user-pref.{userId}.{strategyId} pattern for automatic
|
|
54
85
|
// secret encryption of OAuth tokens.
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Notification target type registry. Owned by a plugin (catalog owns
|
|
89
|
+
* `catalog.system` and `catalog.group`). The owner pushes resource
|
|
90
|
+
* lifecycle events to notification-backend; the backend uses this
|
|
91
|
+
* registry to route subscription specs to known resources and to walk
|
|
92
|
+
* parent inheritance during dispatch.
|
|
93
|
+
*/
|
|
94
|
+
export const notificationTargets = pgTable("notification_targets", {
|
|
95
|
+
targetTypeId: text("target_type_id").primaryKey(),
|
|
96
|
+
ownerPlugin: text("owner_plugin").notNull(),
|
|
97
|
+
resourceKind: text("resource_kind").notNull(),
|
|
98
|
+
parentTargetTypeId: text("parent_target_type_id"),
|
|
99
|
+
/**
|
|
100
|
+
* Template the backend substitutes `{resourceKey}` into to compute
|
|
101
|
+
* the legacy groupId for migration. Null means no legacy migration.
|
|
102
|
+
*/
|
|
103
|
+
legacyGroupIdTemplate: text("legacy_group_id_template"),
|
|
104
|
+
registeredAt: timestamp("registered_at").defaultNow().notNull(),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Resources of a target type. Pushed by the target owner whenever a
|
|
109
|
+
* resource is created or renamed; removed on deletion. notification-
|
|
110
|
+
* backend keeps a notification group materialized for every
|
|
111
|
+
* (registered spec × resource) pair derived from this table.
|
|
112
|
+
*/
|
|
113
|
+
export const notificationResources = pgTable(
|
|
114
|
+
"notification_resources",
|
|
115
|
+
{
|
|
116
|
+
targetTypeId: text("target_type_id")
|
|
117
|
+
.notNull()
|
|
118
|
+
.references(() => notificationTargets.targetTypeId, {
|
|
119
|
+
onDelete: "cascade",
|
|
120
|
+
}),
|
|
121
|
+
resourceKey: text("resource_key").notNull(),
|
|
122
|
+
displayLabel: text("display_label").notNull(),
|
|
123
|
+
upsertedAt: timestamp("upserted_at").defaultNow().notNull(),
|
|
124
|
+
},
|
|
125
|
+
(t) => ({
|
|
126
|
+
pk: primaryKey({ columns: [t.targetTypeId, t.resourceKey] }),
|
|
127
|
+
}),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Parent edges between resources. Populated by target owners via
|
|
132
|
+
* `setNotificationResourceParents` (catalog calls it on
|
|
133
|
+
* addSystemToGroup / removeSystemFromGroup / system create). Read at
|
|
134
|
+
* dispatch time to compute inherited group ids.
|
|
135
|
+
*/
|
|
136
|
+
export const notificationResourceParents = pgTable(
|
|
137
|
+
"notification_resource_parents",
|
|
138
|
+
{
|
|
139
|
+
childTargetTypeId: text("child_target_type_id").notNull(),
|
|
140
|
+
childResourceKey: text("child_resource_key").notNull(),
|
|
141
|
+
parentTargetTypeId: text("parent_target_type_id").notNull(),
|
|
142
|
+
parentResourceKey: text("parent_resource_key").notNull(),
|
|
143
|
+
},
|
|
144
|
+
(t) => ({
|
|
145
|
+
pk: primaryKey({
|
|
146
|
+
columns: [
|
|
147
|
+
t.childTargetTypeId,
|
|
148
|
+
t.childResourceKey,
|
|
149
|
+
t.parentTargetTypeId,
|
|
150
|
+
t.parentResourceKey,
|
|
151
|
+
],
|
|
152
|
+
}),
|
|
153
|
+
childIdx: index("notification_resource_parents_child_idx").on(
|
|
154
|
+
t.childTargetTypeId,
|
|
155
|
+
t.childResourceKey,
|
|
156
|
+
),
|
|
157
|
+
}),
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Subscription-spec registry. Every dispatch must reference a specId
|
|
162
|
+
* that exists here, owned by the calling plugin. notification-backend
|
|
163
|
+
* provisions one notification group per (spec × resource) pair where
|
|
164
|
+
* `resource.targetTypeId == spec.targetTypeId`.
|
|
165
|
+
*/
|
|
166
|
+
export const subscriptionSpecs = pgTable("subscription_specs", {
|
|
167
|
+
specId: text("spec_id").primaryKey(),
|
|
168
|
+
ownerPlugin: text("owner_plugin").notNull(),
|
|
169
|
+
localId: text("local_id").notNull(),
|
|
170
|
+
targetTypeId: text("target_type_id")
|
|
171
|
+
.notNull()
|
|
172
|
+
.references(() => notificationTargets.targetTypeId),
|
|
173
|
+
displayTitle: text("display_title").notNull(),
|
|
174
|
+
displayDescription: text("display_description").notNull(),
|
|
175
|
+
displayIconName: text("display_icon_name"),
|
|
176
|
+
registeredAt: timestamp("registered_at").defaultNow().notNull(),
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Tracks which legacy notification groups have already been migrated
|
|
181
|
+
* for a given (spec × resource) pair. Set after the one-shot seeding
|
|
182
|
+
* runs so re-registering a spec doesn't re-seed (which would silently
|
|
183
|
+
* resubscribe users who deliberately unsubscribed).
|
|
184
|
+
*/
|
|
185
|
+
export const subscriptionMigrations = pgTable(
|
|
186
|
+
"subscription_migrations",
|
|
187
|
+
{
|
|
188
|
+
specId: text("spec_id").notNull(),
|
|
189
|
+
resourceKey: text("resource_key").notNull(),
|
|
190
|
+
migratedAt: timestamp("migrated_at").defaultNow().notNull(),
|
|
191
|
+
},
|
|
192
|
+
(t) => ({
|
|
193
|
+
pk: primaryKey({ columns: [t.specId, t.resourceKey] }),
|
|
194
|
+
}),
|
|
195
|
+
);
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { and, eq, inArray } from "drizzle-orm";
|
|
2
|
+
import type { SafeDatabase } from "@checkstack/backend-api";
|
|
3
|
+
import * as schema from "./schema";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Server-local logic shared by every spec / target / resource / dispatch
|
|
7
|
+
* RPC. Centralizes the auto-provisioning, parent-edge lookup, and
|
|
8
|
+
* group-id derivation so plugins never compute groupIds themselves.
|
|
9
|
+
*
|
|
10
|
+
* Convention:
|
|
11
|
+
* notification group id = `<spec.ownerPlugin>.<spec.localId>.<resourceKey>`
|
|
12
|
+
*
|
|
13
|
+
* That is the *only* way notification groups for spec subscriptions are
|
|
14
|
+
* named; there is no escape hatch.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
type Db = SafeDatabase<typeof schema>;
|
|
18
|
+
|
|
19
|
+
export function deriveGroupId(opts: {
|
|
20
|
+
ownerPlugin: string;
|
|
21
|
+
localId: string;
|
|
22
|
+
resourceKey: string;
|
|
23
|
+
}): string {
|
|
24
|
+
return `${opts.ownerPlugin}.${opts.localId}.${opts.resourceKey}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Materialize a single notification group for a (spec × resource) pair.
|
|
29
|
+
* Idempotent — relies on the existing `notification_groups` upsert.
|
|
30
|
+
*/
|
|
31
|
+
export async function ensureGroupForSpecAndResource(opts: {
|
|
32
|
+
db: Db;
|
|
33
|
+
spec: typeof schema.subscriptionSpecs.$inferSelect;
|
|
34
|
+
resource: typeof schema.notificationResources.$inferSelect;
|
|
35
|
+
}): Promise<{ groupId: string; created: boolean }> {
|
|
36
|
+
const { db, spec, resource } = opts;
|
|
37
|
+
const groupId = deriveGroupId({
|
|
38
|
+
ownerPlugin: spec.ownerPlugin,
|
|
39
|
+
localId: spec.localId,
|
|
40
|
+
resourceKey: resource.resourceKey,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Group naming follows `<DisplayLabel> <SpecTitle>` ("API Gateway
|
|
44
|
+
// Anomaly Detection") so audit lists and the bell render labels that
|
|
45
|
+
// make sense without context.
|
|
46
|
+
const groupName = `${resource.displayLabel} · ${spec.displayTitle}`;
|
|
47
|
+
const description = spec.displayDescription;
|
|
48
|
+
|
|
49
|
+
const existing = await db
|
|
50
|
+
.select({ id: schema.notificationGroups.id })
|
|
51
|
+
.from(schema.notificationGroups)
|
|
52
|
+
.where(eq(schema.notificationGroups.id, groupId))
|
|
53
|
+
.limit(1);
|
|
54
|
+
const created = existing.length === 0;
|
|
55
|
+
|
|
56
|
+
await db
|
|
57
|
+
.insert(schema.notificationGroups)
|
|
58
|
+
.values({
|
|
59
|
+
id: groupId,
|
|
60
|
+
name: groupName,
|
|
61
|
+
description,
|
|
62
|
+
ownerPlugin: spec.ownerPlugin,
|
|
63
|
+
})
|
|
64
|
+
.onConflictDoUpdate({
|
|
65
|
+
target: [schema.notificationGroups.id],
|
|
66
|
+
set: { name: groupName, description },
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return { groupId, created };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function deleteGroupForSpecAndResource(opts: {
|
|
73
|
+
db: Db;
|
|
74
|
+
spec: { ownerPlugin: string; localId: string };
|
|
75
|
+
resourceKey: string;
|
|
76
|
+
}): Promise<void> {
|
|
77
|
+
const { db, spec, resourceKey } = opts;
|
|
78
|
+
const groupId = deriveGroupId({
|
|
79
|
+
ownerPlugin: spec.ownerPlugin,
|
|
80
|
+
localId: spec.localId,
|
|
81
|
+
resourceKey,
|
|
82
|
+
});
|
|
83
|
+
await db
|
|
84
|
+
.delete(schema.notificationGroups)
|
|
85
|
+
.where(eq(schema.notificationGroups.id, groupId));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Provision groups for a newly-registered spec across every existing
|
|
90
|
+
* resource of its target. Also handles the one-shot legacy seeding when
|
|
91
|
+
* the target declared a legacy migration: subscribers of the legacy
|
|
92
|
+
* group are bulk-copied onto the new group, exactly once per
|
|
93
|
+
* (spec × resource), tracked in `subscription_migrations`.
|
|
94
|
+
*/
|
|
95
|
+
export async function provisionGroupsForSpec(opts: {
|
|
96
|
+
db: Db;
|
|
97
|
+
spec: typeof schema.subscriptionSpecs.$inferSelect;
|
|
98
|
+
legacyGroupIdFor?: (resourceKey: string) => string;
|
|
99
|
+
}): Promise<void> {
|
|
100
|
+
const { db, spec, legacyGroupIdFor } = opts;
|
|
101
|
+
|
|
102
|
+
const resources = await db
|
|
103
|
+
.select()
|
|
104
|
+
.from(schema.notificationResources)
|
|
105
|
+
.where(eq(schema.notificationResources.targetTypeId, spec.targetTypeId));
|
|
106
|
+
|
|
107
|
+
for (const resource of resources) {
|
|
108
|
+
const { groupId } = await ensureGroupForSpecAndResource({
|
|
109
|
+
db,
|
|
110
|
+
spec,
|
|
111
|
+
resource,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
if (!legacyGroupIdFor) continue;
|
|
115
|
+
|
|
116
|
+
const [migrated] = await db
|
|
117
|
+
.select()
|
|
118
|
+
.from(schema.subscriptionMigrations)
|
|
119
|
+
.where(
|
|
120
|
+
and(
|
|
121
|
+
eq(schema.subscriptionMigrations.specId, spec.specId),
|
|
122
|
+
eq(schema.subscriptionMigrations.resourceKey, resource.resourceKey),
|
|
123
|
+
),
|
|
124
|
+
)
|
|
125
|
+
.limit(1);
|
|
126
|
+
if (migrated) continue;
|
|
127
|
+
|
|
128
|
+
const legacyGroupId = legacyGroupIdFor(resource.resourceKey);
|
|
129
|
+
const legacySubs = await db
|
|
130
|
+
.select({ userId: schema.notificationSubscriptions.userId })
|
|
131
|
+
.from(schema.notificationSubscriptions)
|
|
132
|
+
.where(eq(schema.notificationSubscriptions.groupId, legacyGroupId));
|
|
133
|
+
|
|
134
|
+
if (legacySubs.length > 0) {
|
|
135
|
+
await db
|
|
136
|
+
.insert(schema.notificationSubscriptions)
|
|
137
|
+
.values(
|
|
138
|
+
legacySubs.map((s) => ({ userId: s.userId, groupId })),
|
|
139
|
+
)
|
|
140
|
+
.onConflictDoNothing();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
await db
|
|
144
|
+
.insert(schema.subscriptionMigrations)
|
|
145
|
+
.values({ specId: spec.specId, resourceKey: resource.resourceKey })
|
|
146
|
+
.onConflictDoNothing();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Provision groups for a newly-upserted resource across every spec
|
|
152
|
+
* registered against its target. Used both on first push and on rename
|
|
153
|
+
* (rename re-runs the upsert which refreshes group display labels).
|
|
154
|
+
*/
|
|
155
|
+
export async function provisionGroupsForResource(opts: {
|
|
156
|
+
db: Db;
|
|
157
|
+
targetTypeId: string;
|
|
158
|
+
resource: typeof schema.notificationResources.$inferSelect;
|
|
159
|
+
}): Promise<void> {
|
|
160
|
+
const { db, targetTypeId, resource } = opts;
|
|
161
|
+
const specs = await db
|
|
162
|
+
.select()
|
|
163
|
+
.from(schema.subscriptionSpecs)
|
|
164
|
+
.where(eq(schema.subscriptionSpecs.targetTypeId, targetTypeId));
|
|
165
|
+
for (const spec of specs) {
|
|
166
|
+
await ensureGroupForSpecAndResource({ db, spec, resource });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Tear down all groups derived from a deleted resource across every
|
|
172
|
+
* matching spec. Returns how many were dropped.
|
|
173
|
+
*/
|
|
174
|
+
export async function teardownGroupsForResource(opts: {
|
|
175
|
+
db: Db;
|
|
176
|
+
targetTypeId: string;
|
|
177
|
+
resourceKey: string;
|
|
178
|
+
}): Promise<number> {
|
|
179
|
+
const { db, targetTypeId, resourceKey } = opts;
|
|
180
|
+
const specs = await db
|
|
181
|
+
.select()
|
|
182
|
+
.from(schema.subscriptionSpecs)
|
|
183
|
+
.where(eq(schema.subscriptionSpecs.targetTypeId, targetTypeId));
|
|
184
|
+
|
|
185
|
+
if (specs.length === 0) return 0;
|
|
186
|
+
|
|
187
|
+
const groupIds = specs.map((spec) =>
|
|
188
|
+
deriveGroupId({
|
|
189
|
+
ownerPlugin: spec.ownerPlugin,
|
|
190
|
+
localId: spec.localId,
|
|
191
|
+
resourceKey,
|
|
192
|
+
}),
|
|
193
|
+
);
|
|
194
|
+
const deleted = await db
|
|
195
|
+
.delete(schema.notificationGroups)
|
|
196
|
+
.where(inArray(schema.notificationGroups.id, groupIds))
|
|
197
|
+
.returning({ id: schema.notificationGroups.id });
|
|
198
|
+
return deleted.length;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* For dispatch: given a child (target, resourceKey), find every parent
|
|
203
|
+
* resource via `notification_resource_parents`, then map each parent to
|
|
204
|
+
* the same plugin's spec(s) whose `targetTypeId` equals the parent's
|
|
205
|
+
* target type. Inherited group ids are derived from those parent specs.
|
|
206
|
+
*/
|
|
207
|
+
export async function resolveInheritedGroupIds(opts: {
|
|
208
|
+
db: Db;
|
|
209
|
+
spec: typeof schema.subscriptionSpecs.$inferSelect;
|
|
210
|
+
resourceKey: string;
|
|
211
|
+
}): Promise<string[]> {
|
|
212
|
+
const { db, spec, resourceKey } = opts;
|
|
213
|
+
|
|
214
|
+
const parents = await db
|
|
215
|
+
.select()
|
|
216
|
+
.from(schema.notificationResourceParents)
|
|
217
|
+
.where(
|
|
218
|
+
and(
|
|
219
|
+
eq(
|
|
220
|
+
schema.notificationResourceParents.childTargetTypeId,
|
|
221
|
+
spec.targetTypeId,
|
|
222
|
+
),
|
|
223
|
+
eq(schema.notificationResourceParents.childResourceKey, resourceKey),
|
|
224
|
+
),
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
if (parents.length === 0) return [];
|
|
228
|
+
|
|
229
|
+
const parentTargetTypeIds = [
|
|
230
|
+
...new Set(parents.map((p) => p.parentTargetTypeId)),
|
|
231
|
+
];
|
|
232
|
+
|
|
233
|
+
const parentSpecs = await db
|
|
234
|
+
.select()
|
|
235
|
+
.from(schema.subscriptionSpecs)
|
|
236
|
+
.where(
|
|
237
|
+
and(
|
|
238
|
+
eq(schema.subscriptionSpecs.ownerPlugin, spec.ownerPlugin),
|
|
239
|
+
inArray(schema.subscriptionSpecs.targetTypeId, parentTargetTypeIds),
|
|
240
|
+
),
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
const out: string[] = [];
|
|
244
|
+
for (const parent of parents) {
|
|
245
|
+
for (const parentSpec of parentSpecs) {
|
|
246
|
+
if (parentSpec.targetTypeId !== parent.parentTargetTypeId) continue;
|
|
247
|
+
out.push(
|
|
248
|
+
deriveGroupId({
|
|
249
|
+
ownerPlugin: parentSpec.ownerPlugin,
|
|
250
|
+
localId: parentSpec.localId,
|
|
251
|
+
resourceKey: parent.parentResourceKey,
|
|
252
|
+
}),
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return out;
|
|
257
|
+
}
|