@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 +68 -0
- package/drizzle/0000_purple_doomsday.sql +35 -0
- package/drizzle/meta/0000_snapshot.json +235 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +7 -0
- package/package.json +34 -0
- package/src/hooks.ts +24 -0
- package/src/index.ts +164 -0
- package/src/router.ts +313 -0
- package/src/schema.ts +51 -0
- package/src/services/entity-service.test.ts +86 -0
- package/src/services/entity-service.ts +152 -0
- package/tsconfig.json +6 -0
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
|
+
}
|
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
|
+
}
|