From ecae7bd64f55f26b572bd22486fbe0e3bc7264a6 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:28:50 +0100 Subject: [PATCH 1/3] test(integrations): reproduce same-connector mutation ordering --- .../integrationStoreMutationOrder.spec.ts | 224 ++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 frontend/taskdeck-web/src/tests/store/integrationStoreMutationOrder.spec.ts diff --git a/frontend/taskdeck-web/src/tests/store/integrationStoreMutationOrder.spec.ts b/frontend/taskdeck-web/src/tests/store/integrationStoreMutationOrder.spec.ts new file mode 100644 index 000000000..dffa2061a --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/integrationStoreMutationOrder.spec.ts @@ -0,0 +1,224 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { integrationsApi } from '../../api/integrationsApi' +import { useIntegrationStore } from '../../store/integrationStore' +import { useSessionStore } from '../../store/sessionStore' +import type { IntegrationConnector, IntegrationConnectorDetail } from '../../types/integration' + +const toastMocks = vi.hoisted(() => ({ + error: vi.fn(), + success: vi.fn(), + info: vi.fn(), + warning: vi.fn(), +})) + +vi.mock('../../utils/demoMode', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, isDemoMode: false } +}) + +vi.mock('../../api/integrationsApi', () => ({ + integrationsApi: { + listConnectors: vi.fn(), + getConnector: vi.fn(), + registerConnector: vi.fn(), + updateConnector: vi.fn(), + deleteConnector: vi.fn(), + enableConnector: vi.fn(), + disableConnector: vi.fn(), + }, +})) + +vi.mock('../../api/authApi', () => ({ + authApi: { + login: vi.fn(), + register: vi.fn(), + changePassword: vi.fn(), + }, +})) + +vi.mock('../../store/toastStore', () => ({ + useToastStore: () => toastMocks, +})) + +vi.mock('../../composables/useErrorMapper', () => ({ + getErrorDisplay: (_error: unknown, fallback: string) => ({ message: fallback }), +})) + +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 connector( + id: string, + name: string, + status: IntegrationConnector['status'] = 'Active', +): IntegrationConnector { + return { + id, + name, + connectorType: 'BrowserClipper', + direction: 'Inbound', + status, + configuration: null, + createdAt: '2026-09-21T00:00:00Z', + updatedAt: '2026-09-21T00:00:00Z', + } +} + +function detail( + id: string, + name: string, + status: IntegrationConnector['status'] = 'Active', +): IntegrationConnectorDetail { + return { ...connector(id, name, status), recentEvents: [] } +} + +async function flushQueue() { + await Promise.resolve() + await Promise.resolve() +} + +describe('integrationStore mutation ordering', () => { + let session: ReturnType + let store: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + session = useSessionStore() + session.userId = 'account-a' + store = useIntegrationStore() + store.connectors = [connector('connector-1', 'Connector')] + store.selectedConnector = detail('connector-1', 'Connector') + vi.clearAllMocks() + }) + + it('serializes enable then disable for one connector', async () => { + const enable = deferred() + const disable = deferred() + vi.mocked(integrationsApi.enableConnector).mockReturnValue(enable.promise) + vi.mocked(integrationsApi.disableConnector).mockReturnValue(disable.promise) + + const enableRequest = store.enableConnector('connector-1') + const disableRequest = store.disableConnector('connector-1') + expect(integrationsApi.disableConnector).not.toHaveBeenCalled() + + enable.resolve(connector('connector-1', 'Connector', 'Active')) + await enableRequest + await flushQueue() + expect(integrationsApi.disableConnector).toHaveBeenCalledTimes(1) + + disable.resolve(connector('connector-1', 'Connector', 'Disabled')) + await disableRequest + expect(store.connectors[0]?.status).toBe('Disabled') + expect(store.selectedConnector?.status).toBe('Disabled') + }) + + it('serializes two updates and keeps the final intent', async () => { + const first = deferred() + const second = deferred() + vi.mocked(integrationsApi.updateConnector) + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + + const firstRequest = store.updateConnector('connector-1', { name: 'First' }) + const secondRequest = store.updateConnector('connector-1', { name: 'Second' }) + expect(integrationsApi.updateConnector).toHaveBeenCalledTimes(1) + + first.resolve(connector('connector-1', 'First')) + await firstRequest + await flushQueue() + expect(integrationsApi.updateConnector).toHaveBeenCalledTimes(2) + + second.resolve(connector('connector-1', 'Second')) + await secondRequest + expect(store.connectors[0]?.name).toBe('Second') + expect(store.selectedConnector?.name).toBe('Second') + }) + + it('continues with queued intent after a failed predecessor', async () => { + const first = deferred() + const second = deferred() + vi.mocked(integrationsApi.updateConnector) + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + + const firstRequest = store.updateConnector('connector-1', { name: 'First' }) + const secondRequest = store.updateConnector('connector-1', { name: 'Second' }) + first.reject(new Error('first failed')) + await expect(firstRequest).rejects.toThrow('first failed') + await flushQueue() + expect(integrationsApi.updateConnector).toHaveBeenCalledTimes(2) + + second.resolve(connector('connector-1', 'Second')) + await secondRequest + expect(store.connectors[0]?.name).toBe('Second') + expect(store.error).toBeNull() + }) + + it('orders update before delete and leaves the connector removed', async () => { + const update = deferred() + const remove = deferred() + vi.mocked(integrationsApi.updateConnector).mockReturnValue(update.promise) + vi.mocked(integrationsApi.deleteConnector).mockReturnValue(remove.promise) + + const updateRequest = store.updateConnector('connector-1', { name: 'Updated' }) + const deleteRequest = store.deleteConnector('connector-1') + expect(integrationsApi.deleteConnector).not.toHaveBeenCalled() + + update.resolve(connector('connector-1', 'Updated')) + await updateRequest + await flushQueue() + expect(integrationsApi.deleteConnector).toHaveBeenCalledTimes(1) + + remove.resolve() + await deleteRequest + expect(store.connectors).toEqual([]) + expect(store.selectedConnector).toBeNull() + }) + + it('does not start queued old-session transport after logout', async () => { + const first = deferred() + vi.mocked(integrationsApi.updateConnector).mockReturnValue(first.promise) + + const firstRequest = store.updateConnector('connector-1', { name: 'First' }) + const queuedRequest = store.updateConnector('connector-1', { name: 'Second' }) + session.userId = null + session.userId = 'account-a' + + first.resolve(connector('connector-1', 'First')) + await firstRequest + await queuedRequest + + expect(integrationsApi.updateConnector).toHaveBeenCalledTimes(1) + expect(store.connectors).toEqual([]) + expect(store.error).toBeNull() + }) + + it('keeps different connectors concurrent', async () => { + store.connectors = [ + connector('connector-1', 'One'), + connector('connector-2', 'Two'), + ] + const first = deferred() + const second = deferred() + vi.mocked(integrationsApi.updateConnector) + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + + const firstRequest = store.updateConnector('connector-1', { name: 'One updated' }) + const secondRequest = store.updateConnector('connector-2', { name: 'Two updated' }) + expect(integrationsApi.updateConnector).toHaveBeenCalledTimes(2) + + first.resolve(connector('connector-1', 'One updated')) + second.resolve(connector('connector-2', 'Two updated')) + await Promise.all([firstRequest, secondRequest]) + expect(store.connectors.map(item => item.name)).toEqual(['One updated', 'Two updated']) + }) +}) From 67aed3cfd07ec6317b0d46a6cd998e2a402248f7 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:30:20 +0100 Subject: [PATCH 2/3] fix(integrations): serialize same-connector mutations --- .../2026-09-21-integration-mutation-order.md | 46 +++++ .../src/store/integrationStore.ts | 163 ++++++++++-------- 2 files changed, 140 insertions(+), 69 deletions(-) create mode 100644 docs/analysis/2026-09-21-integration-mutation-order.md diff --git a/docs/analysis/2026-09-21-integration-mutation-order.md b/docs/analysis/2026-09-21-integration-mutation-order.md new file mode 100644 index 000000000..32d80a936 --- /dev/null +++ b/docs/analysis/2026-09-21-integration-mutation-order.md @@ -0,0 +1,46 @@ +# Connector mutation ordering + +Status: stacked draft PR #3336, 2026-09-21. Parent: PR #3332 at +`ade79cd4ceac6ca3f3130ba1765039bbfbc0337a`. + +## Reproduced defect + +Update, delete, enable and disable started independently for one connector even +though the API accepts no expected revision and the entity has no configured +concurrency token. Same-connector commits and responses could therefore diverge +from user submission order. A queued pre-logout intent also had no transport-time +lifecycle check. + +A supplemental runner executes the actual production store with only +Pinia/Vue/API/session boundaries stubbed. Against the parent, five ordering and +session schedules fail while the different-connector concurrency control passes. +The same six schedules pass after the correction. + +## Contract + +- One queue exists per connector ID. Update, delete, enable and disable for that + connector run in submission order; different connectors remain concurrent. +- The first intent starts transport synchronously. Later same-connector work + waits for its predecessor, regardless of success or failure. +- Immediately before transport, queued work rechecks the lifecycle epoch from + submission. Session replacement clears queue registration and prevents old + intent from using later credentials. +- A predecessor failure does not cancel the next intent. The next transport + clears the predecessor's shared error before running. +- Existing stale-session cache, detail, toast and error settlement rules from + #3332 remain unchanged. +- Delete remains ordered rather than magical: later intent still reaches the + server and may receive NotFound; no client resurrection is introduced. + +The client queue preserves one client's submission order only. It does not solve +cross-device concurrency; the backend currently exposes no revision precondition. + +## Verification and remaining gates + +Actual-module red/green: 1/6 schedules passed on the parent, 6/6 after correction. +The preceding seventeen read, permission and session ownership schedules remain +17/17 green. Changed TypeScript source/tests transpile without diagnostics. +Canonical Pinia/Vitest, lint, project typecheck, build, full hosted CI and +independent review are still required. Because this is a third-level stack, +retarget only after #3329 and #3332 land, verify the child-only diff and requalify +against current `main`. No merge, release or deployment qualification is claimed. diff --git a/frontend/taskdeck-web/src/store/integrationStore.ts b/frontend/taskdeck-web/src/store/integrationStore.ts index c3fc274e8..a8d62ebf5 100644 --- a/frontend/taskdeck-web/src/store/integrationStore.ts +++ b/frontend/taskdeck-web/src/store/integrationStore.ts @@ -30,6 +30,7 @@ export const useIntegrationStore = defineStore('integration', () => { let lifecycleEpoch = 0 const readOwners = new Map() const activeReadTokens = new Set() + const mutationTails = new Map>() function syncLoading() { loading.value = activeReadTokens.size > 0 @@ -63,6 +64,7 @@ export const useIntegrationStore = defineStore('integration', () => { lifecycleEpoch += 1 readOwners.clear() activeReadTokens.clear() + mutationTails.clear() loading.value = false } @@ -70,6 +72,29 @@ export const useIntegrationStore = defineStore('integration', () => { return epoch === lifecycleEpoch } + async function enqueueConnectorMutation( + connectorId: string, + task: (epoch: number) => Promise, + ): Promise { + const epoch = lifecycleEpoch + const predecessor = mutationTails.get(connectorId) + let release!: () => void + const tail = new Promise((resolve) => { release = resolve }) + mutationTails.set(connectorId, tail) + + try { + if (predecessor) await predecessor + if (!ownsLifetime(epoch)) return undefined + + // A predecessor may have failed after this intent was queued. + error.value = null + return await task(epoch) + } finally { + release() + if (mutationTails.get(connectorId) === tail) mutationTails.delete(connectorId) + } + } + function guardDemoMutation(): never | void { if (isDemoMode) { toast.info('This action is view-only in demo mode.') @@ -145,95 +170,95 @@ export const useIntegrationStore = defineStore('integration', () => { async function updateConnector(id: string, request: UpdateIntegrationConnectorRequest) { guardDemoMutation() - const epoch = lifecycleEpoch - try { - error.value = null - const updated = await integrationsApi.updateConnector(id, request) - if (!ownsLifetime(epoch)) return updated + return await enqueueConnectorMutation(id, async (epoch) => { + try { + const updated = await integrationsApi.updateConnector(id, request) + if (!ownsLifetime(epoch)) return updated - connectors.value = connectors.value.map((connector) => connector.id === id ? updated : connector) - if (selectedConnector.value?.id === id) { - selectedConnector.value = { ...selectedConnector.value, ...updated } + connectors.value = connectors.value.map((connector) => connector.id === id ? updated : connector) + if (selectedConnector.value?.id === id) { + selectedConnector.value = { ...selectedConnector.value, ...updated } + } + toast.success('Connector updated.') + return updated + } catch (e: unknown) { + if (ownsLifetime(epoch)) { + const msg = getErrorDisplay(e, 'Failed to update connector').message + error.value = msg + toast.error(msg) + } + throw e } - toast.success('Connector updated.') - return updated - } catch (e: unknown) { - if (ownsLifetime(epoch)) { - const msg = getErrorDisplay(e, 'Failed to update connector').message - error.value = msg - toast.error(msg) - } - throw e - } + }) } async function deleteConnector(id: string) { guardDemoMutation() - const epoch = lifecycleEpoch - try { - error.value = null - await integrationsApi.deleteConnector(id) - if (!ownsLifetime(epoch)) return + await enqueueConnectorMutation(id, async (epoch) => { + try { + await integrationsApi.deleteConnector(id) + if (!ownsLifetime(epoch)) return - connectors.value = connectors.value.filter((connector) => connector.id !== id) - if (selectedConnector.value?.id === id) { - selectedConnector.value = null + connectors.value = connectors.value.filter((connector) => connector.id !== id) + if (selectedConnector.value?.id === id) { + selectedConnector.value = null + } + toast.success('Connector removed.') + } catch (e: unknown) { + if (ownsLifetime(epoch)) { + const msg = getErrorDisplay(e, 'Failed to remove connector').message + error.value = msg + toast.error(msg) + } + throw e } - toast.success('Connector removed.') - } catch (e: unknown) { - if (ownsLifetime(epoch)) { - const msg = getErrorDisplay(e, 'Failed to remove connector').message - error.value = msg - toast.error(msg) - } - throw e - } + }) } async function enableConnector(id: string) { guardDemoMutation() - const epoch = lifecycleEpoch - try { - error.value = null - const updated = await integrationsApi.enableConnector(id) - if (!ownsLifetime(epoch)) return + await enqueueConnectorMutation(id, async (epoch) => { + try { + const updated = await integrationsApi.enableConnector(id) + if (!ownsLifetime(epoch)) return - connectors.value = connectors.value.map((connector) => connector.id === id ? updated : connector) - if (selectedConnector.value?.id === id) { - selectedConnector.value = { ...selectedConnector.value, ...updated } - } - toast.success('Connector enabled.') - } catch (e: unknown) { - if (ownsLifetime(epoch)) { - const msg = getErrorDisplay(e, 'Failed to enable connector').message - error.value = msg - toast.error(msg) + connectors.value = connectors.value.map((connector) => connector.id === id ? updated : connector) + if (selectedConnector.value?.id === id) { + selectedConnector.value = { ...selectedConnector.value, ...updated } + } + toast.success('Connector enabled.') + } catch (e: unknown) { + if (ownsLifetime(epoch)) { + const msg = getErrorDisplay(e, 'Failed to enable connector').message + error.value = msg + toast.error(msg) + } + throw e } - throw e - } + }) } async function disableConnector(id: string) { guardDemoMutation() - const epoch = lifecycleEpoch - try { - error.value = null - const updated = await integrationsApi.disableConnector(id) - if (!ownsLifetime(epoch)) return + await enqueueConnectorMutation(id, async (epoch) => { + try { + const updated = await integrationsApi.disableConnector(id) + if (!ownsLifetime(epoch)) return - connectors.value = connectors.value.map((connector) => connector.id === id ? updated : connector) - if (selectedConnector.value?.id === id) { - selectedConnector.value = { ...selectedConnector.value, ...updated } - } - toast.success('Connector disabled.') - } catch (e: unknown) { - if (ownsLifetime(epoch)) { - const msg = getErrorDisplay(e, 'Failed to disable connector').message - error.value = msg - toast.error(msg) + connectors.value = connectors.value.map((connector) => connector.id === id ? updated : connector) + if (selectedConnector.value?.id === id) { + selectedConnector.value = { ...selectedConnector.value, ...updated } + } + toast.success('Connector disabled.') + } catch (e: unknown) { + if (ownsLifetime(epoch)) { + const msg = getErrorDisplay(e, 'Failed to disable connector').message + error.value = msg + toast.error(msg) + } + throw e } - throw e - } + }) } function $reset() { From ed2edaee632f3fe67934dd15645750ec83a44551 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:16:30 +0100 Subject: [PATCH 3/3] test(integrations): preserve independent mutation errors --- ...grationStoreMutationErrorOwnership.spec.ts | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 frontend/taskdeck-web/src/tests/store/integrationStoreMutationErrorOwnership.spec.ts diff --git a/frontend/taskdeck-web/src/tests/store/integrationStoreMutationErrorOwnership.spec.ts b/frontend/taskdeck-web/src/tests/store/integrationStoreMutationErrorOwnership.spec.ts new file mode 100644 index 000000000..1537fd4c0 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/integrationStoreMutationErrorOwnership.spec.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { integrationsApi } from '../../api/integrationsApi' +import { useIntegrationStore } from '../../store/integrationStore' +import { useSessionStore } from '../../store/sessionStore' +import type { IntegrationConnector } from '../../types/integration' + +vi.mock('../../utils/demoMode', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, isDemoMode: false } +}) + +vi.mock('../../api/integrationsApi', () => ({ + integrationsApi: { + listConnectors: vi.fn(), + getConnector: vi.fn(), + registerConnector: vi.fn(), + updateConnector: vi.fn(), + deleteConnector: vi.fn(), + enableConnector: vi.fn(), + disableConnector: 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(), + }), +})) + +vi.mock('../../composables/useErrorMapper', () => ({ + getErrorDisplay: (error: unknown, fallback: string) => ({ + message: error instanceof Error ? error.message : fallback, + }), +})) + +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 connector(id: string, name: string): IntegrationConnector { + return { + id, + name, + connectorType: 'BrowserClipper', + direction: 'Inbound', + status: 'Active', + configuration: null, + createdAt: '2026-09-21T00:00:00Z', + updatedAt: '2026-09-21T00:00:00Z', + } +} + +async function flushQueue() { + await Promise.resolve() + await Promise.resolve() +} + +describe('integrationStore mutation error ownership', () => { + beforeEach(() => { + setActivePinia(createPinia()) + const session = useSessionStore() + session.userId = 'account-a' + vi.clearAllMocks() + }) + + it('does not erase an independent failure when queued same-connector work starts', async () => { + const store = useIntegrationStore() + store.connectors = [connector('connector-1', 'One'), connector('connector-2', 'Two')] + + const first = deferred() + const independent = deferred() + const queued = deferred() + vi.mocked(integrationsApi.updateConnector) + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(independent.promise) + .mockReturnValueOnce(queued.promise) + + const firstRequest = store.updateConnector('connector-1', { name: 'First' }) + const queuedRequest = store.updateConnector('connector-1', { name: 'Queued' }) + const independentRequest = store.updateConnector('connector-2', { name: 'Independent' }) + + independent.reject(new Error('independent connector failed')) + await expect(independentRequest).rejects.toThrow('independent connector failed') + expect(store.error).toBe('independent connector failed') + + first.resolve(connector('connector-1', 'First')) + await firstRequest + await flushQueue() + + expect(integrationsApi.updateConnector).toHaveBeenCalledTimes(3) + expect(store.error).toBe('independent connector failed') + + queued.resolve(connector('connector-1', 'Queued')) + await queuedRequest + expect(store.error).toBe('independent connector failed') + }) +})