@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.
Files changed (98) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +53 -0
  3. package/README.md +83 -0
  4. package/dist/core/entities/project.d.ts +4 -0
  5. package/dist/core/entities/project.d.ts.map +1 -1
  6. package/dist/core/entities/project.js.map +1 -1
  7. package/dist/core/errors.d.ts +4 -0
  8. package/dist/core/errors.d.ts.map +1 -1
  9. package/dist/core/errors.js +6 -0
  10. package/dist/core/errors.js.map +1 -1
  11. package/dist/core/index.d.ts +3 -3
  12. package/dist/core/index.d.ts.map +1 -1
  13. package/dist/core/index.js +2 -2
  14. package/dist/core/index.js.map +1 -1
  15. package/dist/core/projects.d.ts +8 -0
  16. package/dist/core/projects.d.ts.map +1 -1
  17. package/dist/core/projects.js +62 -19
  18. package/dist/core/projects.js.map +1 -1
  19. package/dist/db/db.d.ts +144 -6
  20. package/dist/db/db.d.ts.map +1 -1
  21. package/dist/db/db.js.map +1 -1
  22. package/dist/db/index.d.ts +2 -2
  23. package/dist/db/index.d.ts.map +1 -1
  24. package/dist/db/index.js +1 -1
  25. package/dist/db/index.js.map +1 -1
  26. package/dist/db/projects.d.ts +53 -0
  27. package/dist/db/projects.d.ts.map +1 -1
  28. package/dist/db/projects.js +116 -18
  29. package/dist/db/projects.js.map +1 -1
  30. package/dist/db/schema/integration-event-dedup.d.ts +1 -1
  31. package/dist/db/schema/outbox.d.ts +1 -1
  32. package/dist/db/schema/projects.d.ts +69 -1
  33. package/dist/db/schema/projects.d.ts.map +1 -1
  34. package/dist/db/schema/projects.js +12 -1
  35. package/dist/db/schema/projects.js.map +1 -1
  36. package/dist/index.d.ts +1 -1
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +8 -5
  39. package/dist/index.js.map +1 -1
  40. package/dist/presentation/dto/project.d.ts +2 -1
  41. package/dist/presentation/dto/project.d.ts.map +1 -1
  42. package/dist/presentation/dto/project.js +1 -0
  43. package/dist/presentation/dto/project.js.map +1 -1
  44. package/dist/presentation/e2eRoutes/create-project.d.ts.map +1 -1
  45. package/dist/presentation/e2eRoutes/create-project.js +34 -8
  46. package/dist/presentation/e2eRoutes/create-project.js.map +1 -1
  47. package/dist/presentation/inter-module.d.ts +4 -1
  48. package/dist/presentation/inter-module.d.ts.map +1 -1
  49. package/dist/presentation/inter-module.js +63 -3
  50. package/dist/presentation/inter-module.js.map +1 -1
  51. package/dist/presentation/routes/create-project.d.ts.map +1 -1
  52. package/dist/presentation/routes/create-project.js +8 -2
  53. package/dist/presentation/routes/create-project.js.map +1 -1
  54. package/dist/presentation/routes/index.d.ts.map +1 -1
  55. package/dist/presentation/routes/index.js +3 -1
  56. package/dist/presentation/routes/index.js.map +1 -1
  57. package/dist/presentation/routes/update-project.d.ts +2 -0
  58. package/dist/presentation/routes/update-project.d.ts.map +1 -0
  59. package/dist/presentation/routes/update-project.js +53 -0
  60. package/dist/presentation/routes/update-project.js.map +1 -0
  61. package/dist/presentation/subscribers/index.d.ts +1 -0
  62. package/dist/presentation/subscribers/index.d.ts.map +1 -1
  63. package/dist/presentation/subscribers/index.js +1 -0
  64. package/dist/presentation/subscribers/index.js.map +1 -1
  65. package/dist/presentation/subscribers/on-source-repository-updated.d.ts +4 -0
  66. package/dist/presentation/subscribers/on-source-repository-updated.d.ts.map +1 -0
  67. package/dist/presentation/subscribers/on-source-repository-updated.js +39 -0
  68. package/dist/presentation/subscribers/on-source-repository-updated.js.map +1 -0
  69. package/dist/tsconfig.test.tsbuildinfo +1 -1
  70. package/drizzle/0000_initial.sql +6 -0
  71. package/drizzle/meta/0000_snapshot.json +78 -0
  72. package/package.json +13 -12
  73. package/src/core/entities/project.ts +4 -0
  74. package/src/core/errors.ts +7 -0
  75. package/src/core/index.ts +3 -2
  76. package/src/core/projects.test.ts +10 -0
  77. package/src/core/projects.ts +90 -35
  78. package/src/db/db.ts +3 -0
  79. package/src/db/index.ts +6 -0
  80. package/src/db/projects.test.ts +44 -1
  81. package/src/db/projects.ts +242 -33
  82. package/src/db/schema/projects.ts +16 -0
  83. package/src/index.ts +9 -3
  84. package/src/presentation/dto/project.ts +1 -0
  85. package/src/presentation/e2eRoutes/create-project.test.ts +23 -0
  86. package/src/presentation/e2eRoutes/create-project.ts +39 -10
  87. package/src/presentation/inter-module.test.ts +349 -0
  88. package/src/presentation/inter-module.ts +90 -4
  89. package/src/presentation/routes/create-project.ts +10 -2
  90. package/src/presentation/routes/index.ts +7 -1
  91. package/src/presentation/routes/projects.test.ts +300 -4
  92. package/src/presentation/routes/update-project.ts +46 -0
  93. package/src/presentation/subscribers/index.ts +1 -0
  94. package/src/presentation/subscribers/on-source-commit-pushed.test.ts +2 -0
  95. package/src/presentation/subscribers/on-source-repository-updated.test.ts +108 -0
  96. package/src/presentation/subscribers/on-source-repository-updated.ts +50 -0
  97. package/test/factories/project.ts +6 -0
  98. package/tsconfig.build.tsbuildinfo +1 -1
@@ -3,7 +3,11 @@ CREATE TABLE "projects_projects" (
3
3
  "workspace_id" uuid NOT NULL,
4
4
  "source_connection_id" uuid NOT NULL,
5
5
  "source_external_repository_id" text NOT NULL,
6
+ "source_repository_owner" text,
7
+ "source_repository_name" text,
8
+ "source_default_branch" text,
6
9
  "name" text NOT NULL,
10
+ "slug" text NOT NULL,
7
11
  "created_at" timestamp with time zone DEFAULT now() NOT NULL,
8
12
  "updated_at" timestamp with time zone DEFAULT now() NOT NULL
9
13
  );
@@ -30,7 +34,9 @@ CREATE TABLE "projects_integration_event_dedup" (
30
34
  );
31
35
  --> statement-breakpoint
32
36
  CREATE UNIQUE INDEX "projects_source_unique" ON "projects_projects" USING btree ("source_connection_id","source_external_repository_id");--> statement-breakpoint
37
+ CREATE UNIQUE INDEX "projects_workspace_slug_unique" ON "projects_projects" USING btree ("workspace_id","slug");--> statement-breakpoint
33
38
  CREATE INDEX "projects_workspace_created_id_idx" ON "projects_projects" USING btree ("workspace_id","created_at","id");--> statement-breakpoint
39
+ CREATE INDEX "projects_source_repository_lookup_idx" ON "projects_projects" USING btree ("workspace_id","source_connection_id",lower("source_repository_owner"),lower("source_repository_name"));--> statement-breakpoint
34
40
  CREATE INDEX "projects_outbox_pending_idx" ON "projects_outbox" USING btree ("next_dispatch_at","created_at") WHERE "dispatched_at" IS NULL AND "dead_lettered_at" IS NULL;
35
41
  --> statement-breakpoint
36
42
  CREATE INDEX "projects_outbox_dispatched_retention_idx" ON "projects_outbox" USING btree ("dispatched_at","id") WHERE "dispatched_at" IS NOT NULL;--> statement-breakpoint
@@ -214,12 +214,36 @@
214
214
  "primaryKey": false,
215
215
  "notNull": true
216
216
  },
217
+ "source_repository_owner": {
218
+ "name": "source_repository_owner",
219
+ "type": "text",
220
+ "primaryKey": false,
221
+ "notNull": false
222
+ },
223
+ "source_repository_name": {
224
+ "name": "source_repository_name",
225
+ "type": "text",
226
+ "primaryKey": false,
227
+ "notNull": false
228
+ },
229
+ "source_default_branch": {
230
+ "name": "source_default_branch",
231
+ "type": "text",
232
+ "primaryKey": false,
233
+ "notNull": false
234
+ },
217
235
  "name": {
218
236
  "name": "name",
219
237
  "type": "text",
220
238
  "primaryKey": false,
221
239
  "notNull": true
222
240
  },
241
+ "slug": {
242
+ "name": "slug",
243
+ "type": "text",
244
+ "primaryKey": false,
245
+ "notNull": true
246
+ },
223
247
  "created_at": {
224
248
  "name": "created_at",
225
249
  "type": "timestamp with time zone",
@@ -257,6 +281,27 @@
257
281
  "method": "btree",
258
282
  "with": {}
259
283
  },
284
+ "projects_workspace_slug_unique": {
285
+ "name": "projects_workspace_slug_unique",
286
+ "columns": [
287
+ {
288
+ "expression": "workspace_id",
289
+ "isExpression": false,
290
+ "asc": true,
291
+ "nulls": "last"
292
+ },
293
+ {
294
+ "expression": "slug",
295
+ "isExpression": false,
296
+ "asc": true,
297
+ "nulls": "last"
298
+ }
299
+ ],
300
+ "isUnique": true,
301
+ "concurrently": false,
302
+ "method": "btree",
303
+ "with": {}
304
+ },
260
305
  "projects_workspace_created_id_idx": {
261
306
  "name": "projects_workspace_created_id_idx",
262
307
  "columns": [
@@ -283,6 +328,39 @@
283
328
  "concurrently": false,
284
329
  "method": "btree",
285
330
  "with": {}
331
+ },
332
+ "projects_source_repository_lookup_idx": {
333
+ "name": "projects_source_repository_lookup_idx",
334
+ "columns": [
335
+ {
336
+ "expression": "workspace_id",
337
+ "isExpression": false,
338
+ "asc": true,
339
+ "nulls": "last"
340
+ },
341
+ {
342
+ "expression": "source_connection_id",
343
+ "isExpression": false,
344
+ "asc": true,
345
+ "nulls": "last"
346
+ },
347
+ {
348
+ "expression": "lower(\"source_repository_owner\")",
349
+ "asc": true,
350
+ "isExpression": true,
351
+ "nulls": "last"
352
+ },
353
+ {
354
+ "expression": "lower(\"source_repository_name\")",
355
+ "asc": true,
356
+ "isExpression": true,
357
+ "nulls": "last"
358
+ }
359
+ ],
360
+ "isUnique": false,
361
+ "concurrently": false,
362
+ "method": "btree",
363
+ "with": {}
286
364
  }
287
365
  },
288
366
  "foreignKeys": {},
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-projects",
3
3
  "license": "MIT",
4
- "version": "11.0.0",
4
+ "version": "12.2.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -21,18 +21,19 @@
21
21
  "@temporalio/workflow": "1.18.1",
22
22
  "drizzle-orm": "^0.45.2",
23
23
  "zod": "^4.4.3",
24
- "@shipfox/api-auth-dto": "10.2.0",
25
- "@shipfox/api-auth-context": "11.0.0",
26
- "@shipfox/api-integration-core-dto": "9.0.2",
27
- "@shipfox/api-projects-dto": "10.1.0",
28
- "@shipfox/inter-module": "0.2.2",
29
- "@shipfox/node-drizzle": "0.3.4",
30
- "@shipfox/node-fastify": "0.4.0",
31
- "@shipfox/node-module": "1.0.4",
32
- "@shipfox/node-opentelemetry": "0.6.3",
24
+ "@shipfox/api-auth-dto": "12.0.0",
25
+ "@shipfox/api-auth-context": "12.2.0",
26
+ "@shipfox/api-common-dto": "12.0.0",
27
+ "@shipfox/api-integration-core-dto": "12.2.0",
28
+ "@shipfox/api-projects-dto": "12.2.0",
29
+ "@shipfox/inter-module": "0.2.3",
30
+ "@shipfox/node-drizzle": "0.3.5",
31
+ "@shipfox/node-fastify": "0.4.2",
32
+ "@shipfox/node-module": "1.0.6",
33
+ "@shipfox/node-opentelemetry": "0.6.4",
33
34
  "@shipfox/node-outbox": "0.2.6",
34
- "@shipfox/node-postgres": "0.4.4",
35
- "@shipfox/node-temporal": "0.4.4"
35
+ "@shipfox/node-postgres": "0.5.0",
36
+ "@shipfox/node-temporal": "0.4.5"
36
37
  },
37
38
  "imports": {
38
39
  "#*": "./dist/*"
@@ -3,7 +3,11 @@ export interface Project {
3
3
  workspaceId: string;
4
4
  sourceConnectionId: string;
5
5
  sourceExternalRepositoryId: string;
6
+ sourceRepositoryOwner: string | null;
7
+ sourceRepositoryName: string | null;
8
+ sourceDefaultBranch: string | null;
6
9
  name: string;
10
+ slug: string;
7
11
  createdAt: Date;
8
12
  updatedAt: Date;
9
13
  }
@@ -24,3 +24,10 @@ export class ProjectAlreadyExistsError extends Error {
24
24
  this.name = 'ProjectAlreadyExistsError';
25
25
  }
26
26
  }
27
+
28
+ export class ProjectSlugConflictError extends Error {
29
+ constructor(public readonly slug: string) {
30
+ super(`Project slug already exists: ${slug}`);
31
+ this.name = 'ProjectSlugConflictError';
32
+ }
33
+ }
package/src/core/index.ts CHANGED
@@ -3,6 +3,7 @@ export {
3
3
  ProjectAccessDeniedError,
4
4
  ProjectAlreadyExistsError,
5
5
  ProjectNotFoundError,
6
+ ProjectSlugConflictError,
6
7
  } from './errors.js';
7
- export type {CreateProjectFromSourceParams} from './projects.js';
8
- export {createProjectFromSource} from './projects.js';
8
+ export type {CreateProjectFromSourceParams, UpdateProjectDetailsParams} from './projects.js';
9
+ export {createProjectFromSource, updateProjectDetails} from './projects.js';
@@ -45,6 +45,7 @@ describe('createProjectFromSource', () => {
45
45
  actorId,
46
46
  workspaceId,
47
47
  name: 'Platform',
48
+ slug: 'platform',
48
49
  sourceConnectionId,
49
50
  sourceExternalRepositoryId: 'gitea:gitea-owner/platform',
50
51
  integrations: integrations as IntegrationsModuleClient,
@@ -52,8 +53,12 @@ describe('createProjectFromSource', () => {
52
53
 
53
54
  expect(project.workspaceId).toBe(workspaceId);
54
55
  expect(project.name).toBe('Platform');
56
+ expect(project.slug).toBe('platform');
55
57
  expect(project.sourceConnectionId).toBe(sourceConnectionId);
56
58
  expect(project.sourceExternalRepositoryId).toBe('gitea:gitea-owner/platform');
59
+ expect(project.sourceRepositoryOwner).toBe('gitea-owner');
60
+ expect(project.sourceRepositoryName).toBe('platform');
61
+ expect(project.sourceDefaultBranch).toBe('main');
57
62
  });
58
63
 
59
64
  test('emits project lifecycle events in the same transaction', async () => {
@@ -61,6 +66,7 @@ describe('createProjectFromSource', () => {
61
66
  actorId,
62
67
  workspaceId,
63
68
  name: 'Platform',
69
+ slug: 'platform',
64
70
  sourceConnectionId,
65
71
  sourceExternalRepositoryId: 'gitea:gitea-owner/platform',
66
72
  integrations: integrations as IntegrationsModuleClient,
@@ -78,6 +84,7 @@ describe('createProjectFromSource', () => {
78
84
  expect(events.map((event) => event.payload)).toEqual(
79
85
  expect.arrayContaining([
80
86
  expect.objectContaining({
87
+ slug: 'platform',
81
88
  sourceConnectionId,
82
89
  sourceExternalRepositoryId: 'gitea:gitea-owner/platform',
83
90
  }),
@@ -95,6 +102,7 @@ describe('createProjectFromSource', () => {
95
102
  actorId,
96
103
  workspaceId,
97
104
  name: 'Platform',
105
+ slug: 'platform',
98
106
  sourceConnectionId,
99
107
  sourceExternalRepositoryId: 'gitea:gitea-owner/platform',
100
108
  integrations: integrations as IntegrationsModuleClient,
@@ -104,6 +112,7 @@ describe('createProjectFromSource', () => {
104
112
  actorId,
105
113
  workspaceId,
106
114
  name: 'Platform Again',
115
+ slug: 'platform-again',
107
116
  sourceConnectionId,
108
117
  sourceExternalRepositoryId: 'gitea:gitea-owner/platform',
109
118
  integrations: integrations as IntegrationsModuleClient,
@@ -125,6 +134,7 @@ describe('createProjectFromSource', () => {
125
134
  actorId,
126
135
  workspaceId,
127
136
  name: 'Missing',
137
+ slug: 'missing',
128
138
  sourceConnectionId,
129
139
  sourceExternalRepositoryId: 'not-found',
130
140
  integrations: integrations as IntegrationsModuleClient,
@@ -2,16 +2,25 @@ import type {IntegrationsModuleClient} from '@shipfox/api-integration-core-dto/i
2
2
  import {
3
3
  PROJECT_CREATED,
4
4
  PROJECT_SOURCE_BOUND,
5
+ PROJECT_UPDATED,
5
6
  type ProjectsEventMap,
6
7
  } from '@shipfox/api-projects-dto';
8
+ import {isUniqueViolation} from '@shipfox/node-drizzle';
7
9
  import {writeOutboxEvent} from '@shipfox/node-outbox';
8
10
  import {and, eq} from 'drizzle-orm';
9
11
  import {db} from '#db/db.js';
12
+ import {updateProject} from '#db/projects.js';
10
13
  import {projectsOutbox} from '#db/schema/outbox.js';
11
14
  import {projects, toProject} from '#db/schema/projects.js';
12
15
  import {recordProjectCreated} from '#metrics/instance.js';
13
16
  import type {Project} from './entities/project.js';
14
- import {ProjectAlreadyExistsError} from './errors.js';
17
+ import {
18
+ ProjectAlreadyExistsError,
19
+ ProjectNotFoundError,
20
+ ProjectSlugConflictError,
21
+ } from './errors.js';
22
+
23
+ const PROJECTS_WORKSPACE_SLUG_UNIQUE_CONSTRAINT = 'projects_workspace_slug_unique';
15
24
 
16
25
  export interface CreateProjectFromSourceParams {
17
26
  actorId: string;
@@ -19,54 +28,68 @@ export interface CreateProjectFromSourceParams {
19
28
  name: string;
20
29
  sourceConnectionId: string;
21
30
  sourceExternalRepositoryId: string;
31
+ slug: string;
22
32
  integrations: IntegrationsModuleClient;
23
33
  }
24
34
 
25
35
  export async function createProjectFromSource(
26
36
  params: CreateProjectFromSourceParams,
27
37
  ): Promise<Project> {
28
- let source: {
29
- connection: {id: string; provider: string};
30
- repository: {externalRepositoryId: string};
31
- };
32
- source = await params.integrations.resolveSourceRepository({
38
+ const source = await params.integrations.resolveSourceRepository({
33
39
  workspaceId: params.workspaceId,
34
40
  connectionId: params.sourceConnectionId,
35
41
  externalRepositoryId: params.sourceExternalRepositoryId,
36
42
  });
37
43
 
38
44
  const project = await db().transaction(async (tx) => {
39
- const [projectRow] = await tx
40
- .insert(projects)
41
- .values({
42
- workspaceId: params.workspaceId,
43
- sourceConnectionId: source.connection.id,
44
- sourceExternalRepositoryId: source.repository.externalRepositoryId,
45
- name: params.name,
46
- })
47
- .onConflictDoNothing({
48
- target: [projects.sourceConnectionId, projects.sourceExternalRepositoryId],
49
- })
50
- .returning();
45
+ let projectRow: typeof projects.$inferSelect | undefined;
46
+ for (let attempt = 0; attempt < 2 && !projectRow; attempt += 1) {
47
+ try {
48
+ [projectRow] = await tx
49
+ .insert(projects)
50
+ .values({
51
+ workspaceId: params.workspaceId,
52
+ sourceConnectionId: source.connection.id,
53
+ sourceExternalRepositoryId: source.repository.externalRepositoryId,
54
+ sourceRepositoryOwner: source.repository.owner,
55
+ sourceRepositoryName: source.repository.name,
56
+ sourceDefaultBranch: source.repository.defaultBranch,
57
+ name: params.name,
58
+ slug: params.slug,
59
+ })
60
+ .onConflictDoNothing({
61
+ target: [projects.sourceConnectionId, projects.sourceExternalRepositoryId],
62
+ })
63
+ .returning();
64
+ } catch (error) {
65
+ if (isUniqueViolation(error, PROJECTS_WORKSPACE_SLUG_UNIQUE_CONSTRAINT)) {
66
+ throw new ProjectSlugConflictError(params.slug);
67
+ }
68
+ throw error;
69
+ }
51
70
 
52
- if (!projectRow) {
53
- const [existing] = await tx
54
- .select()
55
- .from(projects)
56
- .where(
57
- and(
58
- eq(projects.sourceConnectionId, source.connection.id),
59
- eq(projects.sourceExternalRepositoryId, source.repository.externalRepositoryId),
60
- ),
61
- )
62
- .limit(1);
63
- if (existing) {
64
- throw new ProjectAlreadyExistsError(
65
- existing.id,
66
- source.connection.id,
67
- source.repository.externalRepositoryId,
68
- );
71
+ if (!projectRow) {
72
+ const [existing] = await tx
73
+ .select()
74
+ .from(projects)
75
+ .where(
76
+ and(
77
+ eq(projects.sourceConnectionId, source.connection.id),
78
+ eq(projects.sourceExternalRepositoryId, source.repository.externalRepositoryId),
79
+ ),
80
+ )
81
+ .limit(1);
82
+ if (existing) {
83
+ throw new ProjectAlreadyExistsError(
84
+ existing.id,
85
+ source.connection.id,
86
+ source.repository.externalRepositoryId,
87
+ );
88
+ }
69
89
  }
90
+ }
91
+
92
+ if (!projectRow) {
70
93
  throw new Error('Project insert returned no rows');
71
94
  }
72
95
 
@@ -78,6 +101,7 @@ export async function createProjectFromSource(
78
101
  actorId: params.actorId,
79
102
  workspaceId: project.workspaceId,
80
103
  projectId: project.id,
104
+ slug: project.slug,
81
105
  sourceConnectionId: project.sourceConnectionId,
82
106
  sourceExternalRepositoryId: project.sourceExternalRepositoryId,
83
107
  },
@@ -99,3 +123,34 @@ export async function createProjectFromSource(
99
123
  recordProjectCreated();
100
124
  return project;
101
125
  }
126
+
127
+ export interface UpdateProjectDetailsParams {
128
+ actorId: string;
129
+ projectId: string;
130
+ name?: string | undefined;
131
+ slug?: string | undefined;
132
+ }
133
+
134
+ export function updateProjectDetails(params: UpdateProjectDetailsParams): Promise<Project> {
135
+ return db().transaction(async (tx) => {
136
+ const update = await updateProject(
137
+ {projectId: params.projectId, name: params.name, slug: params.slug},
138
+ {tx},
139
+ );
140
+ if (!update) throw new ProjectNotFoundError(params.projectId);
141
+ if (!update.changed) return update.project;
142
+
143
+ await writeOutboxEvent<ProjectsEventMap>(tx, projectsOutbox, {
144
+ type: PROJECT_UPDATED,
145
+ payload: {
146
+ actorId: params.actorId,
147
+ workspaceId: update.project.workspaceId,
148
+ projectId: update.project.id,
149
+ name: update.project.name,
150
+ slug: update.project.slug,
151
+ },
152
+ });
153
+
154
+ return update.project;
155
+ });
156
+ }
package/src/db/db.ts CHANGED
@@ -17,6 +17,9 @@ export function db() {
17
17
  return _db;
18
18
  }
19
19
 
20
+ export type Tx = Parameters<Parameters<ReturnType<typeof db>['transaction']>[0]>[0];
21
+ export type Executor = ReturnType<typeof db> | Tx;
22
+
20
23
  export function closeDb(): void {
21
24
  _db = undefined;
22
25
  }
package/src/db/index.ts CHANGED
@@ -14,6 +14,10 @@ export type {
14
14
  ListAdminProjectsResult,
15
15
  ListProjectsParams,
16
16
  ListProjectsResult,
17
+ ResolveCheckoutTargetParams,
18
+ ResolvedCheckoutTarget,
19
+ UpdateProjectParams,
20
+ UpdateProjectResult,
17
21
  } from './projects.js';
18
22
  export {
19
23
  createProject,
@@ -24,6 +28,8 @@ export {
24
28
  listAdminProjects,
25
29
  listProjects,
26
30
  requireProjectForWorkspace,
31
+ resolveCheckoutTarget,
32
+ updateProject,
27
33
  } from './projects.js';
28
34
  export {projectsIntegrationEventDedup} from './schema/integration-event-dedup.js';
29
35
  export {projectsOutbox} from './schema/outbox.js';
@@ -1,5 +1,13 @@
1
+ import {eq} from 'drizzle-orm';
1
2
  import {projectFactory} from '#test/index.js';
2
- import {getProjectCount, getWorkspaceProjectCounts} from './projects.js';
3
+ import {db} from './db.js';
4
+ import {
5
+ getProjectById,
6
+ getProjectCount,
7
+ getWorkspaceProjectCounts,
8
+ updateProject,
9
+ } from './projects.js';
10
+ import {projects} from './schema/projects.js';
3
11
 
4
12
  describe('getProjectCount', () => {
5
13
  it('reports the current project count', async () => {
@@ -28,3 +36,38 @@ describe('getProjectCount', () => {
28
36
  expect(counts).toEqual([{workspaceId, count: 0}]);
29
37
  });
30
38
  });
39
+
40
+ describe('updateProject', () => {
41
+ it('preserves a concurrent update to fields omitted from a partial update', async () => {
42
+ const project = await projectFactory.create();
43
+ let allowStaleTransactionUpdate!: () => void;
44
+ let staleTransactionRead!: () => void;
45
+ const staleTransactionCanUpdate = new Promise<void>((resolve) => {
46
+ allowStaleTransactionUpdate = resolve;
47
+ });
48
+ const staleTransactionHasRead = new Promise<void>((resolve) => {
49
+ staleTransactionRead = resolve;
50
+ });
51
+
52
+ const staleTransaction = db().transaction(async (tx) => {
53
+ await tx.select({id: projects.id}).from(projects).where(eq(projects.id, project.id)).limit(1);
54
+ staleTransactionRead();
55
+ await staleTransactionCanUpdate;
56
+ return updateProject({projectId: project.id, slug: 'stale-slug-update'}, {tx});
57
+ });
58
+
59
+ await staleTransactionHasRead;
60
+ try {
61
+ await updateProject({projectId: project.id, name: 'Concurrent name'});
62
+ } finally {
63
+ allowStaleTransactionUpdate();
64
+ }
65
+ await staleTransaction;
66
+
67
+ const finalProject = await getProjectById(project.id);
68
+ expect(finalProject).toMatchObject({
69
+ name: 'Concurrent name',
70
+ slug: 'stale-slug-update',
71
+ });
72
+ });
73
+ });