Skip to content

Commit b4a0abe

Browse files
committed
fix(workspace): move design system project with team share
1 parent 1d02222 commit b4a0abe

5 files changed

Lines changed: 663 additions & 4 deletions

File tree

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
import {
2+
TeamResourceShareForbiddenError,
3+
type TeamResourceRequestScope,
4+
type TeamResourceShareService,
5+
} from '../collab/team-resource-share.js';
6+
7+
export interface PreparedLinkedProjectShare {
8+
projectId: string;
9+
/**
10+
* Move the one backing project projection and its remote Team publication.
11+
* Implementations must compensate their own partial transition before
12+
* rejecting; this coordinator compensates the other resource family.
13+
*/
14+
transition(visibility: 'personal' | 'team'): Promise<void>;
15+
}
16+
17+
export interface CreateLinkedProjectTeamResourceShareServiceOptions {
18+
resource: TeamResourceShareService;
19+
/**
20+
* Resolve and authorize the backing project before either hub is mutated.
21+
* The returned transition is pinned to this exact Workspace principal.
22+
*/
23+
prepare(
24+
resourceId: string,
25+
scope: TeamResourceRequestScope,
26+
): Promise<PreparedLinkedProjectShare>;
27+
}
28+
29+
export interface DesignSystemBackingProjectBinding {
30+
workspaceId?: string | null;
31+
createdByWorkspaceMemberId?: string | null;
32+
}
33+
34+
export interface CreateDesignSystemBackingProjectPreparerOptions {
35+
resolveProjectId(resourceId: string): Promise<string | null> | string | null;
36+
projectExists(projectId: string): boolean;
37+
getProjectBinding(projectId: string): DesignSystemBackingProjectBinding | undefined;
38+
publishProject(
39+
projectId: string,
40+
scope: TeamResourceRequestScope,
41+
): Promise<{ version: number | null }>;
42+
unpublishProject(projectId: string, scope: TeamResourceRequestScope): Promise<void>;
43+
persistVisibility(input: {
44+
projectId: string;
45+
scope: TeamResourceRequestScope;
46+
visibility: 'personal' | 'team';
47+
}): Promise<void> | void;
48+
onPrepared?: (input: {
49+
resourceId: string;
50+
projectId: string;
51+
scope: TeamResourceRequestScope;
52+
}) => void;
53+
}
54+
55+
function compensationError(
56+
operation: 'share' | 'unshare',
57+
primary: unknown,
58+
compensation: unknown,
59+
): Error {
60+
const error = new Error(
61+
`linked project ${operation} failed and compensation also failed: ${String(primary)}; ${String(compensation)}`,
62+
);
63+
error.name = 'LinkedProjectShareCompensationError';
64+
(error as Error & { cause?: unknown }).cause = primary;
65+
return error;
66+
}
67+
68+
/**
69+
* Build the exact-Workspace project half of the linked-resource saga.
70+
* Authority is checked before the coordinator publishes the design system,
71+
* and each remote-project/local-projection pair compensates itself before
72+
* rejecting.
73+
*/
74+
export function createDesignSystemBackingProjectPreparer(
75+
options: CreateDesignSystemBackingProjectPreparerOptions,
76+
): CreateLinkedProjectTeamResourceShareServiceOptions['prepare'] {
77+
return async (resourceId, scope) => {
78+
const projectId = (await options.resolveProjectId(resourceId))?.trim() ?? '';
79+
if (!projectId || !options.projectExists(projectId)) {
80+
throw new Error('design system backing project is unavailable');
81+
}
82+
const workspaceId = scope.principal.teamId;
83+
const memberId = scope.principal.memberId;
84+
const binding = options.getProjectBinding(projectId);
85+
if (binding?.workspaceId && binding.workspaceId !== workspaceId) {
86+
throw new Error('design system backing project belongs to another workspace');
87+
}
88+
if (
89+
binding?.createdByWorkspaceMemberId
90+
&& binding.createdByWorkspaceMemberId !== memberId
91+
) {
92+
throw new TeamResourceShareForbiddenError();
93+
}
94+
options.onPrepared?.({ resourceId, projectId, scope });
95+
return {
96+
projectId,
97+
transition: async (visibility) => {
98+
if (visibility === 'team') {
99+
const published = await options.publishProject(projectId, scope);
100+
if (published.version == null) {
101+
throw new Error('design system backing project publish failed');
102+
}
103+
try {
104+
await options.persistVisibility({ projectId, scope, visibility });
105+
} catch (error) {
106+
try {
107+
await options.unpublishProject(projectId, scope);
108+
} catch (rollbackError) {
109+
// Remote rollback failed, so the project publication is still
110+
// Team-authoritative. Retry the local forward projection once;
111+
// a transient SQLite failure can converge to the original share
112+
// intent without asking the outer coordinator to make an unsafe
113+
// assumption about which project state won.
114+
try {
115+
await options.persistVisibility({ projectId, scope, visibility });
116+
return;
117+
} catch (forwardError) {
118+
throw compensationError(
119+
'share',
120+
error,
121+
compensationError('share', rollbackError, forwardError),
122+
);
123+
}
124+
}
125+
throw error;
126+
}
127+
return;
128+
}
129+
await options.unpublishProject(projectId, scope);
130+
try {
131+
await options.persistVisibility({ projectId, scope, visibility });
132+
} catch (error) {
133+
try {
134+
await options.publishProject(projectId, scope);
135+
} catch (rollbackError) {
136+
// The inverse publish failed, so the project remains remotely
137+
// unshared. Retry the Personal projection once and converge
138+
// forward before returning control to the resource half.
139+
try {
140+
await options.persistVisibility({ projectId, scope, visibility });
141+
return;
142+
} catch (forwardError) {
143+
throw compensationError(
144+
'unshare',
145+
error,
146+
compensationError('unshare', rollbackError, forwardError),
147+
);
148+
}
149+
}
150+
throw error;
151+
}
152+
},
153+
};
154+
};
155+
}
156+
157+
/**
158+
* Couple a Team resource with its editable backing project as a small saga.
159+
*
160+
* The Resource Hub has separate design-system and project publications, so no
161+
* SQL transaction can honestly make the cross-hub write atomic. The ordering
162+
* here is deliberate and every second-step failure runs the inverse idempotent
163+
* primitive before the request rejects:
164+
*
165+
* - share: publish resource -> move/publish project; compensate by unsharing
166+
* the resource if the project transition fails;
167+
* - unshare: move/unpublish project -> unpublish resource; compensate by
168+
* moving/publishing the project back if resource removal fails.
169+
*
170+
* `prepare` runs first so a Workspace mismatch or ownership failure cannot
171+
* leave even the first hub changed. Successful calls do not resolve until both
172+
* halves agree, which lets the route invalidate both list projections once.
173+
*/
174+
export function createLinkedProjectTeamResourceShareService(
175+
options: CreateLinkedProjectTeamResourceShareServiceOptions,
176+
): TeamResourceShareService {
177+
const { resource } = options;
178+
const service: TeamResourceShareService = {
179+
configured: resource.configured,
180+
async share(resourceId, scope) {
181+
const linkedProject = await options.prepare(resourceId, scope);
182+
const result = await resource.share(resourceId, scope);
183+
if (!result) return null;
184+
try {
185+
await linkedProject.transition('team');
186+
} catch (error) {
187+
try {
188+
await resource.unshare(resourceId, scope);
189+
} catch (rollbackError) {
190+
// The inverse resource write can fail independently. Retry the
191+
// forward project write once: if that succeeds, the user's original
192+
// share intent is fully true and no orphan remains despite the
193+
// failed rollback.
194+
try {
195+
await linkedProject.transition('team');
196+
return result;
197+
} catch (forwardError) {
198+
throw compensationError(
199+
'share',
200+
error,
201+
compensationError('share', rollbackError, forwardError),
202+
);
203+
}
204+
}
205+
throw error;
206+
}
207+
return result;
208+
},
209+
async unshare(resourceId, scope) {
210+
// The linked project must not move before the resource's authoritative
211+
// owner/admin gate has approved this exact caller. `resource.unshare`
212+
// repeats the same check immediately before its write.
213+
const sharedResource = (await service.sharedResources(scope))
214+
.find((candidate) => candidate.id === resourceId);
215+
if (sharedResource && !sharedResource.canUnshare) {
216+
throw new TeamResourceShareForbiddenError();
217+
}
218+
const linkedProject = await options.prepare(resourceId, scope);
219+
await linkedProject.transition('personal');
220+
try {
221+
return await resource.unshare(resourceId, scope);
222+
} catch (error) {
223+
try {
224+
await linkedProject.transition('team');
225+
} catch (rollbackError) {
226+
// Same convergence rule in reverse: when restoring Team also fails,
227+
// retry the original resource removal once. A success means both
228+
// halves are Personal and the requested unshare can truthfully
229+
// complete.
230+
try {
231+
return await resource.unshare(resourceId, scope);
232+
} catch (forwardError) {
233+
throw compensationError(
234+
'unshare',
235+
error,
236+
compensationError('unshare', rollbackError, forwardError),
237+
);
238+
}
239+
}
240+
throw error;
241+
}
242+
},
243+
sharedIds: (scope) => resource.sharedIds(scope),
244+
sharedResources: (scope, readOptions) =>
245+
resource.sharedResources(scope, readOptions),
246+
isShared: (resourceId, scope) => resource.isShared(resourceId, scope),
247+
};
248+
return service;
249+
}

apps/daemon/src/routes/team-resource-share.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,16 @@ export interface RegisterTeamResourceShareRoutesDeps {
5555
listTeam?: ((scope: TeamResourceRequestScope) => Promise<TeamResourceShareListing>) & {
5656
invalidate?: (scope: TeamResourceRequestScope) => void;
5757
};
58+
/**
59+
* Local fan-out after the authoritative mutation and the list-cache
60+
* invalidation have both completed. Linked resources use this to invalidate
61+
* every projection before the success response escapes.
62+
*/
63+
onMutationCommitted?: (
64+
resourceId: string,
65+
scope: TeamResourceRequestScope,
66+
visibility: 'personal' | 'team',
67+
) => void;
5868
}
5969

6070
/**
@@ -141,6 +151,7 @@ export function registerTeamResourceShareRoutes(
141151
// freshMs, or worse, the client's slower background poll once SSE
142152
// lowers its cadence.
143153
invalidateListTeam(scope);
154+
deps.onMutationCommitted?.(id, scope, 'team');
144155
res.json({ shared: true, version: result.version });
145156
} catch (error) {
146157
if (error instanceof TeamResourceShareForbiddenError) {
@@ -159,7 +170,10 @@ export function registerTeamResourceShareRoutes(
159170
if (!scope) return;
160171
try {
161172
const unshared = await share.unshare(id, scope);
162-
if (unshared) invalidateListTeam(scope);
173+
if (unshared) {
174+
invalidateListTeam(scope);
175+
deps.onMutationCommitted?.(id, scope, 'personal');
176+
}
163177
res.json({ unshared });
164178
} catch (error) {
165179
if (error instanceof TeamResourceShareForbiddenError) {

0 commit comments

Comments
 (0)