bopodev-db 0.1.28 → 0.1.29

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.
@@ -0,0 +1,104 @@
1
+ import { and, eq } from "drizzle-orm";
2
+ import type { BopoDb } from "../client";
3
+ import { agents, goals, issues, projects, templates } from "../schema";
4
+
5
+ export class RepositoryValidationError extends Error {
6
+ constructor(message: string) {
7
+ super(message);
8
+ this.name = "RepositoryValidationError";
9
+ }
10
+ }
11
+
12
+ export async function assertProjectBelongsToCompany(db: BopoDb, companyId: string, projectId: string) {
13
+ const [project] = await db
14
+ .select({ id: projects.id })
15
+ .from(projects)
16
+ .where(and(eq(projects.companyId, companyId), eq(projects.id, projectId)))
17
+ .limit(1);
18
+ if (!project) {
19
+ throw new RepositoryValidationError("Project not found for company.");
20
+ }
21
+ }
22
+
23
+ export async function assertIssueBelongsToCompany(db: BopoDb, companyId: string, issueId: string) {
24
+ const [issue] = await db
25
+ .select({ id: issues.id })
26
+ .from(issues)
27
+ .where(and(eq(issues.companyId, companyId), eq(issues.id, issueId)))
28
+ .limit(1);
29
+ if (!issue) {
30
+ throw new RepositoryValidationError("Issue not found for company.");
31
+ }
32
+ }
33
+
34
+ export async function assertGoalBelongsToCompany(db: BopoDb, companyId: string, goalId: string) {
35
+ const [goal] = await db
36
+ .select({ id: goals.id })
37
+ .from(goals)
38
+ .where(and(eq(goals.companyId, companyId), eq(goals.id, goalId)))
39
+ .limit(1);
40
+ if (!goal) {
41
+ throw new RepositoryValidationError("Parent goal not found for company.");
42
+ }
43
+ }
44
+
45
+ /** Ensures a goal can be linked to an issue: same company; project-scoped goals must match the issue's project. */
46
+ export async function assertIssueGoalAssignable(
47
+ db: BopoDb,
48
+ companyId: string,
49
+ issueProjectId: string,
50
+ goalId: string | null | undefined
51
+ ) {
52
+ if (!goalId) {
53
+ return;
54
+ }
55
+ const [goal] = await db
56
+ .select({ id: goals.id, projectId: goals.projectId })
57
+ .from(goals)
58
+ .where(and(eq(goals.companyId, companyId), eq(goals.id, goalId)))
59
+ .limit(1);
60
+ if (!goal) {
61
+ throw new RepositoryValidationError("Goal not found for company.");
62
+ }
63
+ if (goal.projectId && goal.projectId !== issueProjectId) {
64
+ throw new RepositoryValidationError("Goal is scoped to a different project than this issue.");
65
+ }
66
+ }
67
+
68
+ /** Validates each goal can be linked to an issue (same company; project goals must match issue project). */
69
+ export async function assertIssueGoalsAssignable(
70
+ db: BopoDb,
71
+ companyId: string,
72
+ issueProjectId: string,
73
+ goalIds: string[]
74
+ ) {
75
+ for (const goalId of goalIds) {
76
+ await assertIssueGoalAssignable(db, companyId, issueProjectId, goalId);
77
+ }
78
+ }
79
+
80
+ export async function assertAgentBelongsToCompany(db: BopoDb, companyId: string, agentId: string) {
81
+ const [agent] = await db
82
+ .select({ id: agents.id })
83
+ .from(agents)
84
+ .where(and(eq(agents.companyId, companyId), eq(agents.id, agentId)))
85
+ .limit(1);
86
+ if (!agent) {
87
+ throw new RepositoryValidationError("Agent not found for company.");
88
+ }
89
+ }
90
+
91
+ export async function assertTemplateBelongsToCompany(db: BopoDb, companyId: string, templateId: string) {
92
+ const [template] = await db
93
+ .select({ id: templates.id })
94
+ .from(templates)
95
+ .where(and(eq(templates.companyId, companyId), eq(templates.id, templateId)))
96
+ .limit(1);
97
+ if (!template) {
98
+ throw new RepositoryValidationError("Template not found for company.");
99
+ }
100
+ }
101
+
102
+ export function compactUpdate<T extends Record<string, unknown>>(input: T) {
103
+ return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
104
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./helpers";
2
+ export * from "./companies";
3
+ export * from "./legacy";
@@ -1,6 +1,6 @@
1
1
  import { and, asc, desc, eq, gt, inArray, notInArray, sql } from "drizzle-orm";
2
2
  import { nanoid } from "nanoid";
3
- import type { BopoDb } from "./client";
3
+ import type { BopoDb } from "../client";
4
4
  import {
5
5
  activityLogs,
6
6
  agents,
@@ -8,7 +8,6 @@ import {
8
8
  approvalInboxStates,
9
9
  approvalRequests,
10
10
  auditEvents,
11
- companies,
12
11
  costLedger,
13
12
  goals,
14
13
  heartbeatRunQueue,
@@ -16,6 +15,7 @@ import {
16
15
  heartbeatRunMessages,
17
16
  issueAttachments,
18
17
  issueComments,
18
+ issueGoals,
19
19
  issues,
20
20
  pluginConfigs,
21
21
  pluginRuns,
@@ -26,100 +26,17 @@ import {
26
26
  templateVersions,
27
27
  templates,
28
28
  touchUpdatedAtSql
29
- } from "./schema";
30
-
31
- export class RepositoryValidationError extends Error {
32
- constructor(message: string) {
33
- super(message);
34
- this.name = "RepositoryValidationError";
35
- }
36
- }
37
-
38
- async function assertProjectBelongsToCompany(db: BopoDb, companyId: string, projectId: string) {
39
- const [project] = await db
40
- .select({ id: projects.id })
41
- .from(projects)
42
- .where(and(eq(projects.companyId, companyId), eq(projects.id, projectId)))
43
- .limit(1);
44
- if (!project) {
45
- throw new RepositoryValidationError("Project not found for company.");
46
- }
47
- }
48
-
49
- async function assertIssueBelongsToCompany(db: BopoDb, companyId: string, issueId: string) {
50
- const [issue] = await db
51
- .select({ id: issues.id })
52
- .from(issues)
53
- .where(and(eq(issues.companyId, companyId), eq(issues.id, issueId)))
54
- .limit(1);
55
- if (!issue) {
56
- throw new RepositoryValidationError("Issue not found for company.");
57
- }
58
- }
59
-
60
- async function assertGoalBelongsToCompany(db: BopoDb, companyId: string, goalId: string) {
61
- const [goal] = await db
62
- .select({ id: goals.id })
63
- .from(goals)
64
- .where(and(eq(goals.companyId, companyId), eq(goals.id, goalId)))
65
- .limit(1);
66
- if (!goal) {
67
- throw new RepositoryValidationError("Parent goal not found for company.");
68
- }
69
- }
70
-
71
- async function assertAgentBelongsToCompany(db: BopoDb, companyId: string, agentId: string) {
72
- const [agent] = await db
73
- .select({ id: agents.id })
74
- .from(agents)
75
- .where(and(eq(agents.companyId, companyId), eq(agents.id, agentId)))
76
- .limit(1);
77
- if (!agent) {
78
- throw new RepositoryValidationError("Agent not found for company.");
79
- }
80
- }
81
-
82
- async function assertTemplateBelongsToCompany(db: BopoDb, companyId: string, templateId: string) {
83
- const [template] = await db
84
- .select({ id: templates.id })
85
- .from(templates)
86
- .where(and(eq(templates.companyId, companyId), eq(templates.id, templateId)))
87
- .limit(1);
88
- if (!template) {
89
- throw new RepositoryValidationError("Template not found for company.");
90
- }
91
- }
92
-
93
- export async function createCompany(db: BopoDb, input: { name: string; mission?: string | null }) {
94
- const id = nanoid(12);
95
- await db.insert(companies).values({
96
- id,
97
- name: input.name,
98
- mission: input.mission ?? null
99
- });
100
- return { id, ...input };
101
- }
102
-
103
- export async function listCompanies(db: BopoDb) {
104
- return db.select().from(companies).orderBy(desc(companies.createdAt));
105
- }
106
-
107
- export async function updateCompany(
108
- db: BopoDb,
109
- input: { id: string; name?: string; mission?: string | null }
110
- ) {
111
- const [company] = await db
112
- .update(companies)
113
- .set(compactUpdate({ name: input.name, mission: input.mission }))
114
- .where(eq(companies.id, input.id))
115
- .returning();
116
- return company ?? null;
117
- }
118
-
119
- export async function deleteCompany(db: BopoDb, id: string) {
120
- const [deletedCompany] = await db.delete(companies).where(eq(companies.id, id)).returning({ id: companies.id });
121
- return Boolean(deletedCompany);
122
- }
29
+ } from "../schema";
30
+ import {
31
+ assertAgentBelongsToCompany,
32
+ assertGoalBelongsToCompany,
33
+ assertIssueGoalsAssignable,
34
+ assertIssueBelongsToCompany,
35
+ assertProjectBelongsToCompany,
36
+ assertTemplateBelongsToCompany,
37
+ compactUpdate,
38
+ RepositoryValidationError
39
+ } from "./helpers";
123
40
 
124
41
  export async function listProjects(db: BopoDb, companyId: string) {
125
42
  const rows = await db.select().from(projects).where(eq(projects.companyId, companyId)).orderBy(desc(projects.createdAt));
@@ -540,6 +457,46 @@ export async function listIssues(db: BopoDb, companyId: string, projectId?: stri
540
457
  return db.select().from(issues).where(where).orderBy(desc(issues.updatedAt));
541
458
  }
542
459
 
460
+ export async function listIssueGoalIdsBatch(db: BopoDb, companyId: string, issueIds: string[]) {
461
+ const map = new Map<string, string[]>();
462
+ if (issueIds.length === 0) {
463
+ return map;
464
+ }
465
+ const rows = await db
466
+ .select({ issueId: issueGoals.issueId, goalId: issueGoals.goalId })
467
+ .from(issueGoals)
468
+ .where(and(eq(issueGoals.companyId, companyId), inArray(issueGoals.issueId, issueIds)));
469
+ for (const row of rows) {
470
+ const list = map.get(row.issueId) ?? [];
471
+ list.push(row.goalId);
472
+ map.set(row.issueId, list);
473
+ }
474
+ return map;
475
+ }
476
+
477
+ export async function syncIssueGoals(
478
+ db: BopoDb,
479
+ input: { companyId: string; issueId: string; projectId: string; goalIds: string[] }
480
+ ) {
481
+ const dedupedGoalIds = Array.from(new Set(input.goalIds.map((id) => id.trim()).filter((id) => id.length > 0)));
482
+ await assertIssueBelongsToCompany(db, input.companyId, input.issueId);
483
+ await assertIssueGoalsAssignable(db, input.companyId, input.projectId, dedupedGoalIds);
484
+
485
+ await db
486
+ .delete(issueGoals)
487
+ .where(and(eq(issueGoals.companyId, input.companyId), eq(issueGoals.issueId, input.issueId)));
488
+
489
+ if (dedupedGoalIds.length > 0) {
490
+ await db.insert(issueGoals).values(
491
+ dedupedGoalIds.map((goalId) => ({
492
+ issueId: input.issueId,
493
+ goalId,
494
+ companyId: input.companyId
495
+ }))
496
+ );
497
+ }
498
+ }
499
+
543
500
  export async function getIssue(db: BopoDb, companyId: string, issueId: string) {
544
501
  const [row] = await db
545
502
  .select()
@@ -555,8 +512,10 @@ export async function createIssue(
555
512
  companyId: string;
556
513
  projectId: string;
557
514
  parentIssueId?: string | null;
515
+ goalIds?: string[];
558
516
  title: string;
559
517
  body?: string;
518
+ externalLink?: string | null;
560
519
  status?: string;
561
520
  priority?: string;
562
521
  assigneeAgentId?: string | null;
@@ -572,21 +531,35 @@ export async function createIssue(
572
531
  await assertAgentBelongsToCompany(db, input.companyId, input.assigneeAgentId);
573
532
  }
574
533
  const id = nanoid(12);
575
- await db.insert(issues).values({
576
- id,
577
- companyId: input.companyId,
578
- projectId: input.projectId,
579
- parentIssueId: input.parentIssueId ?? null,
580
- title: input.title,
581
- body: input.body,
582
- status: input.status ?? "todo",
583
- priority: input.priority ?? "none",
584
- assigneeAgentId: input.assigneeAgentId ?? null,
585
- labelsJson: JSON.stringify(input.labels ?? []),
586
- tagsJson: JSON.stringify(input.tags ?? [])
534
+ return await db.transaction(async (tx) => {
535
+ const [row] = await tx
536
+ .insert(issues)
537
+ .values({
538
+ id,
539
+ companyId: input.companyId,
540
+ projectId: input.projectId,
541
+ parentIssueId: input.parentIssueId ?? null,
542
+ title: input.title,
543
+ body: input.body,
544
+ externalLink: input.externalLink?.trim() ? input.externalLink.trim() : null,
545
+ status: input.status ?? "todo",
546
+ priority: input.priority ?? "none",
547
+ assigneeAgentId: input.assigneeAgentId ?? null,
548
+ labelsJson: JSON.stringify(input.labels ?? []),
549
+ tagsJson: JSON.stringify(input.tags ?? [])
550
+ })
551
+ .returning();
552
+ if (!row) {
553
+ throw new RepositoryValidationError("Failed to create issue.");
554
+ }
555
+ await syncIssueGoals(tx as unknown as BopoDb, {
556
+ companyId: input.companyId,
557
+ issueId: row.id,
558
+ projectId: input.projectId,
559
+ goalIds: input.goalIds ?? []
560
+ });
561
+ return row;
587
562
  });
588
-
589
- return { id, ...input };
590
563
  }
591
564
 
592
565
  export async function updateIssue(
@@ -595,8 +568,10 @@ export async function updateIssue(
595
568
  companyId: string;
596
569
  id: string;
597
570
  projectId?: string;
571
+ goalIds?: string[];
598
572
  title?: string;
599
573
  body?: string | null;
574
+ externalLink?: string | null;
600
575
  status?: string;
601
576
  priority?: string;
602
577
  assigneeAgentId?: string | null;
@@ -604,6 +579,14 @@ export async function updateIssue(
604
579
  tags?: string[];
605
580
  }
606
581
  ) {
582
+ const [existing] = await db
583
+ .select()
584
+ .from(issues)
585
+ .where(and(eq(issues.companyId, input.companyId), eq(issues.id, input.id)))
586
+ .limit(1);
587
+ if (!existing) {
588
+ return null;
589
+ }
607
590
  if (input.projectId) {
608
591
  await assertProjectBelongsToCompany(db, input.companyId, input.projectId);
609
592
  }
@@ -617,6 +600,12 @@ export async function updateIssue(
617
600
  projectId: input.projectId,
618
601
  title: input.title,
619
602
  body: input.body,
603
+ externalLink:
604
+ input.externalLink === undefined
605
+ ? undefined
606
+ : input.externalLink?.trim()
607
+ ? input.externalLink.trim()
608
+ : null,
620
609
  status: input.status,
621
610
  priority: input.priority,
622
611
  assigneeAgentId: input.assigneeAgentId,
@@ -627,7 +616,32 @@ export async function updateIssue(
627
616
  )
628
617
  .where(and(eq(issues.companyId, input.companyId), eq(issues.id, input.id)))
629
618
  .returning();
630
- return issue ?? null;
619
+ if (!issue) {
620
+ return null;
621
+ }
622
+ if (input.goalIds !== undefined) {
623
+ await syncIssueGoals(db, {
624
+ companyId: input.companyId,
625
+ issueId: issue.id,
626
+ projectId: issue.projectId,
627
+ goalIds: input.goalIds
628
+ });
629
+ } else if (input.projectId && input.projectId !== existing.projectId) {
630
+ const currentRows = await db
631
+ .select({ goalId: issueGoals.goalId })
632
+ .from(issueGoals)
633
+ .where(and(eq(issueGoals.companyId, input.companyId), eq(issueGoals.issueId, issue.id)));
634
+ const currentGoalIds = currentRows.map((row) => row.goalId);
635
+ if (currentGoalIds.length > 0) {
636
+ await syncIssueGoals(db, {
637
+ companyId: input.companyId,
638
+ issueId: issue.id,
639
+ projectId: issue.projectId,
640
+ goalIds: currentGoalIds
641
+ });
642
+ }
643
+ }
644
+ return issue;
631
645
  }
632
646
 
633
647
  export async function deleteIssue(db: BopoDb, companyId: string, id: string) {
@@ -922,6 +936,7 @@ export async function createGoal(
922
936
  companyId: string;
923
937
  projectId?: string | null;
924
938
  parentGoalId?: string | null;
939
+ ownerAgentId?: string | null;
925
940
  level: "company" | "project" | "agent";
926
941
  title: string;
927
942
  description?: string;
@@ -933,12 +948,16 @@ export async function createGoal(
933
948
  if (input.parentGoalId) {
934
949
  await assertGoalBelongsToCompany(db, input.companyId, input.parentGoalId);
935
950
  }
951
+ if (input.ownerAgentId) {
952
+ await assertAgentBelongsToCompany(db, input.companyId, input.ownerAgentId);
953
+ }
936
954
  const id = nanoid(12);
937
955
  await db.insert(goals).values({
938
956
  id,
939
957
  companyId: input.companyId,
940
958
  projectId: input.projectId ?? null,
941
959
  parentGoalId: input.parentGoalId ?? null,
960
+ ownerAgentId: input.ownerAgentId?.trim() ? input.ownerAgentId.trim() : null,
942
961
  level: input.level,
943
962
  title: input.title,
944
963
  description: input.description ?? null
@@ -957,6 +976,7 @@ export async function updateGoal(
957
976
  id: string;
958
977
  projectId?: string | null;
959
978
  parentGoalId?: string | null;
979
+ ownerAgentId?: string | null;
960
980
  level?: "company" | "project" | "agent";
961
981
  title?: string;
962
982
  description?: string | null;
@@ -969,12 +989,21 @@ export async function updateGoal(
969
989
  if (input.parentGoalId) {
970
990
  await assertGoalBelongsToCompany(db, input.companyId, input.parentGoalId);
971
991
  }
992
+ if (input.ownerAgentId) {
993
+ await assertAgentBelongsToCompany(db, input.companyId, input.ownerAgentId);
994
+ }
972
995
  const [goal] = await db
973
996
  .update(goals)
974
997
  .set(
975
998
  compactUpdate({
976
999
  projectId: input.projectId,
977
1000
  parentGoalId: input.parentGoalId,
1001
+ ownerAgentId:
1002
+ input.ownerAgentId === undefined
1003
+ ? undefined
1004
+ : input.ownerAgentId?.trim()
1005
+ ? input.ownerAgentId.trim()
1006
+ : null,
978
1007
  level: input.level,
979
1008
  title: input.title,
980
1009
  description: input.description,
@@ -1639,6 +1668,7 @@ export async function enqueueHeartbeatJob(
1639
1668
  }
1640
1669
 
1641
1670
  export async function claimNextHeartbeatJob(db: BopoDb, companyId: string) {
1671
+ // Postgres-specific: CTE + NOT EXISTS + FOR UPDATE SKIP LOCKED + UPDATE FROM — kept as raw SQL for correct job claiming under concurrency.
1642
1672
  const result = await db.execute(sql`
1643
1673
  WITH candidate AS (
1644
1674
  SELECT q.id
@@ -1893,6 +1923,7 @@ export async function listHeartbeatRunMessagesForRuns(
1893
1923
  }
1894
1924
  const perRunLimit = Math.min(Math.max(input.perRunLimit ?? 60, 1), 500);
1895
1925
  const runIdValues = sql.join(runIds.map((runId) => sql`(${runId})`), sql`, `);
1926
+ // Window functions (ROW_NUMBER) and VALUES-driven CTE — impractical to express purely with Drizzle’s query builder.
1896
1927
  const rankedRows = await db.execute(sql`
1897
1928
  WITH requested(run_id) AS (
1898
1929
  VALUES ${runIdValues}
@@ -2378,7 +2409,3 @@ export async function createTemplateInstall(
2378
2409
  .returning();
2379
2410
  return row ?? null;
2380
2411
  }
2381
-
2382
- function compactUpdate<T extends Record<string, unknown>>(input: T) {
2383
- return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
2384
- }
package/src/schema.ts CHANGED
@@ -53,6 +53,8 @@ export const goals = pgTable("goals", {
53
53
  .references(() => companies.id, { onDelete: "cascade" }),
54
54
  projectId: text("project_id").references(() => projects.id, { onDelete: "set null" }),
55
55
  parentGoalId: text("parent_goal_id"),
56
+ /** When set, this agent-level goal is included only for that agent's heartbeats; null = all agents. */
57
+ ownerAgentId: text("owner_agent_id"),
56
58
  level: text("level").notNull(),
57
59
  title: text("title").notNull(),
58
60
  description: text("description"),
@@ -114,12 +116,31 @@ export const issues = pgTable("issues", {
114
116
  assigneeAgentId: text("assignee_agent_id"),
115
117
  labelsJson: text("labels_json").notNull().default("[]"),
116
118
  tagsJson: text("tags_json").notNull().default("[]"),
119
+ /** Optional link to a PR, branch page, or other external tracker (GitHub/GitLab/etc.). */
120
+ externalLink: text("external_link"),
117
121
  isClaimed: boolean("is_claimed").notNull().default(false),
118
122
  claimedByHeartbeatRunId: text("claimed_by_heartbeat_run_id"),
119
123
  createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(),
120
124
  updatedAt: timestamp("updated_at", { mode: "date" }).defaultNow().notNull()
121
125
  });
122
126
 
127
+ export const issueGoals = pgTable(
128
+ "issue_goals",
129
+ {
130
+ issueId: text("issue_id")
131
+ .notNull()
132
+ .references(() => issues.id, { onDelete: "cascade" }),
133
+ goalId: text("goal_id")
134
+ .notNull()
135
+ .references(() => goals.id, { onDelete: "cascade" }),
136
+ companyId: text("company_id")
137
+ .notNull()
138
+ .references(() => companies.id, { onDelete: "cascade" }),
139
+ createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull()
140
+ },
141
+ (table) => [primaryKey({ columns: [table.issueId, table.goalId] })]
142
+ );
143
+
123
144
  export const issueComments = pgTable("issue_comments", {
124
145
  id: text("id").primaryKey(),
125
146
  issueId: text("issue_id")
@@ -423,6 +444,7 @@ export const schema = {
423
444
  goals,
424
445
  agents,
425
446
  issues,
447
+ issueGoals,
426
448
  issueComments,
427
449
  issueAttachments,
428
450
  activityLogs,