fix(workshop): enforce atomic revisions

This commit is contained in:
ajnart committed 2026-07-23 00:04:45 +02:00
1 parent 213108dd2f
commit 558a7b65aa
4 files changed
+42 -11

No files matched your search

@@ -106,6 +106,18 @@ migrate(
submissions.addIndex("idx_submissions_author", false, "author", "");
submissions.addIndex("idx_submissions_created", false, "created", "");
app.save(submissions);
app
.db()
.newQuery(
`CREATE TRIGGER submissions_revision_cas
BEFORE UPDATE ON submissions
FOR EACH ROW
WHEN NEW.revision != OLD.revision + 1
BEGIN
SELECT RAISE(ABORT, 'submission revision must increment exactly once');
END`,
)
.execute();
const votes = new Collection({
type: "base",
@@ -24,6 +24,7 @@ const migration = await read("apps/workshop/pb_migrations/1784240000_workshop_wi
for (const required of [
"workshop_migration_state",
"addedUserFields",
"CREATE TRIGGER submissions_revision_cas",
"state.rateLimits.enabled",
"users.passwordAuth = state.users.passwordAuth",
"users.oauth2 = state.users.oauth2",
@@ -67,7 +67,19 @@ Object.assign(users, original.rules);
const settings = { rateLimits: structuredClone(original.rateLimits) };
const collections = new Map([[users.id, users]]);
const records = new Map();
const rawQueries = [];
const app = {
db() {
return {
newQuery(sql) {
return {
execute() {
rawQueries.push(sql);
},
};
},
};
},
findCollectionByNameOrId(name) {
const collection = collections.get(name);
if (!collection) throw new Error(`Missing collection ${name}`);
@@ -113,6 +125,9 @@ migration.up(app);
if (!collections.has("workshop_migration_state") || settings.rateLimits.enabled !== true) {
throw new Error("Workshop migration did not capture state before applying changes");
}
if (!rawQueries.some((query) => query.includes("CREATE TRIGGER submissions_revision_cas"))) {
throw new Error("Workshop migration did not install the atomic submission revision guard");
}
migration.down(app);
if (JSON.stringify(users.passwordAuth) !== JSON.stringify(original.passwordAuth))
+14 -11
View File
@@ -178,17 +178,20 @@ if (!outdatedSubmission.outdated || outdatedSubmission.revision !== 3) {
throw new Error("PocketBase must own submission revision increments");
}
const concurrentUpdates = await Promise.all(
["Concurrent first", "Concurrent second"].map((title) =>
fetch(`${baseUrl}/api/collections/submissions/records/${submission.id}`, {
method: "PATCH",
headers: { "content-type": "application/json", ...authorSession.headers },
body: JSON.stringify({ title, expectedRevision: 3 }),
}),
),
);
if (concurrentUpdates.filter((response) => response.ok).length !== 1) {
throw new Error("Exactly one update may consume an expected submission revision");
for (let attempt = 0; attempt < 5; attempt += 1) {
const expectedRevision = 3 + attempt;
const concurrentUpdates = await Promise.all(
["Concurrent first", "Concurrent second"].map((title) =>
fetch(`${baseUrl}/api/collections/submissions/records/${submission.id}`, {
method: "PATCH",
headers: { "content-type": "application/json", ...authorSession.headers },
body: JSON.stringify({ title: `${title} ${attempt}`, expectedRevision }),
}),
),
);
if (concurrentUpdates.filter((response) => response.ok).length !== 1) {
throw new Error("Exactly one update may consume an expected submission revision");
}
}
await expectStatus(