@shipfox/api-projects 2.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/.turbo/turbo-build.log +2 -0
- package/.turbo/turbo-type$colon$emit.log +1 -0
- package/.turbo/turbo-type.log +1 -0
- package/CHANGELOG.md +117 -0
- package/LICENSE +21 -0
- package/dist/core/entities/index.d.ts +2 -0
- package/dist/core/entities/index.d.ts.map +1 -0
- package/dist/core/entities/index.js +3 -0
- package/dist/core/entities/index.js.map +1 -0
- package/dist/core/entities/project.d.ts +10 -0
- package/dist/core/entities/project.d.ts.map +1 -0
- package/dist/core/entities/project.js +3 -0
- package/dist/core/entities/project.js.map +1 -0
- package/dist/core/errors.d.ts +13 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +20 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/index.d.ts +5 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +4 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/projects.d.ts +12 -0
- package/dist/core/projects.d.ts.map +1 -0
- package/dist/core/projects.js +62 -0
- package/dist/core/projects.js.map +1 -0
- package/dist/db/db.d.ts +763 -0
- package/dist/db/db.d.ts.map +1 -0
- package/dist/db/db.js +22 -0
- package/dist/db/db.js.map +1 -0
- package/dist/db/index.d.ts +8 -0
- package/dist/db/index.d.ts.map +1 -0
- package/dist/db/index.js +10 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/integration-event-dedup.d.ts +17 -0
- package/dist/db/integration-event-dedup.d.ts.map +1 -0
- package/dist/db/integration-event-dedup.js +27 -0
- package/dist/db/integration-event-dedup.js.map +1 -0
- package/dist/db/projects.d.ts +36 -0
- package/dist/db/projects.d.ts.map +1 -0
- package/dist/db/projects.js +84 -0
- package/dist/db/projects.js.map +1 -0
- package/dist/db/schema/common.d.ts +2 -0
- package/dist/db/schema/common.d.ts.map +1 -0
- package/dist/db/schema/common.js +4 -0
- package/dist/db/schema/common.js.map +1 -0
- package/dist/db/schema/integration-event-dedup.d.ts +59 -0
- package/dist/db/schema/integration-event-dedup.d.ts.map +1 -0
- package/dist/db/schema/integration-event-dedup.js +19 -0
- package/dist/db/schema/integration-event-dedup.js.map +1 -0
- package/dist/db/schema/outbox.d.ts +195 -0
- package/dist/db/schema/outbox.d.ts.map +1 -0
- package/dist/db/schema/outbox.js +5 -0
- package/dist/db/schema/outbox.js.map +1 -0
- package/dist/db/schema/projects.d.ts +131 -0
- package/dist/db/schema/projects.d.ts.map +1 -0
- package/dist/db/schema/projects.js +32 -0
- package/dist/db/schema/projects.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +59 -0
- package/dist/index.js.map +1 -0
- package/dist/metrics/index.d.ts +3 -0
- package/dist/metrics/index.d.ts.map +1 -0
- package/dist/metrics/index.js +4 -0
- package/dist/metrics/index.js.map +1 -0
- package/dist/metrics/instance.d.ts +4 -0
- package/dist/metrics/instance.d.ts.map +1 -0
- package/dist/metrics/instance.js +25 -0
- package/dist/metrics/instance.js.map +1 -0
- package/dist/metrics/service.d.ts +2 -0
- package/dist/metrics/service.d.ts.map +1 -0
- package/dist/metrics/service.js +15 -0
- package/dist/metrics/service.js.map +1 -0
- package/dist/presentation/auth/require-project-access.d.ts +12 -0
- package/dist/presentation/auth/require-project-access.d.ts.map +1 -0
- package/dist/presentation/auth/require-project-access.js +25 -0
- package/dist/presentation/auth/require-project-access.js.map +1 -0
- package/dist/presentation/dto/index.d.ts +2 -0
- package/dist/presentation/dto/index.d.ts.map +1 -0
- package/dist/presentation/dto/index.js +3 -0
- package/dist/presentation/dto/index.js.map +1 -0
- package/dist/presentation/dto/project.d.ts +13 -0
- package/dist/presentation/dto/project.d.ts.map +1 -0
- package/dist/presentation/dto/project.js +15 -0
- package/dist/presentation/dto/project.js.map +1 -0
- package/dist/presentation/e2eRoutes/create-project.d.ts +2 -0
- package/dist/presentation/e2eRoutes/create-project.d.ts.map +1 -0
- package/dist/presentation/e2eRoutes/create-project.js +45 -0
- package/dist/presentation/e2eRoutes/create-project.js.map +1 -0
- package/dist/presentation/e2eRoutes/index.d.ts +3 -0
- package/dist/presentation/e2eRoutes/index.d.ts.map +1 -0
- package/dist/presentation/e2eRoutes/index.js +9 -0
- package/dist/presentation/e2eRoutes/index.js.map +1 -0
- package/dist/presentation/index.d.ts +3 -0
- package/dist/presentation/index.d.ts.map +1 -0
- package/dist/presentation/index.js +4 -0
- package/dist/presentation/index.js.map +1 -0
- package/dist/presentation/routes/create-project.d.ts +3 -0
- package/dist/presentation/routes/create-project.d.ts.map +1 -0
- package/dist/presentation/routes/create-project.js +94 -0
- package/dist/presentation/routes/create-project.js.map +1 -0
- package/dist/presentation/routes/cursor.d.ts +2 -0
- package/dist/presentation/routes/cursor.d.ts.map +1 -0
- package/dist/presentation/routes/cursor.js +3 -0
- package/dist/presentation/routes/cursor.js.map +1 -0
- package/dist/presentation/routes/get-project.d.ts +2 -0
- package/dist/presentation/routes/get-project.d.ts.map +1 -0
- package/dist/presentation/routes/get-project.js +30 -0
- package/dist/presentation/routes/get-project.js.map +1 -0
- package/dist/presentation/routes/index.d.ts +4 -0
- package/dist/presentation/routes/index.d.ts.map +1 -0
- package/dist/presentation/routes/index.js +17 -0
- package/dist/presentation/routes/index.js.map +1 -0
- package/dist/presentation/routes/list-projects.d.ts +2 -0
- package/dist/presentation/routes/list-projects.d.ts.map +1 -0
- package/dist/presentation/routes/list-projects.js +43 -0
- package/dist/presentation/routes/list-projects.js.map +1 -0
- package/dist/presentation/subscribers/index.d.ts +2 -0
- package/dist/presentation/subscribers/index.d.ts.map +1 -0
- package/dist/presentation/subscribers/index.js +3 -0
- package/dist/presentation/subscribers/index.js.map +1 -0
- package/dist/presentation/subscribers/on-source-commit-pushed.d.ts +4 -0
- package/dist/presentation/subscribers/on-source-commit-pushed.d.ts.map +1 -0
- package/dist/presentation/subscribers/on-source-commit-pushed.js +54 -0
- package/dist/presentation/subscribers/on-source-commit-pushed.js.map +1 -0
- package/dist/temporal/activities/index.d.ts +5 -0
- package/dist/temporal/activities/index.d.ts.map +1 -0
- package/dist/temporal/activities/index.js +8 -0
- package/dist/temporal/activities/index.js.map +1 -0
- package/dist/temporal/activities/prune-integration-event-dedup.d.ts +4 -0
- package/dist/temporal/activities/prune-integration-event-dedup.d.ts.map +1 -0
- package/dist/temporal/activities/prune-integration-event-dedup.js +11 -0
- package/dist/temporal/activities/prune-integration-event-dedup.js.map +1 -0
- package/dist/temporal/constants.d.ts +3 -0
- package/dist/temporal/constants.d.ts.map +1 -0
- package/dist/temporal/constants.js +4 -0
- package/dist/temporal/constants.js.map +1 -0
- package/dist/temporal/workflows/index.d.ts +2 -0
- package/dist/temporal/workflows/index.d.ts.map +1 -0
- package/dist/temporal/workflows/index.js +3 -0
- package/dist/temporal/workflows/index.js.map +1 -0
- package/dist/temporal/workflows/prune-integration-event-dedup-cron.d.ts +2 -0
- package/dist/temporal/workflows/prune-integration-event-dedup-cron.d.ts.map +1 -0
- package/dist/temporal/workflows/prune-integration-event-dedup-cron.js +14 -0
- package/dist/temporal/workflows/prune-integration-event-dedup-cron.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -0
- package/drizzle/0000_initial.sql +37 -0
- package/drizzle/meta/0000_snapshot.json +307 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +7 -0
- package/package.json +76 -0
- package/src/core/entities/index.ts +1 -0
- package/src/core/entities/project.ts +9 -0
- package/src/core/errors.ts +26 -0
- package/src/core/index.ts +8 -0
- package/src/core/projects.test.ts +142 -0
- package/src/core/projects.ts +97 -0
- package/src/db/db.ts +22 -0
- package/src/db/index.ts +26 -0
- package/src/db/integration-event-dedup.ts +45 -0
- package/src/db/projects.test.ts +22 -0
- package/src/db/projects.ts +158 -0
- package/src/db/schema/common.ts +3 -0
- package/src/db/schema/integration-event-dedup.ts +15 -0
- package/src/db/schema/outbox.ts +4 -0
- package/src/db/schema/projects.ts +39 -0
- package/src/index.ts +71 -0
- package/src/metrics/index.ts +2 -0
- package/src/metrics/instance.ts +34 -0
- package/src/metrics/service.ts +17 -0
- package/src/presentation/auth/require-project-access.ts +29 -0
- package/src/presentation/dto/index.ts +1 -0
- package/src/presentation/dto/project.ts +15 -0
- package/src/presentation/e2eRoutes/create-project.test.ts +119 -0
- package/src/presentation/e2eRoutes/create-project.ts +50 -0
- package/src/presentation/e2eRoutes/index.ts +7 -0
- package/src/presentation/index.ts +2 -0
- package/src/presentation/routes/create-project.ts +102 -0
- package/src/presentation/routes/cursor.ts +5 -0
- package/src/presentation/routes/get-project.ts +24 -0
- package/src/presentation/routes/index.ts +14 -0
- package/src/presentation/routes/list-projects.ts +34 -0
- package/src/presentation/routes/projects.test.ts +266 -0
- package/src/presentation/subscribers/index.ts +1 -0
- package/src/presentation/subscribers/on-source-commit-pushed.test.ts +149 -0
- package/src/presentation/subscribers/on-source-commit-pushed.ts +64 -0
- package/src/temporal/activities/index.ts +7 -0
- package/src/temporal/activities/prune-integration-event-dedup.test.ts +50 -0
- package/src/temporal/activities/prune-integration-event-dedup.ts +9 -0
- package/src/temporal/constants.ts +3 -0
- package/src/temporal/workflows/index.ts +1 -0
- package/src/temporal/workflows/prune-integration-event-dedup-cron.ts +15 -0
- package/test/env.ts +14 -0
- package/test/factories/project.ts +24 -0
- package/test/globalSetup.ts +17 -0
- package/test/index.ts +1 -0
- package/test/setup.ts +17 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +3 -0
- package/tsconfig.test.json +8 -0
- package/vitest.config.ts +12 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type {IntegrationSourceControlService} from '@shipfox/api-integration-core';
|
|
2
|
+
import {
|
|
3
|
+
PROJECT_CREATED,
|
|
4
|
+
PROJECT_SOURCE_BOUND,
|
|
5
|
+
type ProjectsEventMap,
|
|
6
|
+
} from '@shipfox/api-projects-dto';
|
|
7
|
+
import {writeOutboxEvent} from '@shipfox/node-outbox';
|
|
8
|
+
import {and, eq} from 'drizzle-orm';
|
|
9
|
+
import {db} from '#db/db.js';
|
|
10
|
+
import {projectsOutbox} from '#db/schema/outbox.js';
|
|
11
|
+
import {projects, toProject} from '#db/schema/projects.js';
|
|
12
|
+
import {recordProjectCreated} from '#metrics/instance.js';
|
|
13
|
+
import type {Project} from './entities/project.js';
|
|
14
|
+
import {ProjectAlreadyExistsError} from './errors.js';
|
|
15
|
+
|
|
16
|
+
export interface CreateProjectFromSourceParams {
|
|
17
|
+
actorId: string;
|
|
18
|
+
workspaceId: string;
|
|
19
|
+
name: string;
|
|
20
|
+
sourceConnectionId: string;
|
|
21
|
+
sourceExternalRepositoryId: string;
|
|
22
|
+
sourceControl: IntegrationSourceControlService;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function createProjectFromSource(
|
|
26
|
+
params: CreateProjectFromSourceParams,
|
|
27
|
+
): Promise<Project> {
|
|
28
|
+
const source = await params.sourceControl.resolveRepository({
|
|
29
|
+
workspaceId: params.workspaceId,
|
|
30
|
+
connectionId: params.sourceConnectionId,
|
|
31
|
+
externalRepositoryId: params.sourceExternalRepositoryId,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const project = await db().transaction(async (tx) => {
|
|
35
|
+
const [projectRow] = await tx
|
|
36
|
+
.insert(projects)
|
|
37
|
+
.values({
|
|
38
|
+
workspaceId: params.workspaceId,
|
|
39
|
+
sourceConnectionId: source.connection.id,
|
|
40
|
+
sourceExternalRepositoryId: source.repository.externalRepositoryId,
|
|
41
|
+
name: params.name,
|
|
42
|
+
})
|
|
43
|
+
.onConflictDoNothing({
|
|
44
|
+
target: [projects.sourceConnectionId, projects.sourceExternalRepositoryId],
|
|
45
|
+
})
|
|
46
|
+
.returning();
|
|
47
|
+
|
|
48
|
+
if (!projectRow) {
|
|
49
|
+
const [existing] = await tx
|
|
50
|
+
.select()
|
|
51
|
+
.from(projects)
|
|
52
|
+
.where(
|
|
53
|
+
and(
|
|
54
|
+
eq(projects.sourceConnectionId, source.connection.id),
|
|
55
|
+
eq(projects.sourceExternalRepositoryId, source.repository.externalRepositoryId),
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
.limit(1);
|
|
59
|
+
if (existing) {
|
|
60
|
+
throw new ProjectAlreadyExistsError(
|
|
61
|
+
existing.id,
|
|
62
|
+
source.connection.id,
|
|
63
|
+
source.repository.externalRepositoryId,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
throw new Error('Project insert returned no rows');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const project = toProject(projectRow);
|
|
70
|
+
|
|
71
|
+
await writeOutboxEvent<ProjectsEventMap>(tx, projectsOutbox, {
|
|
72
|
+
type: PROJECT_CREATED,
|
|
73
|
+
payload: {
|
|
74
|
+
actorId: params.actorId,
|
|
75
|
+
workspaceId: project.workspaceId,
|
|
76
|
+
projectId: project.id,
|
|
77
|
+
sourceConnectionId: project.sourceConnectionId,
|
|
78
|
+
sourceExternalRepositoryId: project.sourceExternalRepositoryId,
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
await writeOutboxEvent<ProjectsEventMap>(tx, projectsOutbox, {
|
|
82
|
+
type: PROJECT_SOURCE_BOUND,
|
|
83
|
+
payload: {
|
|
84
|
+
actorId: params.actorId,
|
|
85
|
+
workspaceId: project.workspaceId,
|
|
86
|
+
projectId: project.id,
|
|
87
|
+
sourceConnectionId: project.sourceConnectionId,
|
|
88
|
+
provider: source.connection.provider,
|
|
89
|
+
externalRepositoryId: project.sourceExternalRepositoryId,
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return project;
|
|
94
|
+
});
|
|
95
|
+
recordProjectCreated();
|
|
96
|
+
return project;
|
|
97
|
+
}
|
package/src/db/db.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import {drizzle, type NodePgDatabase} from '@shipfox/node-drizzle';
|
|
2
|
+
import {pgClient} from '@shipfox/node-postgres';
|
|
3
|
+
import {projectsIntegrationEventDedup} from './schema/integration-event-dedup.js';
|
|
4
|
+
import {projectsOutbox} from './schema/outbox.js';
|
|
5
|
+
import {projects} from './schema/projects.js';
|
|
6
|
+
|
|
7
|
+
export const schema = {
|
|
8
|
+
projects,
|
|
9
|
+
projectsOutbox,
|
|
10
|
+
projectsIntegrationEventDedup,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
let _db: NodePgDatabase<typeof schema> | undefined;
|
|
14
|
+
|
|
15
|
+
export function db() {
|
|
16
|
+
if (!_db) _db = drizzle(pgClient(), {schema});
|
|
17
|
+
return _db;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function closeDb(): void {
|
|
21
|
+
_db = undefined;
|
|
22
|
+
}
|
package/src/db/index.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import {dirname, resolve} from 'node:path';
|
|
2
|
+
import {fileURLToPath} from 'node:url';
|
|
3
|
+
|
|
4
|
+
export {closeDb, db, schema} from './db.js';
|
|
5
|
+
export {
|
|
6
|
+
pruneIntegrationEventDedup,
|
|
7
|
+
recordIntegrationEventForProject,
|
|
8
|
+
} from './integration-event-dedup.js';
|
|
9
|
+
export type {
|
|
10
|
+
CreateProjectParams,
|
|
11
|
+
GetProjectBySourceParams,
|
|
12
|
+
ListProjectsParams,
|
|
13
|
+
ListProjectsResult,
|
|
14
|
+
} from './projects.js';
|
|
15
|
+
export {
|
|
16
|
+
createProject,
|
|
17
|
+
getProjectById,
|
|
18
|
+
getProjectBySource,
|
|
19
|
+
getProjectCount,
|
|
20
|
+
listProjects,
|
|
21
|
+
requireProjectForWorkspace,
|
|
22
|
+
} from './projects.js';
|
|
23
|
+
export {projectsIntegrationEventDedup} from './schema/integration-event-dedup.js';
|
|
24
|
+
export {projectsOutbox} from './schema/outbox.js';
|
|
25
|
+
|
|
26
|
+
export const migrationsPath = resolve(dirname(fileURLToPath(import.meta.url)), '../../drizzle');
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import {lt} from 'drizzle-orm';
|
|
2
|
+
import {db} from './db.js';
|
|
3
|
+
import {projectsIntegrationEventDedup} from './schema/integration-event-dedup.js';
|
|
4
|
+
|
|
5
|
+
// biome-ignore lint/suspicious/noExplicitAny: cross-module drizzle tx without a portable exported type
|
|
6
|
+
type DrizzleTxLike = any;
|
|
7
|
+
|
|
8
|
+
export interface RecordIntegrationEventForProjectParams {
|
|
9
|
+
tx: DrizzleTxLike;
|
|
10
|
+
integrationEventId: string;
|
|
11
|
+
projectId: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function recordIntegrationEventForProject(
|
|
15
|
+
params: RecordIntegrationEventForProjectParams,
|
|
16
|
+
): Promise<{firstSeen: boolean}> {
|
|
17
|
+
const inserted = await params.tx
|
|
18
|
+
.insert(projectsIntegrationEventDedup)
|
|
19
|
+
.values({
|
|
20
|
+
integrationEventId: params.integrationEventId,
|
|
21
|
+
projectId: params.projectId,
|
|
22
|
+
})
|
|
23
|
+
.onConflictDoNothing({
|
|
24
|
+
target: [
|
|
25
|
+
projectsIntegrationEventDedup.integrationEventId,
|
|
26
|
+
projectsIntegrationEventDedup.projectId,
|
|
27
|
+
],
|
|
28
|
+
})
|
|
29
|
+
.returning({projectId: projectsIntegrationEventDedup.projectId});
|
|
30
|
+
|
|
31
|
+
return {firstSeen: inserted.length > 0};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface PruneIntegrationEventDedupParams {
|
|
35
|
+
olderThan: Date;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function pruneIntegrationEventDedup(
|
|
39
|
+
params: PruneIntegrationEventDedupParams,
|
|
40
|
+
): Promise<{deleted: number}> {
|
|
41
|
+
const result = await db()
|
|
42
|
+
.delete(projectsIntegrationEventDedup)
|
|
43
|
+
.where(lt(projectsIntegrationEventDedup.receivedAt, params.olderThan));
|
|
44
|
+
return {deleted: result.rowCount ?? 0};
|
|
45
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import {projectFactory} from '#test/index.js';
|
|
2
|
+
import {getProjectCount} from './projects.js';
|
|
3
|
+
|
|
4
|
+
describe('getProjectCount', () => {
|
|
5
|
+
it('reports the current project count', async () => {
|
|
6
|
+
const before = await getProjectCount();
|
|
7
|
+
|
|
8
|
+
const after = await getProjectCount();
|
|
9
|
+
|
|
10
|
+
expect(after - before).toBe(0);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('counts newly created projects', async () => {
|
|
14
|
+
const before = await getProjectCount();
|
|
15
|
+
await projectFactory.create();
|
|
16
|
+
await projectFactory.create();
|
|
17
|
+
|
|
18
|
+
const after = await getProjectCount();
|
|
19
|
+
|
|
20
|
+
expect(after - before).toBe(2);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import {and, count, desc, eq, ilike, lt, or, type SQL} from 'drizzle-orm';
|
|
2
|
+
import type {Project} from '#core/entities/project.js';
|
|
3
|
+
import {ProjectAlreadyExistsError, ProjectNotFoundError} from '#core/errors.js';
|
|
4
|
+
import {recordProjectCreated} from '#metrics/instance.js';
|
|
5
|
+
import {db} from './db.js';
|
|
6
|
+
import {projects, toProject} from './schema/projects.js';
|
|
7
|
+
|
|
8
|
+
export interface CreateProjectParams {
|
|
9
|
+
workspaceId: string;
|
|
10
|
+
sourceConnectionId: string;
|
|
11
|
+
sourceExternalRepositoryId: string;
|
|
12
|
+
name: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ProjectCursor {
|
|
16
|
+
createdAt: Date;
|
|
17
|
+
id: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ListProjectsParams {
|
|
21
|
+
workspaceId: string;
|
|
22
|
+
limit: number;
|
|
23
|
+
cursor?: ProjectCursor | undefined;
|
|
24
|
+
search?: string | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function escapeIlikePattern(value: string): string {
|
|
28
|
+
return value.replace(/[\\%_]/g, (char) => `\\${char}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ListProjectsResult {
|
|
32
|
+
projects: Project[];
|
|
33
|
+
nextCursor: ProjectCursor | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function cursorWhere(params: ListProjectsParams): SQL | undefined {
|
|
37
|
+
if (!params.cursor) return undefined;
|
|
38
|
+
return or(
|
|
39
|
+
lt(projects.createdAt, params.cursor.createdAt),
|
|
40
|
+
and(eq(projects.createdAt, params.cursor.createdAt), lt(projects.id, params.cursor.id)),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function createProject(params: CreateProjectParams): Promise<Project> {
|
|
45
|
+
const project = await db().transaction(async (tx) => {
|
|
46
|
+
const [projectRow] = await tx
|
|
47
|
+
.insert(projects)
|
|
48
|
+
.values({
|
|
49
|
+
workspaceId: params.workspaceId,
|
|
50
|
+
sourceConnectionId: params.sourceConnectionId,
|
|
51
|
+
sourceExternalRepositoryId: params.sourceExternalRepositoryId,
|
|
52
|
+
name: params.name,
|
|
53
|
+
})
|
|
54
|
+
.onConflictDoNothing({
|
|
55
|
+
target: [projects.sourceConnectionId, projects.sourceExternalRepositoryId],
|
|
56
|
+
})
|
|
57
|
+
.returning();
|
|
58
|
+
|
|
59
|
+
if (!projectRow) {
|
|
60
|
+
const [conflict] = await tx
|
|
61
|
+
.select()
|
|
62
|
+
.from(projects)
|
|
63
|
+
.where(
|
|
64
|
+
and(
|
|
65
|
+
eq(projects.sourceConnectionId, params.sourceConnectionId),
|
|
66
|
+
eq(projects.sourceExternalRepositoryId, params.sourceExternalRepositoryId),
|
|
67
|
+
),
|
|
68
|
+
)
|
|
69
|
+
.limit(1);
|
|
70
|
+
if (conflict) {
|
|
71
|
+
throw new ProjectAlreadyExistsError(
|
|
72
|
+
conflict.id,
|
|
73
|
+
params.sourceConnectionId,
|
|
74
|
+
params.sourceExternalRepositoryId,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
throw new Error('Insert returned no rows');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return toProject(projectRow);
|
|
81
|
+
});
|
|
82
|
+
recordProjectCreated();
|
|
83
|
+
return project;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function getProjectById(id: string): Promise<Project | undefined> {
|
|
87
|
+
const rows = await db().select().from(projects).where(eq(projects.id, id)).limit(1);
|
|
88
|
+
|
|
89
|
+
const row = rows[0];
|
|
90
|
+
if (!row) return undefined;
|
|
91
|
+
return toProject(row);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface GetProjectBySourceParams {
|
|
95
|
+
workspaceId: string;
|
|
96
|
+
sourceConnectionId: string;
|
|
97
|
+
sourceExternalRepositoryId: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function getProjectBySource(
|
|
101
|
+
params: GetProjectBySourceParams,
|
|
102
|
+
): Promise<Project | undefined> {
|
|
103
|
+
const rows = await db()
|
|
104
|
+
.select()
|
|
105
|
+
.from(projects)
|
|
106
|
+
.where(
|
|
107
|
+
and(
|
|
108
|
+
eq(projects.workspaceId, params.workspaceId),
|
|
109
|
+
eq(projects.sourceConnectionId, params.sourceConnectionId),
|
|
110
|
+
eq(projects.sourceExternalRepositoryId, params.sourceExternalRepositoryId),
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
.limit(1);
|
|
114
|
+
|
|
115
|
+
const row = rows[0];
|
|
116
|
+
if (!row) return undefined;
|
|
117
|
+
return toProject(row);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function requireProjectForWorkspace(params: {
|
|
121
|
+
projectId: string;
|
|
122
|
+
workspaceId: string;
|
|
123
|
+
}): Promise<Project> {
|
|
124
|
+
const project = await getProjectById(params.projectId);
|
|
125
|
+
if (!project) throw new ProjectNotFoundError(params.projectId);
|
|
126
|
+
if (project.workspaceId !== params.workspaceId) throw new ProjectNotFoundError(params.projectId);
|
|
127
|
+
return project;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function listProjects(params: ListProjectsParams): Promise<ListProjectsResult> {
|
|
131
|
+
const conditions = [eq(projects.workspaceId, params.workspaceId)];
|
|
132
|
+
const cursorCondition = cursorWhere(params);
|
|
133
|
+
if (cursorCondition) conditions.push(cursorCondition);
|
|
134
|
+
if (params.search) {
|
|
135
|
+
conditions.push(ilike(projects.name, `%${escapeIlikePattern(params.search)}%`));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const rows = await db()
|
|
139
|
+
.select()
|
|
140
|
+
.from(projects)
|
|
141
|
+
.where(and(...conditions))
|
|
142
|
+
.orderBy(desc(projects.createdAt), desc(projects.id))
|
|
143
|
+
.limit(params.limit + 1);
|
|
144
|
+
|
|
145
|
+
const hasMore = rows.length > params.limit;
|
|
146
|
+
const pageRows = hasMore ? rows.slice(0, params.limit) : rows;
|
|
147
|
+
const last = pageRows.at(-1);
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
projects: pageRows.map(toProject),
|
|
151
|
+
nextCursor: hasMore && last ? {createdAt: last.createdAt, id: last.id} : null,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function getProjectCount(): Promise<number> {
|
|
156
|
+
const [row] = await db().select({value: count()}).from(projects);
|
|
157
|
+
return row?.value ?? 0;
|
|
158
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import {index, primaryKey, timestamp, uuid} from 'drizzle-orm/pg-core';
|
|
2
|
+
import {pgTable} from './common.js';
|
|
3
|
+
|
|
4
|
+
export const projectsIntegrationEventDedup = pgTable(
|
|
5
|
+
'integration_event_dedup',
|
|
6
|
+
{
|
|
7
|
+
integrationEventId: uuid('integration_event_id').notNull(),
|
|
8
|
+
projectId: uuid('project_id').notNull(),
|
|
9
|
+
receivedAt: timestamp('received_at', {withTimezone: true}).notNull().defaultNow(),
|
|
10
|
+
},
|
|
11
|
+
(table) => [
|
|
12
|
+
primaryKey({columns: [table.integrationEventId, table.projectId]}),
|
|
13
|
+
index('projects_integration_event_dedup_received_at_idx').on(table.receivedAt),
|
|
14
|
+
],
|
|
15
|
+
);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import {uuidv7PrimaryKey} from '@shipfox/node-drizzle';
|
|
2
|
+
import {index, text, timestamp, uniqueIndex, uuid} from 'drizzle-orm/pg-core';
|
|
3
|
+
import type {Project} from '#core/entities/project.js';
|
|
4
|
+
import {pgTable} from './common.js';
|
|
5
|
+
|
|
6
|
+
export const projects = pgTable(
|
|
7
|
+
'projects',
|
|
8
|
+
{
|
|
9
|
+
id: uuidv7PrimaryKey(),
|
|
10
|
+
workspaceId: uuid('workspace_id').notNull(),
|
|
11
|
+
sourceConnectionId: uuid('source_connection_id').notNull(),
|
|
12
|
+
sourceExternalRepositoryId: text('source_external_repository_id').notNull(),
|
|
13
|
+
name: text('name').notNull(),
|
|
14
|
+
createdAt: timestamp('created_at', {withTimezone: true}).notNull().defaultNow(),
|
|
15
|
+
updatedAt: timestamp('updated_at', {withTimezone: true}).notNull().defaultNow(),
|
|
16
|
+
},
|
|
17
|
+
(table) => [
|
|
18
|
+
uniqueIndex('projects_source_unique').on(
|
|
19
|
+
table.sourceConnectionId,
|
|
20
|
+
table.sourceExternalRepositoryId,
|
|
21
|
+
),
|
|
22
|
+
index('projects_workspace_created_id_idx').on(table.workspaceId, table.createdAt, table.id),
|
|
23
|
+
],
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
export type ProjectDb = typeof projects.$inferSelect;
|
|
27
|
+
export type ProjectCreateDb = typeof projects.$inferInsert;
|
|
28
|
+
|
|
29
|
+
export function toProject(row: ProjectDb): Project {
|
|
30
|
+
return {
|
|
31
|
+
id: row.id,
|
|
32
|
+
workspaceId: row.workspaceId,
|
|
33
|
+
sourceConnectionId: row.sourceConnectionId,
|
|
34
|
+
sourceExternalRepositoryId: row.sourceExternalRepositoryId,
|
|
35
|
+
name: row.name,
|
|
36
|
+
createdAt: row.createdAt,
|
|
37
|
+
updatedAt: row.updatedAt,
|
|
38
|
+
};
|
|
39
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import {dirname, resolve} from 'node:path';
|
|
2
|
+
import {fileURLToPath} from 'node:url';
|
|
3
|
+
import type {IntegrationSourceControlService} from '@shipfox/api-integration-core';
|
|
4
|
+
import {
|
|
5
|
+
INTEGRATION_SOURCE_COMMIT_PUSHED,
|
|
6
|
+
type IntegrationsEventMap,
|
|
7
|
+
} from '@shipfox/api-integration-core-dto';
|
|
8
|
+
import {projectsEventSchemas} from '@shipfox/api-projects-dto';
|
|
9
|
+
import {type ShipfoxModule, subscriberFactory} from '@shipfox/node-module';
|
|
10
|
+
import {db, migrationsPath, projectsOutbox} from '#db/index.js';
|
|
11
|
+
import {registerProjectsServiceMetrics} from '#metrics/index.js';
|
|
12
|
+
import {projectsE2eRoutes} from '#presentation/e2eRoutes/index.js';
|
|
13
|
+
import {createProjectRoutes} from '#presentation/index.js';
|
|
14
|
+
import {onSourceCommitPushed} from '#presentation/subscribers/index.js';
|
|
15
|
+
import {createProjectsMaintenanceActivities} from '#temporal/activities/index.js';
|
|
16
|
+
import {PROJECTS_MAINTENANCE_TASK_QUEUE} from '#temporal/constants.js';
|
|
17
|
+
|
|
18
|
+
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
19
|
+
const maintenanceWorkflowsPath = resolve(packageRoot, 'dist/temporal/workflows/index.js');
|
|
20
|
+
|
|
21
|
+
const subscriber = subscriberFactory<IntegrationsEventMap>();
|
|
22
|
+
|
|
23
|
+
export type {Project} from '#core/index.js';
|
|
24
|
+
export {
|
|
25
|
+
createProjectFromSource,
|
|
26
|
+
ProjectAccessDeniedError,
|
|
27
|
+
ProjectAlreadyExistsError,
|
|
28
|
+
ProjectNotFoundError,
|
|
29
|
+
} from '#core/index.js';
|
|
30
|
+
export type {GetProjectBySourceParams} from '#db/index.js';
|
|
31
|
+
export {
|
|
32
|
+
createProject,
|
|
33
|
+
db,
|
|
34
|
+
getProjectById,
|
|
35
|
+
getProjectBySource,
|
|
36
|
+
listProjects,
|
|
37
|
+
migrationsPath,
|
|
38
|
+
projectsOutbox,
|
|
39
|
+
requireProjectForWorkspace,
|
|
40
|
+
} from '#db/index.js';
|
|
41
|
+
export {createProjectRoutes, requireProjectAccess} from '#presentation/index.js';
|
|
42
|
+
|
|
43
|
+
export interface CreateProjectsModuleOptions {
|
|
44
|
+
sourceControl: IntegrationSourceControlService;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createProjectsModule({sourceControl}: CreateProjectsModuleOptions): ShipfoxModule {
|
|
48
|
+
return {
|
|
49
|
+
name: 'projects',
|
|
50
|
+
database: {db, migrationsPath},
|
|
51
|
+
routes: createProjectRoutes(sourceControl),
|
|
52
|
+
e2eRoutes: [projectsE2eRoutes],
|
|
53
|
+
metrics: registerProjectsServiceMetrics,
|
|
54
|
+
publishers: [{name: 'projects', table: projectsOutbox, db, eventSchemas: projectsEventSchemas}],
|
|
55
|
+
subscribers: [subscriber(INTEGRATION_SOURCE_COMMIT_PUSHED, onSourceCommitPushed)],
|
|
56
|
+
workers: [
|
|
57
|
+
{
|
|
58
|
+
taskQueue: PROJECTS_MAINTENANCE_TASK_QUEUE,
|
|
59
|
+
workflowsPath: maintenanceWorkflowsPath,
|
|
60
|
+
activities: createProjectsMaintenanceActivities,
|
|
61
|
+
workflows: [
|
|
62
|
+
{
|
|
63
|
+
name: 'pruneIntegrationEventDedupCron',
|
|
64
|
+
id: 'projects-prune-integration-event-dedup',
|
|
65
|
+
cronSchedule: '0 3 * * *',
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import {instanceMetrics} from '@shipfox/node-opentelemetry';
|
|
2
|
+
|
|
3
|
+
const meter = instanceMetrics.getMeter('projects');
|
|
4
|
+
|
|
5
|
+
const projectsCreatedCount = meter.createCounter<Record<string, never>>('projects_created', {
|
|
6
|
+
description: 'Projects created',
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export type SourceCommitPushedOutcome =
|
|
10
|
+
| 'duplicate'
|
|
11
|
+
| 'non_default_branch'
|
|
12
|
+
| 'observed'
|
|
13
|
+
| 'unbound_source';
|
|
14
|
+
|
|
15
|
+
const sourceCommitPushedCount = meter.createCounter<{outcome: SourceCommitPushedOutcome}>(
|
|
16
|
+
'projects_source_commit_pushed',
|
|
17
|
+
{description: 'Source commit pushed events handled by outcome'},
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
function recordMetric(record: () => void): void {
|
|
21
|
+
try {
|
|
22
|
+
record();
|
|
23
|
+
} catch {
|
|
24
|
+
// Metrics must not affect project writes or subscriber acknowledgements.
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function recordProjectCreated(): void {
|
|
29
|
+
recordMetric(() => projectsCreatedCount.add(1));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function recordSourceCommitPushed(outcome: SourceCommitPushedOutcome): void {
|
|
33
|
+
recordMetric(() => sourceCommitPushedCount.add(1, {outcome}));
|
|
34
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import {getServiceMetricsProvider} from '@shipfox/node-opentelemetry';
|
|
2
|
+
import {getProjectCount} from '#db/projects.js';
|
|
3
|
+
|
|
4
|
+
export function registerProjectsServiceMetrics(): void {
|
|
5
|
+
const meter = getServiceMetricsProvider().getMeter('projects');
|
|
6
|
+
|
|
7
|
+
const projectCount = meter.createObservableGauge('projects_project_count', {
|
|
8
|
+
description: 'Projects currently stored',
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
meter.addBatchObservableCallback(
|
|
12
|
+
async (observer) => {
|
|
13
|
+
observer.observe(projectCount, await getProjectCount());
|
|
14
|
+
},
|
|
15
|
+
[projectCount],
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import {getUserContext, requireWorkspaceAccess} from '@shipfox/api-auth-context';
|
|
2
|
+
import {ClientError} from '@shipfox/node-fastify';
|
|
3
|
+
import type {FastifyRequest} from 'fastify';
|
|
4
|
+
import type {Project} from '#core/entities/index.js';
|
|
5
|
+
import {getProjectById} from '#db/index.js';
|
|
6
|
+
|
|
7
|
+
export interface RequireProjectAccessParams {
|
|
8
|
+
request: FastifyRequest;
|
|
9
|
+
projectId: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface RequireProjectAccessResult {
|
|
13
|
+
project: Project;
|
|
14
|
+
workspaceId: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function requireProjectAccess(
|
|
18
|
+
params: RequireProjectAccessParams,
|
|
19
|
+
): Promise<RequireProjectAccessResult> {
|
|
20
|
+
const userContext = getUserContext(params.request);
|
|
21
|
+
if (!userContext) {
|
|
22
|
+
throw new ClientError('Authentication required', 'unauthorized', {status: 401});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const project = await getProjectById(params.projectId);
|
|
26
|
+
if (!project) throw new ClientError('Project not found', 'project-not-found', {status: 404});
|
|
27
|
+
requireWorkspaceAccess({request: params.request, workspaceId: project.workspaceId});
|
|
28
|
+
return {project, workspaceId: project.workspaceId};
|
|
29
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {toProjectDto} from './project.js';
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type {Project} from '#core/entities/index.js';
|
|
2
|
+
|
|
3
|
+
export function toProjectDto(project: Project) {
|
|
4
|
+
return {
|
|
5
|
+
id: project.id,
|
|
6
|
+
workspace_id: project.workspaceId,
|
|
7
|
+
name: project.name,
|
|
8
|
+
source: {
|
|
9
|
+
connection_id: project.sourceConnectionId,
|
|
10
|
+
external_repository_id: project.sourceExternalRepositoryId,
|
|
11
|
+
},
|
|
12
|
+
created_at: project.createdAt.toISOString(),
|
|
13
|
+
updated_at: project.updatedAt.toISOString(),
|
|
14
|
+
};
|
|
15
|
+
}
|