diff --git a/apps/reminder-worker/src/invitations.ts b/apps/reminder-worker/src/invitations.ts index c1745acc..192c5da2 100644 --- a/apps/reminder-worker/src/invitations.ts +++ b/apps/reminder-worker/src/invitations.ts @@ -105,6 +105,11 @@ export async function sendInvitationEmail(pool: Pool, actor: string, token: stri let payload: Payload; try { await client.query("BEGIN"); + // An admin leaving, removed or made a member locks the commune and then + // withdraws their invitations, so the sender takes the commune first and + // the two wait in turn. + await client.query(`SELECT 1 FROM public.communes c JOIN public.commune_invites i ON c.id=i.commune_id + WHERE i.token_hash=$1 FOR KEY SHARE OF c`, [tokenHash]); const { rows } = await client.query(` SELECT i.*, c.name AS commune_name, u.name AS inviter_name FROM public.commune_invites i JOIN public.communes c ON c.id=i.commune_id diff --git a/apps/reminder-worker/tests/invitations.mjs b/apps/reminder-worker/tests/invitations.mjs index 032d6e1d..a4748aa4 100644 --- a/apps/reminder-worker/tests/invitations.mjs +++ b/apps/reminder-worker/tests/invitations.mjs @@ -11,7 +11,7 @@ const pool = new Pool({ connectionString: process.env.DATABASE_URL }); const senderPool = new Pool({ connectionString: process.env.DATABASE_URL, options: "-c search_path=pg_catalog" }); const send = (actorId, token, mail = config) => sendInvitationEmail(senderPool, actorId, token, mail); const tokenHash = (token) => createHash("sha256").update(token).digest(); -const actor = randomUUID(), outsider = randomUUID(), bulkSender = randomUUID(); +const actor = randomUUID(), outsider = randomUUID(), bulkSender = randomUUID(), otherAdmin = randomUUID(), removedAdmin = randomUUID(); const config = { apiKey: "test-only", from: "Commune ", appUrl: "https://example.test" }; const calls = []; let providerStatus = 200; @@ -27,12 +27,23 @@ async function asUser(userId, fn) { } const asActor = (fn) => asUser(actor, fn); const refusal = (status, message) => (e) => e.status === status && (!message || e.message === message); +const settle = (promise) => promise.then((result) => ({ result }), (error) => ({ error })); +// Polls until the query's row says ok, so a race can wait for a session to +// be stuck behind a lock. +async function waitUntil(sql, values) { + for (let attempt = 0; attempt < 400; attempt += 1) { + if ((await pool.query(sql, values)).rows[0]?.ok) return; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error(`Gave up waiting for: ${sql}`); +} +const UNAVAILABLE = "Invitation is unavailable or you are no longer a commune admin."; const NAME_REFUSAL = "This invitation can't be emailed because a name in it looks like a link or phone number. Copy the link and share it yourself."; const SUPPRESSED = "This address can't receive Commune emails. Share the link another way."; const NEW_ACCOUNT = "New accounts can email up to 5 invitations a day. Copy the link and share it yourself."; const PAUSED = "Invitation emails are paused for today. Copy the link and share it yourself."; const deliveries = async () => +(await pool.query("SELECT count(*) FROM public.invitation_email_deliveries WHERE sender_id=$1", [actor])).rows[0].count; -let commune, bulkCommune; +let commune, bulkCommune, removalCommune; try { // The email names its inviter, quotes the commune and offers a report link // that names the invitation by id, never by token. @@ -197,11 +208,45 @@ try { assert.equal(calls.length, before + 1, "simultaneous sends dispatch only once"); await asActor(c => c.query("UPDATE public.commune_members SET role='member' WHERE commune_id=$1 AND user_id=$2", [commune, actor])); await assert.rejects(send(actor, parallelToken), refusal(404), "former admins cannot send"); - console.log("Invitation email: authorization, inviter and commune naming, report link, contact-like names, suppressed addresses, new-account and Commune-wide limits, index use, provider failure/retry, deduplication, rate limit, expiry, token privacy and HTML escaping passed. No real email sent."); + + // An admin removed while their invitation is being emailed. The removal + // locks the commune and then withdraws the admin's invitations, so the + // sender takes the commune first, waits for the removal and then finds the + // invitation gone. Holding the admin's membership row stops the removal + // between its two locks, so this interleaving happens every time. + await pool.query('INSERT INTO neon_auth."user" (id,name,email,"emailVerified") VALUES ($1,\'Other admin\',$2,true), ($3,\'Removed admin\',$4,true)', + [otherAdmin, `${otherAdmin}@example.test`, removedAdmin, `${removedAdmin}@example.test`]); + removalCommune = (await asUser(otherAdmin, c => c.query("SELECT public.create_commune('Removal test','household','GBP') AS id"))).rows[0].id; + await asUser(otherAdmin, c => c.query("INSERT INTO public.commune_members (commune_id,user_id,role) VALUES ($1,$2,'admin')", [removalCommune, removedAdmin])); + const removedToken = (await asUser(removedAdmin, c => c.query("SELECT public.create_commune_invite($1,'removal@example.test','member') AS token", [removalCommune]))).rows[0].token; + const holder = await pool.connect(), remover = await pool.connect(); + try { + await holder.query("BEGIN"); + await holder.query("SELECT 1 FROM public.commune_members WHERE commune_id=$1 AND user_id=$2 FOR UPDATE", [removalCommune, removedAdmin]); + await remover.query("BEGIN"); + await remover.query("SELECT set_config('request.jwt.claims',json_build_object('sub',$1::text)::text,true)", [otherAdmin]); + const { pid } = (await remover.query("SELECT pg_backend_pid() AS pid")).rows[0]; + const removal = settle(remover.query("SELECT public.manage_commune_member($1,$2,'remove')", [removalCommune, removedAdmin])); + await waitUntil("SELECT wait_event_type='Lock' AS ok FROM pg_stat_activity WHERE pid=$1", [pid]); + before = calls.length; + const sending = settle(send(removedAdmin, removedToken)); + await waitUntil("SELECT count(*)>0 AS ok FROM pg_stat_activity WHERE $1=ANY(pg_blocking_pids(pid))", [pid]); + await holder.query("COMMIT"); + const [removed, sent] = await Promise.all([ + removal.then(async (outcome) => { await remover.query(outcome.error ? "ROLLBACK" : "COMMIT"); return outcome; }), sending]); + for (const outcome of [removed, sent]) assert.notEqual(outcome.error?.code, "40P01", `the removal and the sender deadlocked: ${outcome.error?.message}`); + assert.equal(removed.error, undefined, removed.error?.message); + assert.equal(sent.error?.status, 404, sent.error?.message ?? "the invitation was emailed"); + assert.equal(sent.error.message, UNAVAILABLE); + } finally { holder.release(true); remover.release(true); } + assert.equal((await pool.query("SELECT active FROM public.commune_members WHERE commune_id=$1 AND user_id=$2", [removalCommune, removedAdmin])).rows[0].active, false); + assert.equal(+(await pool.query("SELECT count(*) FROM public.invitation_email_deliveries WHERE commune_id=$1", [removalCommune])).rows[0].count, 0, "no delivery is left"); + assert.equal(calls.length, before, "the provider is never called"); + console.log("Invitation email: authorization, inviter and commune naming, report link, contact-like names, suppressed addresses, new-account and Commune-wide limits, index use, provider failure/retry, deduplication, rate limit, expiry, token privacy, HTML escaping and an admin removed while sending passed. No real email sent."); } finally { - await pool.query("DELETE FROM public.communes WHERE id = ANY($1::uuid[])", [[commune, bulkCommune].filter(Boolean)]); + await pool.query("DELETE FROM public.communes WHERE id = ANY($1::uuid[])", [[commune, bulkCommune, removalCommune].filter(Boolean)]); await pool.query("DELETE FROM public.email_suppressions WHERE provider_event_id LIKE 'msg_test_%'"); - await pool.query('DELETE FROM neon_auth."user" WHERE id = ANY($1::text[])', [[actor, bulkSender]]); + await pool.query('DELETE FROM neon_auth."user" WHERE id = ANY($1::text[])', [[actor, bulkSender, otherAdmin, removedAdmin]]); await pool.end(); await senderPool.end(); } diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index ee4daae0..d1db205b 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -65,6 +65,10 @@ lifecycle pack. Work is paused for a handoff, not a production release. (`apps/web/src/data/covers.ts`), on a calm tone for the commune type while they load or if they fail; bundling them needs the three original files from Edem. Production builds preload the data client chunk (`vite.config.ts`). +- Also from 9 October, for review: completing, skipping, handing over and + undoing a task now lock the commune first (migration 0037), and the + invitation email sender does the same, so either meeting a member leaving + waits for it instead of failing as a deadlock. - The website is `web/` (the editorial homepage); the earlier v2 site is in `web-backup/`. Launch names `website` (3004) and `website-backup` (3003). - No push, deployment, hosted migration or provider change has been made. Do @@ -178,7 +182,7 @@ no further copy-cleanup pass is needed for the handoff. Read [the product-depth roadmap](PRODUCT-DEPTH-ROADMAP-2026-10-02.md) and the Remaining section of [money lifecycle](MONEY-LIFECYCLE-2026-10-02.md). -1. **Hosted parity.** Migrations 0013 to 0036, schema-cache refresh, the +1. **Hosted parity.** Migrations 0013 to 0037, schema-cache refresh, the matching web client and sender, auth, invitations and real email delivery are all unverified on Neon. Apply migrations before deploying the client. Measure cold/warm/idle loading with separate accounts. Do not declare diff --git a/infra/local/README.md b/infra/local/README.md index 1589f003..5267158d 100644 --- a/infra/local/README.md +++ b/infra/local/README.md @@ -41,7 +41,8 @@ files in order using a database owner connection: 35. `infra/neon/migrations/0034_database_hardening.sql` 36. `infra/neon/migrations/0035_currency_rule.sql` 37. `infra/neon/migrations/0036_lighter_loading.sql` -38. `infra/local/after-core.sql` +38. `infra/neon/migrations/0037_task_command_lock_order.sql` +39. `infra/local/after-core.sql` `after-core.sql` is idempotent. Re-run it after applying a newer migration to an existing local database so the local-only reminder helpers stay current. diff --git a/infra/neon/README.md b/infra/neon/README.md index d2be9948..15f566bb 100644 --- a/infra/neon/README.md +++ b/infra/neon/README.md @@ -14,8 +14,8 @@ this is not a replacement of Neon Auth or a switch back to Supabase. - [Current design language and preserved layouts](../../docs/DESIGN-LANGUAGE.md) - [Local build-out from 5 October: migrations 0020 onwards](../../docs/LOCAL-BUILDOUT-2026-10-05.md) -Migrations **0015 to 0036 are local only**. They have not been applied to any -Neon branch. Release order when they are: apply 0013 to 0036 in numeric order +Migrations **0015 to 0037 are local only**. They have not been applied to any +Neon branch. Release order when they are: apply 0013 to 0037 in numeric order to the preview branch, refresh that branch's Data API schema cache, then deploy the matching web client. The new client calls `send_reminder_command`, `record_share_receipt`, `reverse_share_receipt`, `correct_expense`, @@ -89,6 +89,16 @@ Six of the 9 October migrations change that rule: `set_recurring_bill_archived`; production has no recurring bills yet, so only the new client uses them. +0037 makes completing, skipping, reopening, handing over and undoing a task +lock the commune before the task, as edits and removals have since 0027, so +one of them meeting a member's departure waits for it instead of failing as a +deadlock. It needs no client or worker change. Separately, the invitation +email sender in `apps/reminder-worker` now locks the invitation's commune +before the invitation, so emailing an invitation while its admin leaves, is +removed or is made a member waits and is then refused as unavailable instead +of failing as a deadlock. That is a worker change only, released with the +worker deploys above. + ## Last recorded production evidence — 27 September 2026 The app is served at `https://app.ourcommune.io` from `main`. The latest @@ -252,7 +262,7 @@ rate limits) are recorded in [`docs/AUTH-SETTINGS.md`](../../docs/AUTH-SETTINGS. Broad grants would let direct writes bypass ledger invariants. Keep preview branches separate from production. 3. Apply `migrations/0001_commune_core.sql` through - `migrations/0036_lighter_loading.sql` in numeric order to the + `migrations/0037_task_command_lock_order.sql` in numeric order to the development branch via an **unpooled** connection. Run `tests/grants.sql`, review RLS, and run two-account signed-JWT tests before applying the same reviewed migrations to production. After a migration adds or changes a diff --git a/infra/neon/migrations/0037_task_command_lock_order.sql b/infra/neon/migrations/0037_task_command_lock_order.sql new file mode 100644 index 00000000..73bb071c --- /dev/null +++ b/infra/neon/migrations/0037_task_command_lock_order.sql @@ -0,0 +1,86 @@ +-- Completing, skipping, reopening, handing over and undoing a task now lock +-- the commune before the task, as editing and removing have since 0027. A +-- member leaving locks the commune and then each of their tasks. These +-- commands locked the task first and needed the commune later, when the +-- occurrence or activity line they add takes a key-share lock on it through +-- its foreign key. So when a member left while someone acted on one of their +-- tasks, each waited on the other until PostgreSQL cancelled one as a +-- deadlock, and the action or the departure failed. Now the command waits +-- for the departure to finish, then finds the task as the departure left it +-- and goes ahead or is refused with its usual message. +-- +-- Only the start of each function changes, so each is patched in place: the +-- task's commune is read without a lock and locked, then the task (for undo, +-- the task through its occurrence, then the occurrence) is locked and read +-- as before. A task never moves to another commune, so that commune is still +-- the task's once the locks are held, and every check runs on the task as it +-- is then. Nothing else locks a task or an occurrence before its commune: +-- adding a task locks the commune first, and the snapshot and search only +-- read. No client change. Apply after 0036. +BEGIN; + +-- Each patch must apply, and leave the commune as the first row its +-- function locks. +DO $$ +DECLARE v_original text; v_source text; +BEGIN + v_original := pg_get_functiondef('public.record_operation_occurrence(uuid,text,date,date,uuid,text)'::regprocedure); + v_source := replace(v_original, ' v_op public.commune_operations%ROWTYPE;', + ' v_commune_id uuid; + v_op public.commune_operations%ROWTYPE;'); + v_source := replace(v_source, + ' -- The row lock serialises two people acting on the same task. + SELECT * INTO v_op FROM public.commune_operations WHERE id = p_operation_id FOR UPDATE;', + ' -- The commune first, then the task, as in update_operation: the order a + -- member leaving takes them in. The other order lets the two wait on each + -- other until one fails as a deadlock. + SELECT commune_id INTO v_commune_id FROM public.commune_operations WHERE id = p_operation_id; + PERFORM 1 FROM public.communes WHERE id = v_commune_id FOR UPDATE; + -- The row lock serialises two people acting on the same task. + SELECT * INTO v_op FROM public.commune_operations WHERE id = p_operation_id FOR UPDATE;'); + IF position(' v_commune_id uuid;' IN v_source) = 0 + OR split_part(v_source, ' FOR UPDATE', 1) !~ 'PERFORM 1 FROM public\.communes WHERE id = v_commune_id$' THEN + RAISE EXCEPTION 'Unsupported occurrence definition'; + END IF; + EXECUTE v_source; + + v_original := pg_get_functiondef('public.hand_over_operation(uuid,text,date,text,date,text,uuid)'::regprocedure); + v_source := replace(v_original, ' v_op public.commune_operations%ROWTYPE;', + ' v_commune_id uuid; + v_op public.commune_operations%ROWTYPE;'); + v_source := replace(v_source, + ' SELECT * INTO v_op FROM public.commune_operations WHERE id = p_operation_id FOR UPDATE;', + ' -- The commune first, then the task, as in update_operation. + SELECT commune_id INTO v_commune_id FROM public.commune_operations WHERE id = p_operation_id; + PERFORM 1 FROM public.communes WHERE id = v_commune_id FOR UPDATE; + SELECT * INTO v_op FROM public.commune_operations WHERE id = p_operation_id FOR UPDATE;'); + IF position(' v_commune_id uuid;' IN v_source) = 0 + OR split_part(v_source, ' FOR UPDATE', 1) !~ 'PERFORM 1 FROM public\.communes WHERE id = v_commune_id$' THEN + RAISE EXCEPTION 'Unsupported handover definition'; + END IF; + EXECUTE v_source; + + v_original := pg_get_functiondef('public.undo_operation_occurrence(uuid)'::regprocedure); + v_source := replace(v_original, ' v_op public.commune_operations%ROWTYPE;', + ' v_commune_id uuid; + v_op public.commune_operations%ROWTYPE;'); + v_source := replace(v_source, + ' SELECT o.* INTO v_op FROM public.commune_operations o + JOIN public.operation_occurrences h ON h.operation_id = o.id WHERE h.id = p_occurrence_id FOR UPDATE OF o;', + ' -- The commune first, then the task, then the occurrence, as in + -- update_operation. + SELECT o.commune_id INTO v_commune_id FROM public.commune_operations o + JOIN public.operation_occurrences h ON h.operation_id = o.id WHERE h.id = p_occurrence_id; + PERFORM 1 FROM public.communes WHERE id = v_commune_id FOR UPDATE; + SELECT o.* INTO v_op FROM public.commune_operations o + JOIN public.operation_occurrences h ON h.operation_id = o.id WHERE h.id = p_occurrence_id FOR UPDATE OF o;'); + IF position(' v_commune_id uuid;' IN v_source) = 0 + OR split_part(v_source, ' FOR UPDATE', 1) !~ 'PERFORM 1 FROM public\.communes WHERE id = v_commune_id$' THEN + RAISE EXCEPTION 'Unsupported undo definition'; + END IF; + EXECUTE v_source; +END; +$$; + +NOTIFY pgrst, 'reload schema'; +COMMIT; diff --git a/infra/neon/tests/task_lock_order.mjs b/infra/neon/tests/task_lock_order.mjs index 5c9f0675..4b20ccd3 100644 --- a/infra/neon/tests/task_lock_order.mjs +++ b/infra/neon/tests/task_lock_order.mjs @@ -1,16 +1,21 @@ -// Lock order between a member leaving and a task edit or removal (0027). -// A member leaving locks the commune and then that member's tasks. An edit -// or removal that locked the task first and then needed the commune would -// wait on the leaving member while the leaving member waited on it, until -// PostgreSQL cancelled one of them as a deadlock. This test holds the -// departure at the point between its two locks, starts the edit or removal, -// then lets both run, so that interleaving happens every time. +// Lock order between a member leaving and the task commands (0027, 0037). +// A member leaving locks the commune and then that member's tasks. A command +// that locked the task first and then needed the commune (adding a history +// line or an occurrence takes a key-share lock on it) would wait on the +// leaving member while the leaving member waited on it, until PostgreSQL +// cancelled one of them as a deadlock. Every task command now locks the +// commune first: editing and removing since 0027; completing, skipping, +// handing over and undoing since 0037. For each command, this test holds the +// departure at the point between its two locks, starts the command, then +// lets both run, so that interleaving happens every time. The command then +// finds the task as the departure left it, and goes ahead or is refused with +// its usual message. // // Run against a disposable local database with every migration applied, on // top of infra/local/bootstrap.sql or infra/neon/tests/local_auth_stub.sql: // DATABASE_URL=postgres://commune:commune@127.0.0.1:5432/ node infra/neon/tests/task_lock_order.mjs -// It adds a commune and removes it again. Never point it at a shared or -// hosted database. +// It adds a commune for each command and removes it again. Never point it at +// a shared or hosted database. import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; import { createRequire } from "node:module"; @@ -60,9 +65,18 @@ async function waitingForLock(pid) { throw new Error(`Session ${pid} never waited for a lock`); } const settle = (promise) => promise.then((result) => ({ result }), (error) => ({ error })); +// A session ends its transaction as soon as its statement settles. A session +// cancelled as a deadlock then lets the other one finish at once. +async function finish(client, outcome) { + const settled = await outcome; + await client.query(settled.error ? "ROLLBACK" : "COMMIT"); + return settled; +} // Avery runs the commune. Casey added a weekly task that is Blake's turn, -// then Blake is removed while Casey changes the task. +// then Blake is removed while Casey acts on the task. A command's prepare +// step runs before the race and returns its values; otherwise the only +// value is the task. async function race(command) { const commune = (await as(avery, "SELECT public.create_commune('Lock order', 'household', 'GBP') AS id")).id; const sessions = []; @@ -75,6 +89,7 @@ async function race(command) { await owner.query("COMMIT"); const task = (await as(casey, `SELECT public.create_operation($1, 'task', 'chore', 'Bins', current_date, NULL, true, $2, jsonb_build_array($2::text, $3::text), 'weekly', '', $4) ->> 'id' AS id`, [commune, blake, casey, randomUUID()])).id; + const values = command.prepare ? await command.prepare(task) : [task]; // Hold Blake's membership row, so the departure stops after locking the commune. const holder = await connect(); @@ -87,19 +102,17 @@ async function race(command) { await waitingForLock(leaving.pid); const changing = await begin(casey); sessions.push(changing.client); - const changed = settle(changing.client.query(command.sql, [task])); + const changed = settle(changing.client.query(command.sql, values)); await waitingForLock(changing.pid); await holder.query("COMMIT"); - const departure = await left; - await leaving.client.query(departure.error ? "ROLLBACK" : "COMMIT"); - const change = await changed; - await changing.client.query(change.error ? "ROLLBACK" : "COMMIT"); + const [departure, change] = await Promise.all([finish(leaving.client, left), finish(changing.client, changed)]); for (const outcome of [departure, change]) { assert.notEqual(outcome.error?.code, "40P01", `${command.name} and a member leaving deadlocked: ${outcome.error?.message}`); } assert.equal(departure.error, undefined, departure.error?.message); - const { rows } = await owner.query("SELECT assignee_id, rotation_ids, title, version, deleted_at FROM public.commune_operations WHERE id = $1", [task]); + const { rows } = await owner.query(`SELECT assignee_id, rotation_ids, turn_owner_id, title, date - current_date AS due_in, + status, version, deleted_at FROM public.commune_operations WHERE id = $1`, [task]); return { change, row: rows[0] }; } finally { // Ending a session rolls back anything it left open. @@ -123,7 +136,46 @@ try { assert.equal(removal.change.error, undefined, removal.change.error?.message); assert.notEqual(removal.row.deleted_at, null); assert.deepEqual([removal.row.assignee_id, removal.row.version], [casey, 3]); - console.log("task lock order passed: a member leaving and a task edit or removal wait in turn"); + + // The completion waits for the departure, then completes the turn that has + // passed to Casey, the only person left in the rotation. + const completion = await race({ name: "A completion", + sql: "SELECT public.record_operation_occurrence($1, 'completed', current_date, current_date, $2)", + prepare: (task) => [task, randomUUID()] }); + assert.equal(completion.change.error, undefined, completion.change.error?.message); + assert.deepEqual([completion.row.assignee_id, completion.row.rotation_ids, completion.row.due_in, completion.row.status, + completion.row.version], [casey, [casey], 7, "open", 3]); + + // The skip waits for the departure, then moves Casey's turn on a week. + const skip = await race({ name: "A skip", + sql: "SELECT public.record_operation_occurrence($1, 'skipped', current_date, current_date, $2)", + prepare: (task) => [task, randomUUID()] }); + assert.equal(skip.change.error, undefined, skip.change.error?.message); + assert.deepEqual([skip.row.assignee_id, skip.row.rotation_ids, skip.row.due_in, skip.row.status, skip.row.version], + [casey, [casey], 7, "open", 3]); + + // Casey offers to take Blake's turn. The handover waits for the departure, + // then finds the turn already passed to Casey and is refused as stale. + const handover = await race({ name: "A handover", + sql: "SELECT public.hand_over_operation($1, $2, current_date, $3, current_date, NULL, $4)", + prepare: (task) => [task, casey, blake, randomUUID()] }); + assert.equal(handover.change.error?.code, "P0001", handover.change.error?.message ?? "The stale handover was saved"); + assert.equal(handover.change.error.message, "This task has already moved on. Refresh to see who has it now."); + assert.deepEqual([handover.row.assignee_id, handover.row.turn_owner_id, handover.row.due_in, handover.row.version], + [casey, null, 0, 2]); + + // Casey completed Blake's turn, so the next one is Casey's. Undoing that + // would give the task back to Blake: the undo waits for the departure, then + // is refused because Blake has left, and the task stays with Casey. + const undo = await race({ name: "An undo", sql: "SELECT public.undo_operation_occurrence($1)", + prepare: async (task) => [(await as(casey, `SELECT public.record_operation_occurrence($1, 'completed', current_date, + current_date, $2) ->> 'id' AS id`, [task, randomUUID()])).id] }); + assert.equal(undo.change.error?.code, "P0001", undo.change.error?.message ?? "The undo gave the task back to Blake"); + assert.equal(undo.change.error.message, + "The person who had this task has left, so it can no longer be undone. Edit the task instead."); + assert.deepEqual([undo.row.assignee_id, undo.row.rotation_ids, undo.row.due_in, undo.row.status, undo.row.version], + [casey, [casey], 7, "open", 3]); + console.log("task lock order passed: a member leaving and a task edit, removal, completion, skip, handover or undo wait in turn"); } finally { await owner.end(); }