bopodev-db 0.1.32 → 0.1.34

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.
@@ -1,5 +1,5 @@
1
1
 
2
2
  
3
- > bopodev-db@0.1.32 build /Users/danielkrusenstrahle/Documents/Projects/Monorepo/bopohq/packages/db
3
+ > bopodev-db@0.1.34 build /Users/danielkrusenstrahle/Documents/Projects/Monorepo/bopodev/packages/db
4
4
  > tsc -p tsconfig.json --emitDeclarationOnly
5
5
 
@@ -1,4 +1,4 @@
1
1
 
2
- > bopodev-db@0.1.15 lint /Users/danielkrusenstrahle/Documents/Projects/Monorepo/bopohq/packages/db
2
+ > bopodev-db@0.1.28 lint /Users/danielkrusenstrahle/Documents/Projects/Monorepo/bopodev/packages/db
3
3
  > tsc -p tsconfig.json --noEmit
4
4
 
@@ -1,4 +1,4 @@
1
1
 
2
- > bopodev-db@0.1.30 typecheck /Users/danielkrusenstrahle/Documents/Projects/Monorepo/bopohq/packages/db
2
+ > bopodev-db@0.1.20 typecheck /Users/danielkrusenstrahle/Documents/Projects/Monorepo/bopodev/packages/db
3
3
  > tsc -p tsconfig.json --noEmit
4
4
 
@@ -14,3 +14,17 @@ export declare function assertTemplateBelongsToCompany(db: BopoDb, companyId: st
14
14
  export declare function compactUpdate<T extends Record<string, unknown>>(input: T): {
15
15
  [k: string]: unknown;
16
16
  };
17
+ type GoalLevel = "company" | "project" | "agent";
18
+ /**
19
+ * Validates level ↔ projectId and optional parent_goal_id tree rules:
20
+ * - company: projectId must be null; parent must be company-level (or absent).
21
+ * - project: projectId required; parent must be company-level or absent.
22
+ * - agent: parent absent, company-level, or project-level with same projectId as child (child projectId required in that case).
23
+ */
24
+ export declare function assertValidGoalHierarchy(db: BopoDb, companyId: string, input: {
25
+ id?: string;
26
+ level: GoalLevel;
27
+ projectId: string | null;
28
+ parentGoalId: string | null;
29
+ }): Promise<void>;
30
+ export {};
@@ -566,9 +566,9 @@ export declare function createGoal(db: BopoDb, input: {
566
566
  title: string;
567
567
  description?: string;
568
568
  }): Promise<{
569
+ projectId: string | null;
570
+ parentGoalId: string | null;
569
571
  companyId: string;
570
- projectId?: string | null;
571
- parentGoalId?: string | null;
572
572
  ownerAgentId?: string | null;
573
573
  level: "company" | "project" | "agent";
574
574
  title: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bopodev-db",
3
- "version": "0.1.32",
3
+ "version": "0.1.34",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -0,0 +1 @@
1
+ ALTER TABLE "goals" ADD CONSTRAINT "goals_parent_goal_id_goals_id_fk" FOREIGN KEY ("parent_goal_id") REFERENCES "goals"("id") ON DELETE SET NULL;
@@ -57,6 +57,13 @@
57
57
  "when": 1743200000000,
58
58
  "tag": "0007_cost_ledger_company_assistant",
59
59
  "breakpoints": true
60
+ },
61
+ {
62
+ "idx": 8,
63
+ "version": "7",
64
+ "when": 1743300000000,
65
+ "tag": "0008_goals_parent_goal_fk",
66
+ "breakpoints": true
60
67
  }
61
68
  ]
62
69
  }
@@ -102,3 +102,113 @@ export async function assertTemplateBelongsToCompany(db: BopoDb, companyId: stri
102
102
  export function compactUpdate<T extends Record<string, unknown>>(input: T) {
103
103
  return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
104
104
  }
105
+
106
+ const GOAL_PARENT_CHAIN_MAX_DEPTH = 32;
107
+
108
+ type GoalLevel = "company" | "project" | "agent";
109
+
110
+ /** Walk parent_goal_id upward; detects cycles and whether `selfId` appears (would make self an ancestor of the parent). */
111
+ async function walkGoalParentChain(
112
+ db: BopoDb,
113
+ companyId: string,
114
+ startParentId: string,
115
+ selfId: string | undefined
116
+ ) {
117
+ const visited = new Set<string>();
118
+ let current: string | null = startParentId;
119
+ let depth = 0;
120
+ while (current && depth < GOAL_PARENT_CHAIN_MAX_DEPTH) {
121
+ if (selfId && current === selfId) {
122
+ throw new RepositoryValidationError("Goal cannot be its own ancestor.");
123
+ }
124
+ if (visited.has(current)) {
125
+ throw new RepositoryValidationError("Parent goal chain contains a cycle.");
126
+ }
127
+ visited.add(current);
128
+ const [row] = await db
129
+ .select({ parentGoalId: goals.parentGoalId })
130
+ .from(goals)
131
+ .where(and(eq(goals.companyId, companyId), eq(goals.id, current)))
132
+ .limit(1);
133
+ current = row?.parentGoalId?.trim() ? row.parentGoalId : null;
134
+ depth += 1;
135
+ }
136
+ if (current && depth >= GOAL_PARENT_CHAIN_MAX_DEPTH) {
137
+ throw new RepositoryValidationError("Parent goal chain exceeds maximum depth.");
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Validates level ↔ projectId and optional parent_goal_id tree rules:
143
+ * - company: projectId must be null; parent must be company-level (or absent).
144
+ * - project: projectId required; parent must be company-level or absent.
145
+ * - agent: parent absent, company-level, or project-level with same projectId as child (child projectId required in that case).
146
+ */
147
+ export async function assertValidGoalHierarchy(
148
+ db: BopoDb,
149
+ companyId: string,
150
+ input: {
151
+ id?: string;
152
+ level: GoalLevel;
153
+ projectId: string | null;
154
+ parentGoalId: string | null;
155
+ }
156
+ ) {
157
+ const level = input.level;
158
+ const projectId = input.projectId?.trim() ? input.projectId.trim() : null;
159
+ const parentGoalId = input.parentGoalId?.trim() ? input.parentGoalId.trim() : null;
160
+
161
+ if (level === "company" && projectId) {
162
+ throw new RepositoryValidationError("Company goals cannot be scoped to a project.");
163
+ }
164
+ if (level === "project" && !projectId) {
165
+ throw new RepositoryValidationError("Project goals must have a project.");
166
+ }
167
+
168
+ if (!parentGoalId) {
169
+ return;
170
+ }
171
+
172
+ const [parent] = await db
173
+ .select({
174
+ id: goals.id,
175
+ level: goals.level,
176
+ projectId: goals.projectId
177
+ })
178
+ .from(goals)
179
+ .where(and(eq(goals.companyId, companyId), eq(goals.id, parentGoalId)))
180
+ .limit(1);
181
+
182
+ if (!parent) {
183
+ throw new RepositoryValidationError("Parent goal not found for company.");
184
+ }
185
+
186
+ const pLevel = parent.level as GoalLevel;
187
+ if (pLevel !== "company" && pLevel !== "project") {
188
+ throw new RepositoryValidationError("Parent goal must be company or project level.");
189
+ }
190
+
191
+ if (level === "company") {
192
+ if (pLevel !== "company") {
193
+ throw new RepositoryValidationError("Company goals may only have a company-level parent.");
194
+ }
195
+ } else if (level === "project") {
196
+ if (pLevel !== "company") {
197
+ throw new RepositoryValidationError("Project goals may only have a company-level parent.");
198
+ }
199
+ } else {
200
+ // agent
201
+ if (pLevel === "company") {
202
+ // ok
203
+ } else if (pLevel === "project") {
204
+ const parentPid = parent.projectId?.trim() ? parent.projectId : null;
205
+ if (!parentPid || parentPid !== projectId) {
206
+ throw new RepositoryValidationError(
207
+ "Agent goals with a project parent must use the same project as the parent goal."
208
+ );
209
+ }
210
+ }
211
+ }
212
+
213
+ await walkGoalParentChain(db, companyId, parentGoalId, input.id);
214
+ }
@@ -29,11 +29,11 @@ import {
29
29
  } from "../schema";
30
30
  import {
31
31
  assertAgentBelongsToCompany,
32
- assertGoalBelongsToCompany,
33
32
  assertIssueGoalsAssignable,
34
33
  assertIssueBelongsToCompany,
35
34
  assertProjectBelongsToCompany,
36
35
  assertTemplateBelongsToCompany,
36
+ assertValidGoalHierarchy,
37
37
  compactUpdate,
38
38
  RepositoryValidationError
39
39
  } from "./helpers";
@@ -946,11 +946,15 @@ export async function createGoal(
946
946
  description?: string;
947
947
  }
948
948
  ) {
949
- if (input.projectId) {
950
- await assertProjectBelongsToCompany(db, input.companyId, input.projectId);
951
- }
952
- if (input.parentGoalId) {
953
- await assertGoalBelongsToCompany(db, input.companyId, input.parentGoalId);
949
+ const projectId = input.projectId?.trim() ? input.projectId.trim() : null;
950
+ const parentGoalId = input.parentGoalId?.trim() ? input.parentGoalId.trim() : null;
951
+ await assertValidGoalHierarchy(db, input.companyId, {
952
+ level: input.level,
953
+ projectId,
954
+ parentGoalId
955
+ });
956
+ if (projectId) {
957
+ await assertProjectBelongsToCompany(db, input.companyId, projectId);
954
958
  }
955
959
  if (input.ownerAgentId) {
956
960
  await assertAgentBelongsToCompany(db, input.companyId, input.ownerAgentId);
@@ -959,14 +963,14 @@ export async function createGoal(
959
963
  await db.insert(goals).values({
960
964
  id,
961
965
  companyId: input.companyId,
962
- projectId: input.projectId ?? null,
963
- parentGoalId: input.parentGoalId ?? null,
966
+ projectId,
967
+ parentGoalId,
964
968
  ownerAgentId: input.ownerAgentId?.trim() ? input.ownerAgentId.trim() : null,
965
969
  level: input.level,
966
970
  title: input.title,
967
971
  description: input.description ?? null
968
972
  });
969
- return { id, ...input };
973
+ return { id, ...input, projectId, parentGoalId };
970
974
  }
971
975
 
972
976
  export async function listGoals(db: BopoDb, companyId: string) {
@@ -987,14 +991,37 @@ export async function updateGoal(
987
991
  status?: string;
988
992
  }
989
993
  ) {
990
- if (input.projectId) {
991
- await assertProjectBelongsToCompany(db, input.companyId, input.projectId);
994
+ const [existing] = await db
995
+ .select({
996
+ level: goals.level,
997
+ projectId: goals.projectId,
998
+ parentGoalId: goals.parentGoalId
999
+ })
1000
+ .from(goals)
1001
+ .where(and(eq(goals.companyId, input.companyId), eq(goals.id, input.id)))
1002
+ .limit(1);
1003
+ if (!existing) {
1004
+ return null;
992
1005
  }
993
- if (input.parentGoalId) {
994
- await assertGoalBelongsToCompany(db, input.companyId, input.parentGoalId);
1006
+
1007
+ const effectiveLevel = (input.level ?? existing.level) as "company" | "project" | "agent";
1008
+ const effectiveProjectId = input.projectId !== undefined ? input.projectId : existing.projectId;
1009
+ const effectiveProjectIdNorm = effectiveProjectId?.trim() ? effectiveProjectId.trim() : null;
1010
+ const effectiveParent =
1011
+ input.parentGoalId !== undefined ? input.parentGoalId : existing.parentGoalId;
1012
+ const effectiveParentNorm = effectiveParent?.trim() ? effectiveParent.trim() : null;
1013
+
1014
+ await assertValidGoalHierarchy(db, input.companyId, {
1015
+ id: input.id,
1016
+ level: effectiveLevel,
1017
+ projectId: effectiveProjectIdNorm,
1018
+ parentGoalId: effectiveParentNorm
1019
+ });
1020
+ if (effectiveProjectIdNorm) {
1021
+ await assertProjectBelongsToCompany(db, input.companyId, effectiveProjectIdNorm);
995
1022
  }
996
- if (input.ownerAgentId) {
997
- await assertAgentBelongsToCompany(db, input.companyId, input.ownerAgentId);
1023
+ if (input.ownerAgentId?.trim()) {
1024
+ await assertAgentBelongsToCompany(db, input.companyId, input.ownerAgentId.trim());
998
1025
  }
999
1026
  const [goal] = await db
1000
1027
  .update(goals)
package/src/schema.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { sql } from "drizzle-orm";
2
+ import type { AnyPgColumn } from "drizzle-orm/pg-core";
2
3
  import { boolean, integer, numeric, pgTable, primaryKey, text, timestamp } from "drizzle-orm/pg-core";
3
4
 
4
5
  export const companies = pgTable("companies", {
@@ -52,7 +53,7 @@ export const goals = pgTable("goals", {
52
53
  .notNull()
53
54
  .references(() => companies.id, { onDelete: "cascade" }),
54
55
  projectId: text("project_id").references(() => projects.id, { onDelete: "set null" }),
55
- parentGoalId: text("parent_goal_id"),
56
+ parentGoalId: text("parent_goal_id").references((): AnyPgColumn => goals.id, { onDelete: "set null" }),
56
57
  /** When set, this agent-level goal is included only for that agent's heartbeats; null = all agents. */
57
58
  ownerAgentId: text("owner_agent_id"),
58
59
  level: text("level").notNull(),