@shipfox/api-projects 11.0.0 → 12.2.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/.turbo/turbo-build.log +4 -4
- package/CHANGELOG.md +53 -0
- package/README.md +83 -0
- package/dist/core/entities/project.d.ts +4 -0
- package/dist/core/entities/project.d.ts.map +1 -1
- package/dist/core/entities/project.js.map +1 -1
- package/dist/core/errors.d.ts +4 -0
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +6 -0
- package/dist/core/errors.js.map +1 -1
- package/dist/core/index.d.ts +3 -3
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +2 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/projects.d.ts +8 -0
- package/dist/core/projects.d.ts.map +1 -1
- package/dist/core/projects.js +62 -19
- package/dist/core/projects.js.map +1 -1
- package/dist/db/db.d.ts +144 -6
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/db.js.map +1 -1
- package/dist/db/index.d.ts +2 -2
- package/dist/db/index.d.ts.map +1 -1
- package/dist/db/index.js +1 -1
- package/dist/db/index.js.map +1 -1
- package/dist/db/projects.d.ts +53 -0
- package/dist/db/projects.d.ts.map +1 -1
- package/dist/db/projects.js +116 -18
- package/dist/db/projects.js.map +1 -1
- package/dist/db/schema/integration-event-dedup.d.ts +1 -1
- package/dist/db/schema/outbox.d.ts +1 -1
- package/dist/db/schema/projects.d.ts +69 -1
- package/dist/db/schema/projects.d.ts.map +1 -1
- package/dist/db/schema/projects.js +12 -1
- package/dist/db/schema/projects.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -5
- package/dist/index.js.map +1 -1
- package/dist/presentation/dto/project.d.ts +2 -1
- package/dist/presentation/dto/project.d.ts.map +1 -1
- package/dist/presentation/dto/project.js +1 -0
- package/dist/presentation/dto/project.js.map +1 -1
- package/dist/presentation/e2eRoutes/create-project.d.ts.map +1 -1
- package/dist/presentation/e2eRoutes/create-project.js +34 -8
- package/dist/presentation/e2eRoutes/create-project.js.map +1 -1
- package/dist/presentation/inter-module.d.ts +4 -1
- package/dist/presentation/inter-module.d.ts.map +1 -1
- package/dist/presentation/inter-module.js +63 -3
- package/dist/presentation/inter-module.js.map +1 -1
- package/dist/presentation/routes/create-project.d.ts.map +1 -1
- package/dist/presentation/routes/create-project.js +8 -2
- package/dist/presentation/routes/create-project.js.map +1 -1
- package/dist/presentation/routes/index.d.ts.map +1 -1
- package/dist/presentation/routes/index.js +3 -1
- package/dist/presentation/routes/index.js.map +1 -1
- package/dist/presentation/routes/update-project.d.ts +2 -0
- package/dist/presentation/routes/update-project.d.ts.map +1 -0
- package/dist/presentation/routes/update-project.js +53 -0
- package/dist/presentation/routes/update-project.js.map +1 -0
- package/dist/presentation/subscribers/index.d.ts +1 -0
- package/dist/presentation/subscribers/index.d.ts.map +1 -1
- package/dist/presentation/subscribers/index.js +1 -0
- package/dist/presentation/subscribers/index.js.map +1 -1
- package/dist/presentation/subscribers/on-source-repository-updated.d.ts +4 -0
- package/dist/presentation/subscribers/on-source-repository-updated.d.ts.map +1 -0
- package/dist/presentation/subscribers/on-source-repository-updated.js +39 -0
- package/dist/presentation/subscribers/on-source-repository-updated.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/drizzle/0000_initial.sql +6 -0
- package/drizzle/meta/0000_snapshot.json +78 -0
- package/package.json +13 -12
- package/src/core/entities/project.ts +4 -0
- package/src/core/errors.ts +7 -0
- package/src/core/index.ts +3 -2
- package/src/core/projects.test.ts +10 -0
- package/src/core/projects.ts +90 -35
- package/src/db/db.ts +3 -0
- package/src/db/index.ts +6 -0
- package/src/db/projects.test.ts +44 -1
- package/src/db/projects.ts +242 -33
- package/src/db/schema/projects.ts +16 -0
- package/src/index.ts +9 -3
- package/src/presentation/dto/project.ts +1 -0
- package/src/presentation/e2eRoutes/create-project.test.ts +23 -0
- package/src/presentation/e2eRoutes/create-project.ts +39 -10
- package/src/presentation/inter-module.test.ts +349 -0
- package/src/presentation/inter-module.ts +90 -4
- package/src/presentation/routes/create-project.ts +10 -2
- package/src/presentation/routes/index.ts +7 -1
- package/src/presentation/routes/projects.test.ts +300 -4
- package/src/presentation/routes/update-project.ts +46 -0
- package/src/presentation/subscribers/index.ts +1 -0
- package/src/presentation/subscribers/on-source-commit-pushed.test.ts +2 -0
- package/src/presentation/subscribers/on-source-repository-updated.test.ts +108 -0
- package/src/presentation/subscribers/on-source-repository-updated.ts +50 -0
- package/test/factories/project.ts +6 -0
- package/tsconfig.build.tsbuildinfo +1 -1
package/src/db/projects.ts
CHANGED
|
@@ -1,15 +1,35 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {isUniqueViolation} from '@shipfox/node-drizzle';
|
|
2
|
+
import {and, count, desc, eq, ilike, inArray, lt, or, type SQL, sql} from 'drizzle-orm';
|
|
2
3
|
import type {Project} from '#core/entities/project.js';
|
|
3
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
ProjectAlreadyExistsError,
|
|
6
|
+
ProjectNotFoundError,
|
|
7
|
+
ProjectSlugConflictError,
|
|
8
|
+
} from '#core/errors.js';
|
|
4
9
|
import {recordProjectCreated} from '#metrics/instance.js';
|
|
5
|
-
import {db} from './db.js';
|
|
10
|
+
import {db, type Executor} from './db.js';
|
|
6
11
|
import {projects, toProject} from './schema/projects.js';
|
|
7
12
|
|
|
13
|
+
type ProjectDatabase = ReturnType<typeof db>;
|
|
14
|
+
type ProjectTransaction = Parameters<Parameters<ProjectDatabase['transaction']>[0]>[0];
|
|
15
|
+
|
|
8
16
|
export interface CreateProjectParams {
|
|
9
17
|
workspaceId: string;
|
|
10
18
|
sourceConnectionId: string;
|
|
11
19
|
sourceExternalRepositoryId: string;
|
|
12
20
|
name: string;
|
|
21
|
+
slug: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface UpdateProjectParams {
|
|
25
|
+
projectId: string;
|
|
26
|
+
name?: string | undefined;
|
|
27
|
+
slug?: string | undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface UpdateProjectResult {
|
|
31
|
+
project: Project;
|
|
32
|
+
changed: boolean;
|
|
13
33
|
}
|
|
14
34
|
|
|
15
35
|
export interface ProjectCursor {
|
|
@@ -50,6 +70,8 @@ export interface ListAdminProjectsResult {
|
|
|
50
70
|
nextCursor: ProjectCursor | null;
|
|
51
71
|
}
|
|
52
72
|
|
|
73
|
+
const PROJECTS_WORKSPACE_SLUG_UNIQUE_CONSTRAINT = 'projects_workspace_slug_unique';
|
|
74
|
+
|
|
53
75
|
function cursorWhere(cursor: ProjectCursor | undefined): SQL | undefined {
|
|
54
76
|
if (!cursor) return undefined;
|
|
55
77
|
return or(
|
|
@@ -60,37 +82,51 @@ function cursorWhere(cursor: ProjectCursor | undefined): SQL | undefined {
|
|
|
60
82
|
|
|
61
83
|
export async function createProject(params: CreateProjectParams): Promise<Project> {
|
|
62
84
|
const project = await db().transaction(async (tx) => {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
85
|
+
let projectRow: typeof projects.$inferSelect | undefined;
|
|
86
|
+
for (let attempt = 0; attempt < 2 && !projectRow; attempt += 1) {
|
|
87
|
+
try {
|
|
88
|
+
[projectRow] = await tx
|
|
89
|
+
.insert(projects)
|
|
90
|
+
.values({
|
|
91
|
+
workspaceId: params.workspaceId,
|
|
92
|
+
sourceConnectionId: params.sourceConnectionId,
|
|
93
|
+
sourceExternalRepositoryId: params.sourceExternalRepositoryId,
|
|
94
|
+
name: params.name,
|
|
95
|
+
slug: params.slug,
|
|
96
|
+
})
|
|
97
|
+
.onConflictDoNothing({
|
|
98
|
+
target: [projects.sourceConnectionId, projects.sourceExternalRepositoryId],
|
|
99
|
+
})
|
|
100
|
+
.returning();
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (isUniqueViolation(error, PROJECTS_WORKSPACE_SLUG_UNIQUE_CONSTRAINT)) {
|
|
103
|
+
throw new ProjectSlugConflictError(params.slug);
|
|
104
|
+
}
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
75
107
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
108
|
+
if (!projectRow) {
|
|
109
|
+
const [conflict] = await tx
|
|
110
|
+
.select()
|
|
111
|
+
.from(projects)
|
|
112
|
+
.where(
|
|
113
|
+
and(
|
|
114
|
+
eq(projects.sourceConnectionId, params.sourceConnectionId),
|
|
115
|
+
eq(projects.sourceExternalRepositoryId, params.sourceExternalRepositoryId),
|
|
116
|
+
),
|
|
117
|
+
)
|
|
118
|
+
.limit(1);
|
|
119
|
+
if (conflict) {
|
|
120
|
+
throw new ProjectAlreadyExistsError(
|
|
121
|
+
conflict.id,
|
|
122
|
+
params.sourceConnectionId,
|
|
123
|
+
params.sourceExternalRepositoryId,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
93
126
|
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!projectRow) {
|
|
94
130
|
throw new Error('Insert returned no rows');
|
|
95
131
|
}
|
|
96
132
|
|
|
@@ -100,6 +136,45 @@ export async function createProject(params: CreateProjectParams): Promise<Projec
|
|
|
100
136
|
return project;
|
|
101
137
|
}
|
|
102
138
|
|
|
139
|
+
export async function updateProject(
|
|
140
|
+
params: UpdateProjectParams,
|
|
141
|
+
options: {tx?: ProjectDatabase | ProjectTransaction | undefined} = {},
|
|
142
|
+
): Promise<UpdateProjectResult | undefined> {
|
|
143
|
+
const executor = options.tx ?? db();
|
|
144
|
+
|
|
145
|
+
const [existingRow] = await executor
|
|
146
|
+
.select()
|
|
147
|
+
.from(projects)
|
|
148
|
+
.where(eq(projects.id, params.projectId))
|
|
149
|
+
.limit(1);
|
|
150
|
+
if (!existingRow) return undefined;
|
|
151
|
+
|
|
152
|
+
const nextName = params.name ?? existingRow.name;
|
|
153
|
+
const nextSlug = params.slug ?? existingRow.slug;
|
|
154
|
+
if (nextName === existingRow.name && nextSlug === existingRow.slug) {
|
|
155
|
+
return {project: toProject(existingRow), changed: false};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
const [row] = await executor
|
|
160
|
+
.update(projects)
|
|
161
|
+
.set({
|
|
162
|
+
...(params.name !== undefined ? {name: params.name} : {}),
|
|
163
|
+
...(params.slug !== undefined ? {slug: params.slug} : {}),
|
|
164
|
+
updatedAt: new Date(),
|
|
165
|
+
})
|
|
166
|
+
.where(eq(projects.id, params.projectId))
|
|
167
|
+
.returning();
|
|
168
|
+
|
|
169
|
+
return row ? {project: toProject(row), changed: true} : undefined;
|
|
170
|
+
} catch (error) {
|
|
171
|
+
if (isUniqueViolation(error, PROJECTS_WORKSPACE_SLUG_UNIQUE_CONSTRAINT)) {
|
|
172
|
+
throw new ProjectSlugConflictError(nextSlug);
|
|
173
|
+
}
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
103
178
|
export async function getProjectById(id: string): Promise<Project | undefined> {
|
|
104
179
|
const rows = await db().select().from(projects).where(eq(projects.id, id)).limit(1);
|
|
105
180
|
|
|
@@ -134,6 +209,39 @@ export async function getProjectBySource(
|
|
|
134
209
|
return toProject(row);
|
|
135
210
|
}
|
|
136
211
|
|
|
212
|
+
export interface UpdateProjectSourceRepositoryParams extends GetProjectBySourceParams {
|
|
213
|
+
tx?: Executor;
|
|
214
|
+
sourceRepositoryOwner: string;
|
|
215
|
+
sourceRepositoryName: string;
|
|
216
|
+
sourceDefaultBranch: string;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export async function updateProjectSourceRepository(
|
|
220
|
+
params: UpdateProjectSourceRepositoryParams,
|
|
221
|
+
): Promise<Project | undefined> {
|
|
222
|
+
const executor = params.tx ?? db();
|
|
223
|
+
const rows = await executor
|
|
224
|
+
.update(projects)
|
|
225
|
+
.set({
|
|
226
|
+
sourceRepositoryOwner: params.sourceRepositoryOwner,
|
|
227
|
+
sourceRepositoryName: params.sourceRepositoryName,
|
|
228
|
+
sourceDefaultBranch: params.sourceDefaultBranch,
|
|
229
|
+
updatedAt: new Date(),
|
|
230
|
+
})
|
|
231
|
+
.where(
|
|
232
|
+
and(
|
|
233
|
+
eq(projects.workspaceId, params.workspaceId),
|
|
234
|
+
eq(projects.sourceConnectionId, params.sourceConnectionId),
|
|
235
|
+
eq(projects.sourceExternalRepositoryId, params.sourceExternalRepositoryId),
|
|
236
|
+
),
|
|
237
|
+
)
|
|
238
|
+
.returning();
|
|
239
|
+
|
|
240
|
+
const row = rows[0];
|
|
241
|
+
if (!row) return undefined;
|
|
242
|
+
return toProject(row);
|
|
243
|
+
}
|
|
244
|
+
|
|
137
245
|
export async function requireProjectForWorkspace(params: {
|
|
138
246
|
projectId: string;
|
|
139
247
|
workspaceId: string;
|
|
@@ -144,12 +252,113 @@ export async function requireProjectForWorkspace(params: {
|
|
|
144
252
|
return project;
|
|
145
253
|
}
|
|
146
254
|
|
|
255
|
+
export interface ResolveCheckoutTargetParams {
|
|
256
|
+
workspaceId: string;
|
|
257
|
+
defaults: {
|
|
258
|
+
connectionId: string;
|
|
259
|
+
owner: string;
|
|
260
|
+
};
|
|
261
|
+
target: {project: string} | {connection?: string | undefined; repository: string};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export interface ResolvedCheckoutTarget {
|
|
265
|
+
projectId: string;
|
|
266
|
+
connectionId: string;
|
|
267
|
+
externalRepositoryId: string;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export interface UpdateProjectSourceMetadataParams {
|
|
271
|
+
workspaceId: string;
|
|
272
|
+
projectId: string;
|
|
273
|
+
sourceConnectionId: string;
|
|
274
|
+
sourceExternalRepositoryId: string;
|
|
275
|
+
sourceRepositoryOwner: string;
|
|
276
|
+
sourceRepositoryName: string;
|
|
277
|
+
sourceDefaultBranch: string;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export async function updateProjectSourceMetadata(
|
|
281
|
+
params: UpdateProjectSourceMetadataParams,
|
|
282
|
+
): Promise<void> {
|
|
283
|
+
await db()
|
|
284
|
+
.update(projects)
|
|
285
|
+
.set({
|
|
286
|
+
sourceConnectionId: params.sourceConnectionId,
|
|
287
|
+
sourceExternalRepositoryId: params.sourceExternalRepositoryId,
|
|
288
|
+
sourceRepositoryOwner: params.sourceRepositoryOwner,
|
|
289
|
+
sourceRepositoryName: params.sourceRepositoryName,
|
|
290
|
+
sourceDefaultBranch: params.sourceDefaultBranch,
|
|
291
|
+
updatedAt: new Date(),
|
|
292
|
+
})
|
|
293
|
+
.where(and(eq(projects.workspaceId, params.workspaceId), eq(projects.id, params.projectId)));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export async function resolveCheckoutTarget(
|
|
297
|
+
params: ResolveCheckoutTargetParams,
|
|
298
|
+
): Promise<ResolvedCheckoutTarget | undefined> {
|
|
299
|
+
const selection = {
|
|
300
|
+
projectId: projects.id,
|
|
301
|
+
connectionId: projects.sourceConnectionId,
|
|
302
|
+
externalRepositoryId: projects.sourceExternalRepositoryId,
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
if ('project' in params.target) {
|
|
306
|
+
const [project] = await db()
|
|
307
|
+
.select(selection)
|
|
308
|
+
.from(projects)
|
|
309
|
+
.where(
|
|
310
|
+
and(eq(projects.workspaceId, params.workspaceId), eq(projects.id, params.target.project)),
|
|
311
|
+
)
|
|
312
|
+
.limit(1);
|
|
313
|
+
return project;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const separator = params.target.repository.indexOf('/');
|
|
317
|
+
if (separator === 0 || separator === params.target.repository.length - 1) return undefined;
|
|
318
|
+
|
|
319
|
+
const repository =
|
|
320
|
+
separator === -1
|
|
321
|
+
? {
|
|
322
|
+
connectionId: params.target.connection ?? params.defaults.connectionId,
|
|
323
|
+
owner: params.defaults.owner,
|
|
324
|
+
name: params.target.repository,
|
|
325
|
+
}
|
|
326
|
+
: params.target.repository.indexOf('/', separator + 1) === -1
|
|
327
|
+
? {
|
|
328
|
+
connectionId: params.target.connection ?? params.defaults.connectionId,
|
|
329
|
+
owner: params.target.repository.slice(0, separator),
|
|
330
|
+
name: params.target.repository.slice(separator + 1),
|
|
331
|
+
}
|
|
332
|
+
: undefined;
|
|
333
|
+
if (repository === undefined) return undefined;
|
|
334
|
+
|
|
335
|
+
const matches = await db()
|
|
336
|
+
.select(selection)
|
|
337
|
+
.from(projects)
|
|
338
|
+
.where(
|
|
339
|
+
and(
|
|
340
|
+
eq(projects.workspaceId, params.workspaceId),
|
|
341
|
+
eq(projects.sourceConnectionId, repository.connectionId),
|
|
342
|
+
sql`lower(${projects.sourceRepositoryOwner}) = lower(${repository.owner})`,
|
|
343
|
+
sql`lower(${projects.sourceRepositoryName}) = lower(${repository.name})`,
|
|
344
|
+
),
|
|
345
|
+
)
|
|
346
|
+
.limit(2);
|
|
347
|
+
|
|
348
|
+
// Owner/name isn't a unique key: renamed or reconnected repositories can leave
|
|
349
|
+
// more than one project row matching. Fail closed instead of picking arbitrarily.
|
|
350
|
+
return matches.length === 1 ? matches[0] : undefined;
|
|
351
|
+
}
|
|
352
|
+
|
|
147
353
|
export async function listProjects(params: ListProjectsParams): Promise<ListProjectsResult> {
|
|
148
354
|
const conditions = [eq(projects.workspaceId, params.workspaceId)];
|
|
149
355
|
const cursorCondition = cursorWhere(params.cursor);
|
|
150
356
|
if (cursorCondition) conditions.push(cursorCondition);
|
|
151
357
|
if (params.search) {
|
|
152
|
-
|
|
358
|
+
const searchPattern = `%${escapeIlikePattern(params.search)}%`;
|
|
359
|
+
conditions.push(
|
|
360
|
+
or(ilike(projects.name, searchPattern), ilike(projects.slug, searchPattern)) as SQL,
|
|
361
|
+
);
|
|
153
362
|
}
|
|
154
363
|
|
|
155
364
|
const rows = await db()
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {uuidv7PrimaryKey} from '@shipfox/node-drizzle';
|
|
2
|
+
import {sql} from 'drizzle-orm';
|
|
2
3
|
import {index, text, timestamp, uniqueIndex, uuid} from 'drizzle-orm/pg-core';
|
|
3
4
|
import type {Project} from '#core/entities/project.js';
|
|
4
5
|
import {pgTable} from './common.js';
|
|
@@ -10,7 +11,11 @@ export const projects = pgTable(
|
|
|
10
11
|
workspaceId: uuid('workspace_id').notNull(),
|
|
11
12
|
sourceConnectionId: uuid('source_connection_id').notNull(),
|
|
12
13
|
sourceExternalRepositoryId: text('source_external_repository_id').notNull(),
|
|
14
|
+
sourceRepositoryOwner: text('source_repository_owner'),
|
|
15
|
+
sourceRepositoryName: text('source_repository_name'),
|
|
16
|
+
sourceDefaultBranch: text('source_default_branch'),
|
|
13
17
|
name: text('name').notNull(),
|
|
18
|
+
slug: text('slug').notNull(),
|
|
14
19
|
createdAt: timestamp('created_at', {withTimezone: true}).notNull().defaultNow(),
|
|
15
20
|
updatedAt: timestamp('updated_at', {withTimezone: true}).notNull().defaultNow(),
|
|
16
21
|
},
|
|
@@ -19,7 +24,14 @@ export const projects = pgTable(
|
|
|
19
24
|
table.sourceConnectionId,
|
|
20
25
|
table.sourceExternalRepositoryId,
|
|
21
26
|
),
|
|
27
|
+
uniqueIndex('projects_workspace_slug_unique').on(table.workspaceId, table.slug),
|
|
22
28
|
index('projects_workspace_created_id_idx').on(table.workspaceId, table.createdAt, table.id),
|
|
29
|
+
index('projects_source_repository_lookup_idx').on(
|
|
30
|
+
table.workspaceId,
|
|
31
|
+
table.sourceConnectionId,
|
|
32
|
+
sql`lower(${table.sourceRepositoryOwner})`,
|
|
33
|
+
sql`lower(${table.sourceRepositoryName})`,
|
|
34
|
+
),
|
|
23
35
|
],
|
|
24
36
|
);
|
|
25
37
|
|
|
@@ -32,7 +44,11 @@ export function toProject(row: ProjectDb): Project {
|
|
|
32
44
|
workspaceId: row.workspaceId,
|
|
33
45
|
sourceConnectionId: row.sourceConnectionId,
|
|
34
46
|
sourceExternalRepositoryId: row.sourceExternalRepositoryId,
|
|
47
|
+
sourceRepositoryOwner: row.sourceRepositoryOwner,
|
|
48
|
+
sourceRepositoryName: row.sourceRepositoryName,
|
|
49
|
+
sourceDefaultBranch: row.sourceDefaultBranch,
|
|
35
50
|
name: row.name,
|
|
51
|
+
slug: row.slug,
|
|
36
52
|
createdAt: row.createdAt,
|
|
37
53
|
updatedAt: row.updatedAt,
|
|
38
54
|
};
|
package/src/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {fileURLToPath} from 'node:url';
|
|
|
3
3
|
import type {AuthInterModuleClient} from '@shipfox/api-auth-dto/inter-module';
|
|
4
4
|
import {
|
|
5
5
|
INTEGRATION_SOURCE_COMMIT_PUSHED,
|
|
6
|
+
INTEGRATION_SOURCE_REPOSITORY_UPDATED,
|
|
6
7
|
type IntegrationsEventMap,
|
|
7
8
|
} from '@shipfox/api-integration-core-dto';
|
|
8
9
|
import type {IntegrationsModuleClient} from '@shipfox/api-integration-core-dto/inter-module';
|
|
@@ -13,7 +14,7 @@ import {registerProjectsServiceMetrics} from '#metrics/index.js';
|
|
|
13
14
|
import {projectsE2eRoutes} from '#presentation/e2eRoutes/index.js';
|
|
14
15
|
import {createProjectRoutes} from '#presentation/index.js';
|
|
15
16
|
import {createProjectsInterModulePresentation} from '#presentation/inter-module.js';
|
|
16
|
-
import {onSourceCommitPushed} from '#presentation/subscribers/index.js';
|
|
17
|
+
import {onSourceCommitPushed, onSourceRepositoryUpdated} from '#presentation/subscribers/index.js';
|
|
17
18
|
import {createProjectsMaintenanceActivities} from '#temporal/activities/index.js';
|
|
18
19
|
import {PROJECTS_MAINTENANCE_TASK_QUEUE} from '#temporal/constants.js';
|
|
19
20
|
|
|
@@ -28,6 +29,8 @@ export {
|
|
|
28
29
|
ProjectAccessDeniedError,
|
|
29
30
|
ProjectAlreadyExistsError,
|
|
30
31
|
ProjectNotFoundError,
|
|
32
|
+
ProjectSlugConflictError,
|
|
33
|
+
updateProjectDetails,
|
|
31
34
|
} from '#core/index.js';
|
|
32
35
|
export type {GetProjectBySourceParams} from '#db/index.js';
|
|
33
36
|
export {
|
|
@@ -59,8 +62,11 @@ export function createProjectsModule({
|
|
|
59
62
|
e2eRoutes: [projectsE2eRoutes],
|
|
60
63
|
metrics: registerProjectsServiceMetrics,
|
|
61
64
|
publishers: [{name: 'projects', table: projectsOutbox, db, eventSchemas: projectsEventSchemas}],
|
|
62
|
-
interModulePresentations: [createProjectsInterModulePresentation()],
|
|
63
|
-
subscribers: [
|
|
65
|
+
interModulePresentations: [createProjectsInterModulePresentation({integrations})],
|
|
66
|
+
subscribers: [
|
|
67
|
+
subscriber(INTEGRATION_SOURCE_COMMIT_PUSHED, onSourceCommitPushed),
|
|
68
|
+
subscriber(INTEGRATION_SOURCE_REPOSITORY_UPDATED, onSourceRepositoryUpdated),
|
|
69
|
+
],
|
|
64
70
|
workers: [
|
|
65
71
|
{
|
|
66
72
|
taskQueue: PROJECTS_MAINTENANCE_TASK_QUEUE,
|
|
@@ -6,6 +6,7 @@ export function toProjectDto(project: Project) {
|
|
|
6
6
|
id: project.id,
|
|
7
7
|
workspace_id: project.workspaceId,
|
|
8
8
|
name: project.name,
|
|
9
|
+
slug: project.slug,
|
|
9
10
|
source: {
|
|
10
11
|
connection_id: project.sourceConnectionId,
|
|
11
12
|
external_repository_id: project.sourceExternalRepositoryId,
|
|
@@ -6,6 +6,7 @@ import {projectsE2eRoutes} from './index.js';
|
|
|
6
6
|
|
|
7
7
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u;
|
|
8
8
|
const E2E_SOURCE_RE = /^e2e:/u;
|
|
9
|
+
const GENERATED_PROJECT_SLUG_RE = /^e2e-project-/u;
|
|
9
10
|
|
|
10
11
|
describe('projects E2E routes', () => {
|
|
11
12
|
afterEach(async () => {
|
|
@@ -34,6 +35,28 @@ describe('projects E2E routes', () => {
|
|
|
34
35
|
expect(res.json().source.external_repository_id).toMatch(E2E_SOURCE_RE);
|
|
35
36
|
});
|
|
36
37
|
|
|
38
|
+
test('generates distinct slugs for repeated names in one workspace', async () => {
|
|
39
|
+
const workspaceId = crypto.randomUUID();
|
|
40
|
+
const app = await createApp({routes: [projectsE2eRoutes], swagger: false});
|
|
41
|
+
|
|
42
|
+
const first = await app.inject({
|
|
43
|
+
method: 'POST',
|
|
44
|
+
url: '/projects',
|
|
45
|
+
payload: {workspace_id: workspaceId, name: 'E2E Project'},
|
|
46
|
+
});
|
|
47
|
+
const second = await app.inject({
|
|
48
|
+
method: 'POST',
|
|
49
|
+
url: '/projects',
|
|
50
|
+
payload: {workspace_id: workspaceId, name: 'E2E Project'},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(first.statusCode).toBe(201);
|
|
54
|
+
expect(second.statusCode).toBe(201);
|
|
55
|
+
expect(first.json().slug).toMatch(GENERATED_PROJECT_SLUG_RE);
|
|
56
|
+
expect(second.json().slug).toMatch(GENERATED_PROJECT_SLUG_RE);
|
|
57
|
+
expect(second.json().slug).not.toBe(first.json().slug);
|
|
58
|
+
});
|
|
59
|
+
|
|
37
60
|
test('preserves explicit synthetic source values', async () => {
|
|
38
61
|
const workspaceId = crypto.randomUUID();
|
|
39
62
|
const sourceConnectionId = crypto.randomUUID();
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import {randomUUID} from 'node:crypto';
|
|
2
|
+
import {slugifyName, withSlugSuffix} from '@shipfox/api-common-dto';
|
|
2
3
|
import {
|
|
3
4
|
e2eCreateProjectBodySchema,
|
|
4
5
|
e2eCreateProjectResponseSchema,
|
|
5
6
|
} from '@shipfox/api-projects-dto';
|
|
6
7
|
import {ClientError, defineRoute} from '@shipfox/node-fastify';
|
|
7
|
-
import {ProjectAlreadyExistsError} from '#core/index.js';
|
|
8
|
+
import {ProjectAlreadyExistsError, ProjectSlugConflictError} from '#core/index.js';
|
|
8
9
|
import {createProject} from '#db/index.js';
|
|
9
10
|
import {toProjectDto} from '#presentation/dto/index.js';
|
|
10
11
|
|
|
@@ -12,6 +13,13 @@ function syntheticExternalRepositoryId(): string {
|
|
|
12
13
|
return `e2e:${randomUUID()}`;
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
const MAX_GENERATED_SLUG_ATTEMPTS = 5;
|
|
17
|
+
|
|
18
|
+
function generatedProjectSlug(slugBase: string): string {
|
|
19
|
+
const suffix = Number.parseInt(randomUUID().replaceAll('-', '').slice(0, 8), 16);
|
|
20
|
+
return withSlugSuffix(slugBase, suffix);
|
|
21
|
+
}
|
|
22
|
+
|
|
15
23
|
export const createE2eProjectRoute = defineRoute({
|
|
16
24
|
method: 'POST',
|
|
17
25
|
path: '/',
|
|
@@ -33,18 +41,39 @@ export const createE2eProjectRoute = defineRoute({
|
|
|
33
41
|
status: 409,
|
|
34
42
|
});
|
|
35
43
|
}
|
|
44
|
+
if (error instanceof ProjectSlugConflictError) {
|
|
45
|
+
throw new ClientError('Project slug already exists', 'slug-conflict', {status: 409});
|
|
46
|
+
}
|
|
36
47
|
throw error;
|
|
37
48
|
},
|
|
38
49
|
handler: async (request, reply) => {
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
50
|
+
const requestedSlug = request.body.slug;
|
|
51
|
+
const slugBase = slugifyName(request.body.name, {fallback: 'project'});
|
|
52
|
+
let slug = requestedSlug ?? generatedProjectSlug(slugBase);
|
|
53
|
+
|
|
54
|
+
for (let attempt = 0; attempt < MAX_GENERATED_SLUG_ATTEMPTS; attempt += 1) {
|
|
55
|
+
try {
|
|
56
|
+
const project = await createProject({
|
|
57
|
+
workspaceId: request.body.workspace_id,
|
|
58
|
+
name: request.body.name,
|
|
59
|
+
slug,
|
|
60
|
+
sourceConnectionId: request.body.source_connection_id ?? randomUUID(),
|
|
61
|
+
sourceExternalRepositoryId:
|
|
62
|
+
request.body.source_external_repository_id ?? syntheticExternalRepositoryId(),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
reply.code(201);
|
|
66
|
+
return toProjectDto(project);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
const shouldRetrySlug =
|
|
69
|
+
requestedSlug === undefined &&
|
|
70
|
+
error instanceof ProjectSlugConflictError &&
|
|
71
|
+
attempt < MAX_GENERATED_SLUG_ATTEMPTS - 1;
|
|
72
|
+
if (!shouldRetrySlug) throw error;
|
|
73
|
+
slug = generatedProjectSlug(slugBase);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
46
76
|
|
|
47
|
-
|
|
48
|
-
return toProjectDto(project);
|
|
77
|
+
throw new Error('Unable to generate a unique project slug');
|
|
49
78
|
},
|
|
50
79
|
});
|