@shipfox/annotations 18.0.0 → 20.0.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": "18.0.0",
4
+ "version": "20.0.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.3.0",
24
- "@shipfox/api-auth-context": "18.0.0",
23
+ "@shipfox/annotations-dto": "19.0.0",
24
+ "@shipfox/api-auth-context": "20.0.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
28
  "@shipfox/node-fastify": "0.4.3",
29
- "@shipfox/node-module": "1.0.7",
29
+ "@shipfox/node-module": "1.0.8",
30
30
  "@shipfox/node-postgres": "0.5.1"
31
31
  },
32
32
  "imports": {
@@ -1,6 +1,10 @@
1
1
  import type {LeasedWriteAnnotationOperationDto} from '@shipfox/annotations-dto';
2
2
  import {config} from '#config.js';
3
- import {type StoredAnnotation, withAnnotationLock} from '#db/annotations.js';
3
+ import {
4
+ type AnnotationWriteRepository,
5
+ type StoredAnnotation,
6
+ withAnnotationLock,
7
+ } from '#db/annotations.js';
4
8
  import {
5
9
  AnnotationBodyTooLargeError,
6
10
  AnnotationCountLimitExceededError,
@@ -28,88 +32,97 @@ export interface WriteAnnotationsResult {
28
32
  };
29
33
  }
30
34
 
31
- export function writeAnnotations(params: WriteAnnotationsParams): Promise<WriteAnnotationsResult> {
32
- return withAnnotationLock(params.jobExecutionId, async (repo) => {
33
- const current = await repo.loadCurrentAnnotations(params.jobExecutionId);
34
- let nextSequence =
35
- Math.max(0, ...Array.from(current.values()).map((annotation) => annotation.sequence)) + 1;
36
- const results: WriteAnnotationsResult['annotations'] = [];
35
+ interface AnnotationWriteState {
36
+ current: Map<string, StoredAnnotation>;
37
+ nextSequence: number;
38
+ results: WriteAnnotationsResult['annotations'];
39
+ }
37
40
 
38
- for (const operation of params.operations) {
39
- if (operation.op === 'remove') {
40
- await repo.removeAnnotation(params.jobExecutionId, operation.context);
41
- current.delete(operation.context);
42
- results.push({context: operation.context, id: null});
43
- continue;
44
- }
41
+ async function applyAnnotationOperation(
42
+ repo: AnnotationWriteRepository,
43
+ params: WriteAnnotationsParams,
44
+ operation: LeasedWriteAnnotationOperationDto,
45
+ state: AnnotationWriteState,
46
+ ): Promise<void> {
47
+ if (operation.op === 'remove') {
48
+ await repo.removeAnnotation(params.jobExecutionId, operation.context);
49
+ state.current.delete(operation.context);
50
+ state.results.push({context: operation.context, id: null});
51
+ return;
52
+ }
45
53
 
46
- const existing = current.get(operation.context);
47
- const body =
48
- operation.op === 'append' ? `${existing?.body ?? ''}${operation.body}` : operation.body;
49
- const bodyBytes = Buffer.byteLength(body);
50
- ensureBodyBudget(bodyBytes);
54
+ const existing = state.current.get(operation.context);
55
+ const body =
56
+ operation.op === 'append' ? `${existing?.body ?? ''}${operation.body}` : operation.body;
57
+ const bodyBytes = Buffer.byteLength(body);
58
+ ensureBodyBudget(bodyBytes);
59
+ const unchanged =
60
+ operation.op === 'replace' &&
61
+ existing !== undefined &&
62
+ existing.body === body &&
63
+ existing.style === operation.style;
64
+ if (unchanged) {
65
+ state.results.push({context: operation.context, id: existing.id});
66
+ return;
67
+ }
51
68
 
52
- const isUnchangedReplace =
53
- operation.op === 'replace' &&
54
- existing !== undefined &&
55
- existing.body === body &&
56
- existing.style === operation.style;
57
- if (isUnchangedReplace) {
58
- results.push({context: operation.context, id: existing.id});
59
- continue;
60
- }
69
+ const draft = new Map(state.current);
70
+ draft.set(operation.context, {
71
+ id: existing?.id ?? '',
72
+ context: operation.context,
73
+ style: operation.style,
74
+ body,
75
+ bodyBytes,
76
+ sequence: existing?.sequence ?? state.nextSequence,
77
+ });
78
+ ensureExecutionBudgets(draft);
61
79
 
62
- const draft = new Map(current);
63
- draft.set(operation.context, {
64
- id: existing?.id ?? '',
80
+ const row = existing
81
+ ? await repo.updateAnnotation({
82
+ id: existing.id,
83
+ originStepId: params.originStepId,
84
+ originStepAttempt: params.originStepAttempt,
85
+ style: operation.style,
86
+ body,
87
+ bodyBytes,
88
+ })
89
+ : await repo.createAnnotation({
90
+ workspaceId: params.workspaceId,
91
+ projectId: params.projectId,
92
+ workflowRunId: params.workflowRunId,
93
+ workflowRunAttempt: params.workflowRunAttempt,
94
+ workflowRunAttemptId: params.workflowRunAttemptId,
95
+ jobId: params.jobId,
96
+ jobExecutionId: params.jobExecutionId,
97
+ originStepId: params.originStepId,
98
+ originStepAttempt: params.originStepAttempt,
65
99
  context: operation.context,
66
100
  style: operation.style,
67
101
  body,
68
102
  bodyBytes,
69
- sequence: existing?.sequence ?? nextSequence,
103
+ sequence: state.nextSequence,
70
104
  });
71
- ensureExecutionBudgets(draft);
105
+ state.current.set(operation.context, row);
106
+ if (!existing) state.nextSequence += 1;
107
+ state.results.push({context: operation.context, id: row.id});
108
+ }
72
109
 
73
- const row = existing
74
- ? await repo.updateAnnotation({
75
- id: existing.id,
76
- originStepId: params.originStepId,
77
- originStepAttempt: params.originStepAttempt,
78
- style: operation.style,
79
- body,
80
- bodyBytes,
81
- })
82
- : await repo.createAnnotation({
83
- workspaceId: params.workspaceId,
84
- projectId: params.projectId,
85
- workflowRunId: params.workflowRunId,
86
- workflowRunAttempt: params.workflowRunAttempt,
87
- workflowRunAttemptId: params.workflowRunAttemptId,
88
- jobId: params.jobId,
89
- jobExecutionId: params.jobExecutionId,
90
- originStepId: params.originStepId,
91
- originStepAttempt: params.originStepAttempt,
92
- context: operation.context,
93
- style: operation.style,
94
- body,
95
- bodyBytes,
96
- sequence: nextSequence,
97
- });
110
+ export function writeAnnotations(params: WriteAnnotationsParams): Promise<WriteAnnotationsResult> {
111
+ return withAnnotationLock(params.jobExecutionId, async (repo) => {
112
+ const current = await repo.loadCurrentAnnotations(params.jobExecutionId);
113
+ const state: AnnotationWriteState = {
114
+ current,
115
+ nextSequence:
116
+ Math.max(0, ...Array.from(current.values()).map((annotation) => annotation.sequence)) + 1,
117
+ results: [],
118
+ };
98
119
 
99
- current.set(operation.context, {
100
- id: row.id,
101
- context: row.context,
102
- style: row.style,
103
- body: row.body,
104
- bodyBytes: row.bodyBytes,
105
- sequence: row.sequence,
106
- });
107
- if (!existing) nextSequence += 1;
108
- results.push({context: operation.context, id: row.id});
120
+ for (const operation of params.operations) {
121
+ await applyAnnotationOperation(repo, params, operation, state);
109
122
  }
110
123
 
111
124
  return {
112
- annotations: results,
125
+ annotations: state.results,
113
126
  accounting: currentAccounting(current),
114
127
  };
115
128
  });
@@ -8,6 +8,7 @@ import {
8
8
  } from '#core/errors.js';
9
9
  import {db} from '#db/db.js';
10
10
  import {annotations} from '#db/schema/annotations.js';
11
+ import {annotationFactory} from '#test/index.js';
11
12
  import {
12
13
  createAnnotationsInterModulePresentation,
13
14
  toReplaceOrRemoveAnnotationKnownError,
@@ -29,6 +30,75 @@ function input() {
29
30
  }
30
31
 
31
32
  describe('Annotations inter-module presentation', () => {
33
+ test('lists only annotations owned by the requested workspace with pagination', async () => {
34
+ const workspaceId = crypto.randomUUID();
35
+ const workflowRunId = crypto.randomUUID();
36
+ const visible = await annotationFactory.create({
37
+ workspaceId,
38
+ workflowRunId,
39
+ context: 'visible',
40
+ sequence: 1,
41
+ });
42
+ const next = await annotationFactory.create({
43
+ workspaceId,
44
+ workflowRunId,
45
+ context: 'next',
46
+ sequence: 2,
47
+ });
48
+ await annotationFactory.create({
49
+ workspaceId,
50
+ workflowRunId,
51
+ context: 'after',
52
+ sequence: 3,
53
+ });
54
+ await annotationFactory.create({
55
+ workspaceId: crypto.randomUUID(),
56
+ workflowRunId,
57
+ context: 'hidden',
58
+ sequence: 4,
59
+ });
60
+ const presentation = createAnnotationsInterModulePresentation();
61
+
62
+ const result = await presentation.handlers.listAnnotationsForRunAttempt(
63
+ {
64
+ workspaceId,
65
+ workflowRunId,
66
+ workflowRunAttempt: 1,
67
+ limit: 2,
68
+ },
69
+ {signal: new AbortController().signal},
70
+ );
71
+
72
+ expect(result).toEqual({
73
+ annotations: [
74
+ {
75
+ id: visible.id,
76
+ job_id: visible.jobId,
77
+ job_execution_id: visible.jobExecutionId,
78
+ origin_step_id: visible.originStepId,
79
+ origin_step_attempt: visible.originStepAttempt,
80
+ context: 'visible',
81
+ style: visible.style,
82
+ sequence: 1,
83
+ body: visible.body,
84
+ },
85
+ {
86
+ id: next.id,
87
+ job_id: next.jobId,
88
+ job_execution_id: next.jobExecutionId,
89
+ origin_step_id: next.originStepId,
90
+ origin_step_attempt: next.originStepAttempt,
91
+ context: 'next',
92
+ style: next.style,
93
+ sequence: 2,
94
+ body: next.body,
95
+ },
96
+ ],
97
+ hasMore: true,
98
+ nextCursor: {value: next.sequence, id: next.id},
99
+ });
100
+ });
101
+
32
102
  test('replaces and removes a warning annotation through PostgreSQL', async () => {
33
103
  const presentation = createAnnotationsInterModulePresentation();
34
104
  const target = input();
@@ -10,6 +10,8 @@ import {
10
10
  AnnotationTotalBytesLimitExceededError,
11
11
  } from '#core/errors.js';
12
12
  import {writeAnnotations} from '#core/write-annotations.js';
13
+ import {listAnnotationsForRunAttempt} from '#db/index.js';
14
+ import {toAnnotationDto} from './dto/annotation.js';
13
15
 
14
16
  export function createAnnotationsInterModulePresentation(): InterModulePresentation<
15
17
  typeof annotationsInterModuleContract
@@ -31,6 +33,24 @@ export function createAnnotationsInterModulePresentation(): InterModulePresentat
31
33
  throw toReplaceOrRemoveAnnotationKnownError(error);
32
34
  }
33
35
  },
36
+ listAnnotationsForRunAttempt: async (input) => {
37
+ const result = await listAnnotationsForRunAttempt({
38
+ workflowRunId: input.workflowRunId,
39
+ workflowRunAttempt: input.workflowRunAttempt,
40
+ workspaceIds: [input.workspaceId],
41
+ jobExecutionId: input.jobExecutionId,
42
+ after: input.cursor ? {sequence: input.cursor.value, id: input.cursor.id} : undefined,
43
+ limit: input.limit,
44
+ });
45
+
46
+ return {
47
+ annotations: result.annotations.map(toAnnotationDto),
48
+ hasMore: result.hasMore,
49
+ nextCursor: result.nextCursor
50
+ ? {value: result.nextCursor.sequence, id: result.nextCursor.id}
51
+ : null,
52
+ };
53
+ },
34
54
  });
35
55
  }
36
56
 
@@ -5,6 +5,12 @@ import {annotationFactory} from '#test/index.js';
5
5
  import {readAnnotationSummaryRoute} from './read-annotation-summary.js';
6
6
  import {readAnnotationsRoute} from './read-annotations.js';
7
7
 
8
+ function workspaceStatus(value: string | undefined): 'active' | 'suspended' | 'deleted' {
9
+ if (value === 'suspended') return 'suspended';
10
+ if (value === 'deleted') return 'deleted';
11
+ return 'active';
12
+ }
13
+
8
14
  const fakeUserAuth: AuthMethod = {
9
15
  name: AUTH_USER,
10
16
  authenticate: (request: FastifyRequest) => {
@@ -20,9 +26,7 @@ const fakeUserAuth: AuthMethod = {
20
26
  .map((value) => {
21
27
  const [workspaceId, status] = value.split('|');
22
28
  if (!workspaceId) throw new Error('missing test workspace id');
23
- const workspaceStatus: 'active' | 'suspended' | 'deleted' =
24
- status === 'suspended' ? 'suspended' : status === 'deleted' ? 'deleted' : 'active';
25
- return {workspaceId, role: 'admin' as const, workspaceStatus};
29
+ return {workspaceId, role: 'admin' as const, workspaceStatus: workspaceStatus(status)};
26
30
  });
27
31
 
28
32
  setUserContext(