@checkstack/catalog-backend 0.0.2

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 ADDED
@@ -0,0 +1,68 @@
1
+ # @checkstack/catalog-backend
2
+
3
+ ## 0.0.2
4
+
5
+ ### Patch Changes
6
+
7
+ - d20d274: Initial release of all @checkstack packages. Rebranded from Checkmate to Checkstack with new npm organization @checkstack and domain checkstack.dev.
8
+ - Updated dependencies [d20d274]
9
+ - @checkstack/backend-api@0.0.2
10
+ - @checkstack/catalog-common@0.0.2
11
+ - @checkstack/command-backend@0.0.2
12
+ - @checkstack/common@0.0.2
13
+ - @checkstack/notification-common@0.0.2
14
+
15
+ ## 0.1.0
16
+
17
+ ### Minor Changes
18
+
19
+ - a65e002: Add command palette commands and deep-linking support
20
+
21
+ **Backend Changes:**
22
+
23
+ - `healthcheck-backend`: Add "Manage Health Checks" (⇧⌘H) and "Create Health Check" commands
24
+ - `catalog-backend`: Add "Manage Systems" (⇧⌘S) and "Create System" commands
25
+ - `integration-backend`: Add "Manage Integrations" (⇧⌘G), "Create Integration Subscription", and "View Integration Logs" commands
26
+ - `auth-backend`: Add "Manage Users" (⇧⌘U), "Create User", "Manage Roles", and "Manage Applications" commands
27
+ - `command-backend`: Auto-cleanup command registrations when plugins are deregistered
28
+
29
+ **Frontend Changes:**
30
+
31
+ - `HealthCheckConfigPage`: Handle `?action=create` URL parameter
32
+ - `CatalogConfigPage`: Handle `?action=create` URL parameter
33
+ - `IntegrationsPage`: Handle `?action=create` URL parameter
34
+ - `AuthSettingsPage`: Handle `?tab=` and `?action=create` URL parameters
35
+
36
+ ### Patch Changes
37
+
38
+ - Updated dependencies [b4eb432]
39
+ - Updated dependencies [a65e002]
40
+ - Updated dependencies [a65e002]
41
+ - @checkstack/backend-api@1.1.0
42
+ - @checkstack/common@0.2.0
43
+ - @checkstack/command-backend@0.1.0
44
+ - @checkstack/catalog-common@0.1.2
45
+ - @checkstack/notification-common@0.1.1
46
+
47
+ ## 0.0.3
48
+
49
+ ### Patch Changes
50
+
51
+ - @checkstack/catalog-common@0.1.1
52
+
53
+ ## 0.0.2
54
+
55
+ ### Patch Changes
56
+
57
+ - Updated dependencies [ffc28f6]
58
+ - Updated dependencies [4dd644d]
59
+ - Updated dependencies [71275dd]
60
+ - Updated dependencies [ae19ff6]
61
+ - Updated dependencies [b55fae6]
62
+ - Updated dependencies [b354ab3]
63
+ - Updated dependencies [81f3f85]
64
+ - @checkstack/common@0.1.0
65
+ - @checkstack/backend-api@1.0.0
66
+ - @checkstack/catalog-common@0.1.0
67
+ - @checkstack/notification-common@0.1.0
68
+ - @checkstack/command-backend@0.0.2
@@ -0,0 +1,35 @@
1
+ CREATE TABLE "groups" (
2
+ "id" text PRIMARY KEY NOT NULL,
3
+ "name" text NOT NULL,
4
+ "metadata" json DEFAULT '{}'::json,
5
+ "created_at" timestamp DEFAULT now() NOT NULL,
6
+ "updated_at" timestamp DEFAULT now() NOT NULL
7
+ );
8
+ --> statement-breakpoint
9
+ CREATE TABLE "systems" (
10
+ "id" text PRIMARY KEY NOT NULL,
11
+ "name" text NOT NULL,
12
+ "description" text,
13
+ "owner" text,
14
+ "metadata" json DEFAULT '{}'::json,
15
+ "created_at" timestamp DEFAULT now() NOT NULL,
16
+ "updated_at" timestamp DEFAULT now() NOT NULL
17
+ );
18
+ --> statement-breakpoint
19
+ CREATE TABLE "systems_groups" (
20
+ "system_id" text NOT NULL,
21
+ "group_id" text NOT NULL,
22
+ CONSTRAINT "systems_groups_system_id_group_id_pk" PRIMARY KEY("system_id","group_id")
23
+ );
24
+ --> statement-breakpoint
25
+ CREATE TABLE "views" (
26
+ "id" text PRIMARY KEY NOT NULL,
27
+ "name" text NOT NULL,
28
+ "description" text,
29
+ "configuration" json DEFAULT '[]'::json NOT NULL,
30
+ "created_at" timestamp DEFAULT now() NOT NULL,
31
+ "updated_at" timestamp DEFAULT now() NOT NULL
32
+ );
33
+ --> statement-breakpoint
34
+ ALTER TABLE "systems_groups" ADD CONSTRAINT "systems_groups_system_id_systems_id_fk" FOREIGN KEY ("system_id") REFERENCES "systems"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
35
+ ALTER TABLE "systems_groups" ADD CONSTRAINT "systems_groups_group_id_groups_id_fk" FOREIGN KEY ("group_id") REFERENCES "groups"("id") ON DELETE cascade ON UPDATE no action;
@@ -0,0 +1,235 @@
1
+ {
2
+ "id": "79e86bc7-76a2-429f-815c-2d6c263aab8f",
3
+ "prevId": "00000000-0000-0000-0000-000000000000",
4
+ "version": "7",
5
+ "dialect": "postgresql",
6
+ "tables": {
7
+ "public.groups": {
8
+ "name": "groups",
9
+ "schema": "",
10
+ "columns": {
11
+ "id": {
12
+ "name": "id",
13
+ "type": "text",
14
+ "primaryKey": true,
15
+ "notNull": true
16
+ },
17
+ "name": {
18
+ "name": "name",
19
+ "type": "text",
20
+ "primaryKey": false,
21
+ "notNull": true
22
+ },
23
+ "metadata": {
24
+ "name": "metadata",
25
+ "type": "json",
26
+ "primaryKey": false,
27
+ "notNull": false,
28
+ "default": "'{}'::json"
29
+ },
30
+ "created_at": {
31
+ "name": "created_at",
32
+ "type": "timestamp",
33
+ "primaryKey": false,
34
+ "notNull": true,
35
+ "default": "now()"
36
+ },
37
+ "updated_at": {
38
+ "name": "updated_at",
39
+ "type": "timestamp",
40
+ "primaryKey": false,
41
+ "notNull": true,
42
+ "default": "now()"
43
+ }
44
+ },
45
+ "indexes": {},
46
+ "foreignKeys": {},
47
+ "compositePrimaryKeys": {},
48
+ "uniqueConstraints": {},
49
+ "policies": {},
50
+ "checkConstraints": {},
51
+ "isRLSEnabled": false
52
+ },
53
+ "public.systems": {
54
+ "name": "systems",
55
+ "schema": "",
56
+ "columns": {
57
+ "id": {
58
+ "name": "id",
59
+ "type": "text",
60
+ "primaryKey": true,
61
+ "notNull": true
62
+ },
63
+ "name": {
64
+ "name": "name",
65
+ "type": "text",
66
+ "primaryKey": false,
67
+ "notNull": true
68
+ },
69
+ "description": {
70
+ "name": "description",
71
+ "type": "text",
72
+ "primaryKey": false,
73
+ "notNull": false
74
+ },
75
+ "owner": {
76
+ "name": "owner",
77
+ "type": "text",
78
+ "primaryKey": false,
79
+ "notNull": false
80
+ },
81
+ "metadata": {
82
+ "name": "metadata",
83
+ "type": "json",
84
+ "primaryKey": false,
85
+ "notNull": false,
86
+ "default": "'{}'::json"
87
+ },
88
+ "created_at": {
89
+ "name": "created_at",
90
+ "type": "timestamp",
91
+ "primaryKey": false,
92
+ "notNull": true,
93
+ "default": "now()"
94
+ },
95
+ "updated_at": {
96
+ "name": "updated_at",
97
+ "type": "timestamp",
98
+ "primaryKey": false,
99
+ "notNull": true,
100
+ "default": "now()"
101
+ }
102
+ },
103
+ "indexes": {},
104
+ "foreignKeys": {},
105
+ "compositePrimaryKeys": {},
106
+ "uniqueConstraints": {},
107
+ "policies": {},
108
+ "checkConstraints": {},
109
+ "isRLSEnabled": false
110
+ },
111
+ "public.systems_groups": {
112
+ "name": "systems_groups",
113
+ "schema": "",
114
+ "columns": {
115
+ "system_id": {
116
+ "name": "system_id",
117
+ "type": "text",
118
+ "primaryKey": false,
119
+ "notNull": true
120
+ },
121
+ "group_id": {
122
+ "name": "group_id",
123
+ "type": "text",
124
+ "primaryKey": false,
125
+ "notNull": true
126
+ }
127
+ },
128
+ "indexes": {},
129
+ "foreignKeys": {
130
+ "systems_groups_system_id_systems_id_fk": {
131
+ "name": "systems_groups_system_id_systems_id_fk",
132
+ "tableFrom": "systems_groups",
133
+ "tableTo": "systems",
134
+ "columnsFrom": [
135
+ "system_id"
136
+ ],
137
+ "columnsTo": [
138
+ "id"
139
+ ],
140
+ "onDelete": "cascade",
141
+ "onUpdate": "no action"
142
+ },
143
+ "systems_groups_group_id_groups_id_fk": {
144
+ "name": "systems_groups_group_id_groups_id_fk",
145
+ "tableFrom": "systems_groups",
146
+ "tableTo": "groups",
147
+ "columnsFrom": [
148
+ "group_id"
149
+ ],
150
+ "columnsTo": [
151
+ "id"
152
+ ],
153
+ "onDelete": "cascade",
154
+ "onUpdate": "no action"
155
+ }
156
+ },
157
+ "compositePrimaryKeys": {
158
+ "systems_groups_system_id_group_id_pk": {
159
+ "name": "systems_groups_system_id_group_id_pk",
160
+ "columns": [
161
+ "system_id",
162
+ "group_id"
163
+ ]
164
+ }
165
+ },
166
+ "uniqueConstraints": {},
167
+ "policies": {},
168
+ "checkConstraints": {},
169
+ "isRLSEnabled": false
170
+ },
171
+ "public.views": {
172
+ "name": "views",
173
+ "schema": "",
174
+ "columns": {
175
+ "id": {
176
+ "name": "id",
177
+ "type": "text",
178
+ "primaryKey": true,
179
+ "notNull": true
180
+ },
181
+ "name": {
182
+ "name": "name",
183
+ "type": "text",
184
+ "primaryKey": false,
185
+ "notNull": true
186
+ },
187
+ "description": {
188
+ "name": "description",
189
+ "type": "text",
190
+ "primaryKey": false,
191
+ "notNull": false
192
+ },
193
+ "configuration": {
194
+ "name": "configuration",
195
+ "type": "json",
196
+ "primaryKey": false,
197
+ "notNull": true,
198
+ "default": "'[]'::json"
199
+ },
200
+ "created_at": {
201
+ "name": "created_at",
202
+ "type": "timestamp",
203
+ "primaryKey": false,
204
+ "notNull": true,
205
+ "default": "now()"
206
+ },
207
+ "updated_at": {
208
+ "name": "updated_at",
209
+ "type": "timestamp",
210
+ "primaryKey": false,
211
+ "notNull": true,
212
+ "default": "now()"
213
+ }
214
+ },
215
+ "indexes": {},
216
+ "foreignKeys": {},
217
+ "compositePrimaryKeys": {},
218
+ "uniqueConstraints": {},
219
+ "policies": {},
220
+ "checkConstraints": {},
221
+ "isRLSEnabled": false
222
+ }
223
+ },
224
+ "enums": {},
225
+ "schemas": {},
226
+ "sequences": {},
227
+ "roles": {},
228
+ "policies": {},
229
+ "views": {},
230
+ "_meta": {
231
+ "columns": {},
232
+ "schemas": {},
233
+ "tables": {}
234
+ }
235
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "version": "7",
3
+ "dialect": "postgresql",
4
+ "entries": [
5
+ {
6
+ "idx": 0,
7
+ "version": "7",
8
+ "when": 1767319091249,
9
+ "tag": "0000_purple_doomsday",
10
+ "breakpoints": true
11
+ }
12
+ ]
13
+ }
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from "drizzle-kit";
2
+
3
+ export default defineConfig({
4
+ schema: "./src/schema.ts",
5
+ out: "./drizzle",
6
+ dialect: "postgresql",
7
+ });
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@checkstack/catalog-backend",
3
+ "version": "0.0.2",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "scripts": {
7
+ "typecheck": "tsc --noEmit",
8
+ "generate": "drizzle-kit generate",
9
+ "lint": "bun run lint:code",
10
+ "lint:code": "eslint . --max-warnings 0"
11
+ },
12
+ "dependencies": {
13
+ "@checkstack/backend-api": "workspace:*",
14
+ "@checkstack/catalog-common": "workspace:*",
15
+ "@checkstack/command-backend": "workspace:*",
16
+ "@checkstack/notification-common": "workspace:*",
17
+ "@orpc/server": "^1.13.2",
18
+ "drizzle-orm": "^0.45.1",
19
+ "hono": "^4.0.0",
20
+ "uuid": "^13.0.0",
21
+ "zod": "^4.2.1",
22
+ "@checkstack/common": "workspace:*"
23
+ },
24
+ "devDependencies": {
25
+ "@checkstack/drizzle-helper": "workspace:*",
26
+ "@checkstack/scripts": "workspace:*",
27
+ "@checkstack/tsconfig": "workspace:*",
28
+ "@types/bun": "^1.3.5",
29
+ "@types/node": "^20.0.0",
30
+ "@types/uuid": "^11.0.0",
31
+ "drizzle-kit": "^0.31.8",
32
+ "typescript": "^5.0.0"
33
+ }
34
+ }
package/src/hooks.ts ADDED
@@ -0,0 +1,24 @@
1
+ import { createHook } from "@checkstack/backend-api";
2
+
3
+ /**
4
+ * Catalog hooks for cross-plugin communication
5
+ */
6
+ export const catalogHooks = {
7
+ /**
8
+ * Emitted when a system is deleted.
9
+ * Plugins can subscribe (work-queue mode) to clean up related data.
10
+ */
11
+ systemDeleted: createHook<{
12
+ systemId: string;
13
+ systemName?: string;
14
+ }>("catalog.system.deleted"),
15
+
16
+ /**
17
+ * Emitted when a group is deleted.
18
+ * Plugins can subscribe (work-queue mode) to clean up related data.
19
+ */
20
+ groupDeleted: createHook<{
21
+ groupId: string;
22
+ groupName?: string;
23
+ }>("catalog.group.deleted"),
24
+ } as const;
package/src/index.ts ADDED
@@ -0,0 +1,164 @@
1
+ import { createBackendPlugin } from "@checkstack/backend-api";
2
+ import { type NodePgDatabase } from "drizzle-orm/node-postgres";
3
+ import { coreServices } from "@checkstack/backend-api";
4
+ import {
5
+ permissionList,
6
+ pluginMetadata,
7
+ catalogContract,
8
+ catalogRoutes,
9
+ permissions,
10
+ } from "@checkstack/catalog-common";
11
+ import { createCatalogRouter } from "./router";
12
+ import { NotificationApi } from "@checkstack/notification-common";
13
+ import { resolveRoute, type InferClient } from "@checkstack/common";
14
+ import { registerSearchProvider } from "@checkstack/command-backend";
15
+
16
+ // Database schema is still needed for types in creating the router
17
+ import * as schema from "./schema";
18
+
19
+ export let db: NodePgDatabase<typeof schema> | undefined;
20
+
21
+ // Export hooks for other plugins to subscribe to
22
+ export { catalogHooks } from "./hooks";
23
+
24
+ export default createBackendPlugin({
25
+ metadata: pluginMetadata,
26
+ register(env) {
27
+ env.registerPermissions(permissionList);
28
+
29
+ env.registerInit({
30
+ schema,
31
+ deps: {
32
+ rpc: coreServices.rpc,
33
+ rpcClient: coreServices.rpcClient,
34
+ logger: coreServices.logger,
35
+ },
36
+ // Phase 2: Register router only - no RPC calls to other plugins
37
+ init: async ({ database, rpc, rpcClient, logger }) => {
38
+ logger.debug("Initializing Catalog Backend...");
39
+
40
+ const typedDb = database as NodePgDatabase<typeof schema>;
41
+
42
+ // Get notification client for group management and sending notifications
43
+ const notificationClient = rpcClient.forPlugin(NotificationApi);
44
+
45
+ // Register oRPC router with notification client
46
+ const catalogRouter = createCatalogRouter({
47
+ database: typedDb,
48
+ notificationClient,
49
+ pluginId: pluginMetadata.pluginId,
50
+ });
51
+ rpc.registerRouter(catalogRouter, catalogContract);
52
+
53
+ // Register catalog systems as searchable in the command palette
54
+ registerSearchProvider({
55
+ pluginMetadata,
56
+ provider: {
57
+ id: "systems",
58
+ name: "Systems",
59
+ priority: 100, // High priority - systems are primary search target
60
+ search: async (query) => {
61
+ const systems = await typedDb.select().from(schema.systems);
62
+ const q = query.toLowerCase();
63
+
64
+ return systems
65
+ .filter(
66
+ (s) =>
67
+ !q ||
68
+ s.name.toLowerCase().includes(q) ||
69
+ s.description?.toLowerCase().includes(q)
70
+ )
71
+ .map((s) => ({
72
+ id: s.id,
73
+ type: "entity" as const,
74
+ title: s.name,
75
+ subtitle: s.description ?? undefined,
76
+ category: "Systems",
77
+ iconName: "Activity",
78
+ route: resolveRoute(catalogRoutes.routes.systemDetail, {
79
+ systemId: s.id,
80
+ }),
81
+ }));
82
+ },
83
+ },
84
+ commands: [
85
+ {
86
+ id: "create",
87
+ title: "Create System",
88
+ subtitle: "Add a new system to the catalog",
89
+ iconName: "Activity",
90
+ route:
91
+ resolveRoute(catalogRoutes.routes.config) + "?action=create",
92
+ requiredPermissions: [permissions.catalogManage],
93
+ },
94
+ {
95
+ id: "manage",
96
+ title: "Manage Systems",
97
+ subtitle: "Manage systems in the catalog",
98
+ iconName: "Activity",
99
+ shortcuts: ["meta+shift+s", "ctrl+shift+s"],
100
+ route: resolveRoute(catalogRoutes.routes.config),
101
+ requiredPermissions: [permissions.catalogManage],
102
+ },
103
+ ],
104
+ });
105
+
106
+ logger.debug("✅ Catalog Backend initialized.");
107
+ },
108
+ // Phase 3: Safe to make RPC calls after all plugins are ready
109
+ afterPluginsReady: async ({ database, rpcClient, logger }) => {
110
+ const typedDb = database as NodePgDatabase<typeof schema>;
111
+ const notificationClient = rpcClient.forPlugin(NotificationApi);
112
+
113
+ // Bootstrap: Create notification groups for existing systems and groups
114
+ await bootstrapNotificationGroups(typedDb, notificationClient, logger);
115
+ },
116
+ });
117
+ },
118
+ });
119
+
120
+ /**
121
+ * Bootstrap notification groups for existing catalog entities
122
+ */
123
+ async function bootstrapNotificationGroups(
124
+ database: NodePgDatabase<typeof schema>,
125
+ notificationClient: InferClient<typeof NotificationApi>,
126
+ logger: { debug: (msg: string) => void }
127
+ ) {
128
+ try {
129
+ // Get all existing systems and groups
130
+ const systems = await database.select().from(schema.systems);
131
+ const groups = await database.select().from(schema.groups);
132
+
133
+ // Create notification groups for each system
134
+ for (const system of systems) {
135
+ await notificationClient.createGroup({
136
+ groupId: `system.${system.id}`,
137
+ name: `${system.name} Notifications`,
138
+ description: `Notifications for the ${system.name} system`,
139
+ ownerPlugin: pluginMetadata.pluginId,
140
+ });
141
+ }
142
+
143
+ // Create notification groups for each catalog group
144
+ for (const group of groups) {
145
+ await notificationClient.createGroup({
146
+ groupId: `group.${group.id}`,
147
+ name: `${group.name} Notifications`,
148
+ description: `Notifications for the ${group.name} group`,
149
+ ownerPlugin: pluginMetadata.pluginId,
150
+ });
151
+ }
152
+
153
+ logger.debug(
154
+ `Bootstrapped notification groups for ${systems.length} systems and ${groups.length} groups`
155
+ );
156
+ } catch (error) {
157
+ // Don't fail startup if notification service is unavailable
158
+ logger.debug(
159
+ `Failed to bootstrap notification groups: ${
160
+ error instanceof Error ? error.message : "Unknown error"
161
+ }`
162
+ );
163
+ }
164
+ }
package/src/router.ts ADDED
@@ -0,0 +1,313 @@
1
+ import { implement, ORPCError } from "@orpc/server";
2
+ import {
3
+ autoAuthMiddleware,
4
+ type RpcContext,
5
+ } from "@checkstack/backend-api";
6
+ import { catalogContract } from "@checkstack/catalog-common";
7
+ import { EntityService } from "./services/entity-service";
8
+ import type { NodePgDatabase } from "drizzle-orm/node-postgres";
9
+ import * as schema from "./schema";
10
+ import { NotificationApi } from "@checkstack/notification-common";
11
+ import type { InferClient } from "@checkstack/common";
12
+ import { catalogHooks } from "./hooks";
13
+ import { eq } from "drizzle-orm";
14
+
15
+ /**
16
+ * Creates the catalog router using contract-based implementation.
17
+ *
18
+ * Auth and permissions are automatically enforced via autoAuthMiddleware
19
+ * based on the contract's meta.userType and meta.permissions.
20
+ */
21
+ const os = implement(catalogContract)
22
+ .$context<RpcContext>()
23
+ .use(autoAuthMiddleware);
24
+
25
+ export interface CatalogRouterDeps {
26
+ database: NodePgDatabase<typeof schema>;
27
+ notificationClient: InferClient<typeof NotificationApi>;
28
+ pluginId: string;
29
+ }
30
+
31
+ export const createCatalogRouter = ({
32
+ database,
33
+ notificationClient,
34
+ pluginId,
35
+ }: CatalogRouterDeps) => {
36
+ const entityService = new EntityService(database);
37
+
38
+ // Helper to create notification group for an entity
39
+ const createNotificationGroup = async (
40
+ type: "system" | "group",
41
+ id: string,
42
+ name: string
43
+ ) => {
44
+ try {
45
+ await notificationClient.createGroup({
46
+ groupId: `${type}.${id}`,
47
+ name: `${name} Notifications`,
48
+ description: `Notifications for the ${name} ${type}`,
49
+ ownerPlugin: pluginId,
50
+ });
51
+ } catch (error) {
52
+ // Log but don't fail the operation
53
+ console.warn(
54
+ `Failed to create notification group for ${type} ${id}:`,
55
+ error
56
+ );
57
+ }
58
+ };
59
+
60
+ // Helper to delete notification group for an entity
61
+ const deleteNotificationGroup = async (
62
+ type: "system" | "group",
63
+ id: string
64
+ ) => {
65
+ try {
66
+ await notificationClient.deleteGroup({
67
+ groupId: `${pluginId}.${type}.${id}`,
68
+ ownerPlugin: pluginId,
69
+ });
70
+ } catch (error) {
71
+ // Log but don't fail the operation
72
+ console.warn(
73
+ `Failed to delete notification group for ${type} ${id}:`,
74
+ error
75
+ );
76
+ }
77
+ };
78
+
79
+ // Implement each contract method
80
+ const getEntities = os.getEntities.handler(async () => {
81
+ const systems = await entityService.getSystems();
82
+ const groups = await entityService.getGroups();
83
+ // Cast to match contract - Drizzle json() returns unknown, but we expect Record | null
84
+ return {
85
+ systems: systems as unknown as Array<
86
+ (typeof systems)[number] & {
87
+ metadata: Record<string, unknown> | null;
88
+ }
89
+ >,
90
+ groups: groups as unknown as Array<
91
+ (typeof groups)[number] & { metadata: Record<string, unknown> | null }
92
+ >,
93
+ };
94
+ });
95
+
96
+ const getSystems = os.getSystems.handler(async () => {
97
+ const systems = await entityService.getSystems();
98
+ return systems as unknown as Array<
99
+ (typeof systems)[number] & { metadata: Record<string, unknown> | null }
100
+ >;
101
+ });
102
+
103
+ const getSystem = os.getSystem.handler(async ({ input }) => {
104
+ const system = await entityService.getSystem(input.systemId);
105
+ if (!system) {
106
+ // oRPC contract uses .nullable() which requires null
107
+ // eslint-disable-next-line unicorn/no-null
108
+ return null;
109
+ }
110
+ return system as typeof system & {
111
+ metadata: Record<string, unknown> | null;
112
+ };
113
+ });
114
+
115
+ const getGroups = os.getGroups.handler(async () => {
116
+ const groups = await entityService.getGroups();
117
+ return groups as unknown as Array<
118
+ (typeof groups)[number] & { metadata: Record<string, unknown> | null }
119
+ >;
120
+ });
121
+
122
+ const createSystem = os.createSystem.handler(async ({ input }) => {
123
+ const result = await entityService.createSystem(input);
124
+
125
+ // Create a notification group for this system
126
+ await createNotificationGroup("system", result.id, result.name);
127
+
128
+ return result as typeof result & {
129
+ metadata: Record<string, unknown> | null;
130
+ };
131
+ });
132
+
133
+ const updateSystem = os.updateSystem.handler(async ({ input }) => {
134
+ // Convert null to undefined and filter out fields
135
+ const cleanData: Partial<{
136
+ name: string;
137
+ description?: string;
138
+ owner?: string;
139
+ metadata?: Record<string, unknown>;
140
+ }> = {};
141
+ if (input.data.name !== undefined) cleanData.name = input.data.name;
142
+ if (input.data.description !== undefined)
143
+ cleanData.description = input.data.description ?? undefined;
144
+ if (input.data.owner !== undefined)
145
+ cleanData.owner = input.data.owner ?? undefined;
146
+ if (input.data.metadata !== undefined)
147
+ cleanData.metadata = input.data.metadata ?? undefined;
148
+
149
+ const result = await entityService.updateSystem(input.id, cleanData);
150
+ if (!result) {
151
+ throw new ORPCError("NOT_FOUND", {
152
+ message: "System not found",
153
+ });
154
+ }
155
+ return result as typeof result & {
156
+ metadata: Record<string, unknown> | null;
157
+ };
158
+ });
159
+
160
+ const deleteSystem = os.deleteSystem.handler(async ({ input, context }) => {
161
+ await entityService.deleteSystem(input);
162
+
163
+ // Delete the notification group for this system
164
+ await deleteNotificationGroup("system", input);
165
+
166
+ // Emit hook for other plugins to clean up related data
167
+ await context.emitHook(catalogHooks.systemDeleted, { systemId: input });
168
+
169
+ return { success: true };
170
+ });
171
+
172
+ const createGroup = os.createGroup.handler(async ({ input }) => {
173
+ const result = await entityService.createGroup({
174
+ name: input.name,
175
+ metadata: input.metadata,
176
+ });
177
+
178
+ // Create a notification group for this catalog group
179
+ await createNotificationGroup("group", result.id, result.name);
180
+
181
+ // New groups have no systems yet
182
+ return {
183
+ ...result,
184
+ systemIds: [],
185
+ metadata: result.metadata as Record<string, unknown> | null,
186
+ };
187
+ });
188
+
189
+ const updateGroup = os.updateGroup.handler(async ({ input }) => {
190
+ // Convert null to undefined for optional fields
191
+ const cleanData = {
192
+ ...input.data,
193
+ metadata: input.data.metadata ?? undefined,
194
+ };
195
+ const result = await entityService.updateGroup(input.id, cleanData);
196
+ if (!result) {
197
+ throw new ORPCError("NOT_FOUND", {
198
+ message: "Group not found",
199
+ });
200
+ }
201
+ // Get the full group with systemIds after update
202
+ const groups = await entityService.getGroups();
203
+ const fullGroup = groups.find((g) => g.id === result.id);
204
+ if (!fullGroup) {
205
+ throw new ORPCError("INTERNAL_SERVER_ERROR", {
206
+ message: "Group not found after update",
207
+ });
208
+ }
209
+ return fullGroup as unknown as typeof fullGroup & {
210
+ metadata: Record<string, unknown> | null;
211
+ };
212
+ });
213
+
214
+ const deleteGroup = os.deleteGroup.handler(async ({ input, context }) => {
215
+ await entityService.deleteGroup(input);
216
+
217
+ // Delete the notification group for this catalog group
218
+ await deleteNotificationGroup("group", input);
219
+
220
+ // Emit hook for other plugins to clean up related data
221
+ await context.emitHook(catalogHooks.groupDeleted, { groupId: input });
222
+
223
+ return { success: true };
224
+ });
225
+
226
+ const addSystemToGroup = os.addSystemToGroup.handler(async ({ input }) => {
227
+ await entityService.addSystemToGroup(input);
228
+ return { success: true };
229
+ });
230
+
231
+ const removeSystemFromGroup = os.removeSystemFromGroup.handler(
232
+ async ({ input }) => {
233
+ await entityService.removeSystemFromGroup(input);
234
+ return { success: true };
235
+ }
236
+ );
237
+
238
+ const getViews = os.getViews.handler(async () => entityService.getViews());
239
+
240
+ const createView = os.createView.handler(async ({ input }) => {
241
+ return entityService.createView({
242
+ name: input.name,
243
+ type: "custom",
244
+ config: input.configuration as Record<string, unknown>,
245
+ });
246
+ });
247
+
248
+ /**
249
+ * Notify all users subscribed to a system (and optionally its groups).
250
+ * Delegates deduplication to notification-backend via notifyGroups RPC.
251
+ */
252
+ const notifySystemSubscribers = os.notifySystemSubscribers.handler(
253
+ async ({ input }) => {
254
+ const {
255
+ systemId,
256
+ title,
257
+ body,
258
+ importance,
259
+ action,
260
+ includeGroupSubscribers,
261
+ } = input;
262
+
263
+ // Collect all notification group IDs to notify
264
+ // Start with the system's notification group
265
+ const groupIds = [`${pluginId}.system.${systemId}`];
266
+
267
+ // If includeGroupSubscribers is true, add groups containing this system
268
+ if (includeGroupSubscribers) {
269
+ const systemGroups = await database
270
+ .select({ groupId: schema.systemsGroups.groupId })
271
+ .from(schema.systemsGroups)
272
+ .where(eq(schema.systemsGroups.systemId, systemId));
273
+
274
+ // Spread to avoid mutation
275
+ groupIds.push(
276
+ ...systemGroups.map(({ groupId }) => `${pluginId}.group.${groupId}`)
277
+ );
278
+ }
279
+
280
+ // 3. Send to notification-backend, which handles deduplication
281
+ const result = await notificationClient.notifyGroups({
282
+ groupIds,
283
+ title,
284
+ body,
285
+ importance: importance ?? "info",
286
+ action,
287
+ });
288
+
289
+ return { notifiedCount: result.notifiedCount };
290
+ }
291
+ );
292
+
293
+ // Build and return the router
294
+ return os.router({
295
+ getEntities,
296
+ getSystems,
297
+ getSystem,
298
+ getGroups,
299
+ createSystem,
300
+ updateSystem,
301
+ deleteSystem,
302
+ createGroup,
303
+ updateGroup,
304
+ deleteGroup,
305
+ addSystemToGroup,
306
+ removeSystemFromGroup,
307
+ getViews,
308
+ createView,
309
+ notifySystemSubscribers,
310
+ });
311
+ };
312
+
313
+ export type CatalogRouter = ReturnType<typeof createCatalogRouter>;
package/src/schema.ts ADDED
@@ -0,0 +1,51 @@
1
+ import {
2
+ pgTable,
3
+ text,
4
+ timestamp,
5
+ json,
6
+ primaryKey,
7
+ } from "drizzle-orm/pg-core";
8
+
9
+ // Tables use pgTable (schemaless) - runtime schema is set via search_path
10
+ export const systems = pgTable("systems", {
11
+ id: text("id").primaryKey(),
12
+ name: text("name").notNull(),
13
+ description: text("description"),
14
+ owner: text("owner"), // user_id or group_id reference? Keeping as text for now.
15
+ metadata: json("metadata").default({}),
16
+ createdAt: timestamp("created_at").defaultNow().notNull(),
17
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
18
+ });
19
+
20
+ export const groups = pgTable("groups", {
21
+ id: text("id").primaryKey(),
22
+ name: text("name").notNull(),
23
+
24
+ metadata: json("metadata").default({}),
25
+ createdAt: timestamp("created_at").defaultNow().notNull(),
26
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
27
+ });
28
+
29
+ export const systemsGroups = pgTable(
30
+ "systems_groups",
31
+ {
32
+ systemId: text("system_id")
33
+ .notNull()
34
+ .references(() => systems.id, { onDelete: "cascade" }),
35
+ groupId: text("group_id")
36
+ .notNull()
37
+ .references(() => groups.id, { onDelete: "cascade" }),
38
+ },
39
+ (t) => ({
40
+ pk: primaryKey(t.systemId, t.groupId),
41
+ })
42
+ );
43
+
44
+ export const views = pgTable("views", {
45
+ id: text("id").primaryKey(),
46
+ name: text("name").notNull(),
47
+ description: text("description"),
48
+ configuration: json("configuration").default([]).notNull(), // List of group_ids to show
49
+ createdAt: timestamp("created_at").defaultNow().notNull(),
50
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
51
+ });
@@ -0,0 +1,86 @@
1
+ import { describe, it, expect, mock } from "bun:test";
2
+ import { EntityService } from "./entity-service";
3
+ import * as schema from "../schema";
4
+ import { NodePgDatabase } from "drizzle-orm/node-postgres";
5
+
6
+ describe("EntityService", () => {
7
+ const mockDb = {
8
+ select: mock(() => ({
9
+ from: mock(() => []),
10
+ })),
11
+ insert: mock(() => ({
12
+ values: mock(() => ({
13
+ returning: mock(() => []),
14
+ })),
15
+ })),
16
+ update: mock(() => ({
17
+ set: mock(() => ({
18
+ where: mock(() => ({
19
+ returning: mock(() => []),
20
+ })),
21
+ })),
22
+ })),
23
+ delete: mock(() => ({
24
+ where: mock(() => Promise.resolve()),
25
+ })),
26
+ } as unknown as NodePgDatabase<typeof schema>;
27
+
28
+ const service = new EntityService(mockDb);
29
+
30
+ it("should get systems", async () => {
31
+ await service.getSystems();
32
+ expect(mockDb.select).toHaveBeenCalled();
33
+ });
34
+
35
+ it("should create system", async () => {
36
+ const data = { id: "test", name: "Test" };
37
+ const fullSystem = {
38
+ ...data,
39
+ description: null,
40
+ owner: null,
41
+ status: "healthy" as "healthy" | "degraded" | "unhealthy",
42
+ metadata: {},
43
+ createdAt: new Date(),
44
+ updatedAt: new Date(),
45
+ };
46
+ (mockDb.insert as any).mockReturnValue({
47
+ values: mock(() => ({
48
+ returning: mock(() => [fullSystem]),
49
+ })),
50
+ });
51
+
52
+ const result = await service.createSystem(data);
53
+ expect(result).toEqual(fullSystem);
54
+ expect(mockDb.insert).toHaveBeenCalledWith(schema.systems);
55
+ });
56
+
57
+ it("should update system", async () => {
58
+ const data = { name: "Updated" };
59
+ const fullSystem = {
60
+ id: "test",
61
+ name: "Updated",
62
+ description: null,
63
+ owner: null,
64
+ status: "healthy" as "healthy" | "degraded" | "unhealthy",
65
+ metadata: {},
66
+ createdAt: new Date(),
67
+ updatedAt: new Date(),
68
+ };
69
+ (mockDb.update as any).mockReturnValue({
70
+ set: mock(() => ({
71
+ where: mock(() => ({
72
+ returning: mock(() => [fullSystem]),
73
+ })),
74
+ })),
75
+ });
76
+
77
+ const result = await service.updateSystem("test", data);
78
+ expect(result).toEqual(fullSystem);
79
+ expect(mockDb.update).toHaveBeenCalledWith(schema.systems);
80
+ });
81
+
82
+ it("should delete system", async () => {
83
+ await service.deleteSystem("test");
84
+ expect(mockDb.delete).toHaveBeenCalledWith(schema.systems);
85
+ });
86
+ });
@@ -0,0 +1,152 @@
1
+ import { eq, and } from "drizzle-orm";
2
+ import * as schema from "../schema";
3
+ import { NodePgDatabase } from "drizzle-orm/node-postgres";
4
+ import { v4 as uuidv4 } from "uuid";
5
+
6
+ // Type aliases for entity creation
7
+ type NewSystem = {
8
+ name: string;
9
+ description?: string;
10
+ owner?: string;
11
+ metadata?: Record<string, unknown>;
12
+ };
13
+
14
+ type NewGroup = {
15
+ name: string;
16
+ metadata?: Record<string, unknown>;
17
+ };
18
+
19
+ type NewView = {
20
+ name: string;
21
+ type: string;
22
+ config: Record<string, unknown>;
23
+ };
24
+
25
+ export class EntityService {
26
+ private database: NodePgDatabase<typeof schema>;
27
+
28
+ constructor(database: NodePgDatabase<typeof schema>) {
29
+ this.database = database;
30
+ }
31
+
32
+ // Systems
33
+ async getSystems() {
34
+ return this.database.select().from(schema.systems);
35
+ }
36
+
37
+ async getSystem(id: string) {
38
+ const result = await this.database
39
+ .select()
40
+ .from(schema.systems)
41
+ .where(eq(schema.systems.id, id));
42
+ return result[0];
43
+ }
44
+
45
+ async createSystem(data: NewSystem) {
46
+ const result = await this.database
47
+ .insert(schema.systems)
48
+ .values({ id: uuidv4(), ...data })
49
+ .returning();
50
+ return result[0];
51
+ }
52
+
53
+ async updateSystem(id: string, data: Partial<NewSystem>) {
54
+ const result = await this.database
55
+ .update(schema.systems)
56
+ .set({ ...data, updatedAt: new Date() })
57
+ .where(eq(schema.systems.id, id))
58
+ .returning();
59
+ return result[0];
60
+ }
61
+
62
+ async deleteSystem(id: string) {
63
+ await this.database.delete(schema.systems).where(eq(schema.systems.id, id));
64
+ }
65
+
66
+ // Groups
67
+ async getGroups() {
68
+ // Fetch all groups
69
+ const allGroups = await this.database.select().from(schema.groups);
70
+
71
+ // Fetch all system-group associations
72
+ const associations = await this.database
73
+ .select()
74
+ .from(schema.systemsGroups);
75
+
76
+ // Build a map of groupId -> systemIds[]
77
+ const groupSystemsMap = new Map<string, string[]>();
78
+ for (const assoc of associations) {
79
+ const existing = groupSystemsMap.get(assoc.groupId) ?? [];
80
+ existing.push(assoc.systemId);
81
+ groupSystemsMap.set(assoc.groupId, existing);
82
+ }
83
+
84
+ // Attach systemIds to each group
85
+ return allGroups.map((group) => ({
86
+ ...group,
87
+ systemIds: groupSystemsMap.get(group.id) ?? [],
88
+ }));
89
+ }
90
+
91
+ async createGroup(data: NewGroup) {
92
+ const result = await this.database
93
+ .insert(schema.groups)
94
+ .values({ id: uuidv4(), ...data })
95
+ .returning();
96
+ return result[0];
97
+ }
98
+
99
+ async updateGroup(id: string, data: Partial<NewGroup>) {
100
+ const result = await this.database
101
+ .update(schema.groups)
102
+ .set({ ...data, updatedAt: new Date() })
103
+ .where(eq(schema.groups.id, id))
104
+ .returning();
105
+ return result[0];
106
+ }
107
+
108
+ async deleteGroup(id: string) {
109
+ await this.database.delete(schema.groups).where(eq(schema.groups.id, id));
110
+ }
111
+
112
+ async addSystemToGroup(props: { groupId: string; systemId: string }) {
113
+ const { groupId, systemId } = props;
114
+ await this.database
115
+ .insert(schema.systemsGroups)
116
+ .values({ groupId, systemId })
117
+ .onConflictDoNothing();
118
+ }
119
+
120
+ async removeSystemFromGroup(props: { groupId: string; systemId: string }) {
121
+ const { groupId, systemId } = props;
122
+ await this.database
123
+ .delete(schema.systemsGroups)
124
+ .where(
125
+ and(
126
+ eq(schema.systemsGroups.groupId, groupId),
127
+ eq(schema.systemsGroups.systemId, systemId)
128
+ )
129
+ );
130
+ }
131
+
132
+ // Views
133
+ async getViews() {
134
+ return this.database.select().from(schema.views);
135
+ }
136
+
137
+ async getView(id: string) {
138
+ const result = await this.database
139
+ .select()
140
+ .from(schema.views)
141
+ .where(eq(schema.views.id, id));
142
+ return result[0];
143
+ }
144
+
145
+ async createView(data: NewView) {
146
+ const result = await this.database
147
+ .insert(schema.views)
148
+ .values({ id: uuidv4(), ...data })
149
+ .returning();
150
+ return result[0];
151
+ }
152
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "@checkstack/tsconfig/backend.json",
3
+ "include": [
4
+ "src"
5
+ ]
6
+ }