Skip to content

Commit a45537f

Browse files
Prevent project write race, add aria prop
1 parent e390a2a commit a45537f

2 files changed

Lines changed: 59 additions & 27 deletions

File tree

src/components/ProjectMetadataModal.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ export function ProjectMetadataModal({
149149
<div className="tw-fixed tw-inset-0 tw-z-50 tw-flex tw-items-center tw-justify-center tw-bg-black/40">
150150
<dialog
151151
aria-labelledby="project-metadata-modal-title"
152+
aria-modal="true"
152153
className="tw-bg-background tw-text-foreground tw-rounded-lg tw-border tw-border-border tw-p-6 tw-w-[32rem] tw-shadow-lg"
153154
open
154155
>

src/services/projectStorage.ts

Lines changed: 58 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ const PROJECT_IDS_KEY = 'projectIds';
1111
*/
1212
let indexQueue: Promise<unknown> = Promise.resolve();
1313

14+
/**
15+
* Per-project serialization queues. Keyed by project ID; each entry serializes all
16+
* read-modify-write operations on that project's storage record so concurrent update and delete
17+
* calls cannot interleave and create orphaned or stale records.
18+
*/
19+
const projectQueues = new Map<string, Promise<unknown>>();
20+
1421
/**
1522
* Enqueues `fn` on the index serialization queue and returns a promise that resolves or rejects
1623
* with `fn`'s result. The queue always advances regardless of whether `fn` throws.
@@ -24,6 +31,26 @@ function enqueueIndexOp<T>(fn: () => Promise<T>): Promise<T> {
2431
return result;
2532
}
2633

34+
/**
35+
* Enqueues `fn` on the per-project serialization queue for `id` and returns a promise that resolves
36+
* or rejects with `fn`'s result. Cleans up the queue entry when the operation settles.
37+
*
38+
* @param id - The project UUID whose queue `fn` should join.
39+
* @param fn - The async function to serialize.
40+
* @returns A promise that resolves or rejects with the return value of `fn`.
41+
*/
42+
function enqueueProjectOp<T>(id: string, fn: () => Promise<T>): Promise<T> {
43+
const previous = projectQueues.get(id) ?? Promise.resolve();
44+
const result = previous.then(fn);
45+
let settled: Promise<void>;
46+
const cleanup = () => {
47+
if (projectQueues.get(id) === settled) projectQueues.delete(id);
48+
};
49+
settled = result.then(cleanup, cleanup);
50+
projectQueues.set(id, settled);
51+
return result;
52+
}
53+
2754
/**
2855
* Returns the storage key for a project by ID.
2956
*
@@ -201,24 +228,26 @@ export async function updateProjectMetadata(
201228
description: string | undefined,
202229
analysisWritingSystem?: string,
203230
): Promise<InterlinearProject | undefined> {
204-
const project = await getProject(token, id);
205-
if (!project) return undefined;
206-
const updated: InterlinearProject = { ...project };
207-
if (name === undefined) {
208-
delete updated.name;
209-
} else {
210-
updated.name = name;
211-
}
212-
if (description === undefined) {
213-
delete updated.description;
214-
} else {
215-
updated.description = description;
216-
}
217-
if (analysisWritingSystem) {
218-
updated.analysisWritingSystem = analysisWritingSystem;
219-
}
220-
await papi.storage.writeUserData(token, projectKey(id), JSON.stringify(updated));
221-
return updated;
231+
return enqueueProjectOp(id, async () => {
232+
const project = await getProject(token, id);
233+
if (!project) return undefined;
234+
const updated: InterlinearProject = { ...project };
235+
if (name === undefined) {
236+
delete updated.name;
237+
} else {
238+
updated.name = name;
239+
}
240+
if (description === undefined) {
241+
delete updated.description;
242+
} else {
243+
updated.description = description;
244+
}
245+
if (analysisWritingSystem) {
246+
updated.analysisWritingSystem = analysisWritingSystem;
247+
}
248+
await papi.storage.writeUserData(token, projectKey(id), JSON.stringify(updated));
249+
return updated;
250+
});
222251
}
223252

224253
/**
@@ -231,14 +260,16 @@ export async function updateProjectMetadata(
231260
* @throws If `papi.storage.writeUserData` rejects when updating the index.
232261
*/
233262
export async function deleteProject(token: ExecutionToken, id: string): Promise<void> {
234-
try {
235-
await papi.storage.deleteUserData(token, projectKey(id));
236-
} catch (e) {
237-
if (!isNotFound(e)) throw e;
238-
}
239-
await enqueueIndexOp(async () => {
240-
const ids = await readIds(token);
241-
const updated = ids.filter((i) => i !== id);
242-
await papi.storage.writeUserData(token, PROJECT_IDS_KEY, JSON.stringify(updated));
263+
await enqueueProjectOp(id, async () => {
264+
try {
265+
await papi.storage.deleteUserData(token, projectKey(id));
266+
} catch (e) {
267+
if (!isNotFound(e)) throw e;
268+
}
269+
await enqueueIndexOp(async () => {
270+
const ids = await readIds(token);
271+
const updated = ids.filter((i) => i !== id);
272+
await papi.storage.writeUserData(token, PROJECT_IDS_KEY, JSON.stringify(updated));
273+
});
243274
});
244275
}

0 commit comments

Comments
 (0)