@shipfox/annotations 12.0.0 → 12.3.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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/annotations",
3
3
  "license": "MIT",
4
- "version": "12.0.0",
4
+ "version": "12.3.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -20,13 +20,13 @@
20
20
  "dependencies": {
21
21
  "drizzle-orm": "^0.45.2",
22
22
  "zod": "^4.4.3",
23
- "@shipfox/annotations-dto": "12.0.0",
24
- "@shipfox/api-auth-context": "12.0.0",
23
+ "@shipfox/annotations-dto": "12.3.0",
24
+ "@shipfox/api-auth-context": "12.2.0",
25
25
  "@shipfox/config": "1.2.4",
26
26
  "@shipfox/inter-module": "0.2.3",
27
27
  "@shipfox/node-drizzle": "0.3.5",
28
- "@shipfox/node-fastify": "0.4.1",
29
- "@shipfox/node-module": "1.0.5",
28
+ "@shipfox/node-fastify": "0.4.2",
29
+ "@shipfox/node-module": "1.0.6",
30
30
  "@shipfox/node-postgres": "0.5.0"
31
31
  },
32
32
  "imports": {
@@ -1,5 +1,5 @@
1
1
  import {type AnnotationStyleDto, READ_ANNOTATIONS_MAX_LIMIT} from '@shipfox/annotations-dto';
2
- import {and, asc, eq, gt, inArray, or, type SQL, sql} from 'drizzle-orm';
2
+ import {and, asc, count, eq, gt, inArray, or, type SQL, sql} from 'drizzle-orm';
3
3
  import type {Annotation} from '#core/entities/annotation.js';
4
4
  import {db} from './db.js';
5
5
  import {annotations, toAnnotation} from './schema/annotations.js';
@@ -61,6 +61,89 @@ export async function listAnnotationsForRunAttempt(
61
61
  };
62
62
  }
63
63
 
64
+ export interface AnnotationSummary {
65
+ total: number;
66
+ error: number;
67
+ warning: number;
68
+ info: number;
69
+ success: number;
70
+ stepCounts: Array<{
71
+ originStepId: string;
72
+ originStepAttempt: number;
73
+ total: number;
74
+ }>;
75
+ }
76
+
77
+ export interface SummarizeAnnotationsForRunAttemptParams {
78
+ workflowRunId: string;
79
+ workflowRunAttempt: number;
80
+ workspaceIds: readonly string[];
81
+ jobExecutionId?: string | undefined;
82
+ }
83
+
84
+ /** Count annotation styles without reading any annotation bodies. */
85
+ export async function summarizeAnnotationsForRunAttempt(
86
+ params: SummarizeAnnotationsForRunAttemptParams,
87
+ ): Promise<AnnotationSummary> {
88
+ const summary: AnnotationSummary = {
89
+ total: 0,
90
+ error: 0,
91
+ warning: 0,
92
+ info: 0,
93
+ success: 0,
94
+ stepCounts: [],
95
+ };
96
+ if (params.workspaceIds.length === 0) return summary;
97
+
98
+ const conditions: SQL[] = [
99
+ eq(annotations.workflowRunId, params.workflowRunId),
100
+ eq(annotations.workflowRunAttempt, params.workflowRunAttempt),
101
+ inArray(annotations.workspaceId, [...params.workspaceIds]),
102
+ ];
103
+ if (params.jobExecutionId) {
104
+ conditions.push(eq(annotations.jobExecutionId, params.jobExecutionId));
105
+ }
106
+
107
+ const {rows, stepRows} = await db().transaction(async (tx) => {
108
+ // Both aggregates must observe the same committed state. READ COMMITTED would allow a
109
+ // concurrent annotation write between these selects, producing contradictory totals.
110
+ await tx.execute(sql`set transaction isolation level repeatable read, read only`);
111
+
112
+ const rows = await tx
113
+ .select({style: annotations.style, count: count()})
114
+ .from(annotations)
115
+ .where(and(...conditions))
116
+ .groupBy(annotations.style);
117
+
118
+ const stepRows = await tx
119
+ .select({
120
+ originStepId: annotations.originStepId,
121
+ originStepAttempt: annotations.originStepAttempt,
122
+ total: count(),
123
+ })
124
+ .from(annotations)
125
+ .where(and(...conditions))
126
+ .groupBy(annotations.originStepId, annotations.originStepAttempt)
127
+ .orderBy(asc(annotations.originStepId), asc(annotations.originStepAttempt));
128
+
129
+ return {rows, stepRows};
130
+ });
131
+
132
+ for (const row of rows) {
133
+ const value = Number(row.count);
134
+ summary.total += value;
135
+ if (row.style !== 'default') summary[row.style] += value;
136
+ }
137
+
138
+ summary.stepCounts = stepRows.map((row) => ({
139
+ originStepId: row.originStepId,
140
+ originStepAttempt: row.originStepAttempt,
141
+ total: Number(row.total),
142
+ }));
143
+
144
+ return summary;
145
+ }
146
+
64
147
  export interface StoredAnnotation {
65
148
  id: string;
66
149
  context: string;
package/src/db/index.ts CHANGED
@@ -3,5 +3,13 @@ import {fileURLToPath} from 'node:url';
3
3
 
4
4
  export const migrationsPath = resolve(dirname(fileURLToPath(import.meta.url)), '../../drizzle');
5
5
 
6
- export type {ListAnnotationsForRunAttemptParams} from './annotations.js';
7
- export {DEFAULT_ANNOTATIONS_READ_LIMIT, listAnnotationsForRunAttempt} from './annotations.js';
6
+ export type {
7
+ AnnotationSummary,
8
+ ListAnnotationsForRunAttemptParams,
9
+ SummarizeAnnotationsForRunAttemptParams,
10
+ } from './annotations.js';
11
+ export {
12
+ DEFAULT_ANNOTATIONS_READ_LIMIT,
13
+ listAnnotationsForRunAttempt,
14
+ summarizeAnnotationsForRunAttempt,
15
+ } from './annotations.js';
@@ -1,5 +1,6 @@
1
1
  import {AUTH_LEASED_JOB, AUTH_USER} from '@shipfox/api-auth-context';
2
2
  import type {RouteGroup} from '@shipfox/node-fastify';
3
+ import {readAnnotationSummaryRoute} from './read-annotation-summary.js';
3
4
  import {readAnnotationsRoute} from './read-annotations.js';
4
5
  import {writeAnnotationsRoute} from './write-annotations.js';
5
6
 
@@ -7,7 +8,7 @@ export const annotationsRoutes: RouteGroup[] = [
7
8
  {
8
9
  prefix: '/annotations',
9
10
  auth: AUTH_USER,
10
- routes: [readAnnotationsRoute],
11
+ routes: [readAnnotationSummaryRoute, readAnnotationsRoute],
11
12
  },
12
13
  {
13
14
  prefix: '/runs/jobs/current',
@@ -0,0 +1,50 @@
1
+ import {
2
+ annotationSummaryResponseSchema,
3
+ readAnnotationsQuerySchema,
4
+ } from '@shipfox/annotations-dto';
5
+ import {requireUserContext} from '@shipfox/api-auth-context';
6
+ import {defineRoute} from '@shipfox/node-fastify';
7
+ import {summarizeAnnotationsForRunAttempt} from '#db/index.js';
8
+
9
+ export const readAnnotationSummaryRoute = defineRoute({
10
+ method: 'GET',
11
+ path: '/summary',
12
+ description: 'Read annotation counts for a workflow run attempt.',
13
+ schema: {
14
+ querystring: readAnnotationsQuerySchema.omit({cursor: true, limit: true}),
15
+ response: {
16
+ 200: annotationSummaryResponseSchema,
17
+ },
18
+ },
19
+ handler: async (request) => {
20
+ const user = requireUserContext(request);
21
+ const {
22
+ workflow_run_id: workflowRunId,
23
+ attempt,
24
+ job_execution_id: jobExecutionId,
25
+ } = request.query;
26
+ const workspaceIds = user.memberships
27
+ .filter((membership) => membership.workspaceStatus === 'active')
28
+ .map((membership) => membership.workspaceId);
29
+
30
+ const summary = await summarizeAnnotationsForRunAttempt({
31
+ workflowRunId,
32
+ workflowRunAttempt: attempt,
33
+ workspaceIds,
34
+ jobExecutionId,
35
+ });
36
+
37
+ return {
38
+ total: summary.total,
39
+ error: summary.error,
40
+ warning: summary.warning,
41
+ info: summary.info,
42
+ success: summary.success,
43
+ step_counts: summary.stepCounts.map((step) => ({
44
+ origin_step_id: step.originStepId,
45
+ origin_step_attempt: step.originStepAttempt,
46
+ total: step.total,
47
+ })),
48
+ };
49
+ },
50
+ });
@@ -2,6 +2,7 @@ import {AUTH_USER, buildUserContext, setUserContext} from '@shipfox/api-auth-con
2
2
  import {type AuthMethod, ClientError, closeApp, createApp} from '@shipfox/node-fastify';
3
3
  import type {FastifyRequest} from 'fastify';
4
4
  import {annotationFactory} from '#test/index.js';
5
+ import {readAnnotationSummaryRoute} from './read-annotation-summary.js';
5
6
  import {readAnnotationsRoute} from './read-annotations.js';
6
7
 
7
8
  const fakeUserAuth: AuthMethod = {
@@ -42,7 +43,13 @@ describe('GET /annotations', () => {
42
43
  beforeAll(async () => {
43
44
  app = await createApp({
44
45
  auth: [fakeUserAuth],
45
- routes: [{prefix: '/annotations', auth: AUTH_USER, routes: [readAnnotationsRoute]}],
46
+ routes: [
47
+ {
48
+ prefix: '/annotations',
49
+ auth: AUTH_USER,
50
+ routes: [readAnnotationSummaryRoute, readAnnotationsRoute],
51
+ },
52
+ ],
46
53
  swagger: false,
47
54
  });
48
55
  await app.ready();
@@ -136,6 +143,54 @@ describe('GET /annotations', () => {
136
143
  expect(res.json()).toEqual({annotations: [], has_more: false, next_cursor: null});
137
144
  });
138
145
 
146
+ it('returns complete style counts without loading annotation bodies', async () => {
147
+ const workspaceId = crypto.randomUUID();
148
+ const workflowRunId = crypto.randomUUID();
149
+ const firstStepId = '11111111-1111-4111-8111-111111111111';
150
+ const secondStepId = '22222222-2222-4222-8222-222222222222';
151
+ await annotationFactory.create({
152
+ workspaceId,
153
+ workflowRunId,
154
+ originStepId: firstStepId,
155
+ context: 'default',
156
+ style: 'default',
157
+ });
158
+ await annotationFactory.create({
159
+ workspaceId,
160
+ workflowRunId,
161
+ originStepId: firstStepId,
162
+ context: 'error',
163
+ style: 'error',
164
+ });
165
+ await annotationFactory.create({
166
+ workspaceId,
167
+ workflowRunId,
168
+ originStepId: secondStepId,
169
+ originStepAttempt: 2,
170
+ context: 'warning',
171
+ style: 'warning',
172
+ });
173
+
174
+ const res = await app.inject({
175
+ method: 'GET',
176
+ url: `/annotations/summary?workflow_run_id=${workflowRunId}&attempt=1`,
177
+ headers: {authorization: 'Bearer user', 'x-test-workspaces': workspaceId},
178
+ });
179
+
180
+ expect(res.statusCode).toBe(200);
181
+ expect(res.json()).toEqual({
182
+ total: 3,
183
+ error: 1,
184
+ warning: 1,
185
+ info: 0,
186
+ success: 0,
187
+ step_counts: [
188
+ {origin_step_id: firstStepId, origin_step_attempt: 1, total: 2},
189
+ {origin_step_id: secondStepId, origin_step_attempt: 2, total: 1},
190
+ ],
191
+ });
192
+ });
193
+
139
194
  it('returns an empty list for annotations outside the user workspaces', async () => {
140
195
  const workflowRunId = crypto.randomUUID();
141
196
  await annotationFactory.create({workflowRunId, workspaceId: crypto.randomUUID()});