diff --git a/docs/STATUS.md b/docs/STATUS.md index 5e2d827f2..71423b14a 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -14,8 +14,24 @@ Mutation transport is never replayed. This avoids manual permission refresh and presentation while retaining server authorization and the existing review-first board flow. Real-Pinia store tests cover session, token, read and mutation races; the source evidence note -is [board-access ownership](analysis/2026-09-21-permission-read-ownership.md). Same-entry mutation -ordering remains in #3335; human decisions in OUTSTANDING_TASKS.md remain open. +is [board-access ownership](analysis/2026-09-21-permission-read-ownership.md). Human decisions in +OUTSTANDING_TASKS.md remain open. + +## Board-access mutations preserve same-row intent order (#3333) + +Updates and revokes for one board-access entry now run in submission order. A queued operation +waits through a predecessor failure, holds its loading ownership while waiting, and checks the +initiating session again before transport. A new session's same-entry intent also waits for an +older in-flight write to settle. Other entries remain concurrent. A queued update after +a successful revoke cannot restore the removed row from an older response; a failed update reports +its own error. Starting a queued operation does not erase an unrelated entry's error receipt. + +This protects access changes during rapid board maintenance while keeping server authorization +and review-first board actions intact. Ordering is local to one client; the API has no revision +precondition for cross-device conflicts. Deferred Pinia tests cover the mutation schedules and +token rotation; [the evidence note](analysis/2026-09-21-permission-mutation-order.md) records +the contract and qualification limits. + ## Column writes follow their board visit (#3314) Create, update, delete and reorder share one mutation lane per board, preserving intent order diff --git a/docs/analysis/2026-09-21-permission-mutation-order.md b/docs/analysis/2026-09-21-permission-mutation-order.md new file mode 100644 index 000000000..94d08363f --- /dev/null +++ b/docs/analysis/2026-09-21-permission-mutation-order.md @@ -0,0 +1,28 @@ +# Board-access mutation ordering + +Status: integration of source PR #3335 after parent PR #3330 merged, 2026-09-22. + +## Reproduced defects + +`updateAccess` and `revokeAccess` started independently for one access row even though the API accepts no expected revision and the entity has no configured concurrency token. Two role changes could therefore commit or settle in an order that differed from the user's clicks. Update/revoke could also overlap, and a queued pre-logout intent had no transport-time session check. + +Independent mutation failures exposed a second ownership gap during review. A queued same-entry mutation cleared the store's shared error when its transport started. If another access row failed while the queued intent was waiting, the queued start erased that unrelated receipt. + +## Contract + +- One queue exists per `{boardId, accessId}`. Update and revoke for that row run in submission order; different rows remain concurrent. +- The first intent starts transport synchronously. A later intent waits for its predecessor to finish, regardless of success or failure. +- Each queued operation owns a loading token from submission through settlement, so loading does not drop between same-entry operations. +- Immediately before transport, queued work rechecks the initiating session epoch. Identity, token, authentication or demo replacement retires old queued intent without using later credentials. In-flight write tails remain registered until settlement so a new session's same-entry intent cannot overtake an older server write. +- A predecessor failure does not cancel the next intent. The queued transport clears an error only when that receipt is owned by its own predecessor; an unrelated concurrent failure remains visible. +- Existing successful-mutation read invalidation, stable-ID grant deduplication and stale settlement rules from #3330 remain unchanged. + +The client queue preserves one client's submission order only. It does not solve cross-device concurrency; the backend currently exposes no revision precondition. + +## Evidence and remaining gates + +The initial ordering negative control ran the actual production store with only framework/API/session boundaries stubbed: one independent-row control passed and four ordering/session schedules failed on the parent, then all five passed after serialization. + +Codex review identified the independent-error case. A dedicated deferred Pinia regression was committed before the correction: access row 2 fails while row 1's second update waits, then row 1's queued transport starts without erasing row 2's error. + +The integration is rebuilt on the parent's token-rotation correction. A revoke-then-update regression proves the second transport waits and a failed update does not resurrect the revoked row. Independent review found that clearing lane tails on token rotation let a new intent race an old in-flight write. A deferred regression failed on that reviewed head and passes after retaining the tail. The final focused permission run passes 47 tests in five files. The earlier full frontend run passed 520 files, 7,387 tests with three existing skips; final-head hosted CI remains required. Typecheck and changed-file lint pass at the fix, while full build/lint passed before it with 11 existing warnings. Docs governance and doc links pass after the fix. The source commits and child-only diff are verified, while exact-head hosted CI and fix review remain required before merge. A never-settling transport can hold its lane indefinitely; that lower-severity risk is tracked in #3364. No release or deployment qualification is claimed. diff --git a/frontend/taskdeck-web/src/store/permissionsStore.ts b/frontend/taskdeck-web/src/store/permissionsStore.ts index 05d4136b3..b7f80b1bc 100644 --- a/frontend/taskdeck-web/src/store/permissionsStore.ts +++ b/frontend/taskdeck-web/src/store/permissionsStore.ts @@ -29,13 +29,20 @@ export const usePermissionsStore = defineStore('permissions', () => { revalidateOnTokenRotation: boolean } + interface MutationTail { + promise: Promise + ownerToken: symbol + } + type InvalidationKind = 'session-change' | 'token-rotation' + let errorOwner: symbol | null = null let sessionEpoch = 0 let lastInvalidation: { epoch: number; kind: InvalidationKind } = { epoch: 0, kind: 'session-change', } + const mutationTails = new Map() const activeOperations = new Set() const activeReadByBoard = new Map() const readRetryByBoard = new Map() @@ -45,10 +52,20 @@ export const usePermissionsStore = defineStore('permissions', () => { loading.value = activeOperations.size > 0 } + function clearError() { + error.value = null + errorOwner = null + } + + function recordError(owner: OperationOwner, message: string) { + error.value = message + errorOwner = owner.token + } + function beginOperation(label: string): OperationOwner { const owner = { epoch: sessionEpoch, token: Symbol(label), userId: session.userId } activeOperations.add(owner.token) - error.value = null + clearError() syncLoading() return owner } @@ -120,8 +137,10 @@ export const usePermissionsStore = defineStore('permissions', () => { activeReadByBoard.clear() readRetryByBoard.clear() mutationGenerationByBoard.clear() + // In-flight writes may still commit after token or account replacement. + // Keep their tails so a new same-entry intent cannot overtake them. loading.value = false - error.value = null + clearError() } function retryMissingActiveReads(isTokenRotation: boolean) { @@ -165,6 +184,34 @@ export const usePermissionsStore = defineStore('permissions', () => { } } + async function enqueueAccessMutation( + boardId: string, + accessId: string, + label: string, + task: (owner: OperationOwner) => Promise, + ): Promise { + const key = `${boardId}:${accessId}` + const predecessor = mutationTails.get(key) + const owner = beginOperation(label) + let release!: () => void + const tail = new Promise((resolve) => { release = resolve }) + mutationTails.set(key, { promise: tail, ownerToken: owner.token }) + + try { + if (predecessor) await predecessor.promise + if (!ownsSession(owner)) return undefined + + // Retire only an error produced by this lane's predecessor. Another + // access row can fail while this intent waits and must keep its receipt. + if (predecessor && errorOwner === predecessor.ownerToken) clearError() + return await task(owner) + } finally { + finishOperation(owner) + release() + if (mutationTails.get(key)?.promise === tail) mutationTails.delete(key) + } + } + function resetForSession() { invalidateOperations() boardAccess.value = new Map() @@ -234,7 +281,7 @@ export const usePermissionsStore = defineStore('permissions', () => { async function fetchBoardAccess(boardId: string, revalidateOnTokenRotation = true) { if (isDemoMode) { loading.value = true - error.value = null + clearError() boardAccess.value.set(boardId, []) loading.value = false return @@ -252,7 +299,7 @@ export const usePermissionsStore = defineStore('permissions', () => { } catch (e: unknown) { if (ownsRead(boardId, owner)) { const msg = getErrorDisplay(e, 'Failed to fetch board access').message - error.value = msg + recordError(owner, msg) toast.error(msg) } throw e @@ -282,7 +329,7 @@ export const usePermissionsStore = defineStore('permissions', () => { } catch (e: unknown) { if (ownsSession(owner)) { const msg = getErrorDisplay(e, 'Failed to grant access').message - error.value = msg + recordError(owner, msg) toast.error(msg) } throw e @@ -293,62 +340,70 @@ export const usePermissionsStore = defineStore('permissions', () => { async function updateAccess(boardId: string, accessId: string, dto: UpdateAccessDto) { guardDemoMutation() - const owner = beginOperation(`update:${boardId}:${accessId}`) - try { - session.requireUserId('board access management') - const updated = await boardAccessApi.updateAccess(boardId, accessId, dto) - if (!ownsSession(owner)) { - await reconcileStaleMutation(boardId, owner) - return updated - } - - recordMutation(boardId) - const existing = boardAccess.value.get(boardId) ?? [] - if (existing.some(access => access.id === accessId)) { - boardAccess.value.set( - boardId, - existing.map(access => access.id === accessId ? updated : access), - ) - } - toast.success('Access updated') - return updated - } catch (e: unknown) { - if (ownsSession(owner)) { - const msg = getErrorDisplay(e, 'Failed to update access').message - error.value = msg - toast.error(msg) - } - throw e - } finally { - finishOperation(owner) - } + return await enqueueAccessMutation( + boardId, + accessId, + `update:${boardId}:${accessId}`, + async (owner) => { + try { + session.requireUserId('board access management') + const updated = await boardAccessApi.updateAccess(boardId, accessId, dto) + if (!ownsSession(owner)) { + await reconcileStaleMutation(boardId, owner) + return updated + } + + recordMutation(boardId) + const existing = boardAccess.value.get(boardId) ?? [] + if (existing.some(access => access.id === accessId)) { + boardAccess.value.set( + boardId, + existing.map(access => access.id === accessId ? updated : access), + ) + } + toast.success('Access updated') + return updated + } catch (e: unknown) { + if (ownsSession(owner)) { + const msg = getErrorDisplay(e, 'Failed to update access').message + recordError(owner, msg) + toast.error(msg) + } + throw e + } + }, + ) } async function revokeAccess(boardId: string, accessId: string) { guardDemoMutation() - const owner = beginOperation(`revoke:${boardId}:${accessId}`) - try { - session.requireUserId('board access management') - await boardAccessApi.revokeAccess(boardId, accessId) - if (!ownsSession(owner)) { - await reconcileStaleMutation(boardId, owner) - return - } - - recordMutation(boardId) - const existing = boardAccess.value.get(boardId) ?? [] - boardAccess.value.set(boardId, existing.filter(access => access.id !== accessId)) - toast.success('Access revoked') - } catch (e: unknown) { - if (ownsSession(owner)) { - const msg = getErrorDisplay(e, 'Failed to revoke access').message - error.value = msg - toast.error(msg) - } - throw e - } finally { - finishOperation(owner) - } + await enqueueAccessMutation( + boardId, + accessId, + `revoke:${boardId}:${accessId}`, + async (owner) => { + try { + session.requireUserId('board access management') + await boardAccessApi.revokeAccess(boardId, accessId) + if (!ownsSession(owner)) { + await reconcileStaleMutation(boardId, owner) + return + } + + recordMutation(boardId) + const existing = boardAccess.value.get(boardId) ?? [] + boardAccess.value.set(boardId, existing.filter(access => access.id !== accessId)) + toast.success('Access revoked') + } catch (e: unknown) { + if (ownsSession(owner)) { + const msg = getErrorDisplay(e, 'Failed to revoke access').message + recordError(owner, msg) + toast.error(msg) + } + throw e + } + }, + ) } return { diff --git a/frontend/taskdeck-web/src/tests/store/permissionsStoreMutationErrorOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/permissionsStoreMutationErrorOwnership.spec.ts new file mode 100644 index 000000000..259430539 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/permissionsStoreMutationErrorOwnership.spec.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { boardAccessApi } from '../../api/boardAccessApi' +import { usePermissionsStore } from '../../store/permissionsStore' +import { useSessionStore } from '../../store/sessionStore' +import type { BoardAccess } from '../../types/access' + +vi.mock('../../api/boardAccessApi', () => ({ + boardAccessApi: { + getAccess: vi.fn(), + grantAccess: vi.fn(), + updateAccess: vi.fn(), + revokeAccess: vi.fn(), + }, +})) + +vi.mock('../../api/authApi', () => ({ + authApi: { + login: vi.fn(), + register: vi.fn(), + changePassword: vi.fn(), + }, +})) + +vi.mock('../../store/toastStore', () => ({ + useToastStore: () => ({ + error: vi.fn(), + success: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + }), +})) + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((yes, no) => { + resolve = yes + reject = no + }) + return { promise, resolve, reject } +} + +function access(id: string, role: BoardAccess['role'] = 'Viewer'): BoardAccess { + return { + id, + boardId: 'board-1', + userId: `${id}-user`, + role, + grantedBy: 'owner-1', + grantedAt: '2026-09-21T00:00:00Z', + } +} + +async function flushQueue() { + await Promise.resolve() + await Promise.resolve() +} + +describe('permissionsStore mutation error ownership', () => { + beforeEach(() => { + setActivePinia(createPinia()) + const session = useSessionStore() + session.userId = 'owner-1' + vi.clearAllMocks() + }) + + it('does not erase an independent failure when queued same-entry work starts', async () => { + const store = usePermissionsStore() + store.boardAccess.set('board-1', [access('access-1'), access('access-2')]) + + const first = deferred() + const independent = deferred() + const queued = deferred() + vi.mocked(boardAccessApi.updateAccess) + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(independent.promise) + .mockReturnValueOnce(queued.promise) + + const firstRequest = store.updateAccess('board-1', 'access-1', { role: 'Editor' }) + const queuedRequest = store.updateAccess('board-1', 'access-1', { role: 'Admin' }) + const independentRequest = store.updateAccess('board-1', 'access-2', { role: 'Admin' }) + + independent.reject(new Error('independent access failed')) + await expect(independentRequest).rejects.toThrow('independent access failed') + expect(store.error).toBe('independent access failed') + + first.resolve(access('access-1', 'Editor')) + await firstRequest + await flushQueue() + + expect(boardAccessApi.updateAccess).toHaveBeenCalledTimes(3) + expect(store.error).toBe('independent access failed') + + queued.resolve(access('access-1', 'Admin')) + await queuedRequest + expect(store.error).toBe('independent access failed') + }) +}) diff --git a/frontend/taskdeck-web/src/tests/store/permissionsStoreMutationOrder.spec.ts b/frontend/taskdeck-web/src/tests/store/permissionsStoreMutationOrder.spec.ts new file mode 100644 index 000000000..70704270a --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/permissionsStoreMutationOrder.spec.ts @@ -0,0 +1,197 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { boardAccessApi } from '../../api/boardAccessApi' +import { usePermissionsStore } from '../../store/permissionsStore' +import { useSessionStore } from '../../store/sessionStore' +import type { BoardAccess } from '../../types/access' + +const toastMocks = vi.hoisted(() => ({ + error: vi.fn(), + success: vi.fn(), + info: vi.fn(), + warning: vi.fn(), +})) + +vi.mock('../../api/boardAccessApi', () => ({ + boardAccessApi: { + getAccess: vi.fn(), + grantAccess: vi.fn(), + updateAccess: vi.fn(), + revokeAccess: vi.fn(), + }, +})) + +vi.mock('../../api/authApi', () => ({ + authApi: { + login: vi.fn(), + register: vi.fn(), + changePassword: vi.fn(), + }, +})) + +vi.mock('../../store/toastStore', () => ({ + useToastStore: () => toastMocks, +})) + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((yes, no) => { + resolve = yes + reject = no + }) + return { promise, resolve, reject } +} + +function access(overrides: Partial = {}): BoardAccess { + return { + id: 'access-1', + boardId: 'board-1', + userId: 'viewer-1', + role: 'Viewer', + grantedBy: 'owner-1', + grantedAt: '2026-09-21T00:00:00Z', + ...overrides, + } +} + +async function flushQueue() { + await Promise.resolve() + await Promise.resolve() +} + +describe('permissionsStore mutation ordering', () => { + let session: ReturnType + let store: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + session = useSessionStore() + session.userId = 'owner-1' + store = usePermissionsStore() + store.boardAccess.set('board-1', [access()]) + vi.clearAllMocks() + }) + + it('serializes two role updates for one access row in intent order', async () => { + const first = deferred() + const second = deferred() + vi.mocked(boardAccessApi.updateAccess) + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + + const firstRequest = store.updateAccess('board-1', 'access-1', { role: 'Editor' }) + const secondRequest = store.updateAccess('board-1', 'access-1', { role: 'Admin' }) + + expect(boardAccessApi.updateAccess).toHaveBeenCalledTimes(1) + first.resolve(access({ role: 'Editor' })) + await firstRequest + await flushQueue() + + expect(boardAccessApi.updateAccess).toHaveBeenCalledTimes(2) + second.resolve(access({ role: 'Admin' })) + await secondRequest + + expect(store.boardAccess.get('board-1')?.[0].role).toBe('Admin') + expect(store.error).toBeNull() + }) + + it('continues with the queued update after its predecessor fails', async () => { + const first = deferred() + const second = deferred() + vi.mocked(boardAccessApi.updateAccess) + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + + const firstRequest = store.updateAccess('board-1', 'access-1', { role: 'Editor' }) + const secondRequest = store.updateAccess('board-1', 'access-1', { role: 'Admin' }) + + first.reject(new Error('first failed')) + await expect(firstRequest).rejects.toThrow('first failed') + await flushQueue() + expect(boardAccessApi.updateAccess).toHaveBeenCalledTimes(2) + + second.resolve(access({ role: 'Admin' })) + await secondRequest + expect(store.boardAccess.get('board-1')?.[0].role).toBe('Admin') + expect(store.error).toBeNull() + }) + + it('orders update before revoke and leaves the row removed', async () => { + const update = deferred() + const revoke = deferred() + vi.mocked(boardAccessApi.updateAccess).mockReturnValue(update.promise) + vi.mocked(boardAccessApi.revokeAccess).mockReturnValue(revoke.promise) + + const updateRequest = store.updateAccess('board-1', 'access-1', { role: 'Admin' }) + const revokeRequest = store.revokeAccess('board-1', 'access-1') + expect(boardAccessApi.revokeAccess).not.toHaveBeenCalled() + + update.resolve(access({ role: 'Admin' })) + await updateRequest + await flushQueue() + expect(boardAccessApi.revokeAccess).toHaveBeenCalledTimes(1) + + revoke.resolve() + await revokeRequest + expect(store.boardAccess.get('board-1')).toEqual([]) + }) + + it('starts an update only after revoke and does not resurrect a removed row', async () => { + const revoke = deferred() + vi.mocked(boardAccessApi.revokeAccess).mockReturnValue(revoke.promise) + vi.mocked(boardAccessApi.updateAccess).mockRejectedValue(new Error('access not found')) + + const revokeRequest = store.revokeAccess('board-1', 'access-1') + const updateRequest = store.updateAccess('board-1', 'access-1', { role: 'Admin' }) + expect(boardAccessApi.updateAccess).not.toHaveBeenCalled() + + revoke.resolve() + await revokeRequest + await expect(updateRequest).rejects.toThrow('access not found') + + expect(boardAccessApi.updateAccess).toHaveBeenCalledTimes(1) + expect(store.boardAccess.get('board-1')).toEqual([]) + expect(store.error).toBe('access not found') + }) + + it('does not start queued old-session transport after logout', async () => { + const first = deferred() + vi.mocked(boardAccessApi.updateAccess).mockReturnValue(first.promise) + + const firstRequest = store.updateAccess('board-1', 'access-1', { role: 'Editor' }) + const queuedRequest = store.updateAccess('board-1', 'access-1', { role: 'Admin' }) + session.userId = null + session.userId = 'owner-1' + + first.resolve(access({ role: 'Editor' })) + await firstRequest + await queuedRequest + + expect(boardAccessApi.updateAccess).toHaveBeenCalledTimes(1) + expect(store.boardAccess.size).toBe(0) + expect(store.loading).toBe(false) + }) + + it('keeps different access rows concurrent', async () => { + store.boardAccess.set('board-1', [ + access({ id: 'access-1', userId: 'viewer-1' }), + access({ id: 'access-2', userId: 'viewer-2' }), + ]) + const first = deferred() + const second = deferred() + vi.mocked(boardAccessApi.updateAccess) + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + + const firstRequest = store.updateAccess('board-1', 'access-1', { role: 'Editor' }) + const secondRequest = store.updateAccess('board-1', 'access-2', { role: 'Admin' }) + + expect(boardAccessApi.updateAccess).toHaveBeenCalledTimes(2) + first.resolve(access({ id: 'access-1', userId: 'viewer-1', role: 'Editor' })) + second.resolve(access({ id: 'access-2', userId: 'viewer-2', role: 'Admin' })) + await Promise.all([firstRequest, secondRequest]) + + expect(store.boardAccess.get('board-1')?.map(item => item.role)).toEqual(['Editor', 'Admin']) + }) +}) diff --git a/frontend/taskdeck-web/src/tests/store/permissionsStoreTokenOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/permissionsStoreTokenOwnership.spec.ts index 0ff0d50d8..53781913e 100644 --- a/frontend/taskdeck-web/src/tests/store/permissionsStoreTokenOwnership.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/permissionsStoreTokenOwnership.spec.ts @@ -76,6 +76,86 @@ describe('permissionsStore token ownership', () => { vi.clearAllMocks() }) + it('reconciles a started update after token rotation without sending its queued revoke', async () => { + const row = access('access-1') + const updated = { ...row, role: 'Editor' as const } + store.boardAccess.set('board-1', [row]) + const write = deferred() + const read = deferred() + vi.mocked(boardAccessApi.updateAccess).mockReturnValueOnce(write.promise) + vi.mocked(boardAccessApi.getAccess).mockReturnValueOnce(read.promise) + + const first = store.updateAccess('board-1', row.id, { role: 'Editor' }) + const queued = store.revokeAccess('board-1', row.id) + expect(boardAccessApi.revokeAccess).not.toHaveBeenCalled() + session.token = token('replacement') + write.resolve(updated) + + await vi.waitFor(() => expect(boardAccessApi.getAccess).toHaveBeenCalledWith('board-1')) + expect(store.loading).toBe(true) + read.resolve([updated]) + await expect(first).resolves.toEqual(updated) + await expect(queued).resolves.toBeUndefined() + expect(boardAccessApi.revokeAccess).not.toHaveBeenCalled() + expect(store.boardAccess.get('board-1')).toEqual([updated]) + expect(store.loading).toBe(false) + }) + + it('reconciles a started revoke after token rotation without sending its queued update', async () => { + const row = access('access-1') + store.boardAccess.set('board-1', [row]) + const write = deferred() + const read = deferred() + vi.mocked(boardAccessApi.revokeAccess).mockReturnValueOnce(write.promise) + vi.mocked(boardAccessApi.getAccess).mockReturnValueOnce(read.promise) + + const first = store.revokeAccess('board-1', row.id) + const queued = store.updateAccess('board-1', row.id, { role: 'Editor' }) + expect(boardAccessApi.updateAccess).not.toHaveBeenCalled() + session.token = token('replacement') + write.resolve() + + await vi.waitFor(() => expect(boardAccessApi.getAccess).toHaveBeenCalledWith('board-1')) + expect(store.loading).toBe(true) + read.resolve([]) + await expect(first).resolves.toBeUndefined() + await expect(queued).resolves.toBeUndefined() + expect(boardAccessApi.updateAccess).not.toHaveBeenCalled() + expect(store.boardAccess.get('board-1')).toEqual([]) + expect(store.loading).toBe(false) + }) + + it('waits for an old-token update before starting a new same-entry update', async () => { + const row = access('access-1') + const oldResult = { ...row, role: 'Editor' as const } + const newResult = { ...row, role: 'Admin' as const } + store.boardAccess.set('board-1', [row]) + const oldWrite = deferred() + const oldReconciliation = deferred() + const newWrite = deferred() + vi.mocked(boardAccessApi.updateAccess) + .mockReturnValueOnce(oldWrite.promise) + .mockReturnValueOnce(newWrite.promise) + vi.mocked(boardAccessApi.getAccess).mockReturnValueOnce(oldReconciliation.promise) + + const first = store.updateAccess('board-1', row.id, { role: 'Editor' }) + session.token = token('replacement') + const second = store.updateAccess('board-1', row.id, { role: 'Admin' }) + expect(boardAccessApi.updateAccess).toHaveBeenCalledTimes(1) + + oldWrite.resolve(oldResult) + await vi.waitFor(() => expect(boardAccessApi.getAccess).toHaveBeenCalledWith('board-1')) + expect(boardAccessApi.updateAccess).toHaveBeenCalledTimes(1) + oldReconciliation.resolve([oldResult]) + await first + await vi.waitFor(() => expect(boardAccessApi.updateAccess).toHaveBeenCalledTimes(2)) + + newWrite.resolve(newResult) + await second + expect(store.boardAccess.get('board-1')).toEqual([newResult]) + expect(store.loading).toBe(false) + }) + it('preserves loaded access while invalidating an old read after same-user token rotation', async () => { store.boardAccess.set('board-1', [access('existing')]) const pending = deferred()