bopodev-db 0.1.12 → 0.1.14

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,4 +1,4 @@
1
- import { and, desc, eq, inArray, notInArray, sql } from "drizzle-orm";
1
+ import { and, asc, desc, eq, gt, inArray, notInArray, sql } from "drizzle-orm";
2
2
  import { nanoid } from "nanoid";
3
3
  import type { BopoDb } from "./client";
4
4
  import {
@@ -11,8 +11,14 @@ import {
11
11
  costLedger,
12
12
  goals,
13
13
  heartbeatRuns,
14
+ heartbeatRunMessages,
15
+ issueAttachments,
14
16
  issueComments,
15
17
  issues,
18
+ modelPricing,
19
+ pluginConfigs,
20
+ pluginRuns,
21
+ plugins,
16
22
  projects,
17
23
  touchUpdatedAtSql
18
24
  } from "./schema";
@@ -106,6 +112,7 @@ export async function listProjects(db: BopoDb, companyId: string) {
106
112
  export async function createProject(
107
113
  db: BopoDb,
108
114
  input: {
115
+ id?: string;
109
116
  companyId: string;
110
117
  name: string;
111
118
  description?: string | null;
@@ -303,6 +310,76 @@ export async function deleteIssue(db: BopoDb, companyId: string, id: string) {
303
310
  return Boolean(deletedIssue);
304
311
  }
305
312
 
313
+ export async function addIssueAttachment(
314
+ db: BopoDb,
315
+ input: {
316
+ id?: string;
317
+ companyId: string;
318
+ issueId: string;
319
+ projectId: string;
320
+ fileName: string;
321
+ mimeType?: string | null;
322
+ fileSizeBytes: number;
323
+ relativePath: string;
324
+ uploadedByActorType?: "human" | "agent" | "system";
325
+ uploadedByActorId?: string | null;
326
+ }
327
+ ) {
328
+ await assertIssueBelongsToCompany(db, input.companyId, input.issueId);
329
+ await assertProjectBelongsToCompany(db, input.companyId, input.projectId);
330
+ const id = input.id ?? nanoid(14);
331
+ await db.insert(issueAttachments).values({
332
+ id,
333
+ companyId: input.companyId,
334
+ issueId: input.issueId,
335
+ projectId: input.projectId,
336
+ fileName: input.fileName,
337
+ mimeType: input.mimeType ?? null,
338
+ fileSizeBytes: input.fileSizeBytes,
339
+ relativePath: input.relativePath,
340
+ uploadedByActorType: input.uploadedByActorType ?? "human",
341
+ uploadedByActorId: input.uploadedByActorId ?? null
342
+ });
343
+ return { id, ...input };
344
+ }
345
+
346
+ export async function listIssueAttachments(db: BopoDb, companyId: string, issueId: string) {
347
+ return db
348
+ .select()
349
+ .from(issueAttachments)
350
+ .where(and(eq(issueAttachments.companyId, companyId), eq(issueAttachments.issueId, issueId)))
351
+ .orderBy(desc(issueAttachments.createdAt));
352
+ }
353
+
354
+ export async function getIssueAttachment(db: BopoDb, companyId: string, issueId: string, attachmentId: string) {
355
+ const [attachment] = await db
356
+ .select()
357
+ .from(issueAttachments)
358
+ .where(
359
+ and(
360
+ eq(issueAttachments.companyId, companyId),
361
+ eq(issueAttachments.issueId, issueId),
362
+ eq(issueAttachments.id, attachmentId)
363
+ )
364
+ )
365
+ .limit(1);
366
+ return attachment ?? null;
367
+ }
368
+
369
+ export async function deleteIssueAttachment(db: BopoDb, companyId: string, issueId: string, attachmentId: string) {
370
+ const [deletedAttachment] = await db
371
+ .delete(issueAttachments)
372
+ .where(
373
+ and(
374
+ eq(issueAttachments.companyId, companyId),
375
+ eq(issueAttachments.issueId, issueId),
376
+ eq(issueAttachments.id, attachmentId)
377
+ )
378
+ )
379
+ .returning();
380
+ return deletedAttachment ?? null;
381
+ }
382
+
306
383
  export async function addIssueComment(
307
384
  db: BopoDb,
308
385
  input: {
@@ -461,7 +538,16 @@ export async function createAgent(
461
538
  managerAgentId?: string | null;
462
539
  role: string;
463
540
  name: string;
464
- providerType: "claude_code" | "codex" | "cursor" | "opencode" | "http" | "shell";
541
+ providerType:
542
+ | "claude_code"
543
+ | "codex"
544
+ | "cursor"
545
+ | "opencode"
546
+ | "gemini_cli"
547
+ | "openai_api"
548
+ | "anthropic_api"
549
+ | "http"
550
+ | "shell";
465
551
  heartbeatCron: string;
466
552
  monthlyBudgetUsd: string;
467
553
  canHireAgents?: boolean;
@@ -523,7 +609,16 @@ export async function updateAgent(
523
609
  managerAgentId?: string | null;
524
610
  role?: string;
525
611
  name?: string;
526
- providerType?: "claude_code" | "codex" | "cursor" | "opencode" | "http" | "shell";
612
+ providerType?:
613
+ | "claude_code"
614
+ | "codex"
615
+ | "cursor"
616
+ | "opencode"
617
+ | "gemini_cli"
618
+ | "openai_api"
619
+ | "anthropic_api"
620
+ | "http"
621
+ | "shell";
527
622
  status?: string;
528
623
  heartbeatCron?: string;
529
624
  monthlyBudgetUsd?: string;
@@ -751,6 +846,10 @@ export async function appendCost(
751
846
  input: {
752
847
  companyId: string;
753
848
  providerType: string;
849
+ runtimeModelId?: string | null;
850
+ pricingProviderType?: string | null;
851
+ pricingModelId?: string | null;
852
+ pricingSource?: "exact" | "missing" | null;
754
853
  tokenInput: number;
755
854
  tokenOutput: number;
756
855
  usdCost: string;
@@ -764,6 +863,10 @@ export async function appendCost(
764
863
  id,
765
864
  companyId: input.companyId,
766
865
  providerType: input.providerType,
866
+ runtimeModelId: input.runtimeModelId ?? null,
867
+ pricingProviderType: input.pricingProviderType ?? null,
868
+ pricingModelId: input.pricingModelId ?? null,
869
+ pricingSource: input.pricingSource ?? null,
767
870
  tokenInput: input.tokenInput,
768
871
  tokenOutput: input.tokenOutput,
769
872
  usdCost: input.usdCost,
@@ -792,6 +895,177 @@ export async function listHeartbeatRuns(db: BopoDb, companyId: string, limit = 1
792
895
  .limit(limit);
793
896
  }
794
897
 
898
+ export async function getHeartbeatRun(db: BopoDb, companyId: string, runId: string) {
899
+ const [run] = await db
900
+ .select()
901
+ .from(heartbeatRuns)
902
+ .where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, runId)))
903
+ .limit(1);
904
+ return run ?? null;
905
+ }
906
+
907
+ export async function appendHeartbeatRunMessages(
908
+ db: BopoDb,
909
+ input: {
910
+ companyId: string;
911
+ runId: string;
912
+ messages: Array<{
913
+ id?: string;
914
+ sequence: number;
915
+ kind: string;
916
+ label?: string | null;
917
+ text?: string | null;
918
+ payloadJson?: string | null;
919
+ signalLevel?: "high" | "medium" | "low" | "noise" | null;
920
+ groupKey?: string | null;
921
+ source?: "stdout" | "stderr" | "trace_fallback" | null;
922
+ createdAt?: Date;
923
+ }>;
924
+ }
925
+ ) {
926
+ if (input.messages.length === 0) {
927
+ return [] as string[];
928
+ }
929
+ const values = input.messages.map((message) => ({
930
+ id: message.id ?? nanoid(14),
931
+ companyId: input.companyId,
932
+ runId: input.runId,
933
+ sequence: message.sequence,
934
+ kind: message.kind,
935
+ label: message.label ?? null,
936
+ text: message.text ?? null,
937
+ payloadJson: message.payloadJson ?? null,
938
+ signalLevel: message.signalLevel ?? null,
939
+ groupKey: message.groupKey ?? null,
940
+ source: message.source ?? null,
941
+ createdAt: message.createdAt ?? new Date()
942
+ }));
943
+ await db.insert(heartbeatRunMessages).values(values);
944
+ return values.map((message) => message.id);
945
+ }
946
+
947
+ export async function listHeartbeatRunMessages(
948
+ db: BopoDb,
949
+ input: { companyId: string; runId: string; afterSequence?: number; limit?: number }
950
+ ) {
951
+ const limit = Math.min(Math.max(input.limit ?? 200, 1), 500);
952
+ const whereClause =
953
+ input.afterSequence !== undefined
954
+ ? and(
955
+ eq(heartbeatRunMessages.companyId, input.companyId),
956
+ eq(heartbeatRunMessages.runId, input.runId),
957
+ gt(heartbeatRunMessages.sequence, input.afterSequence)
958
+ )
959
+ : and(eq(heartbeatRunMessages.companyId, input.companyId), eq(heartbeatRunMessages.runId, input.runId));
960
+ const rows = await db
961
+ .select()
962
+ .from(heartbeatRunMessages)
963
+ .where(whereClause)
964
+ .orderBy(asc(heartbeatRunMessages.sequence))
965
+ .limit(limit + 1);
966
+ const hasMore = rows.length > limit;
967
+ const items = hasMore ? rows.slice(0, limit) : rows;
968
+ return {
969
+ items,
970
+ nextCursor: hasMore ? String(items[items.length - 1]?.sequence ?? "") : null
971
+ };
972
+ }
973
+
974
+ export async function listHeartbeatRunMessagesForRuns(
975
+ db: BopoDb,
976
+ input: { companyId: string; runIds: string[]; perRunLimit?: number }
977
+ ) {
978
+ const runIds = Array.from(new Set(input.runIds.filter((runId) => runId.trim().length > 0)));
979
+ if (runIds.length === 0) {
980
+ return new Map<string, { items: Array<(typeof heartbeatRunMessages.$inferSelect)>; nextCursor: string | null }>();
981
+ }
982
+ const perRunLimit = Math.min(Math.max(input.perRunLimit ?? 60, 1), 500);
983
+ const runIdValues = sql.join(runIds.map((runId) => sql`(${runId})`), sql`, `);
984
+ const rankedRows = await db.execute(sql`
985
+ WITH requested(run_id) AS (
986
+ VALUES ${runIdValues}
987
+ ),
988
+ ranked AS (
989
+ SELECT
990
+ m.id,
991
+ m.company_id,
992
+ m.run_id,
993
+ m.sequence,
994
+ m.kind,
995
+ m.label,
996
+ m.text,
997
+ m.payload_json,
998
+ m.signal_level,
999
+ m.group_key,
1000
+ m.source,
1001
+ m.created_at,
1002
+ ROW_NUMBER() OVER (PARTITION BY m.run_id ORDER BY m.sequence DESC) AS rn,
1003
+ COUNT(*) OVER (PARTITION BY m.run_id) AS total_count
1004
+ FROM heartbeat_run_messages m
1005
+ JOIN requested r ON r.run_id = m.run_id
1006
+ WHERE m.company_id = ${input.companyId}
1007
+ )
1008
+ SELECT
1009
+ id,
1010
+ company_id,
1011
+ run_id,
1012
+ sequence,
1013
+ kind,
1014
+ label,
1015
+ text,
1016
+ payload_json,
1017
+ signal_level,
1018
+ group_key,
1019
+ source,
1020
+ created_at,
1021
+ total_count
1022
+ FROM ranked
1023
+ WHERE rn <= ${perRunLimit}
1024
+ ORDER BY run_id ASC, sequence ASC
1025
+ `);
1026
+ const rows = (rankedRows.rows ?? []) as Array<{
1027
+ id: string;
1028
+ company_id: string;
1029
+ run_id: string;
1030
+ sequence: number;
1031
+ kind: string;
1032
+ label: string | null;
1033
+ text: string | null;
1034
+ payload_json: string | null;
1035
+ signal_level: string | null;
1036
+ group_key: string | null;
1037
+ source: string | null;
1038
+ created_at: Date | string;
1039
+ total_count: number;
1040
+ }>;
1041
+ const grouped = new Map<string, { items: Array<(typeof heartbeatRunMessages.$inferSelect)>; nextCursor: string | null }>();
1042
+ for (const runId of runIds) {
1043
+ grouped.set(runId, { items: [], nextCursor: null });
1044
+ }
1045
+ for (const row of rows) {
1046
+ const bucket = grouped.get(row.run_id) ?? { items: [], nextCursor: null };
1047
+ bucket.items.push({
1048
+ id: row.id,
1049
+ companyId: row.company_id,
1050
+ runId: row.run_id,
1051
+ sequence: row.sequence,
1052
+ kind: row.kind,
1053
+ label: row.label,
1054
+ text: row.text,
1055
+ payloadJson: row.payload_json,
1056
+ signalLevel: row.signal_level,
1057
+ groupKey: row.group_key,
1058
+ source: row.source,
1059
+ createdAt: row.created_at instanceof Date ? row.created_at : new Date(row.created_at)
1060
+ });
1061
+ if (row.total_count > perRunLimit) {
1062
+ bucket.nextCursor = String(row.sequence);
1063
+ }
1064
+ grouped.set(row.run_id, bucket);
1065
+ }
1066
+ return grouped;
1067
+ }
1068
+
795
1069
  export async function appendActivity(
796
1070
  db: BopoDb,
797
1071
  input: {
@@ -816,6 +1090,238 @@ export async function appendActivity(
816
1090
  return id;
817
1091
  }
818
1092
 
1093
+ export async function upsertPlugin(
1094
+ db: BopoDb,
1095
+ input: {
1096
+ id: string;
1097
+ name: string;
1098
+ version: string;
1099
+ kind: string;
1100
+ runtimeType: string;
1101
+ runtimeEntrypoint: string;
1102
+ hooksJson?: string;
1103
+ capabilitiesJson?: string;
1104
+ manifestJson?: string;
1105
+ }
1106
+ ) {
1107
+ await db
1108
+ .insert(plugins)
1109
+ .values({
1110
+ id: input.id,
1111
+ name: input.name,
1112
+ version: input.version,
1113
+ kind: input.kind,
1114
+ runtimeType: input.runtimeType,
1115
+ runtimeEntrypoint: input.runtimeEntrypoint,
1116
+ hooksJson: input.hooksJson ?? "[]",
1117
+ capabilitiesJson: input.capabilitiesJson ?? "[]",
1118
+ manifestJson: input.manifestJson ?? "{}"
1119
+ })
1120
+ .onConflictDoUpdate({
1121
+ target: plugins.id,
1122
+ set: {
1123
+ name: input.name,
1124
+ version: input.version,
1125
+ kind: input.kind,
1126
+ runtimeType: input.runtimeType,
1127
+ runtimeEntrypoint: input.runtimeEntrypoint,
1128
+ hooksJson: input.hooksJson ?? "[]",
1129
+ capabilitiesJson: input.capabilitiesJson ?? "[]",
1130
+ manifestJson: input.manifestJson ?? "{}",
1131
+ updatedAt: touchUpdatedAtSql
1132
+ }
1133
+ });
1134
+ return input.id;
1135
+ }
1136
+
1137
+ export async function listPlugins(db: BopoDb) {
1138
+ return db.select().from(plugins).orderBy(asc(plugins.name));
1139
+ }
1140
+
1141
+ export async function updatePluginConfig(
1142
+ db: BopoDb,
1143
+ input: {
1144
+ companyId: string;
1145
+ pluginId: string;
1146
+ enabled?: boolean;
1147
+ priority?: number;
1148
+ configJson?: string;
1149
+ grantedCapabilitiesJson?: string;
1150
+ }
1151
+ ) {
1152
+ await db
1153
+ .insert(pluginConfigs)
1154
+ .values({
1155
+ companyId: input.companyId,
1156
+ pluginId: input.pluginId,
1157
+ enabled: input.enabled ?? false,
1158
+ priority: input.priority ?? 100,
1159
+ configJson: input.configJson ?? "{}",
1160
+ grantedCapabilitiesJson: input.grantedCapabilitiesJson ?? "[]"
1161
+ })
1162
+ .onConflictDoUpdate({
1163
+ target: [pluginConfigs.companyId, pluginConfigs.pluginId],
1164
+ set: compactUpdate({
1165
+ enabled: input.enabled,
1166
+ priority: input.priority,
1167
+ configJson: input.configJson,
1168
+ grantedCapabilitiesJson: input.grantedCapabilitiesJson,
1169
+ updatedAt: touchUpdatedAtSql
1170
+ })
1171
+ });
1172
+ }
1173
+
1174
+ export async function deletePluginConfig(
1175
+ db: BopoDb,
1176
+ input: {
1177
+ companyId: string;
1178
+ pluginId: string;
1179
+ }
1180
+ ) {
1181
+ await db
1182
+ .delete(pluginConfigs)
1183
+ .where(and(eq(pluginConfigs.companyId, input.companyId), eq(pluginConfigs.pluginId, input.pluginId)));
1184
+ }
1185
+
1186
+ export async function deletePluginById(db: BopoDb, pluginId: string) {
1187
+ await db.delete(plugins).where(eq(plugins.id, pluginId));
1188
+ }
1189
+
1190
+ export async function listCompanyPluginConfigs(db: BopoDb, companyId: string) {
1191
+ return db
1192
+ .select({
1193
+ companyId: pluginConfigs.companyId,
1194
+ pluginId: pluginConfigs.pluginId,
1195
+ enabled: pluginConfigs.enabled,
1196
+ priority: pluginConfigs.priority,
1197
+ configJson: pluginConfigs.configJson,
1198
+ grantedCapabilitiesJson: pluginConfigs.grantedCapabilitiesJson,
1199
+ pluginName: plugins.name,
1200
+ pluginVersion: plugins.version,
1201
+ pluginKind: plugins.kind,
1202
+ runtimeType: plugins.runtimeType,
1203
+ runtimeEntrypoint: plugins.runtimeEntrypoint,
1204
+ hooksJson: plugins.hooksJson,
1205
+ capabilitiesJson: plugins.capabilitiesJson,
1206
+ manifestJson: plugins.manifestJson
1207
+ })
1208
+ .from(pluginConfigs)
1209
+ .innerJoin(plugins, eq(pluginConfigs.pluginId, plugins.id))
1210
+ .where(eq(pluginConfigs.companyId, companyId))
1211
+ .orderBy(asc(pluginConfigs.priority), asc(pluginConfigs.pluginId));
1212
+ }
1213
+
1214
+ export async function appendPluginRun(
1215
+ db: BopoDb,
1216
+ input: {
1217
+ companyId: string;
1218
+ runId?: string | null;
1219
+ pluginId: string;
1220
+ hook: string;
1221
+ status: string;
1222
+ durationMs: number;
1223
+ error?: string | null;
1224
+ diagnosticsJson?: string;
1225
+ }
1226
+ ) {
1227
+ const id = nanoid(14);
1228
+ await db.insert(pluginRuns).values({
1229
+ id,
1230
+ companyId: input.companyId,
1231
+ runId: input.runId ?? null,
1232
+ pluginId: input.pluginId,
1233
+ hook: input.hook,
1234
+ status: input.status,
1235
+ durationMs: Math.max(0, Math.floor(input.durationMs)),
1236
+ error: input.error ?? null,
1237
+ diagnosticsJson: input.diagnosticsJson ?? "{}"
1238
+ });
1239
+ return id;
1240
+ }
1241
+
1242
+ export async function listPluginRuns(
1243
+ db: BopoDb,
1244
+ input: { companyId: string; pluginId?: string; runId?: string; limit?: number }
1245
+ ) {
1246
+ const limit = Math.min(Math.max(input.limit ?? 200, 1), 1000);
1247
+ return db
1248
+ .select()
1249
+ .from(pluginRuns)
1250
+ .where(
1251
+ and(
1252
+ eq(pluginRuns.companyId, input.companyId),
1253
+ input.pluginId ? eq(pluginRuns.pluginId, input.pluginId) : undefined,
1254
+ input.runId ? eq(pluginRuns.runId, input.runId) : undefined
1255
+ )
1256
+ )
1257
+ .orderBy(desc(pluginRuns.createdAt))
1258
+ .limit(limit);
1259
+ }
1260
+
1261
+ export async function listModelPricing(db: BopoDb, companyId: string) {
1262
+ return db
1263
+ .select()
1264
+ .from(modelPricing)
1265
+ .where(eq(modelPricing.companyId, companyId))
1266
+ .orderBy(asc(modelPricing.providerType), asc(modelPricing.modelId));
1267
+ }
1268
+
1269
+ export async function getModelPricing(
1270
+ db: BopoDb,
1271
+ input: { companyId: string; providerType: string; modelId: string }
1272
+ ) {
1273
+ const rows = await db
1274
+ .select()
1275
+ .from(modelPricing)
1276
+ .where(
1277
+ and(
1278
+ eq(modelPricing.companyId, input.companyId),
1279
+ eq(modelPricing.providerType, input.providerType),
1280
+ eq(modelPricing.modelId, input.modelId)
1281
+ )
1282
+ )
1283
+ .limit(1);
1284
+ return rows[0] ?? null;
1285
+ }
1286
+
1287
+ export async function upsertModelPricing(
1288
+ db: BopoDb,
1289
+ input: {
1290
+ companyId: string;
1291
+ providerType: string;
1292
+ modelId: string;
1293
+ displayName?: string | null;
1294
+ inputUsdPer1M?: string | null;
1295
+ outputUsdPer1M?: string | null;
1296
+ currency?: string | null;
1297
+ updatedBy?: string | null;
1298
+ }
1299
+ ) {
1300
+ await db
1301
+ .insert(modelPricing)
1302
+ .values({
1303
+ companyId: input.companyId,
1304
+ providerType: input.providerType,
1305
+ modelId: input.modelId,
1306
+ displayName: input.displayName ?? null,
1307
+ inputUsdPer1M: input.inputUsdPer1M ?? "0.000000",
1308
+ outputUsdPer1M: input.outputUsdPer1M ?? "0.000000",
1309
+ currency: input.currency ?? "USD",
1310
+ updatedBy: input.updatedBy ?? null
1311
+ })
1312
+ .onConflictDoUpdate({
1313
+ target: [modelPricing.companyId, modelPricing.providerType, modelPricing.modelId],
1314
+ set: compactUpdate({
1315
+ displayName: input.displayName ?? null,
1316
+ inputUsdPer1M: input.inputUsdPer1M ?? "0.000000",
1317
+ outputUsdPer1M: input.outputUsdPer1M ?? "0.000000",
1318
+ currency: input.currency ?? "USD",
1319
+ updatedBy: input.updatedBy ?? null,
1320
+ updatedAt: touchUpdatedAtSql
1321
+ })
1322
+ });
1323
+ }
1324
+
819
1325
  function compactUpdate<T extends Record<string, unknown>>(input: T) {
820
1326
  return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
821
1327
  }