@checkstack/catalog-backend 1.3.0 → 1.4.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 +149 -0
- package/drizzle/0003_tan_spot.sql +17 -0
- package/drizzle/0004_heavy_sharon_carter.sql +13 -0
- package/drizzle/0005_normal_shaman.sql +60 -0
- package/drizzle/0006_optimal_gamora.sql +43 -0
- package/drizzle/meta/0003_snapshot.json +479 -0
- package/drizzle/meta/0004_snapshot.json +495 -0
- package/drizzle/meta/0005_snapshot.json +592 -0
- package/drizzle/meta/0006_snapshot.json +592 -0
- package/drizzle/meta/_journal.json +28 -0
- package/package.json +15 -12
- package/src/ai/catalog-add-system-to-group.test.ts +51 -0
- package/src/ai/catalog-add-system-to-group.ts +68 -0
- package/src/ai/catalog-create-group.test.ts +62 -0
- package/src/ai/catalog-create-group.ts +71 -0
- package/src/ai/catalog-create-system.test.ts +62 -0
- package/src/ai/catalog-create-system.ts +78 -0
- package/src/ai/catalog-delete-group.test.ts +83 -0
- package/src/ai/catalog-delete-group.ts +77 -0
- package/src/ai/catalog-delete-system.test.ts +84 -0
- package/src/ai/catalog-delete-system.ts +77 -0
- package/src/ai/catalog-remove-system-from-group.test.ts +55 -0
- package/src/ai/catalog-remove-system-from-group.ts +74 -0
- package/src/ai/catalog-update-group.test.ts +85 -0
- package/src/ai/catalog-update-group.ts +88 -0
- package/src/ai/catalog-update-system.test.ts +87 -0
- package/src/ai/catalog-update-system.ts +93 -0
- package/src/ai/catalog.projection.test.ts +37 -0
- package/src/ai/register-ai-tools.ts +35 -0
- package/src/automations.test.ts +2 -1
- package/src/catalog-gitops-kinds.test.ts +288 -0
- package/src/index.ts +149 -0
- package/src/router.test.ts +107 -0
- package/src/router.ts +200 -26
- package/src/schema.ts +124 -38
- package/src/services/entity-service.test.ts +28 -0
- package/src/services/entity-service.ts +154 -1
- package/src/services/environment-membership.test.ts +66 -0
- package/src/services/environment-membership.ts +40 -0
- package/src/services/pg-errors.test.ts +24 -0
- package/src/services/pg-errors.ts +21 -0
- package/tsconfig.json +6 -0
package/src/router.ts
CHANGED
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
type SystemContact,
|
|
8
8
|
} from "@checkstack/catalog-common";
|
|
9
9
|
import { EntityService } from "./services/entity-service";
|
|
10
|
+
import { diffSystemEnvironments } from "./services/environment-membership";
|
|
11
|
+
import { isUniqueViolation } from "./services/pg-errors";
|
|
10
12
|
import type { SafeDatabase } from "@checkstack/backend-api";
|
|
11
13
|
import * as schema from "./schema";
|
|
12
14
|
import { NotificationApi } from "@checkstack/notification-common";
|
|
@@ -233,6 +235,16 @@ export const createCatalogRouter = ({
|
|
|
233
235
|
);
|
|
234
236
|
|
|
235
237
|
const createSystem = os.createSystem.handler(async ({ input }) => {
|
|
238
|
+
// Reject duplicate names up front for a clean error on the common path.
|
|
239
|
+
// The DB also has a unique index on `name` (see migration 0004); the catch
|
|
240
|
+
// below converts a race-induced unique violation into the same CONFLICT.
|
|
241
|
+
const nameClash = await entityService.getSystemByName(input.name);
|
|
242
|
+
if (nameClash) {
|
|
243
|
+
throw new ORPCError("CONFLICT", {
|
|
244
|
+
message: `A system named "${input.name}" already exists`,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
236
248
|
// Drive the create through the reactive `catalog-system` entity (§10.4):
|
|
237
249
|
// `apply` performs the REAL `systems` write (the plugin's own db/tx) and
|
|
238
250
|
// returns the new reactive state; the deriver fires `catalog.created`
|
|
@@ -241,18 +253,27 @@ export const createCatalogRouter = ({
|
|
|
241
253
|
// not-yet-existing row as absent.
|
|
242
254
|
const systemId = crypto.randomUUID();
|
|
243
255
|
let result!: Awaited<ReturnType<typeof entityService.createSystem>>;
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
256
|
+
try {
|
|
257
|
+
await writeCatalogSystemEntity({
|
|
258
|
+
handle: getSystemEntity?.(),
|
|
259
|
+
systemId,
|
|
260
|
+
apply: async () => {
|
|
261
|
+
result = await entityService.createSystem(input, systemId);
|
|
262
|
+
return toCatalogSystemState({
|
|
263
|
+
name: result.name,
|
|
264
|
+
description: result.description,
|
|
265
|
+
metadata: result.metadata as Record<string, unknown> | null,
|
|
266
|
+
});
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
} catch (error) {
|
|
270
|
+
if (isUniqueViolation(error)) {
|
|
271
|
+
throw new ORPCError("CONFLICT", {
|
|
272
|
+
message: `A system named "${input.name}" already exists`,
|
|
253
273
|
});
|
|
254
|
-
}
|
|
255
|
-
|
|
274
|
+
}
|
|
275
|
+
throw error;
|
|
276
|
+
}
|
|
256
277
|
|
|
257
278
|
// Push the new system into notification-backend's resource registry.
|
|
258
279
|
// notification-backend handles all per-spec group provisioning from
|
|
@@ -297,6 +318,18 @@ export const createCatalogRouter = ({
|
|
|
297
318
|
});
|
|
298
319
|
}
|
|
299
320
|
|
|
321
|
+
// Renaming to a name another system already uses is rejected, so the
|
|
322
|
+
// create-side uniqueness guard cannot be sidestepped via rename. The DB
|
|
323
|
+
// unique index + the catch below cover the concurrent-rename race.
|
|
324
|
+
if (cleanData.name !== undefined) {
|
|
325
|
+
const nameClash = await entityService.getSystemByName(cleanData.name);
|
|
326
|
+
if (nameClash && nameClash.id !== input.id) {
|
|
327
|
+
throw new ORPCError("CONFLICT", {
|
|
328
|
+
message: `A system named "${cleanData.name}" already exists`,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
300
333
|
// Drive the update through the reactive `catalog-system` entity (§10.4).
|
|
301
334
|
// The REAL update runs INSIDE `apply`, so `prev` is snapshotted before
|
|
302
335
|
// the write and the deriver fires `catalog.updated` from the resulting
|
|
@@ -306,22 +339,31 @@ export const createCatalogRouter = ({
|
|
|
306
339
|
let result!: NonNullable<
|
|
307
340
|
Awaited<ReturnType<typeof entityService.updateSystem>>
|
|
308
341
|
>;
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
342
|
+
try {
|
|
343
|
+
await writeCatalogSystemEntity({
|
|
344
|
+
handle: getSystemEntity?.(),
|
|
345
|
+
systemId: input.id,
|
|
346
|
+
apply: async () => {
|
|
347
|
+
const updated = await entityService.updateSystem(input.id, cleanData);
|
|
348
|
+
if (!updated) {
|
|
349
|
+
throw new ORPCError("NOT_FOUND", { message: "System not found" });
|
|
350
|
+
}
|
|
351
|
+
result = updated;
|
|
352
|
+
return toCatalogSystemState({
|
|
353
|
+
name: result.name,
|
|
354
|
+
description: result.description,
|
|
355
|
+
metadata: result.metadata as Record<string, unknown> | null,
|
|
356
|
+
});
|
|
357
|
+
},
|
|
358
|
+
});
|
|
359
|
+
} catch (error) {
|
|
360
|
+
if (isUniqueViolation(error)) {
|
|
361
|
+
throw new ORPCError("CONFLICT", {
|
|
362
|
+
message: `A system named "${cleanData.name ?? input.data.name}" already exists`,
|
|
322
363
|
});
|
|
323
|
-
}
|
|
324
|
-
|
|
364
|
+
}
|
|
365
|
+
throw error;
|
|
366
|
+
}
|
|
325
367
|
|
|
326
368
|
await cache.invalidateTopology();
|
|
327
369
|
// Refresh display label in notification-backend on rename so the
|
|
@@ -659,6 +701,129 @@ export const createCatalogRouter = ({
|
|
|
659
701
|
}),
|
|
660
702
|
);
|
|
661
703
|
|
|
704
|
+
// ── Environments ──────────────────────────────────────────────────────
|
|
705
|
+
// Instance-wide catalog primitive. The drizzle `json()` metadata column is
|
|
706
|
+
// typed `unknown`; the contract expects `Record<string, unknown> | null`,
|
|
707
|
+
// so each handler narrows it the same way the system/group handlers do.
|
|
708
|
+
type EnvironmentRow = Awaited<
|
|
709
|
+
ReturnType<typeof entityService.getEnvironments>
|
|
710
|
+
>[number];
|
|
711
|
+
const toEnvironmentOutput = (environment: EnvironmentRow) =>
|
|
712
|
+
environment as EnvironmentRow & {
|
|
713
|
+
metadata: Record<string, unknown> | null;
|
|
714
|
+
};
|
|
715
|
+
|
|
716
|
+
const listEnvironments = os.listEnvironments.handler(async () => {
|
|
717
|
+
const environments = await entityService.getEnvironments();
|
|
718
|
+
return environments.map((environment) => toEnvironmentOutput(environment));
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
const getEnvironment = os.getEnvironment.handler(async ({ input }) => {
|
|
722
|
+
const environment = await entityService.getEnvironment(input.environmentId);
|
|
723
|
+
if (!environment) return null;
|
|
724
|
+
return toEnvironmentOutput(environment);
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
const createEnvironment = os.createEnvironment.handler(async ({ input }) => {
|
|
728
|
+
const created = await entityService.createEnvironment(input);
|
|
729
|
+
// New environments have no systems yet.
|
|
730
|
+
return toEnvironmentOutput({ ...created, systemIds: [] });
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
const updateEnvironment = os.updateEnvironment.handler(async ({ input }) => {
|
|
734
|
+
await enforceNotGitOpsLocked("Environment", input.environmentId);
|
|
735
|
+
const existing = await entityService.getEnvironment(input.environmentId);
|
|
736
|
+
if (!existing) {
|
|
737
|
+
throw new ORPCError("NOT_FOUND", { message: "Environment not found" });
|
|
738
|
+
}
|
|
739
|
+
const cleanData: Partial<{
|
|
740
|
+
name: string;
|
|
741
|
+
description?: string;
|
|
742
|
+
metadata?: Record<string, unknown>;
|
|
743
|
+
}> = {};
|
|
744
|
+
if (input.data.name !== undefined) cleanData.name = input.data.name;
|
|
745
|
+
if (input.data.description !== undefined) {
|
|
746
|
+
cleanData.description = input.data.description;
|
|
747
|
+
}
|
|
748
|
+
if (input.data.metadata !== undefined) {
|
|
749
|
+
cleanData.metadata = input.data.metadata;
|
|
750
|
+
}
|
|
751
|
+
await entityService.updateEnvironment(input.environmentId, cleanData);
|
|
752
|
+
const updated = await entityService.getEnvironment(input.environmentId);
|
|
753
|
+
if (!updated) {
|
|
754
|
+
throw new ORPCError("INTERNAL_SERVER_ERROR", {
|
|
755
|
+
message: "Environment not found after update",
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
return toEnvironmentOutput(updated);
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
const deleteEnvironment = os.deleteEnvironment.handler(async ({ input }) => {
|
|
762
|
+
await enforceNotGitOpsLocked("Environment", input.environmentId);
|
|
763
|
+
await entityService.deleteEnvironment(input.environmentId);
|
|
764
|
+
return { success: true };
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
const setSystemEnvironments = os.setSystemEnvironments.handler(
|
|
768
|
+
async ({ input }) => {
|
|
769
|
+
await enforceNotGitOpsLocked("System", input.systemId);
|
|
770
|
+
const current = await entityService.getEnvironmentsForSystem(
|
|
771
|
+
input.systemId,
|
|
772
|
+
);
|
|
773
|
+
const { toAdd, toRemove } = diffSystemEnvironments({
|
|
774
|
+
current: current.map((row) => row.environmentId),
|
|
775
|
+
desired: input.environmentIds,
|
|
776
|
+
});
|
|
777
|
+
for (const environmentId of toAdd) {
|
|
778
|
+
await entityService.addSystemToEnvironment({
|
|
779
|
+
environmentId,
|
|
780
|
+
systemId: input.systemId,
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
for (const environmentId of toRemove) {
|
|
784
|
+
await entityService.removeSystemFromEnvironment({
|
|
785
|
+
environmentId,
|
|
786
|
+
systemId: input.systemId,
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
return { success: true };
|
|
790
|
+
},
|
|
791
|
+
);
|
|
792
|
+
|
|
793
|
+
const getSystemEnvironments = os.getSystemEnvironments.handler(
|
|
794
|
+
async ({ input }) => {
|
|
795
|
+
const memberships = await entityService.getEnvironmentsForSystem(
|
|
796
|
+
input.systemId,
|
|
797
|
+
);
|
|
798
|
+
const environments = await entityService.getEnvironmentsByIds(
|
|
799
|
+
memberships.map((m) => m.environmentId),
|
|
800
|
+
);
|
|
801
|
+
return environments.map((environment) => toEnvironmentOutput(environment));
|
|
802
|
+
},
|
|
803
|
+
);
|
|
804
|
+
|
|
805
|
+
// Service-grade cross-plugin reads (healthcheck fan-out resolution).
|
|
806
|
+
const resolveSystemEnvironments = os.resolveSystemEnvironments.handler(
|
|
807
|
+
async ({ input }) => {
|
|
808
|
+
const memberships = await entityService.getEnvironmentsForSystem(
|
|
809
|
+
input.systemId,
|
|
810
|
+
);
|
|
811
|
+
const environments = await entityService.getEnvironmentsByIds(
|
|
812
|
+
memberships.map((m) => m.environmentId),
|
|
813
|
+
);
|
|
814
|
+
return environments.map((environment) => toEnvironmentOutput(environment));
|
|
815
|
+
},
|
|
816
|
+
);
|
|
817
|
+
|
|
818
|
+
const resolveEnvironments = os.resolveEnvironments.handler(
|
|
819
|
+
async ({ input }) => {
|
|
820
|
+
const environments = await entityService.getEnvironmentsByIds(
|
|
821
|
+
input.environmentIds,
|
|
822
|
+
);
|
|
823
|
+
return environments.map((environment) => toEnvironmentOutput(environment));
|
|
824
|
+
},
|
|
825
|
+
);
|
|
826
|
+
|
|
662
827
|
// Build and return the router
|
|
663
828
|
return os.router({
|
|
664
829
|
getEntities,
|
|
@@ -683,6 +848,15 @@ export const createCatalogRouter = ({
|
|
|
683
848
|
getViews,
|
|
684
849
|
createView,
|
|
685
850
|
getSystemGroupIds,
|
|
851
|
+
listEnvironments,
|
|
852
|
+
getEnvironment,
|
|
853
|
+
createEnvironment,
|
|
854
|
+
updateEnvironment,
|
|
855
|
+
deleteEnvironment,
|
|
856
|
+
setSystemEnvironments,
|
|
857
|
+
getSystemEnvironments,
|
|
858
|
+
resolveSystemEnvironments,
|
|
859
|
+
resolveEnvironments,
|
|
686
860
|
});
|
|
687
861
|
};
|
|
688
862
|
|
package/src/schema.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sql } from "drizzle-orm";
|
|
1
2
|
import {
|
|
2
3
|
pgTable,
|
|
3
4
|
pgEnum,
|
|
@@ -5,44 +6,78 @@ import {
|
|
|
5
6
|
timestamp,
|
|
6
7
|
json,
|
|
7
8
|
primaryKey,
|
|
9
|
+
uniqueIndex,
|
|
8
10
|
} from "drizzle-orm/pg-core";
|
|
9
11
|
|
|
10
12
|
// Enums
|
|
11
13
|
export const contactTypeEnum = pgEnum("contact_type", ["user", "mailbox"]);
|
|
12
14
|
|
|
13
15
|
// Tables use pgTable (schemaless) - runtime schema is set via search_path
|
|
14
|
-
export const systems = pgTable(
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
16
|
+
export const systems = pgTable(
|
|
17
|
+
"systems",
|
|
18
|
+
{
|
|
19
|
+
id: text("id").primaryKey(),
|
|
20
|
+
name: text("name").notNull(),
|
|
21
|
+
description: text("description"),
|
|
22
|
+
metadata: json("metadata").default({}),
|
|
23
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
24
|
+
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
|
25
|
+
},
|
|
26
|
+
(t) => ({
|
|
27
|
+
// System names are unique, CASE-INSENSITIVELY ("Api" and "api" collide).
|
|
28
|
+
// A functional index on lower(name) enforces this at the DB while the stored
|
|
29
|
+
// value keeps its original casing.
|
|
30
|
+
nameUnique: uniqueIndex("systems_name_unique").on(sql`lower(${t.name})`),
|
|
31
|
+
}),
|
|
32
|
+
);
|
|
22
33
|
|
|
23
|
-
export const systemContacts = pgTable(
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
export const systemContacts = pgTable(
|
|
35
|
+
"system_contacts",
|
|
36
|
+
{
|
|
37
|
+
id: text("id").primaryKey(),
|
|
38
|
+
systemId: text("system_id")
|
|
39
|
+
.notNull()
|
|
40
|
+
.references(() => systems.id, { onDelete: "cascade" }),
|
|
41
|
+
type: contactTypeEnum("type").notNull(),
|
|
42
|
+
// For type="user": userId references auth user
|
|
43
|
+
userId: text("user_id"),
|
|
44
|
+
// For type="mailbox": store email directly
|
|
45
|
+
email: text("email"),
|
|
46
|
+
// Optional label for display (e.g., "On-Call", "Team Lead")
|
|
47
|
+
label: text("label"),
|
|
48
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
49
|
+
},
|
|
50
|
+
(t) => ({
|
|
51
|
+
// A given user, or a given mailbox email, may be attached to a system only
|
|
52
|
+
// once. NULLs are distinct in Postgres btree, so the two partial keys don't
|
|
53
|
+
// interfere: user contacts (email NULL) are deduped by (system, user) and
|
|
54
|
+
// mailbox contacts (userId NULL) by (system, email).
|
|
55
|
+
systemUserUnique: uniqueIndex("system_contacts_system_user_unique").on(
|
|
56
|
+
t.systemId,
|
|
57
|
+
t.userId,
|
|
58
|
+
),
|
|
59
|
+
systemEmailUnique: uniqueIndex("system_contacts_system_email_unique").on(
|
|
60
|
+
t.systemId,
|
|
61
|
+
t.email,
|
|
62
|
+
),
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
37
65
|
|
|
38
|
-
export const groups = pgTable(
|
|
39
|
-
|
|
40
|
-
|
|
66
|
+
export const groups = pgTable(
|
|
67
|
+
"groups",
|
|
68
|
+
{
|
|
69
|
+
id: text("id").primaryKey(),
|
|
70
|
+
name: text("name").notNull(),
|
|
41
71
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
72
|
+
metadata: json("metadata").default({}),
|
|
73
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
74
|
+
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
|
75
|
+
},
|
|
76
|
+
(t) => ({
|
|
77
|
+
// Group names are unique, CASE-INSENSITIVELY (consistent with systems.name).
|
|
78
|
+
nameUnique: uniqueIndex("groups_name_unique").on(sql`lower(${t.name})`),
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
46
81
|
|
|
47
82
|
export const systemsGroups = pgTable(
|
|
48
83
|
"systems_groups",
|
|
@@ -59,19 +94,70 @@ export const systemsGroups = pgTable(
|
|
|
59
94
|
}),
|
|
60
95
|
);
|
|
61
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Instance-wide environments. A sibling of `groups`: a free-form set of
|
|
99
|
+
* custom fields (baseUrl, region, tier, ...) that any system can belong to
|
|
100
|
+
* many-to-many. `metadata` reuses the same json+default({}) precedent as
|
|
101
|
+
* systems.metadata / groups.metadata; its values surface in templating
|
|
102
|
+
* verbatim. Unlike `groups`, environments carry a `description` (matching
|
|
103
|
+
* `systems`).
|
|
104
|
+
*/
|
|
105
|
+
export const environments = pgTable(
|
|
106
|
+
"environments",
|
|
107
|
+
{
|
|
108
|
+
id: text("id").primaryKey(),
|
|
109
|
+
name: text("name").notNull(),
|
|
110
|
+
description: text("description"),
|
|
111
|
+
metadata: json("metadata").default({}),
|
|
112
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
113
|
+
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
|
114
|
+
},
|
|
115
|
+
(t) => ({
|
|
116
|
+
// Environment names are unique, CASE-INSENSITIVELY (like systems / groups).
|
|
117
|
+
nameUnique: uniqueIndex("environments_name_unique").on(
|
|
118
|
+
sql`lower(${t.name})`,
|
|
119
|
+
),
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
export const systemsEnvironments = pgTable(
|
|
124
|
+
"systems_environments",
|
|
125
|
+
{
|
|
126
|
+
systemId: text("system_id")
|
|
127
|
+
.notNull()
|
|
128
|
+
.references(() => systems.id, { onDelete: "cascade" }),
|
|
129
|
+
environmentId: text("environment_id")
|
|
130
|
+
.notNull()
|
|
131
|
+
.references(() => environments.id, { onDelete: "cascade" }),
|
|
132
|
+
},
|
|
133
|
+
(t) => ({
|
|
134
|
+
pk: primaryKey(t.systemId, t.environmentId),
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
|
|
62
138
|
/**
|
|
63
139
|
* Free-form hotlinks attached to a system — e.g. Jira board, dashboard URL,
|
|
64
140
|
* runbook. Sits alongside contacts but is purely URL-based, no user/email.
|
|
65
141
|
*/
|
|
66
|
-
export const systemLinks = pgTable(
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
.
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
142
|
+
export const systemLinks = pgTable(
|
|
143
|
+
"system_links",
|
|
144
|
+
{
|
|
145
|
+
id: text("id").primaryKey(),
|
|
146
|
+
systemId: text("system_id")
|
|
147
|
+
.notNull()
|
|
148
|
+
.references(() => systems.id, { onDelete: "cascade" }),
|
|
149
|
+
label: text("label"),
|
|
150
|
+
url: text("url").notNull(),
|
|
151
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
152
|
+
},
|
|
153
|
+
(t) => ({
|
|
154
|
+
// The same URL may be attached to a system only once.
|
|
155
|
+
systemUrlUnique: uniqueIndex("system_links_system_url_unique").on(
|
|
156
|
+
t.systemId,
|
|
157
|
+
t.url,
|
|
158
|
+
),
|
|
159
|
+
}),
|
|
160
|
+
);
|
|
75
161
|
|
|
76
162
|
export const views = pgTable("views", {
|
|
77
163
|
id: text("id").primaryKey(),
|
|
@@ -81,4 +81,32 @@ describe("EntityService", () => {
|
|
|
81
81
|
await service.deleteSystem("test");
|
|
82
82
|
expect(mockDb.delete).toHaveBeenCalledWith(schema.systems);
|
|
83
83
|
});
|
|
84
|
+
|
|
85
|
+
it("getSystemByName returns the matching system", async () => {
|
|
86
|
+
const row = {
|
|
87
|
+
id: "s1",
|
|
88
|
+
name: "Payments",
|
|
89
|
+
description: null,
|
|
90
|
+
status: "healthy" as "healthy" | "degraded" | "unhealthy",
|
|
91
|
+
metadata: {},
|
|
92
|
+
createdAt: new Date(),
|
|
93
|
+
updatedAt: new Date(),
|
|
94
|
+
};
|
|
95
|
+
(mockDb.select as any).mockReturnValue({
|
|
96
|
+
from: mock(() => ({ where: mock(() => Promise.resolve([row])) })),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const result = await service.getSystemByName("Payments");
|
|
100
|
+
expect(result).toEqual(row);
|
|
101
|
+
expect(mockDb.select).toHaveBeenCalled();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("getSystemByName returns undefined when the name is free", async () => {
|
|
105
|
+
(mockDb.select as any).mockReturnValue({
|
|
106
|
+
from: mock(() => ({ where: mock(() => Promise.resolve([])) })),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const result = await service.getSystemByName("Unused Name");
|
|
110
|
+
expect(result).toBeUndefined();
|
|
111
|
+
});
|
|
84
112
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { eq, and, inArray } from "drizzle-orm";
|
|
1
|
+
import { eq, and, inArray, sql } from "drizzle-orm";
|
|
2
2
|
import * as schema from "../schema";
|
|
3
3
|
import { SafeDatabase } from "@checkstack/backend-api";
|
|
4
4
|
import { v4 as uuidv4 } from "uuid";
|
|
@@ -49,6 +49,12 @@ type NewGroup = {
|
|
|
49
49
|
metadata?: Record<string, unknown>;
|
|
50
50
|
};
|
|
51
51
|
|
|
52
|
+
type NewEnvironment = {
|
|
53
|
+
name: string;
|
|
54
|
+
description?: string;
|
|
55
|
+
metadata?: Record<string, unknown>;
|
|
56
|
+
};
|
|
57
|
+
|
|
52
58
|
type NewView = {
|
|
53
59
|
name: string;
|
|
54
60
|
type: string;
|
|
@@ -75,6 +81,22 @@ export class EntityService {
|
|
|
75
81
|
return result[0];
|
|
76
82
|
}
|
|
77
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Look up a system by name, CASE-INSENSITIVELY. Used for the friendly
|
|
86
|
+
* pre-write uniqueness check on create/rename; the `systems_name_unique`
|
|
87
|
+
* functional index (`lower(name)`) is the actual race-safe guard, and this
|
|
88
|
+
* mirrors its case-folding so the pre-check catches "Api" vs "api" too (a
|
|
89
|
+
* case-sensitive `eq` would miss it and leave only the generic DB conflict).
|
|
90
|
+
* Returns the first match, or undefined when the name is free.
|
|
91
|
+
*/
|
|
92
|
+
async getSystemByName(name: string) {
|
|
93
|
+
const result = await this.database
|
|
94
|
+
.select()
|
|
95
|
+
.from(schema.systems)
|
|
96
|
+
.where(sql`lower(${schema.systems.name}) = lower(${name})`);
|
|
97
|
+
return result[0];
|
|
98
|
+
}
|
|
99
|
+
|
|
78
100
|
/**
|
|
79
101
|
* Create a system.
|
|
80
102
|
*
|
|
@@ -304,6 +326,137 @@ export class EntityService {
|
|
|
304
326
|
);
|
|
305
327
|
}
|
|
306
328
|
|
|
329
|
+
// Environments — instance-wide catalog primitive (M:N with systems)
|
|
330
|
+
async getEnvironments() {
|
|
331
|
+
const allEnvironments = await this.database
|
|
332
|
+
.select()
|
|
333
|
+
.from(schema.environments);
|
|
334
|
+
|
|
335
|
+
const associations = await this.database
|
|
336
|
+
.select()
|
|
337
|
+
.from(schema.systemsEnvironments);
|
|
338
|
+
|
|
339
|
+
const envSystemsMap = new Map<string, string[]>();
|
|
340
|
+
for (const assoc of associations) {
|
|
341
|
+
const existing = envSystemsMap.get(assoc.environmentId) ?? [];
|
|
342
|
+
existing.push(assoc.systemId);
|
|
343
|
+
envSystemsMap.set(assoc.environmentId, existing);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return allEnvironments.map((environment) => ({
|
|
347
|
+
...environment,
|
|
348
|
+
systemIds: envSystemsMap.get(environment.id) ?? [],
|
|
349
|
+
}));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async getEnvironment(id: string) {
|
|
353
|
+
const rows = await this.database
|
|
354
|
+
.select()
|
|
355
|
+
.from(schema.environments)
|
|
356
|
+
.where(eq(schema.environments.id, id));
|
|
357
|
+
const environment = rows[0];
|
|
358
|
+
if (!environment) return;
|
|
359
|
+
const associations = await this.database
|
|
360
|
+
.select()
|
|
361
|
+
.from(schema.systemsEnvironments)
|
|
362
|
+
.where(eq(schema.systemsEnvironments.environmentId, id));
|
|
363
|
+
return {
|
|
364
|
+
...environment,
|
|
365
|
+
systemIds: associations.map((a) => a.systemId),
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Resolve a set of environment ids to their full records (with systemIds).
|
|
371
|
+
* Unknown ids are silently dropped. Used by the cross-plugin
|
|
372
|
+
* `resolveEnvironments` read for the explicit-subset fan-out case.
|
|
373
|
+
*/
|
|
374
|
+
async getEnvironmentsByIds(ids: ReadonlyArray<string>) {
|
|
375
|
+
if (ids.length === 0) return [];
|
|
376
|
+
const rows = await this.database
|
|
377
|
+
.select()
|
|
378
|
+
.from(schema.environments)
|
|
379
|
+
.where(inArray(schema.environments.id, [...ids]));
|
|
380
|
+
if (rows.length === 0) return [];
|
|
381
|
+
const associations = await this.database
|
|
382
|
+
.select()
|
|
383
|
+
.from(schema.systemsEnvironments)
|
|
384
|
+
.where(inArray(schema.systemsEnvironments.environmentId, [...ids]));
|
|
385
|
+
const envSystemsMap = new Map<string, string[]>();
|
|
386
|
+
for (const assoc of associations) {
|
|
387
|
+
const existing = envSystemsMap.get(assoc.environmentId) ?? [];
|
|
388
|
+
existing.push(assoc.systemId);
|
|
389
|
+
envSystemsMap.set(assoc.environmentId, existing);
|
|
390
|
+
}
|
|
391
|
+
return rows.map((environment) => ({
|
|
392
|
+
...environment,
|
|
393
|
+
systemIds: envSystemsMap.get(environment.id) ?? [],
|
|
394
|
+
}));
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async createEnvironment(data: NewEnvironment, id: string = uuidv4()) {
|
|
398
|
+
const result = await this.database
|
|
399
|
+
.insert(schema.environments)
|
|
400
|
+
.values({ id, ...data })
|
|
401
|
+
.returning();
|
|
402
|
+
return result[0];
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async updateEnvironment(id: string, data: Partial<NewEnvironment>) {
|
|
406
|
+
const result = await this.database
|
|
407
|
+
.update(schema.environments)
|
|
408
|
+
.set({ ...data, updatedAt: new Date() })
|
|
409
|
+
.where(eq(schema.environments.id, id))
|
|
410
|
+
.returning();
|
|
411
|
+
return result[0];
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async deleteEnvironment(id: string) {
|
|
415
|
+
await this.database
|
|
416
|
+
.delete(schema.environments)
|
|
417
|
+
.where(eq(schema.environments.id, id));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async getEnvironmentsForSystem(systemId: string) {
|
|
421
|
+
return this.database
|
|
422
|
+
.select()
|
|
423
|
+
.from(schema.systemsEnvironments)
|
|
424
|
+
.where(eq(schema.systemsEnvironments.systemId, systemId));
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async getSystemsForEnvironment(environmentId: string) {
|
|
428
|
+
return this.database
|
|
429
|
+
.select()
|
|
430
|
+
.from(schema.systemsEnvironments)
|
|
431
|
+
.where(eq(schema.systemsEnvironments.environmentId, environmentId));
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async addSystemToEnvironment(props: {
|
|
435
|
+
environmentId: string;
|
|
436
|
+
systemId: string;
|
|
437
|
+
}) {
|
|
438
|
+
const { environmentId, systemId } = props;
|
|
439
|
+
await this.database
|
|
440
|
+
.insert(schema.systemsEnvironments)
|
|
441
|
+
.values({ environmentId, systemId })
|
|
442
|
+
.onConflictDoNothing();
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async removeSystemFromEnvironment(props: {
|
|
446
|
+
environmentId: string;
|
|
447
|
+
systemId: string;
|
|
448
|
+
}) {
|
|
449
|
+
const { environmentId, systemId } = props;
|
|
450
|
+
await this.database
|
|
451
|
+
.delete(schema.systemsEnvironments)
|
|
452
|
+
.where(
|
|
453
|
+
and(
|
|
454
|
+
eq(schema.systemsEnvironments.environmentId, environmentId),
|
|
455
|
+
eq(schema.systemsEnvironments.systemId, systemId),
|
|
456
|
+
),
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
|
|
307
460
|
// Views
|
|
308
461
|
async getViews() {
|
|
309
462
|
return this.database.select().from(schema.views);
|