From 558a7b65aa423d7a09af23f165e4cb9413350a33 Mon Sep 17 00:00:00 2001 From: ajnart Date: Thu, 23 Jul 2026 00:04:45 +0200 Subject: [PATCH] fix(workshop): enforce atomic revisions --- .../1784240000_workshop_widgets.js | 12 +++++++++ apps/workshop/tests/workshop-contracts.mjs | 1 + apps/workshop/tests/workshop-migration.mjs | 15 +++++++++++ apps/workshop/tests/workshop.integration.mjs | 25 +++++++++++-------- 4 files changed, 42 insertions(+), 11 deletions(-) diff --git a/apps/workshop/pb_migrations/1784240000_workshop_widgets.js b/apps/workshop/pb_migrations/1784240000_workshop_widgets.js index 320b825a2..31e379c05 100644 --- a/apps/workshop/pb_migrations/1784240000_workshop_widgets.js +++ b/apps/workshop/pb_migrations/1784240000_workshop_widgets.js @@ -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", diff --git a/apps/workshop/tests/workshop-contracts.mjs b/apps/workshop/tests/workshop-contracts.mjs index 5f6d8501c..2ecd77426 100644 --- a/apps/workshop/tests/workshop-contracts.mjs +++ b/apps/workshop/tests/workshop-contracts.mjs @@ -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", diff --git a/apps/workshop/tests/workshop-migration.mjs b/apps/workshop/tests/workshop-migration.mjs index 0a0cd56ef..424eb640a 100644 --- a/apps/workshop/tests/workshop-migration.mjs +++ b/apps/workshop/tests/workshop-migration.mjs @@ -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)) diff --git a/apps/workshop/tests/workshop.integration.mjs b/apps/workshop/tests/workshop.integration.mjs index e5a49562b..2bb173552 100644 --- a/apps/workshop/tests/workshop.integration.mjs +++ b/apps/workshop/tests/workshop.integration.mjs @@ -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(