@checkstack/catalog-backend 1.3.1 → 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.
Files changed (42) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/drizzle/0003_tan_spot.sql +17 -0
  3. package/drizzle/0004_heavy_sharon_carter.sql +13 -0
  4. package/drizzle/0005_normal_shaman.sql +60 -0
  5. package/drizzle/0006_optimal_gamora.sql +43 -0
  6. package/drizzle/meta/0003_snapshot.json +479 -0
  7. package/drizzle/meta/0004_snapshot.json +495 -0
  8. package/drizzle/meta/0005_snapshot.json +592 -0
  9. package/drizzle/meta/0006_snapshot.json +592 -0
  10. package/drizzle/meta/_journal.json +28 -0
  11. package/package.json +15 -12
  12. package/src/ai/catalog-add-system-to-group.test.ts +51 -0
  13. package/src/ai/catalog-add-system-to-group.ts +68 -0
  14. package/src/ai/catalog-create-group.test.ts +62 -0
  15. package/src/ai/catalog-create-group.ts +71 -0
  16. package/src/ai/catalog-create-system.test.ts +62 -0
  17. package/src/ai/catalog-create-system.ts +78 -0
  18. package/src/ai/catalog-delete-group.test.ts +83 -0
  19. package/src/ai/catalog-delete-group.ts +77 -0
  20. package/src/ai/catalog-delete-system.test.ts +84 -0
  21. package/src/ai/catalog-delete-system.ts +77 -0
  22. package/src/ai/catalog-remove-system-from-group.test.ts +55 -0
  23. package/src/ai/catalog-remove-system-from-group.ts +74 -0
  24. package/src/ai/catalog-update-group.test.ts +85 -0
  25. package/src/ai/catalog-update-group.ts +88 -0
  26. package/src/ai/catalog-update-system.test.ts +87 -0
  27. package/src/ai/catalog-update-system.ts +93 -0
  28. package/src/ai/catalog.projection.test.ts +37 -0
  29. package/src/ai/register-ai-tools.ts +35 -0
  30. package/src/automations.test.ts +2 -1
  31. package/src/catalog-gitops-kinds.test.ts +288 -0
  32. package/src/index.ts +149 -0
  33. package/src/router.test.ts +107 -0
  34. package/src/router.ts +200 -26
  35. package/src/schema.ts +124 -38
  36. package/src/services/entity-service.test.ts +28 -0
  37. package/src/services/entity-service.ts +154 -1
  38. package/src/services/environment-membership.test.ts +66 -0
  39. package/src/services/environment-membership.ts +40 -0
  40. package/src/services/pg-errors.test.ts +24 -0
  41. package/src/services/pg-errors.ts +21 -0
  42. 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
- await writeCatalogSystemEntity({
245
- handle: getSystemEntity?.(),
246
- systemId,
247
- apply: async () => {
248
- result = await entityService.createSystem(input, systemId);
249
- return toCatalogSystemState({
250
- name: result.name,
251
- description: result.description,
252
- metadata: result.metadata as Record<string, unknown> | null,
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
- await writeCatalogSystemEntity({
310
- handle: getSystemEntity?.(),
311
- systemId: input.id,
312
- apply: async () => {
313
- const updated = await entityService.updateSystem(input.id, cleanData);
314
- if (!updated) {
315
- throw new ORPCError("NOT_FOUND", { message: "System not found" });
316
- }
317
- result = updated;
318
- return toCatalogSystemState({
319
- name: result.name,
320
- description: result.description,
321
- metadata: result.metadata as Record<string, unknown> | null,
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("systems", {
15
- id: text("id").primaryKey(),
16
- name: text("name").notNull(),
17
- description: text("description"),
18
- metadata: json("metadata").default({}),
19
- createdAt: timestamp("created_at").defaultNow().notNull(),
20
- updatedAt: timestamp("updated_at").defaultNow().notNull(),
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("system_contacts", {
24
- id: text("id").primaryKey(),
25
- systemId: text("system_id")
26
- .notNull()
27
- .references(() => systems.id, { onDelete: "cascade" }),
28
- type: contactTypeEnum("type").notNull(),
29
- // For type="user": userId references auth user
30
- userId: text("user_id"),
31
- // For type="mailbox": store email directly
32
- email: text("email"),
33
- // Optional label for display (e.g., "On-Call", "Team Lead")
34
- label: text("label"),
35
- createdAt: timestamp("created_at").defaultNow().notNull(),
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("groups", {
39
- id: text("id").primaryKey(),
40
- name: text("name").notNull(),
66
+ export const groups = pgTable(
67
+ "groups",
68
+ {
69
+ id: text("id").primaryKey(),
70
+ name: text("name").notNull(),
41
71
 
42
- metadata: json("metadata").default({}),
43
- createdAt: timestamp("created_at").defaultNow().notNull(),
44
- updatedAt: timestamp("updated_at").defaultNow().notNull(),
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("system_links", {
67
- id: text("id").primaryKey(),
68
- systemId: text("system_id")
69
- .notNull()
70
- .references(() => systems.id, { onDelete: "cascade" }),
71
- label: text("label"),
72
- url: text("url").notNull(),
73
- createdAt: timestamp("created_at").defaultNow().notNull(),
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);