bopodev-db 0.1.27 → 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.
@@ -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));
@@ -215,7 +132,7 @@ export async function updateProject(
215
132
  }
216
133
  if (input.workspaceLocalPath !== undefined || input.workspaceGithubRepo !== undefined) {
217
134
  const existingWorkspaces = await listProjectWorkspaces(db, input.companyId, input.id);
218
- const primaryWorkspace = existingWorkspaces.find((workspace) => workspace.isPrimary) ?? existingWorkspaces[0] ?? null;
135
+ const primaryWorkspace = existingWorkspaces.find((workspace: any) => workspace.isPrimary) ?? existingWorkspaces[0] ?? null;
219
136
  const hasAnyWorkspaceField =
220
137
  (input.workspaceLocalPath?.trim() ?? "").length > 0 || (input.workspaceGithubRepo?.trim() ?? "").length > 0;
221
138
  if (!hasAnyWorkspaceField) {
@@ -272,7 +189,7 @@ export async function createProjectWorkspace(
272
189
  }
273
190
  ) {
274
191
  const id = nanoid(12);
275
- return db.transaction(async (tx) => {
192
+ return db.transaction(async (tx: any) => {
276
193
  const existingWorkspaces = await tx
277
194
  .select({ id: projectWorkspaces.id })
278
195
  .from(projectWorkspaces)
@@ -315,7 +232,7 @@ export async function updateProjectWorkspace(
315
232
  isPrimary?: boolean;
316
233
  }
317
234
  ) {
318
- return db.transaction(async (tx) => {
235
+ return db.transaction(async (tx: any) => {
319
236
  if (input.isPrimary === true) {
320
237
  await tx
321
238
  .update(projectWorkspaces)
@@ -385,7 +302,7 @@ export async function deleteProjectWorkspace(
385
302
  db: BopoDb,
386
303
  input: { companyId: string; projectId: string; id: string }
387
304
  ) {
388
- return db.transaction(async (tx) => {
305
+ return db.transaction(async (tx: any) => {
389
306
  const [workspace] = await tx
390
307
  .delete(projectWorkspaces)
391
308
  .where(
@@ -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) {
@@ -763,7 +777,7 @@ export async function listIssueComments(db: BopoDb, companyId: string, issueId:
763
777
  .from(issueComments)
764
778
  .where(and(eq(issueComments.companyId, companyId), eq(issueComments.issueId, issueId)))
765
779
  .orderBy(asc(issueComments.createdAt));
766
- return comments.map((comment) => normalizeIssueComment(comment));
780
+ return comments.map((comment: any) => normalizeIssueComment(comment));
767
781
  }
768
782
 
769
783
  export async function listIssueActivity(db: BopoDb, companyId: string, issueId: string, limit = 100) {
@@ -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
@@ -1674,7 +1704,7 @@ export async function claimNextHeartbeatJob(db: BopoDb, companyId: string) {
1674
1704
  WHERE q.id = c.id
1675
1705
  RETURNING q.*;
1676
1706
  `);
1677
- const row = (result.rows ?? [])[0] as Record<string, unknown> | undefined;
1707
+ const row = result[0] as Record<string, unknown> | undefined;
1678
1708
  return row ? normalizeHeartbeatQueueJob(row) : null;
1679
1709
  }
1680
1710
 
@@ -1804,7 +1834,7 @@ export async function listHeartbeatQueueJobs(
1804
1834
  .where(and(...conditions))
1805
1835
  .orderBy(asc(heartbeatRunQueue.priority), asc(heartbeatRunQueue.availableAt), asc(heartbeatRunQueue.createdAt))
1806
1836
  .limit(limit);
1807
- return rows.map((row) => normalizeHeartbeatQueueJob(row));
1837
+ return rows.map((row: any) => normalizeHeartbeatQueueJob(row));
1808
1838
  }
1809
1839
 
1810
1840
  export async function getHeartbeatRun(db: BopoDb, companyId: string, runId: string) {
@@ -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}
@@ -1935,7 +1966,7 @@ export async function listHeartbeatRunMessagesForRuns(
1935
1966
  WHERE rn <= ${perRunLimit}
1936
1967
  ORDER BY run_id ASC, sequence ASC
1937
1968
  `);
1938
- const rows = (rankedRows.rows ?? []) as Array<{
1969
+ const rows = rankedRows as unknown as Array<{
1939
1970
  id: string;
1940
1971
  company_id: string;
1941
1972
  run_id: string;
@@ -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,