From c4af31e073619afa4e36eb4c3af9637b81821ecc Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Wed, 14 Oct 2026 11:49:49 -0400 Subject: [PATCH 01/21] fix(browse): report an import job Studio stopped following as outcome unknown, not failed Once Harper accepts a CSV load, `waitForJob` follows its job until COMPLETE or ERROR. It also stopped after five failed get_job polls in a row (rethrowing the poll's error) and at its 10-minute deadline (a plain "Timed out" error), and the global mutation toast showed both as an ordinary failure. The load may still be running or may have finished, and a retry of a CSV with no primary-key column adds every row a second time. Both now reject with `ImportJobUnconfirmedError`, carrying the job id and why following stopped (a deadline that passes on a failed poll is reported as that poll's failure, with it as the cause). `describeError` shows it under #1897's "Outcome unknown" title with copy that points to the table, warns that importing again may add the rows twice, and names the job on the Status page's Jobs tab for a role that can list jobs. A job Harper finished with ERROR is still an `ImportJobError` failure. `describeError`, `shouldToastOwnFailure` and the toast's never-collapse rule now read one `describeUnknownOutcome` list (unanswered write, unconfirmed restart, unconfirmed import job), so a kind added there reaches all three. Closes #1939 Co-Authored-By: Claude Opus 5.5 --- DESIGN.md | 3 +- src/features/instance/databases/DESIGN.md | 11 ++ .../modals/ImportDataModal.outcome.test.tsx | 136 +++++++++++++++++ .../api/instance/database/getJob.test.ts | 137 ++++++++++++++++-- .../api/instance/database/getJob.ts | 43 +++++- .../api/instance/database/importData.test.tsx | 23 ++- src/react-query/queryClient.test.ts | 79 ++++++++++ src/react-query/queryClient.ts | 47 +++--- 8 files changed, 436 insertions(+), 43 deletions(-) create mode 100644 src/features/instance/databases/modals/ImportDataModal.outcome.test.tsx diff --git a/DESIGN.md b/DESIGN.md index 5490667ae..916f99a0a 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -10,7 +10,7 @@ This file collects architecture and design notes for studio that aren't otherwis - [`src/features/cluster/DESIGN.md`](src/features/cluster/DESIGN.md) — when the Starting-up and Scaling screens may report done, how clone progress is read, where the cluster card's sync chip gets its data, how custom-domain cert intent, certificate progress and the Bind selection work, why a domain change is built from a fresh read, which pages a terminated cluster still opens, and when its sign-ins are forgotten. -- [`src/features/instance/databases/DESIGN.md`](src/features/instance/databases/DESIGN.md) — when the browse grid may show its spinner, why a table without a primary key can't be listed, why a filtered list pages without a count, why Export CSV writes the file in the browser instead of asking Harper for `text/csv`, where columns for undeclared properties come from, and why a filter no index can answer asks first. +- [`src/features/instance/databases/DESIGN.md`](src/features/instance/databases/DESIGN.md) — when the browse grid may show its spinner, why a table without a primary key can't be listed, why a filtered list pages without a count, why Export CSV writes the file in the browser instead of asking Harper for `text/csv`, where columns for undeclared properties come from, why a filter no index can answer asks first, and why an import job Studio stopped following reads as outcome unknown. - [`src/features/instance/applications/DESIGN.md`](src/features/instance/applications/DESIGN.md) — what happens to a path's unsaved edits, loaded contents and Monaco model when its file is deleted, replaced or moved; when a rolling deploy is done: following its restart job, and why a job Studio can't follow is never a success; and when a live deploy may be sent again. - [`src/features/instance/config/roles/DESIGN.md`](src/features/instance/config/roles/DESIGN.md) — why the role editor's JSON text is the only source of truth, what forces the JSON view, and what Save changes on purpose. @@ -72,6 +72,7 @@ Every `getInstanceClient` client replays a 502/503/504 up to three times (5s/10s The same rule decides what a failure says. [`isUnansweredWrite`](src/integrations/api/unansweredWrite.ts) is a request `isReadRequest` rejects, answered 502/503/504 or not at all (`ERR_NETWORK`, a timeout). [`describeError`](src/react-query/queryClient.ts) checks it before reading any body and returns "Outcome unknown … check whether it took effect before you try again", so the global mutation toast and every `describeError` caller say so with no copy of their own. Gate on status, never body (a gateway's body describes the gateway), and keep it to display: rewriting `error.message` would defeat `shouldKeepEvent`, which recognises timeouts and `Network Error` by message. - **A caller that adds its own failure toast to a mutation** asks [`shouldToastOwnFailure`](src/react-query/queryClient.ts) first, so "Failed to …" never sits beside the global warning. A caller with no mutation behind it (rename, the chat tools), or one whose mutation opts out of the global toast (the deploy mutation behind import, redeploy and staged-release activation), shows `describeError`'s text itself, under a title from [`deployFailureTitle`](src/features/instance/applications/modals/reportDeployHealth.ts) for a deploy ("Couldn't confirm … was redeployed", never "Failed to …"), and a loop that tallies one write per target (cluster restart, rolling config, licenses) counts these apart from failures. +- **Every error that means an unknown outcome is listed once**, in `describeUnknownOutcome` ([`queryClient.ts`](src/react-query/queryClient.ts)): this one, an unconfirmed restart (below), and an import job Studio stopped following ([databases DESIGN.md](src/features/instance/databases/DESIGN.md)). `describeError`, `shouldToastOwnFailure` and the toast's never-collapse rule (below) all read it, so a kind added there gets all three. - **Do not re-wrap an AxiosError into a plain `Error`** for a write: the predicate needs its `config`. `secrets.ts` passes an unanswered one through as is. - **A restart with no verdict stays marked until its target answers again or the watch gives up**, as an accepted one does, because a gateway can lose the answer to a restart that ran. The Restart button holds the mark while it watches, and the cluster loop waits on that member before restarting the next. - **These toasts are never collapsed** onto a shared id: each warns about a different write, and a read timing out alongside one must not replace it through `request-timeout`. diff --git a/src/features/instance/databases/DESIGN.md b/src/features/instance/databases/DESIGN.md index a429e16ca..29a5b8b49 100644 --- a/src/features/instance/databases/DESIGN.md +++ b/src/features/instance/databases/DESIGN.md @@ -166,3 +166,14 @@ import took effect (`isUnansweredWrite`). A central-manager 504 on a long insert example, and only a re-read shows the answer. When the instance is really down, the re-reads fail too and add their own error toasts. That noise is cheaper than a stale grid inviting a retry that duplicates every record without an id. + +## An import job Studio stopped following is not a failed import + +Once Harper accepts a CSV load, which answers with a `job_id`, only the job's own `ERROR` is a failure +(`ImportJobError`). [`waitForJob`](../../../integrations/api/instance/database/getJob.ts) also stops after five +failed `get_job` polls in a row, and at its 10-minute deadline whatever the last poll read: a job still running +or queued, or no row at all. Both reject with `ImportJobUnconfirmedError`, which `describeError` shows as "Outcome +unknown": the load may still be running or may have finished, so check the table (and, for a role that can list +jobs, the job on the Status page's Jobs tab). A retry is the thing to avoid: a CSV row with no primary key gets a +new id on every load (harper `resources/Table.ts`, `getNewId`), so a second load duplicates it. Any new way to +stop following rejects the same way. diff --git a/src/features/instance/databases/modals/ImportDataModal.outcome.test.tsx b/src/features/instance/databases/modals/ImportDataModal.outcome.test.tsx new file mode 100644 index 000000000..c086b9475 --- /dev/null +++ b/src/features/instance/databases/modals/ImportDataModal.outcome.test.tsx @@ -0,0 +1,136 @@ +/** + * @vitest-environment jsdom + */ +import { ImportDataModal } from '@/features/instance/databases/modals/ImportDataModal'; +import { OUTCOME_UNKNOWN_TITLE } from '@/integrations/api/unansweredWrite'; +import { mutationErrorHandler } from '@/react-query/queryClient'; +import { MutationCache, QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import axios, { AxiosError, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const instance = vi.hoisted(() => ({ client: undefined as unknown })); +const toast = vi.hoisted(() => ({ + error: vi.fn(), + loading: vi.fn(() => 'progress-toast'), + success: vi.fn(), + dismiss: vi.fn(), +})); + +vi.mock('sonner', () => ({ toast })); + +vi.mock('@tanstack/react-router', () => ({ + useParams: () => ({ instanceId: 'instance-1' }), +})); + +vi.mock('@/hooks/useAuth', () => ({ + useInstanceAuth: () => ({ user: { role: { permission: { super_user: true } } } }), + isAdminMode: () => false, + useCloudAuth: () => ({ user: null }), +})); + +vi.mock('@/config/useInstanceClient', () => ({ + useInstanceClientIdParams: () => ({ instanceClient: instance.client, entityId: 'entity-1', entityType: 'instance' }), +})); + +vi.mock('@/features/instance/config/roles/operations/useOperationsAllowlistSupported', () => ({ + useOperationsAllowlistSupported: () => true, +})); + +type Poll = { status: string; message?: string } | 'no-answer'; + +function instanceClient(polls: Poll[]) { + let poll = 0; + const client = axios.create(); + client.defaults.adapter = async (config: InternalAxiosRequestConfig): Promise => { + const { operation } = JSON.parse(config.data as string) as { operation: string }; + const answer = (data: unknown) => ({ data, status: 200, statusText: 'OK', headers: {}, config }); + if (operation === 'csv_url_load') { + return answer({ message: 'Starting job', job_id: 'job-7' }); + } + const next = polls[Math.min(poll, polls.length - 1)]; + poll += 1; + if (next === 'no-answer') { + throw new AxiosError('Network Error', AxiosError.ERR_NETWORK, config); + } + return answer([{ id: 'job-7', ...next }]); + }; + return client; +} + +const withTable = { data: { dog: { attributes: [{ attribute: 'id', is_primary_key: true }] } } }; + +async function importFromUrl(polls: Poll[], waitMs: number) { + instance.client = instanceClient(polls); + const onImported = vi.fn(); + const queryClient = new QueryClient({ mutationCache: new MutationCache({ onError: mutationErrorHandler }) }); + render( + + undefined} + instanceDatabaseMap={withTable as never} + databaseName="data" + tableName="dog" + initialMethod="url" + onImported={onImported} + /> + , + ); + fireEvent.change(screen.getByPlaceholderText('https://example.com/data.csv'), { + target: { value: 'https://example.com/dogs.csv' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Import Data' })); + await act(() => vi.advanceTimersByTimeAsync(waitMs)); + return { onImported }; +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + vi.spyOn(console, 'debug').mockImplementation(() => undefined); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.clearAllMocks(); +}); + +describe('ImportDataModal when Studio stops following the job', () => { + it.each([ + ['the polls stop answering', [{ status: 'IN_PROGRESS' }, 'no-answer'] as Poll[], 20_000], + ['the job is still running at the deadline', [{ status: 'IN_PROGRESS' }] as Poll[], 11 * 60_000], + ])('reports an unknown outcome, never a failure, when %s', async (_, polls, waitMs) => { + const { onImported } = await importFromUrl(polls, waitMs); + + expect(toast.loading).toHaveBeenCalled(); + expect(toast.dismiss).toHaveBeenCalledWith('progress-toast'); + expect(toast.error).toHaveBeenCalledTimes(1); + const [title, options] = toast.error.mock.calls[0] as unknown as [string, { description: string }]; + expect(title).toBe(OUTCOME_UNKNOWN_TITLE); + expect(options.description).toContain('It may still be running, or it may have finished.'); + expect(options.description).toContain('Check the table before you import this data again'); + expect(options.description).toContain( + "If your role can list jobs, also look for job job-7 on the Status page's Jobs tab.", + ); + expect(toast.success).not.toHaveBeenCalled(); + expect(onImported).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: 'Import Data' })).toBeTruthy(); + }); + + it('still reports a job Harper finished with ERROR as a failure, in its own words', async () => { + const { onImported } = await importFromUrl( + [{ status: 'IN_PROGRESS' }, { status: 'ERROR', message: 'CSV was malformed' }], + 5_000, + ); + + expect(toast.dismiss).toHaveBeenCalledWith('progress-toast'); + expect(toast.error).toHaveBeenCalledTimes(1); + const [title, options] = toast.error.mock.calls[0] as unknown as [string, { description: string }]; + expect(title).not.toBe(OUTCOME_UNKNOWN_TITLE); + expect(options.description).toBe('CSV was malformed'); + expect(onImported).not.toHaveBeenCalled(); + }); +}); diff --git a/src/integrations/api/instance/database/getJob.test.ts b/src/integrations/api/instance/database/getJob.test.ts index b9f25ab2b..50f10bad0 100644 --- a/src/integrations/api/instance/database/getJob.test.ts +++ b/src/integrations/api/instance/database/getJob.test.ts @@ -1,21 +1,30 @@ +import { shouldKeepEvent } from '@/integrations/datadog/shouldKeepEvent'; +import { AxiosError } from 'axios'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { ImportJobError, waitForJob } from './getJob'; +import { ImportJobError, ImportJobUnconfirmedError, waitForJob } from './getJob'; type InstanceClient = Parameters[0]['instanceClient']; -function makeClient(responses: ({ status: string; message?: unknown } | { reject: true })[]) { +function makeClient(responses: ({ status: string; message?: unknown } | { reject: true } | { missing: true })[]) { let call = 0; const post = vi.fn(() => { const job = responses[Math.min(call, responses.length - 1)]; call += 1; if ('reject' in job) { - return Promise.reject(new Error('network down')); + return Promise.reject(new Error(`network down (poll ${call})`)); + } + if ('missing' in job) { + return Promise.resolve({ data: [] }); } return Promise.resolve({ data: [{ id: 'job-1', ...job }] }); }); return { post } as unknown as InstanceClient; } +async function rejectionOf(pending: Promise): Promise { + return pending.then(() => expect.unreachable('expected a rejection'), (error: unknown) => error); +} + describe('waitForJob', () => { beforeEach(() => vi.useFakeTimers()); afterEach(() => vi.useRealTimers()); @@ -83,20 +92,69 @@ describe('waitForJob', () => { expect(job.status).toBe('COMPLETE'); }); - it('gives up after several consecutive get_job failures', async () => { - const instanceClient = makeClient([{ reject: true }]); + it('stops following after five get_job failures in a row, as an unconfirmed outcome rather than a failure', async () => { + const instanceClient = makeClient([{ status: 'IN_PROGRESS' }, { reject: true }]); + const pending = rejectionOf(waitForJob({ jobId: 'job-1', instanceClient, pollIntervalMs: 100 })); + await vi.advanceTimersByTimeAsync(2000); + + const error = await pending; + expect(error).toBeInstanceOf(ImportJobUnconfirmedError); + expect(error).not.toBeInstanceOf(ImportJobError); + expect(error).toMatchObject({ jobId: 'job-1', reason: 'polling-failed' }); + expect((error as Error).cause).toEqual(new Error('network down (poll 6)')); + expect(instanceClient.post).toHaveBeenCalledTimes(6); + }); + + it('counts only failures in a row: a poll that answers starts the count again', async () => { + const instanceClient = makeClient([ + ...Array<{ reject: true }>(4).fill({ reject: true }), + { status: 'IN_PROGRESS' }, + ...Array<{ reject: true }>(4).fill({ reject: true }), + { status: 'COMPLETE', message: 'done' }, + ]); const pending = waitForJob({ jobId: 'job-1', instanceClient, pollIntervalMs: 100 }); - pending.catch(() => {}); await vi.advanceTimersByTimeAsync(2000); - await expect(pending).rejects.toThrow('network down'); + + await expect(pending).resolves.toMatchObject({ status: 'COMPLETE' }); }); - it('rejects when the job never finishes before the timeout', async () => { - const instanceClient = makeClient([{ status: 'IN_PROGRESS' }]); - const pending = waitForJob({ jobId: 'job-1', instanceClient, pollIntervalMs: 100, timeoutMs: 1000 }); - pending.catch(() => {}); + it.each( + [ + ['still running', { status: 'IN_PROGRESS' }], + ['still queued', { status: 'CREATED' }], + ['never found', { missing: true }], + ] as const, + )('stops following at the deadline when the job is %s, as an unconfirmed outcome', async (_, job) => { + const instanceClient = makeClient([job]); + const pending = rejectionOf(waitForJob({ jobId: 'job-1', instanceClient, pollIntervalMs: 100, timeoutMs: 1000 })); await vi.advanceTimersByTimeAsync(2000); - await expect(pending).rejects.toThrow(/Timed out/); + + const error = await pending; + expect(error).toBeInstanceOf(ImportJobUnconfirmedError); + expect(error).toMatchObject({ jobId: 'job-1', reason: 'deadline', cause: undefined }); + }); + + it('names the failed poll, not the clock, when the deadline passes while polls are failing', async () => { + const instanceClient = makeClient([ + ...Array<{ status: string }>(10).fill({ status: 'IN_PROGRESS' }), + { reject: true }, + ]); + const pending = rejectionOf(waitForJob({ jobId: 'job-1', instanceClient, pollIntervalMs: 100, timeoutMs: 1000 })); + await vi.advanceTimersByTimeAsync(2000); + + const error = await pending; + expect(error).toMatchObject({ jobId: 'job-1', reason: 'polling-failed' }); + expect((error as Error).cause).toEqual(new Error('network down (poll 11)')); + }); + + it('still reports an ERROR that follows failed polls as the failure it is', async () => { + const instanceClient = makeClient([{ reject: true }, { reject: true }, { status: 'ERROR', message: 'bad row' }]); + const pending = rejectionOf(waitForJob({ jobId: 'job-1', instanceClient, pollIntervalMs: 100 })); + await vi.advanceTimersByTimeAsync(1000); + + const error = await pending; + expect(error).toBeInstanceOf(ImportJobError); + expect(error).not.toBeInstanceOf(ImportJobUnconfirmedError); }); it('reports every job record a poll returns, the finished one included, and skips a failed poll', async () => { @@ -144,3 +202,58 @@ describe('waitForJob', () => { debug.mockRestore(); }); }); + +describe('ImportJobUnconfirmedError', () => { + function consoleEvent(error: Error) { + return { type: 'error', error: { message: error.message, type: error.name, source: 'console' } }; + } + const getJobFailure = (message: string, status?: number) => + new AxiosError( + message, + status ? AxiosError.ERR_BAD_RESPONSE : AxiosError.ECONNABORTED, + undefined, + undefined, + status + ? { status, statusText: '', headers: {}, config: {} as never, data: 'secret upstream page' } + : undefined, + ); + + it('reaches RUM, or not, exactly as the poll failure it wraps would', () => { + const timeout = getJobFailure('timeout of 30000ms exceeded'); + const unauthorized = getJobFailure('Request failed with status code 401', 401); + const gateway = getJobFailure('Request failed with status code 502', 502); + const network = new AxiosError('Network Error', AxiosError.ERR_NETWORK); + + for (const pollFailure of [timeout, unauthorized, gateway, network]) { + expect(shouldKeepEvent(consoleEvent(new ImportJobUnconfirmedError('job-1', 'polling-failed', pollFailure)))) + .toBe(shouldKeepEvent(consoleEvent(pollFailure))); + } + expect(shouldKeepEvent(consoleEvent(new ImportJobUnconfirmedError('job-1', 'polling-failed', timeout)))).toBe( + false, + ); + expect(shouldKeepEvent(consoleEvent(new ImportJobUnconfirmedError('job-1', 'deadline')))).toBe(true); + }); + + it("keeps the job id and the poll's response body out of its message", () => { + const error = new ImportJobUnconfirmedError( + '4f1c2a9e-job', + 'polling-failed', + getJobFailure('Request failed with status code 502', 502), + ); + + expect(error.message).toBe( + 'Stopped following the import job, whose status could not be read: Request failed with status code 502', + ); + expect(new ImportJobUnconfirmedError('4f1c2a9e-job', 'deadline').message).not.toContain('4f1c2a9e-job'); + }); + + const circular: Record = {}; + circular.self = circular; + it.each([null, undefined, 'socket hang up', { code: 'E_SOMETHING' }, circular])('accepts any cause (%o)', (cause) => { + const error = new ImportJobUnconfirmedError('job-1', 'polling-failed', cause); + + expect(error.cause).toBe(cause); + expect(error.name).toBe('ImportJobUnconfirmedError'); + expect(error.message).toMatch(/^Stopped following the import job, whose status could not be read: /); + }); +}); diff --git a/src/integrations/api/instance/database/getJob.ts b/src/integrations/api/instance/database/getJob.ts index cce14c06d..4d81b372b 100644 --- a/src/integrations/api/instance/database/getJob.ts +++ b/src/integrations/api/instance/database/getJob.ts @@ -33,11 +33,43 @@ export class ImportJobError extends Error { } } +type ImportJobUnconfirmedReason = 'polling-failed' | 'deadline'; + +/** Studio stopped following an accepted load before its job ended: never a failure, since a retry can duplicate rows. */ +export class ImportJobUnconfirmedError extends Error { + readonly jobId: string; + readonly reason: ImportJobUnconfirmedReason; + + constructor(jobId: string, reason: ImportJobUnconfirmedReason, cause?: unknown) { + // The poll's text stays in the message because Datadog's `shouldKeepEvent` filters on it. The job id + // stays out of it, so these group as one issue in Error Tracking. + super( + reason === 'deadline' + ? 'Stopped following the import job at its deadline, before it reported how it ended' + : `Stopped following the import job, whose status could not be read: ${errorText(cause) ?? 'no answer'}`, + { cause }, + ); + this.name = 'ImportJobUnconfirmedError'; + this.jobId = jobId; + this.reason = reason; + } +} + +export function describeImportJobUnconfirmed({ jobId, reason }: ImportJobUnconfirmedError): string { + const why = reason === 'deadline' + ? 'Studio stopped following this import before it could confirm how it ended.' + : "Studio could no longer read this import's job, so it can't tell how it ended."; + return `${why} It may still be running, or it may have finished. Check the table before you import this data ` + + 'again, or its rows may be added twice. ' + + `If your role can list jobs, also look for job ${jobId} on the Status page's Jobs tab.`; +} + const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); /** - * Polls get_job until the job leaves IN_PROGRESS. Resolves with the finished job, - * or rejects with the job's error message (ERROR status) or on timeout. + * Polls get_job until the job reports COMPLETE or ERROR. Resolves with the finished job, or rejects with + * {@link ImportJobError} on ERROR. Stopping without either, after repeated failed polls or at the + * deadline, rejects with {@link ImportJobUnconfirmedError}, never as a failure. * `onUpdate` sees every job record a poll returns, the finished one included. */ export async function waitForJob({ @@ -64,8 +96,9 @@ export async function waitForJob({ consecutiveFailures = 0; } catch (err) { consecutiveFailures += 1; - if (consecutiveFailures >= 5) { - throw err; + // At the deadline too: the failed poll, not the clock, is what left the outcome unread. + if (consecutiveFailures >= 5 || Date.now() >= deadline) { + throw new ImportJobUnconfirmedError(jobId, 'polling-failed', err); } } if (job && onUpdate) { @@ -87,7 +120,7 @@ export async function waitForJob({ return job; } if (Date.now() >= deadline) { - throw new Error('Timed out waiting for the import job to finish. Check the table in a few minutes.'); + throw new ImportJobUnconfirmedError(jobId, 'deadline'); } await sleep(pollIntervalMs); } diff --git a/src/integrations/api/instance/database/importData.test.tsx b/src/integrations/api/instance/database/importData.test.tsx index ba95efac7..7ffa21037 100644 --- a/src/integrations/api/instance/database/importData.test.tsx +++ b/src/integrations/api/instance/database/importData.test.tsx @@ -6,6 +6,7 @@ import type { ReactNode } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getDescribeAllQueryOptions } from './getDescribeAll'; import { getDescribeTableQueryOptions } from './getDescribeTable'; +import { ImportJobError, ImportJobUnconfirmedError } from './getJob'; import { getSearchByConditionsOptions } from './getSearchByConditions'; import { getSearchByIdOptions } from './getSearchById'; import { getSearchByValueOptions } from './getSearchByValue'; @@ -245,20 +246,27 @@ describe('useImportDataMutation refreshing Browse', () => { throw rejection(status); }), }) as unknown as AxiosInstance; - const failures: [string, () => AxiosInstance, Partial, number][] = [ - ['the job ended in ERROR', () => csvClient(['IN_PROGRESS', 'ERROR']), {}, 5_000], - ['the wait timed out on a load still running', () => csvClient(['IN_PROGRESS']), {}, 11 * 60_000], + const failures: [string, () => AxiosInstance, Partial, number, new(...args: never[]) => Error][] = [ + ['the job ended in ERROR', () => csvClient(['IN_PROGRESS', 'ERROR']), {}, 5_000, ImportJobError], + [ + 'the wait timed out on a load still running', + () => csvClient(['IN_PROGRESS']), + {}, + 11 * 60_000, + ImportJobUnconfirmedError, + ], [ 'Harper refused the records for a table the import had just created', createsTableThenRefuses, { tableExists: false, source: { kind: 'json-records', records: [{ name: 'Rex' }] } }, 5_000, + AxiosError, ], - ['the submission got no answer', rejectAll(), {}, 5_000], - ['a gateway answered 504', rejectAll(504), {}, 5_000], - ['the polls stopped answering', pollsStopAnswering, {}, 10_000], + ['the submission got no answer', rejectAll(), {}, 5_000, AxiosError], + ['a gateway answered 504', rejectAll(504), {}, 5_000, AxiosError], + ['the polls stopped answering', pollsStopAnswering, {}, 10_000, ImportJobUnconfirmedError], ]; - it.each(failures)('refetches too when %s', async (_case, instanceClient, overrides, waitMs) => { + it.each(failures)('refetches too when %s', async (_case, instanceClient, overrides, waitMs, settledWith) => { const client = new QueryClient(); const reads = await openBrowse(client); @@ -266,6 +274,7 @@ describe('useImportDataMutation refreshing Browse', () => { await vi.advanceTimersByTimeAsync(waitMs); expect(modal.result.current.isError).toBe(true); + expect(modal.result.current.error).toBeInstanceOf(settledWith); expect(reads.page).toHaveBeenCalledTimes(2); expect(reads.tree).toHaveBeenCalledTimes(2); }); diff --git a/src/react-query/queryClient.test.ts b/src/react-query/queryClient.test.ts index 63503afc5..90bfaa53c 100644 --- a/src/react-query/queryClient.test.ts +++ b/src/react-query/queryClient.test.ts @@ -1,3 +1,4 @@ +import { ImportJobError, ImportJobUnconfirmedError } from '@/integrations/api/instance/database/getJob'; import { RESTART_UNCONFIRMED_MESSAGE, RESTART_UNCONFIRMED_TITLE, @@ -453,3 +454,81 @@ describe('a restart Harper accepted whose target was never confirmed back', () = expect(shouldToastOwnFailure(unconfirmed())).toBe(false); }); }); + +describe('a load job Studio stopped following before it ended', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + const pollFailure = () => { + const config = { method: 'post', data: JSON.stringify({ operation: 'get_job', id: 'job-7' }) }; + return new AxiosError( + 'Request failed with status code 502', + AxiosError.ERR_BAD_RESPONSE, + config as InternalAxiosRequestConfig, + undefined, + { + status: 502, + statusText: '', + data: 'upstream secret-host-01 is down', + headers: {}, + config: config as InternalAxiosRequestConfig, + }, + ); + }; + + it.each( + [ + ['polling-failed', "Studio could no longer read this import's job"], + ['deadline', 'Studio stopped following this import before it could confirm how it ended'], + ] as const, + )('is described as an unknown outcome when %s, naming the job and where to check', (reason, why) => { + const described = describeError(new ImportJobUnconfirmedError('job-7', reason, pollFailure())); + + expect(described.title).toBe(OUTCOME_UNKNOWN_TITLE); + expect(described.description).toBe(described.message); + expect(described.message).toContain(why); + expect(described.message).toContain('It may still be running, or it may have finished.'); + expect(described.message).toContain('Check the table before you import this data again'); + expect(described.message).toContain( + "If your role can list jobs, also look for job job-7 on the Status page's Jobs tab.", + ); + expect(described.message).not.toMatch(/try again|retry|fail/i); + expect(described.message).not.toContain('secret-host-01'); + expect(described.message).not.toContain('502'); + }); + + it('tells a mutation’s caller not to add a "failed" toast of its own', () => { + expect(shouldToastOwnFailure(new ImportJobUnconfirmedError('job-7', 'deadline'))).toBe(false); + expect(shouldToastOwnFailure(new ImportJobUnconfirmedError('job-7', 'polling-failed', pollFailure()))).toBe( + false, + ); + }); + + it('gets a toast of its own, never collapsed with read timeouts, whatever it carries', () => { + const timeout = new AxiosError('timeout of 60000ms exceeded', AxiosError.ECONNABORTED); + errorHandler( + Object.assign(new ImportJobUnconfirmedError('job-7', 'polling-failed', timeout), { code: timeout.code }), + ); + errorHandler(timeout); + + const toasts = vi.mocked(toast.error).mock.calls.map(([title, options]) => [title, options?.id]); + expect(toasts).toEqual([[OUTCOME_UNKNOWN_TITLE, undefined], ['Error', 'request-timeout']]); + }); + + it('leaves a job Harper finished with ERROR an ordinary failure', () => { + const failed = new ImportJobError('Value "x" in property age must be a number'); + + expect(describeError(failed).title).not.toBe(OUTCOME_UNKNOWN_TITLE); + expect(describeError(failed).message).toBe('Value "x" in property age must be a number'); + expect(shouldToastOwnFailure(failed)).toBe(true); + }); + + it('leaves the poll failure itself to the usual extraction, as a read', () => { + expect(describeError(pollFailure()).title).not.toBe(OUTCOME_UNKNOWN_TITLE); + }); +}); diff --git a/src/react-query/queryClient.ts b/src/react-query/queryClient.ts index 8185d0157..caceb239a 100644 --- a/src/react-query/queryClient.ts +++ b/src/react-query/queryClient.ts @@ -1,3 +1,4 @@ +import { describeImportJobUnconfirmed, ImportJobUnconfirmedError } from '@/integrations/api/instance/database/getJob'; import { RESTART_UNCONFIRMED_MESSAGE, RESTART_UNCONFIRMED_TITLE, @@ -13,6 +14,26 @@ import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query'; import { AxiosError } from 'axios'; import { toast } from 'sonner'; +interface DescribedError { + title: string; + description: string; + message: string; +} + +function describeUnknownOutcome(rawErr: unknown): DescribedError | undefined { + const copy = (title: string, message: string) => ({ title, description: message, message }); + if (isUnansweredWrite(rawErr)) { + return copy(OUTCOME_UNKNOWN_TITLE, UNANSWERED_WRITE_MESSAGE); + } + if (rawErr instanceof RestartUnconfirmedError) { + return copy(RESTART_UNCONFIRMED_TITLE, RESTART_UNCONFIRMED_MESSAGE); + } + if (rawErr instanceof ImportJobUnconfirmedError) { + return copy(OUTCOME_UNKNOWN_TITLE, describeImportJobUnconfirmed(rawErr)); + } + return undefined; +} + /** * Turn an error of unknown shape into display text, in both the shapes the UI needs. * @@ -21,21 +42,11 @@ import { toast } from 'sonner'; * inputs. They differ only for a legacy `"Conflict: user already exists"` body, where the split * below moves the first clause into `title`: a single line built from `description` alone would * lose it. Everything comes from this one extractor so the inline text and the toast can't drift. - * - * A write that got no verdict ({@link isUnansweredWrite}) gets our own copy instead, chosen by status - * and never by body: a gateway's body describes the gateway, and "failed" is exactly what it can't claim. - * So does a restart Harper accepted whose target was never confirmed back ({@link RestartUnconfirmedError}). */ -export function describeError(rawErr: unknown): { title: string; description: string; message: string } { - if (isUnansweredWrite(rawErr)) { - return { title: OUTCOME_UNKNOWN_TITLE, description: UNANSWERED_WRITE_MESSAGE, message: UNANSWERED_WRITE_MESSAGE }; - } - if (rawErr instanceof RestartUnconfirmedError) { - return { - title: RESTART_UNCONFIRMED_TITLE, - description: RESTART_UNCONFIRMED_MESSAGE, - message: RESTART_UNCONFIRMED_MESSAGE, - }; +export function describeError(rawErr: unknown): DescribedError { + const unknownOutcome = describeUnknownOutcome(rawErr); + if (unknownOutcome) { + return unknownOutcome; } let errorTitle = 'Error'; let errorMsg = 'We had some trouble!'; @@ -84,11 +95,11 @@ export function describeError(rawErr: unknown): { title: string; description: st /** * For a mutation's caller that toasts its own failure on top of the global toast: whether it should. - * Not for an unanswered write or an unconfirmed restart, whose global toast already says what is known — - * a second one saying "failed" would contradict it and invite the retry it warns against. + * Not for a failure whose outcome is not known, whose global toast already says what is known — a + * second one saying "failed" would contradict it and invite the retry it warns against. */ export function shouldToastOwnFailure(rawErr: unknown): boolean { - return !isUnansweredWrite(rawErr) && !(rawErr instanceof RestartUnconfirmedError); + return describeUnknownOutcome(rawErr) === undefined; } export function errorHandler(rawErr: unknown) { @@ -106,7 +117,7 @@ export function errorHandler(rawErr: unknown) { const isForbidden = axiosWrappedErr?.response?.status === 403; // Never collapsed: each warns about a different write, and a read timing out alongside one must // not replace it through `request-timeout`. - const collapseId = isUnansweredWrite(rawErr) + const collapseId = describeUnknownOutcome(rawErr) ? undefined : isTimeout ? 'request-timeout' From 3d298382f0baa82cff8caea90dc9711570a35f25 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Wed, 14 Oct 2026 12:58:55 -0400 Subject: [PATCH 02/21] fix(browse): stop following an import job the polled node does not have, after 20 seconds On a cluster reached through its own address, a `get_job` poll can land on a node other than the one that ran the import. `hdb_job` does not replicate, so that node answers `[]`, and Studio kept polling until the 10-minute deadline. Studio cannot poll the right node instead: the load answers only `message` and `job_id`, the job row records no node, and `get_job` reads only the local table. `waitForJob` now rejects with `ImportJobUnconfirmedError` (reason `not-found`) once `get_job` has answered `[]` for 20 seconds straight. A poll that finds the job, or one that fails, starts the grace again. The copy says the same as the other unknown outcomes, and points a role that can list jobs to the Jobs tab of each instance's Status page, since only the node that ran the job lists it. The grace is now one constant, `JOB_NOT_FOUND_GRACE_MS`, shared with the rolling-deploy follower, which already applied the same 20 seconds for the same reason. Co-Authored-By: Claude Opus 5.5 --- src/features/instance/databases/DESIGN.md | 19 ++++++-- .../modals/ImportDataModal.outcome.test.tsx | 21 +++++---- .../api/instance/applications/restartJob.ts | 8 +--- .../api/instance/database/getJob.test.ts | 32 ++++++++++++- .../api/instance/database/getJob.ts | 47 ++++++++++++++----- .../api/instance/database/importData.test.tsx | 8 ++++ src/react-query/queryClient.test.ts | 17 ++++--- 7 files changed, 114 insertions(+), 38 deletions(-) diff --git a/src/features/instance/databases/DESIGN.md b/src/features/instance/databases/DESIGN.md index 29a5b8b49..e4da7ae05 100644 --- a/src/features/instance/databases/DESIGN.md +++ b/src/features/instance/databases/DESIGN.md @@ -171,9 +171,18 @@ duplicates every record without an id. Once Harper accepts a CSV load, which answers with a `job_id`, only the job's own `ERROR` is a failure (`ImportJobError`). [`waitForJob`](../../../integrations/api/instance/database/getJob.ts) also stops after five -failed `get_job` polls in a row, and at its 10-minute deadline whatever the last poll read: a job still running -or queued, or no row at all. Both reject with `ImportJobUnconfirmedError`, which `describeError` shows as "Outcome -unknown": the load may still be running or may have finished, so check the table (and, for a role that can list -jobs, the job on the Status page's Jobs tab). A retry is the thing to avoid: a CSV row with no primary key gets a -new id on every load (harper `resources/Table.ts`, `getNewId`), so a second load duplicates it. Any new way to +failed `get_job` polls in a row, after `get_job` has answered `[]` for 20 seconds straight, and at its 10-minute +deadline whatever the last poll read. All three reject with `ImportJobUnconfirmedError`, which `describeError` +shows as "Outcome unknown": the load may still be running or may have finished, so check the table (and, for a +role that can list jobs, the job on a Jobs tab). A retry is the thing to avoid: a CSV row with no primary key gets +a new id on every load (harper `resources/Table.ts`, `getNewId`), so a second load duplicates it. Any new way to stop following rejects the same way. + +The `[]` case is a cluster reached through its own address, which can send a poll to a node other than the one +that ran the job. Studio can't poll the right node, because nothing names it: the load answers only `message` and +`job_id` (harper v5.3.2 `server/serverHelpers/serverUtilities.ts:581-584`), the job row records no node +(`server/jobs/JobObject.ts:10-19`), `get_job` reads only the local `hdb_job` (`server/jobs/jobs.ts:38-54`, `:280-290`), +and `hdb_job` does not replicate (`resources/databases.ts:330-339`). Central manager's proxy sends every request +to the first running instance (central-manager `src/resources/cluster/Cluster.js:375-380`), so it reaches the same +node unless instance states change. The 20-second grace (`JOB_NOT_FOUND_GRACE_MS`) is the one the rolling-deploy +follower uses for the same reason. diff --git a/src/features/instance/databases/modals/ImportDataModal.outcome.test.tsx b/src/features/instance/databases/modals/ImportDataModal.outcome.test.tsx index c086b9475..316c140ff 100644 --- a/src/features/instance/databases/modals/ImportDataModal.outcome.test.tsx +++ b/src/features/instance/databases/modals/ImportDataModal.outcome.test.tsx @@ -37,7 +37,7 @@ vi.mock('@/features/instance/config/roles/operations/useOperationsAllowlistSuppo useOperationsAllowlistSupported: () => true, })); -type Poll = { status: string; message?: string } | 'no-answer'; +type Poll = { status: string; message?: string } | 'no-answer' | 'missing'; function instanceClient(polls: Poll[]) { let poll = 0; @@ -53,7 +53,7 @@ function instanceClient(polls: Poll[]) { if (next === 'no-answer') { throw new AxiosError('Network Error', AxiosError.ERR_NETWORK, config); } - return answer([{ id: 'job-7', ...next }]); + return answer(next === 'missing' ? [] : [{ id: 'job-7', ...next }]); }; return client; } @@ -99,10 +99,17 @@ afterEach(() => { }); describe('ImportDataModal when Studio stops following the job', () => { + const anyNode = "If your role can list jobs, also look for job job-7 on the Status page's Jobs tab."; it.each([ - ['the polls stop answering', [{ status: 'IN_PROGRESS' }, 'no-answer'] as Poll[], 20_000], - ['the job is still running at the deadline', [{ status: 'IN_PROGRESS' }] as Poll[], 11 * 60_000], - ])('reports an unknown outcome, never a failure, when %s', async (_, polls, waitMs) => { + ['the polls stop answering', [{ status: 'IN_PROGRESS' }, 'no-answer'] as Poll[], 20_000, anyNode], + [ + 'the node Studio reaches does not have the job', + [{ status: 'IN_PROGRESS' }, 'missing'] as Poll[], + 30_000, + "look for job job-7 on the Jobs tab of each instance's Status page", + ], + ['the job is still running at the deadline', [{ status: 'IN_PROGRESS' }] as Poll[], 11 * 60_000, anyNode], + ])('reports an unknown outcome, never a failure, when %s', async (_, polls, waitMs, where) => { const { onImported } = await importFromUrl(polls, waitMs); expect(toast.loading).toHaveBeenCalled(); @@ -112,9 +119,7 @@ describe('ImportDataModal when Studio stops following the job', () => { expect(title).toBe(OUTCOME_UNKNOWN_TITLE); expect(options.description).toContain('It may still be running, or it may have finished.'); expect(options.description).toContain('Check the table before you import this data again'); - expect(options.description).toContain( - "If your role can list jobs, also look for job job-7 on the Status page's Jobs tab.", - ); + expect(options.description).toContain(where); expect(toast.success).not.toHaveBeenCalled(); expect(onImported).not.toHaveBeenCalled(); expect(screen.getByRole('button', { name: 'Import Data' })).toBeTruthy(); diff --git a/src/integrations/api/instance/applications/restartJob.ts b/src/integrations/api/instance/applications/restartJob.ts index d054e7c53..02dd7c9b6 100644 --- a/src/integrations/api/instance/applications/restartJob.ts +++ b/src/integrations/api/instance/applications/restartJob.ts @@ -1,5 +1,5 @@ import { InstanceClientConfig } from '@/config/instanceClientConfig'; -import { getJob, HarperJob } from '@/integrations/api/instance/database/getJob'; +import { getJob, HarperJob, JOB_NOT_FOUND_GRACE_MS } from '@/integrations/api/instance/database/getJob'; import { errorText } from '@/lib/errorText'; import { sleep } from '@/lib/sleep'; import { isDeterministicRejection } from '@/react-query/pollUnlessForbidden'; @@ -113,10 +113,6 @@ export function rolloutPollDelayMs(elapsedMs: number, fastMs: number): number { return elapsedMs < 5 * 60_000 ? 5_000 : 15_000; } -// A cluster's address can reach a node other than the one that received the deploy, and reach that one -// again on the next request, so a missing row only counts once the job has stayed missing for a while. -const NOT_FOUND_GRACE_MS = 20_000; - interface FollowRestartJobParams extends InstanceClientConfig { jobId: string; onUpdate?: (snapshot: RolloutSnapshot) => void; @@ -161,7 +157,7 @@ export async function followRestartJob({ } } else { missingSince ??= Date.now(); - if (Date.now() - missingSince >= NOT_FOUND_GRACE_MS) { + if (Date.now() - missingSince >= JOB_NOT_FOUND_GRACE_MS) { return unfollowable('not-found'); } } diff --git a/src/integrations/api/instance/database/getJob.test.ts b/src/integrations/api/instance/database/getJob.test.ts index 50f10bad0..c5325cf3f 100644 --- a/src/integrations/api/instance/database/getJob.test.ts +++ b/src/integrations/api/instance/database/getJob.test.ts @@ -1,7 +1,7 @@ import { shouldKeepEvent } from '@/integrations/datadog/shouldKeepEvent'; import { AxiosError } from 'axios'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { ImportJobError, ImportJobUnconfirmedError, waitForJob } from './getJob'; +import { ImportJobError, ImportJobUnconfirmedError, JOB_NOT_FOUND_GRACE_MS, waitForJob } from './getJob'; type InstanceClient = Parameters[0]['instanceClient']; @@ -134,6 +134,36 @@ describe('waitForJob', () => { expect(error).toMatchObject({ jobId: 'job-1', reason: 'deadline', cause: undefined }); }); + it('stops following a job that stays missing, long before the deadline, as an unconfirmed outcome', async () => { + const instanceClient = makeClient([{ status: 'IN_PROGRESS' }, { missing: true }]); + const pending = rejectionOf(waitForJob({ jobId: 'job-1', instanceClient, pollIntervalMs: 100 })); + await vi.advanceTimersByTimeAsync(JOB_NOT_FOUND_GRACE_MS + 1000); + + const error = await Promise.race([pending, Promise.resolve('still following')]); + expect(error).toBeInstanceOf(ImportJobUnconfirmedError); + expect(error).toMatchObject({ jobId: 'job-1', reason: 'not-found', cause: undefined }); + }); + + const missingFor = (ms: number) => Array<{ missing: true }>(ms / 100).fill({ missing: true }); + it.each( + [ + ['a poll that finds the job', { status: 'IN_PROGRESS' }], + ['a poll that fails', { reject: true }], + ] as const, + )('starts the missing-job grace again after %s', async (_, between) => { + const halfGrace = JOB_NOT_FOUND_GRACE_MS / 2; + const instanceClient = makeClient([ + ...missingFor(halfGrace + 5000), + between, + ...missingFor(halfGrace + 5000), + { status: 'COMPLETE', message: 'done' }, + ]); + const pending = waitForJob({ jobId: 'job-1', instanceClient, pollIntervalMs: 100 }); + await vi.advanceTimersByTimeAsync(2 * JOB_NOT_FOUND_GRACE_MS + 5000); + + await expect(pending).resolves.toMatchObject({ status: 'COMPLETE' }); + }); + it('names the failed poll, not the clock, when the deadline passes while polls are failing', async () => { const instanceClient = makeClient([ ...Array<{ status: string }>(10).fill({ status: 'IN_PROGRESS' }), diff --git a/src/integrations/api/instance/database/getJob.ts b/src/integrations/api/instance/database/getJob.ts index 4d81b372b..48ec131b6 100644 --- a/src/integrations/api/instance/database/getJob.ts +++ b/src/integrations/api/instance/database/getJob.ts @@ -33,7 +33,17 @@ export class ImportJobError extends Error { } } -type ImportJobUnconfirmedReason = 'polling-failed' | 'deadline'; +// `hdb_job` does not replicate, so a cluster's address can answer `[]` from a node other than the one that +// ran the job, and reach that one again on the next request: a missing row only counts once it has stayed +// missing for a while. +export const JOB_NOT_FOUND_GRACE_MS = 20_000; + +type ImportJobUnconfirmedReason = 'polling-failed' | 'not-found' | 'deadline'; + +const UNCONFIRMED_MESSAGES: Record, string> = { + 'not-found': 'Stopped following the import job, which the node Studio reached does not have', + deadline: 'Stopped following the import job at its deadline, before it reported how it ended', +}; /** Studio stopped following an accepted load before its job ended: never a failure, since a retry can duplicate rows. */ export class ImportJobUnconfirmedError extends Error { @@ -44,9 +54,9 @@ export class ImportJobUnconfirmedError extends Error { // The poll's text stays in the message because Datadog's `shouldKeepEvent` filters on it. The job id // stays out of it, so these group as one issue in Error Tracking. super( - reason === 'deadline' - ? 'Stopped following the import job at its deadline, before it reported how it ended' - : `Stopped following the import job, whose status could not be read: ${errorText(cause) ?? 'no answer'}`, + reason === 'polling-failed' + ? `Stopped following the import job, whose status could not be read: ${errorText(cause) ?? 'no answer'}` + : UNCONFIRMED_MESSAGES[reason], { cause }, ); this.name = 'ImportJobUnconfirmedError'; @@ -55,21 +65,27 @@ export class ImportJobUnconfirmedError extends Error { } } +const UNCONFIRMED_WHY: Record = { + 'polling-failed': "Studio could no longer read this import's job, so it can't tell how it ended.", + 'not-found': "Studio couldn't find this import's job on the node it reached, so it can't tell how it ended.", + deadline: 'Studio stopped following this import before it could confirm how it ended.', +}; + export function describeImportJobUnconfirmed({ jobId, reason }: ImportJobUnconfirmedError): string { - const why = reason === 'deadline' - ? 'Studio stopped following this import before it could confirm how it ended.' - : "Studio could no longer read this import's job, so it can't tell how it ended."; - return `${why} It may still be running, or it may have finished. Check the table before you import this data ` - + 'again, or its rows may be added twice. ' - + `If your role can list jobs, also look for job ${jobId} on the Status page's Jobs tab.`; + const where = reason === 'not-found' + ? `look for job ${jobId} on the Jobs tab of each instance's Status page: only the node that ran it lists it.` + : `also look for job ${jobId} on the Status page's Jobs tab.`; + return `${UNCONFIRMED_WHY[reason]} It may still be running, or it may have finished. Check the table before you ` + + `import this data again, or its rows may be added twice. If your role can list jobs, ${where}`; } const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); /** * Polls get_job until the job reports COMPLETE or ERROR. Resolves with the finished job, or rejects with - * {@link ImportJobError} on ERROR. Stopping without either, after repeated failed polls or at the - * deadline, rejects with {@link ImportJobUnconfirmedError}, never as a failure. + * {@link ImportJobError} on ERROR. Stopping without either, after repeated failed polls, a job missing for + * {@link JOB_NOT_FOUND_GRACE_MS}, or at the deadline, rejects with {@link ImportJobUnconfirmedError}, never + * as a failure. * `onUpdate` sees every job record a poll returns, the finished one included. */ export async function waitForJob({ @@ -89,12 +105,16 @@ export async function waitForJob({ // transient get_job failures are tolerated — but only a few in a row, so a persistent // problem (revoked token, instance gone) surfaces quickly instead of at the deadline. let consecutiveFailures = 0; + let missingSince: number | undefined; for (;;) { let job: HarperJob | undefined; try { job = await getJob({ jobId, instanceClient }); consecutiveFailures = 0; + missingSince = job ? undefined : (missingSince ?? Date.now()); } catch (err) { + // An answer that never came is not a miss, so not-found needs an unbroken run of them. + missingSince = undefined; consecutiveFailures += 1; // At the deadline too: the failed poll, not the clock, is what left the outcome unread. if (consecutiveFailures >= 5 || Date.now() >= deadline) { @@ -119,6 +139,9 @@ export async function waitForJob({ if (status === 'COMPLETE' && job) { return job; } + if (missingSince !== undefined && Date.now() - missingSince >= JOB_NOT_FOUND_GRACE_MS) { + throw new ImportJobUnconfirmedError(jobId, 'not-found'); + } if (Date.now() >= deadline) { throw new ImportJobUnconfirmedError(jobId, 'deadline'); } diff --git a/src/integrations/api/instance/database/importData.test.tsx b/src/integrations/api/instance/database/importData.test.tsx index 7ffa21037..cea58fde0 100644 --- a/src/integrations/api/instance/database/importData.test.tsx +++ b/src/integrations/api/instance/database/importData.test.tsx @@ -214,6 +214,13 @@ describe('useImportDataMutation refreshing Browse', () => { return { post } as unknown as AxiosInstance; } + function jobNeverFound() { + const post = vi.fn(async (_url: string, body: { operation: string }) => + body.operation === 'csv_url_load' ? { data: { message: 'Starting job', job_id: 'job-1' } } : { data: [] } + ); + return { post } as unknown as AxiosInstance; + } + function pollsStopAnswering() { const post = vi.fn(async (_url: string, body: { operation: string }) => { if (body.operation === 'csv_url_load') { @@ -265,6 +272,7 @@ describe('useImportDataMutation refreshing Browse', () => { ['the submission got no answer', rejectAll(), {}, 5_000, AxiosError], ['a gateway answered 504', rejectAll(504), {}, 5_000, AxiosError], ['the polls stopped answering', pollsStopAnswering, {}, 10_000, ImportJobUnconfirmedError], + ['the node polled never had the job', jobNeverFound, {}, 30_000, ImportJobUnconfirmedError], ]; it.each(failures)('refetches too when %s', async (_case, instanceClient, overrides, waitMs, settledWith) => { const client = new QueryClient(); diff --git a/src/react-query/queryClient.test.ts b/src/react-query/queryClient.test.ts index 90bfaa53c..7df94777c 100644 --- a/src/react-query/queryClient.test.ts +++ b/src/react-query/queryClient.test.ts @@ -481,12 +481,19 @@ describe('a load job Studio stopped following before it ended', () => { ); }; + const anyNode = "If your role can list jobs, also look for job job-7 on the Status page's Jobs tab."; it.each( [ - ['polling-failed', "Studio could no longer read this import's job"], - ['deadline', 'Studio stopped following this import before it could confirm how it ended'], + ['polling-failed', "Studio could no longer read this import's job", anyNode], + [ + 'not-found', + "Studio couldn't find this import's job on the node it reached", + "If your role can list jobs, look for job job-7 on the Jobs tab of each instance's Status page: " + + 'only the node that ran it lists it.', + ], + ['deadline', 'Studio stopped following this import before it could confirm how it ended', anyNode], ] as const, - )('is described as an unknown outcome when %s, naming the job and where to check', (reason, why) => { + )('is described as an unknown outcome when %s, naming the job and where to check', (reason, why, where) => { const described = describeError(new ImportJobUnconfirmedError('job-7', reason, pollFailure())); expect(described.title).toBe(OUTCOME_UNKNOWN_TITLE); @@ -494,9 +501,7 @@ describe('a load job Studio stopped following before it ended', () => { expect(described.message).toContain(why); expect(described.message).toContain('It may still be running, or it may have finished.'); expect(described.message).toContain('Check the table before you import this data again'); - expect(described.message).toContain( - "If your role can list jobs, also look for job job-7 on the Status page's Jobs tab.", - ); + expect(described.message).toContain(where); expect(described.message).not.toMatch(/try again|retry|fail/i); expect(described.message).not.toContain('secret-host-01'); expect(described.message).not.toContain('502'); From c6630f2efc5eeba2536fd55e24628c0ae1f80b2b Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Wed, 14 Oct 2026 13:06:42 -0400 Subject: [PATCH 03/21] docs(browse): record that an ERROR Studio never read reads as unknown, by design A load whose job ends in ERROR after the last poll Studio could read is reported as an unknown outcome, not a failure: Studio can't report an outcome it never observed. Say so in the databases design note, so it reads as the contract it is. Drops the class JSDoc on ImportJobUnconfirmedError and trims waitForJob's to its rejection contract: both repeated the design note. Co-Authored-By: Claude Opus 5.5 --- src/features/instance/databases/DESIGN.md | 17 +++++++++-------- .../api/instance/database/getJob.ts | 9 +++------ 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/features/instance/databases/DESIGN.md b/src/features/instance/databases/DESIGN.md index e4da7ae05..7fe18d91a 100644 --- a/src/features/instance/databases/DESIGN.md +++ b/src/features/instance/databases/DESIGN.md @@ -169,14 +169,15 @@ duplicates every record without an id. ## An import job Studio stopped following is not a failed import -Once Harper accepts a CSV load, which answers with a `job_id`, only the job's own `ERROR` is a failure -(`ImportJobError`). [`waitForJob`](../../../integrations/api/instance/database/getJob.ts) also stops after five -failed `get_job` polls in a row, after `get_job` has answered `[]` for 20 seconds straight, and at its 10-minute -deadline whatever the last poll read. All three reject with `ImportJobUnconfirmedError`, which `describeError` -shows as "Outcome unknown": the load may still be running or may have finished, so check the table (and, for a -role that can list jobs, the job on a Jobs tab). A retry is the thing to avoid: a CSV row with no primary key gets -a new id on every load (harper `resources/Table.ts`, `getNewId`), so a second load duplicates it. Any new way to -stop following rejects the same way. +Once Harper accepts a CSV load, which answers with a `job_id`, only an `ERROR` Studio reads from the job is a +failure (`ImportJobError`). [`waitForJob`](../../../integrations/api/instance/database/getJob.ts) also stops +after five failed `get_job` polls in a row, after `get_job` has answered `[]` for 20 seconds straight, and at its +10-minute deadline whatever the last poll read. All three reject with `ImportJobUnconfirmedError`, which +`describeError` shows as "Outcome unknown": the load may still be running or may have finished, so check the +table (and, for a role that can list jobs, the job on a Jobs tab). That holds even for a job that failed after the +last poll Studio could read: Studio can't report an outcome it never observed. A retry is the thing to avoid: a +CSV row with no primary key gets a new id on every load (harper `resources/Table.ts`, `getNewId`), so a second +load duplicates it. Any new way to stop following rejects the same way. The `[]` case is a cluster reached through its own address, which can send a poll to a node other than the one that ran the job. Studio can't poll the right node, because nothing names it: the load answers only `message` and diff --git a/src/integrations/api/instance/database/getJob.ts b/src/integrations/api/instance/database/getJob.ts index 48ec131b6..e7cb95ef6 100644 --- a/src/integrations/api/instance/database/getJob.ts +++ b/src/integrations/api/instance/database/getJob.ts @@ -45,7 +45,6 @@ const UNCONFIRMED_MESSAGES: Record new Promise((resolve) => setTimeout(resolve, ms)); /** - * Polls get_job until the job reports COMPLETE or ERROR. Resolves with the finished job, or rejects with - * {@link ImportJobError} on ERROR. Stopping without either, after repeated failed polls, a job missing for - * {@link JOB_NOT_FOUND_GRACE_MS}, or at the deadline, rejects with {@link ImportJobUnconfirmedError}, never - * as a failure. - * `onUpdate` sees every job record a poll returns, the finished one included. + * Rejects with {@link ImportJobError} only on an ERROR it read, and with {@link ImportJobUnconfirmedError} + * whenever it stops following without COMPLETE or ERROR. `onUpdate` sees every job record a poll returns, + * the finished one included. */ export async function waitForJob({ jobId, From 1f93df1337dcc0c9837358923d156cc2705ef981 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Wed, 14 Oct 2026 13:33:32 -0400 Subject: [PATCH 04/21] fix(browse): read the clock once per import-job poll `waitForJob` read `Date.now()` in the failed-poll branch and again at the deadline check. A poll that failed just under the deadline could pass the first read and fail the second, and was then reported as `deadline` with no cause instead of `polling-failed` with the poll's error. Datadog then kept a timed-out poll it filters everywhere else. One read per poll makes the checks agree. Co-Authored-By: Claude Opus 5.5 --- .../api/instance/database/getJob.test.ts | 17 ++++++++++++++- .../api/instance/database/getJob.ts | 21 +++++++++++++------ 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/src/integrations/api/instance/database/getJob.test.ts b/src/integrations/api/instance/database/getJob.test.ts index c5325cf3f..9afdaf374 100644 --- a/src/integrations/api/instance/database/getJob.test.ts +++ b/src/integrations/api/instance/database/getJob.test.ts @@ -27,7 +27,10 @@ async function rejectionOf(pending: Promise): Promise { describe('waitForJob', () => { beforeEach(() => vi.useFakeTimers()); - afterEach(() => vi.useRealTimers()); + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); it('resolves once the job reaches COMPLETE', async () => { const instanceClient = makeClient([ @@ -177,6 +180,18 @@ describe('waitForJob', () => { expect((error as Error).cause).toEqual(new Error('network down (poll 11)')); }); + it('reads the clock once per poll, so a failed poll just under the deadline is never reported as the deadline', async () => { + const instanceClient = makeClient([{ reject: true }]); + const clock = [0, 999, 1000]; + vi.spyOn(Date, 'now').mockImplementation(() => (clock.length > 1 ? clock.shift()! : clock[0])); + const pending = rejectionOf(waitForJob({ jobId: 'job-1', instanceClient, pollIntervalMs: 100, timeoutMs: 1000 })); + await vi.advanceTimersByTimeAsync(500); + + const error = await pending; + expect(error).toMatchObject({ jobId: 'job-1', reason: 'polling-failed' }); + expect((error as Error).cause).toEqual(new Error('network down (poll 2)')); + }); + it('still reports an ERROR that follows failed polls as the failure it is', async () => { const instanceClient = makeClient([{ reject: true }, { reject: true }, { status: 'ERROR', message: 'bad row' }]); const pending = rejectionOf(waitForJob({ jobId: 'job-1', instanceClient, pollIntervalMs: 100 })); diff --git a/src/integrations/api/instance/database/getJob.ts b/src/integrations/api/instance/database/getJob.ts index e7cb95ef6..60eec989f 100644 --- a/src/integrations/api/instance/database/getJob.ts +++ b/src/integrations/api/instance/database/getJob.ts @@ -105,18 +105,27 @@ export async function waitForJob({ let missingSince: number | undefined; for (;;) { let job: HarperJob | undefined; + let pollFailed = false; + let pollError: unknown; try { job = await getJob({ jobId, instanceClient }); - consecutiveFailures = 0; - missingSince = job ? undefined : (missingSince ?? Date.now()); } catch (err) { + pollFailed = true; + pollError = err; + } + // Read once, so the checks below agree on which side of the deadline this poll landed. + const now = Date.now(); + if (pollFailed) { // An answer that never came is not a miss, so not-found needs an unbroken run of them. missingSince = undefined; consecutiveFailures += 1; // At the deadline too: the failed poll, not the clock, is what left the outcome unread. - if (consecutiveFailures >= 5 || Date.now() >= deadline) { - throw new ImportJobUnconfirmedError(jobId, 'polling-failed', err); + if (consecutiveFailures >= 5 || now >= deadline) { + throw new ImportJobUnconfirmedError(jobId, 'polling-failed', pollError); } + } else { + consecutiveFailures = 0; + missingSince = job ? undefined : (missingSince ?? now); } if (job && onUpdate) { // An observer failure must not turn a load Harper completed into a reported failure, which @@ -136,10 +145,10 @@ export async function waitForJob({ if (status === 'COMPLETE' && job) { return job; } - if (missingSince !== undefined && Date.now() - missingSince >= JOB_NOT_FOUND_GRACE_MS) { + if (missingSince !== undefined && now - missingSince >= JOB_NOT_FOUND_GRACE_MS) { throw new ImportJobUnconfirmedError(jobId, 'not-found'); } - if (Date.now() >= deadline) { + if (now >= deadline) { throw new ImportJobUnconfirmedError(jobId, 'deadline'); } await sleep(pollIntervalMs); From ac4e62e43553a0e8b49476ab0c6803695e611f36 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Wed, 14 Oct 2026 12:12:03 -0400 Subject: [PATCH 05/21] fix(browse): let Next past a remembered last page once the table's count moves The row editor's Next probes the next page past a full unfiltered page, and an empty answer is remembered (knownLastPage) so the dead probe isn't offered again. Only the view's own Refresh retired that proof, so after a window-focus refetch or an import's refresh Next stayed disabled on the last record even when records had been added. The proof is about how many records the table holds, so it now carries the record count the step set out with and holds only while the count is the same. A focus refetch or an import's refresh reads describe_table again; a count that moved voids it. A probe whose count moved while it was out proves nothing, so it neither stores a proof nor says "This is the last record". Reading a page doesn't, so the read on the way back from the probe keeps the proof however slow the probe. A step that turns the page now first drops the table's cached pages that aren't on screen, so it reads the next one afresh. Otherwise Next could reuse the probe's cached empty answer, or join a read of the page that started before the change, and prove the old last page again; and a step could open a row from a copy older than the page on screen. Co-Authored-By: Claude Opus 5.5 --- src/features/instance/databases/DESIGN.md | 13 + .../DatabaseTableView.lastPageProof.test.tsx | 308 ++++++++++++++++++ .../components/DatabaseTableView.tsx | 55 +++- 3 files changed, 364 insertions(+), 12 deletions(-) create mode 100644 src/features/instance/databases/components/DatabaseTableView.lastPageProof.test.tsx diff --git a/src/features/instance/databases/DESIGN.md b/src/features/instance/databases/DESIGN.md index 7fe18d91a..e715373da 100644 --- a/src/features/instance/databases/DESIGN.md +++ b/src/features/instance/databases/DESIGN.md @@ -50,6 +50,19 @@ and [`TablePagination`](components/TablePagination.tsx) pages by whether it came stated only once the page that ends the result set is on screen (`uncountedTotal`). The unfiltered list keeps paging by the table count. +Past the last record of a full unfiltered page, the row editor's Next doesn't go by that count. It +probes the next page, and an empty answer proves the page it came from is the last (`knownLastPage` +in [`DatabaseTableView`](components/DatabaseTableView.tsx)). The proof is about how many records the +table holds, so it carries the count the step set out with and holds only while the count is the +same (#1940). A window-focus refetch or an import's refresh reads the count again, and Refresh +retires the proof outright. Reading a page doesn't, so the read on the way back from the probe keeps +it, however slow the probe; and a count that moves while the probe is out voids the answer it brings. +A step that turns the page first drops the table's cached pages that aren't on screen, so it reads +the next page afresh rather than trust a copy, or join a read, older than the page on screen (the +probe's empty answer among them). Where `describe_table` only estimates the count, growth that leaves +the estimate unchanged keeps the proof until Refresh, and a count that first arrives, or shrinks, +costs one more probe. + ## Export CSV writes the file itself, from JSON The export reads every matching record as JSON in one request, with the grid's select minus its diff --git a/src/features/instance/databases/components/DatabaseTableView.lastPageProof.test.tsx b/src/features/instance/databases/components/DatabaseTableView.lastPageProof.test.tsx new file mode 100644 index 000000000..ab2b9e41d --- /dev/null +++ b/src/features/instance/databases/components/DatabaseTableView.lastPageProof.test.tsx @@ -0,0 +1,308 @@ +/** + * @vitest-environment jsdom + */ +import { InstanceTable } from '@/integrations/api/api.patch'; +import { focusManager, QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { toast } from 'sonner'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { RecordNavigation } from '../modals/EditTableRowModal'; +import { DatabaseTableView } from './DatabaseTableView'; + +// The grid and React Query stay real: what retires the proof is a refetch React Query decides to +// make, so a stubbed query would decide it for the test. + +vi.mock('@tanstack/react-router', () => { + const params = {}; + const search = {}; + return { + useParams: () => params, + useSearch: () => search, + Link: ({ children }: { children?: React.ReactNode }) => <>{children}, + }; +}); + +const instance = vi.hoisted(() => ({ + records: [] as Record[], + pageReads: [] as number[], + failingOffset: undefined as number | undefined, + heldOffset: undefined as number | undefined, + releaseHeldRead: () => undefined as void, +})); + +vi.mock('@/config/useInstanceClient', () => { + const instanceClient = { + post: async (_url: string, body: { operation: string; offset?: number; limit?: number }) => { + switch (body.operation) { + case 'describe_table': + return { + data: { + schema: 'data', + name: 'pets', + hash_attribute: 'id', + audit: true, + schema_defined: false, + attributes: [{ attribute: 'id', is_primary_key: true }], + db_size: 65536, + sources: [], + record_count: instance.records.length, + table_size: 4096, + db_audit_size: 4096, + } satisfies InstanceTable, + status: 200, + }; + case 'registration_info': + return { data: { version: '4.7.40' }, status: 200 }; + case 'search_by_value': { + const offset = body.offset ?? 0; + instance.pageReads.push(offset); + if (offset === instance.failingOffset) { + throw new Error('The instance is unreachable'); + } + const data = instance.records.slice(offset, offset + (body.limit ?? 0)); + if (offset === instance.heldOffset) { + instance.heldOffset = undefined; + await new Promise((resolve) => { + instance.releaseHeldRead = resolve; + }); + } + return { data, status: 200 }; + } + default: + return { data: [], status: 200 }; + } + }, + }; + const params = { entityId: 'instance-1', instanceClient, entityType: 'instance' }; + return { useInstanceClientIdParams: () => params }; +}); + +vi.mock('@/hooks/useAuth', () => ({ + useStaffPermission: () => false, +})); + +vi.mock('@/hooks/usePermissions', () => ({ + useInstanceBrowseManagePermission: () => true, + useInstanceImportDataPermission: () => true, + useInstanceImportCapabilities: () => ({ + methods: { sample: true, file: true, url: true }, + allowsSource: () => true, + allowsDestination: () => true, + }), + useInstanceSchemaTablePermission: () => true, + useInstanceTablePutPermission: () => true, +})); + +vi.mock('./PickColumnsDropdown', () => ({ PickColumnsDropdown: () => null })); +const editor = vi.hoisted(() => ({ navigation: undefined as RecordNavigation | undefined })); +vi.mock('../modals/EditTableRowModal', () => ({ + EditTableRowModal: ({ recordNavigation }: { recordNavigation?: RecordNavigation }) => { + editor.navigation = recordNavigation; + return null; + }, +})); + +beforeAll(() => { + Element.prototype.hasPointerCapture ??= () => false; + Element.prototype.setPointerCapture ??= () => undefined; + Element.prototype.releasePointerCapture ??= () => undefined; + Element.prototype.scrollIntoView ??= () => undefined; + if (typeof window.PointerEvent === 'undefined') { + window.PointerEvent = class extends MouseEvent {} as typeof PointerEvent; + } +}); + +const realNow = Date.now.bind(Date); +let clockAhead = 0; + +// Every Browse read has a 60s staleTime, which the tests step past by moving the clock. +beforeEach(() => { + vi.spyOn(Date, 'now').mockImplementation(() => realNow() + clockAhead); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + clockAhead = 0; + focusManager.setFocused(undefined); + instance.records = []; + instance.pageReads = []; + instance.failingOffset = undefined; + instance.heldOffset = undefined; + editor.navigation = undefined; +}); + +function seedPets(count: number) { + instance.records = Array.from({ length: count }, (_, index) => ({ id: index + 1 })); +} + +function renderPets(count: number) { + seedPets(count); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); + return queryClient; +} + +const findCell = (name: string) => screen.findByRole('cell', { name }, { timeout: 10_000 }); + +async function proveTheFirstPageIsTheLast({ clockAheadBeforeTheStep = 0 } = {}) { + const queryClient = renderPets(20); + fireEvent.click(await findCell('20')); + expect(editor.navigation).toMatchObject({ position: 20, hasNext: true }); + clockAhead = clockAheadBeforeTheStep; + + act(() => editor.navigation!.onNext()); + await waitFor(() => expect(instance.pageReads).toContain(20)); + await waitFor(() => expect(editor.navigation).toMatchObject({ position: 20, hasNext: false })); + return queryClient; +} + +async function settle(queryClient: QueryClient) { + await waitFor(() => expect(queryClient.isFetching()).toBe(0)); + await act(() => new Promise((resolve) => setTimeout(resolve, 50))); +} + +function refocusTheWindow() { + act(() => { + focusManager.setFocused(false); + focusManager.setFocused(true); + }); +} + +async function expectNextToReachTheNewRecord() { + await waitFor(() => expect(editor.navigation).toMatchObject({ position: 20, hasNext: true })); + act(() => editor.navigation!.onNext()); + await waitFor(() => expect(editor.navigation).toMatchObject({ position: 21 })); +} + +describe('DatabaseTableView remembering the last page', { timeout: 30_000 }, () => { + it('offers Next again once an import refreshes the page and the table has grown', async () => { + const queryClient = await proveTheFirstPageIsTheLast(); + + seedPets(21); + // What useImportDataMutation's onSettled does for the table it imported into. + await act(() => queryClient.invalidateQueries({ queryKey: ['instance-1', 'data', 'pets'] })); + + await expectNextToReachTheNewRecord(); + }); + + // At 61s the focus refetch re-reads the count; the probe read at 59s is still fresh in the cache. + it('re-probes after a window-focus refetch instead of reusing the cached empty answer', async () => { + await proveTheFirstPageIsTheLast({ clockAheadBeforeTheStep: 59_000 }); + + seedPets(21); + clockAhead = 61_000; + refocusTheWindow(); + + await expectNextToReachTheNewRecord(); + expect(instance.pageReads).toEqual([0, 20, 0, 20]); + }); + + it('keeps the proof through the read of the page the step returns to', async () => { + const queryClient = await proveTheFirstPageIsTheLast({ clockAheadBeforeTheStep: 61_000 }); + await settle(queryClient); + + expect(instance.pageReads).toEqual([0, 20, 0]); + expect(editor.navigation).toMatchObject({ position: 20, hasNext: false }); + }); + + it('forgets the proof when the count moves while another page is on screen', async () => { + const queryClient = renderPets(40); + await findCell('20'); + await settle(queryClient); + fireEvent.click(screen.getByRole('button', { name: 'Next page' })); + fireEvent.click(await findCell('40')); + act(() => editor.navigation!.onNext()); + await waitFor(() => expect(instance.pageReads).toContain(40)); + await waitFor(() => expect(editor.navigation).toMatchObject({ position: 40, hasNext: false })); + + fireEvent.click(screen.getByRole('button', { name: 'Previous page' })); + await findCell('1'); + seedPets(41); + await act(() => queryClient.invalidateQueries({ queryKey: ['instance-1', 'data', 'pets'] })); + await settle(queryClient); + fireEvent.click(screen.getByRole('button', { name: 'Next page' })); + fireEvent.click(await findCell('40')); + + expect(editor.navigation).toMatchObject({ position: 40, hasNext: true }); + }); + + // The probe's empty answer was read before the count moved, so it proves nothing about the new count. + it('ignores a probe whose answer predates a count that moved while it was out', async () => { + const toastInfo = vi.spyOn(toast, 'info'); + const queryClient = renderPets(20); + fireEvent.click(await findCell('20')); + instance.heldOffset = 20; + act(() => editor.navigation!.onNext()); + await waitFor(() => expect(instance.pageReads).toContain(20)); + + seedPets(21); + await act(() => queryClient.invalidateQueries({ queryKey: ['instance-1', 'data', 'pets', 'describe_table'] })); + await waitFor(() => expect(editor.navigation?.total).toBe(21)); + act(() => instance.releaseHeldRead()); + + await waitFor(() => expect(editor.navigation).toMatchObject({ position: 20, hasNext: true })); + await settle(queryClient); + expect(toastInfo).not.toHaveBeenCalledWith('This is the last record'); + }); + + it('reads the next page afresh rather than joining a read of it that started earlier', async () => { + const queryClient = renderPets(40); + await findCell('20'); + await settle(queryClient); + instance.heldOffset = 20; + fireEvent.click(screen.getByRole('button', { name: 'Next page' })); + await waitFor(() => expect(instance.pageReads).toContain(20)); + fireEvent.click(screen.getByRole('button', { name: 'Previous page' })); + + seedPets(20); + await act(() => queryClient.invalidateQueries({ queryKey: ['instance-1', 'data', 'pets', 'describe_table'] })); + fireEvent.click(await findCell('20')); + act(() => editor.navigation!.onNext()); + act(() => instance.releaseHeldRead()); + await settle(queryClient); + + expect(editor.navigation).toMatchObject({ position: 20, hasNext: false }); + }); + + it('reads a cached neighbouring page afresh before opening its row', async () => { + const queryClient = await openTheFirstRecordOfTheNextPageAndComeBack(); + + seedPets(20); + // What the view's refreshTable does after a write. + await act(() => queryClient.invalidateQueries({ queryKey: ['instance-1', 'data', 'pets'] })); + await settle(queryClient); + act(() => editor.navigation!.onNext()); + await settle(queryClient); + + expect(editor.navigation).toMatchObject({ position: 20, hasNext: false }); + }); + + it('stays put when that read fails, rather than opening the cached row', async () => { + const toastError = vi.spyOn(toast, 'error'); + const queryClient = await openTheFirstRecordOfTheNextPageAndComeBack(); + + instance.failingOffset = 20; + await act(() => queryClient.invalidateQueries({ queryKey: ['instance-1', 'data', 'pets'] })); + await settle(queryClient); + act(() => editor.navigation!.onNext()); + await settle(queryClient); + + expect(editor.navigation).toMatchObject({ position: 20 }); + expect(toastError).toHaveBeenCalledWith("Couldn't load the next page of records"); + }); +}); + +async function openTheFirstRecordOfTheNextPageAndComeBack() { + const queryClient = renderPets(21); + fireEvent.click(await findCell('20')); + act(() => editor.navigation!.onNext()); + await waitFor(() => expect(editor.navigation).toMatchObject({ position: 21 })); + act(() => editor.navigation!.onPrevious()); + await waitFor(() => expect(editor.navigation).toMatchObject({ position: 20 })); + return queryClient; +} diff --git a/src/features/instance/databases/components/DatabaseTableView.tsx b/src/features/instance/databases/components/DatabaseTableView.tsx index bec305d10..5c3800fe0 100644 --- a/src/features/instance/databases/components/DatabaseTableView.tsx +++ b/src/features/instance/databases/components/DatabaseTableView.tsx @@ -213,9 +213,9 @@ export function DatabaseTableView({ // A step off either end of the page can only open its record once the neighbouring page has been // fetched: which end of it to open, plus the page to put the grid back on if that fetch brings // nothing (an empty page, or a failed request -- both list queries are `retry: false`). - const [pendingRecordStep, setPendingRecordStep] = useState<{ end: 'first' | 'last'; fromPage: number } | null>( - null, - ); + const [pendingRecordStep, setPendingRecordStep] = useState< + { end: 'first' | 'last'; fromPage: number; totalRecords: number | undefined } | null + >(null); // While that page loads, the open record is the one the user stepped away from -- so the editor // shows nothing rather than a record its own header can no longer name. const isStepPending = pendingRecordStep !== null; @@ -370,10 +370,13 @@ export function DatabaseTableView({ const [pageIndex, setPageIndex] = useEffectedState(0, [databaseName, tableName, appliedSearchConditions]); const [pageSize, setPageSize] = useState(20); - // The index of a page a step has proven to be the last one. A full unfiltered page can't say - // whether more records follow, so the step probes; an empty answer is the only proof, and - // remembering it keeps the Next button from offering that same dead probe again. - const [knownLastPage, setKnownLastPage] = useEffectedState(null, [ + // The page a step has proven to be the last one. A full unfiltered page can't say whether more + // records follow, so the step probes; an empty answer is the only proof, and remembering it keeps + // the Next button from offering that same dead probe again. It holds only while the record count + // the step set out with does. + const [knownLastPage, setKnownLastPage] = useEffectedState< + { pageIndex: number; totalRecords: number | undefined } | null + >(null, [ databaseName, tableName, appliedSearchConditions, @@ -556,7 +559,8 @@ export function DatabaseTableView({ // full one is only proven terminal by a step that found nothing past it. const hasNextPage = uncountedPage ? !!uncountedPage.hasNextPage - : !!pageRows && pageRows.length === pageSize && knownLastPage !== pageIndex; + : !!pageRows && pageRows.length === pageSize + && !(knownLastPage?.pageIndex === pageIndex && knownLastPage.totalRecords === totalRecords); // What counts as selected is DERIVED from the rows on screen, not just read out of the stored // set. The epoch reset and the refresh clear bound what can accumulate, but neither fires when @@ -847,9 +851,23 @@ export function DatabaseTableView({ if (targetPage < 0) { return; } + // A copy of that page still in the cache, or a read of it still in flight, can be older than the + // page on screen (the probe's empty answer among them), so the step reads it afresh. + queryClient.removeQueries({ queryKey: [instanceParams.entityId, databaseName, tableName], type: 'inactive' }); setPageIndex(targetPage); - setPendingRecordStep({ end: offset === 1 ? 'first' : 'last', fromPage: pageIndex }); - }, [openRowIndex, pageRows, openRecordAt, pageIndex, setPageIndex]); + setPendingRecordStep({ end: offset === 1 ? 'first' : 'last', fromPage: pageIndex, totalRecords }); + }, [ + openRowIndex, + pageRows, + openRecordAt, + pageIndex, + setPageIndex, + totalRecords, + queryClient, + instanceParams.entityId, + databaseName, + tableName, + ]); useEffect(function openTheRecordWaitingOnTheNeighbouringPage() { if (!pendingRecordStep) { @@ -882,15 +900,28 @@ export function DatabaseTableView({ return; } if (pendingRecordStep.end === 'first') { + // A count that moved while the probe was out leaves its empty answer proving nothing. + if (pendingRecordStep.totalRecords !== totalRecords) { + return; + } // An empty page forward is proof there is nothing past the page we came from -- the only // proof available when that page was full. Backwards it means something else entirely // (records disappeared behind us), so it must not mark that page as the last one. - setKnownLastPage(pendingRecordStep.fromPage); + setKnownLastPage({ pageIndex: pendingRecordStep.fromPage, totalRecords }); toast.info('This is the last record'); return; } toast.info('This is the first record'); - }, [pendingRecordStep, pageRows, isPageError, isEditModalOpen, openRecordAt, setPageIndex, setKnownLastPage]); + }, [ + pendingRecordStep, + pageRows, + isPageError, + isEditModalOpen, + openRecordAt, + setPageIndex, + totalRecords, + setKnownLastPage, + ]); const onRowClick = (rowData: Row>) => { openRecordAt(rowData.index, rowData.original); From 480597bfe40a7055ccdd801329e9c99019e54df9 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Wed, 14 Oct 2026 11:53:48 -0400 Subject: [PATCH 06/21] fix(applications): settle an editor save after the page is gone, and save each file on its own A save settled in its `mutate()` callback, which TanStack Query drops once the page unmounts, so a save that landed after you left kept the file marked unsaved and its stale copy could be saved back later. It now settles in the mutation's own `onSuccess`, from the request alone, and cancels a read of the file that was already in flight so it can't put the old contents back. A save that lands after sign-out cleared the caches settles nothing. The cached size becomes the UTF-8 length of what was written, and the cached time is dropped, since set_component_file reports neither. The .env secrets view's whole-file write now goes through the same save (`writeFile`, which rejects instead of toasting), so it is tracked, refused like any other, and settles after the page is gone. "Saving" was one flag for the whole page, so Save on another file did nothing while one saved. Saves are now tracked per file in the mutation cache, so another file stays editable and saves alongside, and a second save of the same file is refused with a toast when it held newer changes. A visual schema put back to the saved one during a save stays unsaved, so the save landing no longer replaces it. A save and a rename or move of an instance's files no longer overlap: a save that landed after the rename read the file dropped the saved text with the original. `useRenameFiles` refuses while an editor save is in flight, `saveFile` refuses while a rename is, and the Rename dialog is disabled during a save, so F2 and the tree's menu wait like the toolbar did. A folder rename holds off saves until its old folder is dropped too. Closes #1945 Co-Authored-By: Claude Opus 5.5 --- DESIGN.md | 2 +- src/features/instance/applications/DESIGN.md | 25 +- .../ApplicationsSidebar/DropTarget.test.tsx | 2 + .../components/ContentActions.tsx | 7 +- .../EnvEditorView/EnvEditorView.test.tsx | 39 ++- .../components/EnvEditorView/index.tsx | 30 +-- .../SchemaEditorView.test.tsx | 55 ++++- .../components/SchemaEditorView/index.tsx | 28 +-- .../components/TextEditorView/index.tsx | 19 +- .../context/EditorViewContext.tsx | 6 +- .../EditorViewProvider.contents.test.tsx | 219 ++++++++++++++++- .../context/EditorViewProvider.test.tsx | 2 +- .../context/EditorViewProvider.tsx | 147 +++++++++--- .../applications/editorPathReuse.test.tsx | 227 ++++++++++++++++++ .../applications/hooks/useRenameFiles.tsx | 8 + .../applications/lib/editorSaveInterlock.ts | 28 +++ .../modals/DownloadApplicationModal.test.tsx | 2 + .../applications/modals/RenameFileModal.tsx | 12 +- .../instance/applications/getComponentFile.ts | 3 +- .../instance/applications/setComponentFile.ts | 2 +- 20 files changed, 762 insertions(+), 101 deletions(-) create mode 100644 src/features/instance/applications/lib/editorSaveInterlock.ts diff --git a/DESIGN.md b/DESIGN.md index 916f99a0a..c3fec3c59 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -12,7 +12,7 @@ This file collects architecture and design notes for studio that aren't otherwis - [`src/features/instance/databases/DESIGN.md`](src/features/instance/databases/DESIGN.md) — when the browse grid may show its spinner, why a table without a primary key can't be listed, why a filtered list pages without a count, why Export CSV writes the file in the browser instead of asking Harper for `text/csv`, where columns for undeclared properties come from, why a filter no index can answer asks first, and why an import job Studio stopped following reads as outcome unknown. -- [`src/features/instance/applications/DESIGN.md`](src/features/instance/applications/DESIGN.md) — what happens to a path's unsaved edits, loaded contents and Monaco model when its file is deleted, replaced or moved; when a rolling deploy is done: following its restart job, and why a job Studio can't follow is never a success; and when a live deploy may be sent again. +- [`src/features/instance/applications/DESIGN.md`](src/features/instance/applications/DESIGN.md) — what happens to a path's unsaved edits, loaded contents and Monaco model when its file is deleted, replaced or moved; how an editor save settles after the page is gone, one file at a time; when a rolling deploy is done: following its restart job, and why a job Studio can't follow is never a success; and when a live deploy may be sent again. - [`src/features/instance/config/roles/DESIGN.md`](src/features/instance/config/roles/DESIGN.md) — why the role editor's JSON text is the only source of truth, what forces the JSON view, and what Save changes on purpose. - [`src/router/DESIGN.md`](src/router/DESIGN.md) — why a failed route load is reported and shown only by its route, and why a preload still toasts. diff --git a/src/features/instance/applications/DESIGN.md b/src/features/instance/applications/DESIGN.md index 9ae788b97..38fcb3bab 100644 --- a/src/features/instance/applications/DESIGN.md +++ b/src/features/instance/applications/DESIGN.md @@ -46,12 +46,33 @@ Buffers are read and written through the session-storage store (`sessionStorageS `useSessionStorage` on a key renders, so an open editor or the tree's unsaved marker updates at once. Writing to session storage directly would leave a mounted editor showing the old buffer. The store keeps a write that storage refuses in memory, so a full storage doesn't lose edits mid-rename. The buffers under a folder are found from what -session storage persisted and from the keys the store holds only in memory (`refusedSessionKeys`). A save that lands -after another file was opened clears the saved file's buffer the same way, if it still equals what was saved. +session storage persisted and from the keys the store holds only in memory (`refusedSessionKeys`). The assistant's `setComponentFile` tool leaves the buffer alone: it writes to a file the person may be editing, which is a conflict between two editors rather than a path naming a different file. +## Saving a file + +The editor's save settles in the mutation's own `onSuccess`, from the request alone +([`EditorViewProvider`](context/EditorViewProvider.tsx)), so a save that lands after another file was opened, or after +the page is gone, still does it: it puts what was saved in the file's cached contents, and clears the file's buffer if +the buffer still equals what was saved. `set_component_file` reports neither a size nor a time, so the cached size is +the UTF-8 length of what was written and the time stays unknown until the next read. It cancels any read of the file +first, since a read sent before the save landed would answer with the old contents after it. A save that lands after +sign-out cleared the caches settles nothing. + +Every whole-file write of an open file is this save: the text and schema editors call `saveFile`, and the `.env` secrets +view calls `writeFile`, which rejects instead of toasting so its dialog reports the outcome. Saves are tracked per file +in the mutation cache, so another file can be edited and saved while one is in flight, and a remounted page still sees a +save it started. A second save of the same file is refused until the first settles, with a toast when it would have +written something newer. + +A rename or move reads a file, copies it and its buffer, then drops it, so a save that lands in between writes to the +file being dropped and clears the buffer the copy needs. Within a tab the two never overlap on an instance +([`editorSaveInterlock`](lib/editorSaveInterlock.ts)): `useRenameFiles` refuses while an editor save is in flight, and +the save refuses while a rename is, each saying why. Another tab's saves and renames are not seen (#1964). The Rename +dialog and the toolbar's Rename and Restart are disabled while a save is in flight. + ## When a deploy is done Studio deploys with `restart: 'rolling'`. The node that receives the deploy answers once its own part is done and hands diff --git a/src/features/instance/applications/components/ApplicationsSidebar/DropTarget.test.tsx b/src/features/instance/applications/components/ApplicationsSidebar/DropTarget.test.tsx index c136037b8..1f21f83bd 100644 --- a/src/features/instance/applications/components/ApplicationsSidebar/DropTarget.test.tsx +++ b/src/features/instance/applications/components/ApplicationsSidebar/DropTarget.test.tsx @@ -70,7 +70,9 @@ function mount() { selectedItems: [], setSelectedItems: () => {}, saveFile: () => {}, + writeFile: () => Promise.resolve(), isSavingFile: false, + isSavingAnyFile: false, }; return render( diff --git a/src/features/instance/applications/components/ContentActions.tsx b/src/features/instance/applications/components/ContentActions.tsx index 39fd53629..f5e942e62 100644 --- a/src/features/instance/applications/components/ContentActions.tsx +++ b/src/features/instance/applications/components/ContentActions.tsx @@ -78,7 +78,8 @@ export function ContentActions({ toggleSidebar: () => void; }) { const instanceParams = useInstanceClientIdParams(); - const { openedEntryContents, openedEntry, isSavingFile, restrictPackageModification } = useEditorView(); + const { openedEntryContents, openedEntry, isSavingFile, isSavingAnyFile, restrictPackageModification } = + useEditorView(); const { content: updatedFileContent } = useEditorFileContent(openedEntry?.path); const canManageBrowseInstance = useInstanceBrowseManagePermission(); const canManageInstance = useInstanceManagePermission(); @@ -258,7 +259,7 @@ export function ContentActions({ )} {canRename && ( - + Rename {SHORTCUTS.rename} @@ -392,7 +393,7 @@ export function ContentActions({ {canManageBrowseInstance && ( diff --git a/src/features/instance/applications/components/EnvEditorView/EnvEditorView.test.tsx b/src/features/instance/applications/components/EnvEditorView/EnvEditorView.test.tsx index 1bc7a5b8d..f58abf49c 100644 --- a/src/features/instance/applications/components/EnvEditorView/EnvEditorView.test.tsx +++ b/src/features/instance/applications/components/EnvEditorView/EnvEditorView.test.tsx @@ -1,6 +1,7 @@ /** * @vitest-environment jsdom */ +import { discardEditorFileContentIfSaved } from '@/features/instance/applications/context/editorFileContent'; import type { FileEntry } from '@/features/instance/applications/context/fileEntry'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; @@ -28,15 +29,21 @@ vi.mock('@/config/useInstanceClient', () => ({ useInstanceClientIdParams: () => ({ instanceClient, entityId: 'test-instance', entityType: 'instance' }), })); -vi.mock('@/features/instance/applications/hooks/useEditorView', () => ({ - useEditorView: () => ({ openedEntry }), -})); +// The provider's save, which the plaintext mode writes through, without the provider around it. +vi.mock('@/features/instance/applications/hooks/useEditorView', async () => { + const { setComponentFile } = await import('@/integrations/api/instance/applications/setComponentFile'); + const writeFile = vi.fn(setComponentFile); + return { useEditorView: () => ({ openedEntry, writeFile }) }; +}); // Avoids the router dependency of the real sessionStorage-backed hook; the raw-buffer // interplay it powers isn't under test here. const setContent = vi.fn(); +const buffer = vi.hoisted(() => ({ content: undefined as string | undefined })); vi.mock('@/features/instance/applications/context/editorFileContent', () => ({ - useEditorFileContent: () => ({ content: undefined, setContent }), + useEditorFileContent: () => ({ content: buffer.content, setContent }), + editorFileContentKey: (id: string, path: string) => `EditorFileContent/${id}}/${path}`, + discardEditorFileContentIfSaved: vi.fn(), })); vi.mock('@/hooks/usePermissions', () => ({ @@ -62,6 +69,7 @@ beforeAll(() => { afterEach(() => { cleanup(); vi.clearAllMocks(); + buffer.content = undefined; }); const PLAINTEXT_FILE = '# note\nAPI_KEY=secret123\nDB_URL=postgres://x\n'; @@ -121,6 +129,29 @@ describe('EnvEditorView (plaintext mode — Harper < 5.2 returns raw text)', () expect(screen.queryByText('secret123')).toBeNull(); }); + it('treats unsaved raw edits it wrote as saved, unless they changed since', async () => { + mockPlaintext(); + buffer.content = `${PLAINTEXT_FILE}EDITED=1\n`; + renderView(); + await screen.findByText('EDITED'); + + fireEvent.click(screen.getByRole('button', { name: /add/i })); + fireEvent.change(await screen.findByLabelText('Key'), { target: { value: 'NEW_KEY' } }); + fireEvent.change(screen.getByLabelText('Value'), { target: { value: 'x' } }); + const submit = screen.getByRole('button', { name: /add secret/i }); + await waitFor(() => expect(submit.hasAttribute('disabled')).toBe(false)); + fireEvent.click(submit); + + await waitFor(() => expect(vi.mocked(discardEditorFileContentIfSaved)).toHaveBeenCalledOnce()); + expect(vi.mocked(discardEditorFileContentIfSaved)).toHaveBeenCalledWith( + 'EditorFileContent/test-instance}/myapp/.env', + `${PLAINTEXT_FILE}EDITED=1\n`, + ); + expect(operationCalls('set_component_file')[0]).toMatchObject({ + payload: `${PLAINTEXT_FILE}EDITED=1\nNEW_KEY=x\n`, + }); + }); + it('adds a secret by rewriting the whole file, preserving comments and other keys', async () => { mockPlaintext(); renderView(); diff --git a/src/features/instance/applications/components/EnvEditorView/index.tsx b/src/features/instance/applications/components/EnvEditorView/index.tsx index 1b04f66db..438b0eaed 100644 --- a/src/features/instance/applications/components/EnvEditorView/index.tsx +++ b/src/features/instance/applications/components/EnvEditorView/index.tsx @@ -18,7 +18,11 @@ */ import { Button } from '@/components/ui/button'; import { useInstanceClientIdParams } from '@/config/useInstanceClient'; -import { useEditorFileContent } from '@/features/instance/applications/context/editorFileContent'; +import { + discardEditorFileContentIfSaved, + editorFileContentKey, + useEditorFileContent, +} from '@/features/instance/applications/context/editorFileContent'; import { useEditorView } from '@/features/instance/applications/hooks/useEditorView'; import { SecretRow, SecretsManager } from '@/features/instance/secrets/SecretsManager'; import { useInstanceBrowseManagePermission } from '@/hooks/usePermissions'; @@ -28,7 +32,6 @@ import { getComponentFileQueryOptions, GetComponentFileResponse, } from '@/integrations/api/instance/applications/getComponentFile'; -import { useSetComponentFile } from '@/integrations/api/instance/applications/setComponentFile'; import { parseEnv, removeEnvKeys, renderMaskedEnv, upsertEnvValues } from '@/lib/env/envFile'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { CodeIcon, ShieldCheckIcon, Table2Icon } from 'lucide-react'; @@ -38,7 +41,7 @@ import { TextEditorView } from '../TextEditorView'; export function EnvEditorView() { const instanceParams = useInstanceClientIdParams(); const queryClient = useQueryClient(); - const { openedEntry } = useEditorView(); + const { openedEntry, writeFile } = useEditorView(); const canManageBrowseInstance = useInstanceBrowseManagePermission(); const path = openedEntry?.path ?? ''; @@ -51,7 +54,7 @@ export function EnvEditorView() { // Unsaved edits from the raw text view. The secrets table is a GUI over the same buffer the // text editor shows, so it parses (and writes back) the dirty content when there is any. - const { content: updatedFileContent, setContent } = useEditorFileContent( + const { content: updatedFileContent } = useEditorFileContent( !!openedEntry && !openedEntry.package && path, ); @@ -71,22 +74,21 @@ export function EnvEditorView() { const canManage = !!openedEntry && !openedEntry.package && canManageBrowseInstance; /* - Persistence, per mode. Plaintext-mode writes go through the ordinary whole-file save and then - sync the query cache (the EditorViewProvider derives the opened contents from it). Protected- + Persistence, per mode. Plaintext-mode writes are the editor's own save, which settles the query + cache (the EditorViewProvider derives the opened contents from it) and waits for renames. Protected- mode writes go through the key-level operations, whose responses carry the resulting key list. */ - const { mutateAsync: writeComponentFile } = useSetComponentFile(); const { mutateAsync: setEnvValueAsync } = useSetEnvValue(); const { mutateAsync: deleteEnvValueAsync } = useDeleteEnvValue(); const writeWholeFile = useCallback(async (next: string) => { - await writeComponentFile({ ...instanceParams, project, file, payload: next }); - queryClient.setQueryData( - fileQueryKey, - (old) => old && { ...old, message: next }, - ); - setContent(undefined); // the raw buffer (dirty edits included) is now saved - }, [writeComponentFile, instanceParams, project, file, queryClient, fileQueryKey, setContent]); + const included = updatedFileContent; + await writeFile({ ...instanceParams, project, file, payload: next }); + if (included !== undefined) { + // The raw buffer went into the write, so it is saved, unless it was edited since. + discardEditorFileContentIfSaved(editorFileContentKey(instanceParams.entityId, path), included); + } + }, [updatedFileContent, writeFile, instanceParams, project, file, path]); const applyProtectedKeys = useCallback((keys: string[]) => { queryClient.setQueryData( diff --git a/src/features/instance/applications/components/SchemaEditorView/SchemaEditorView.test.tsx b/src/features/instance/applications/components/SchemaEditorView/SchemaEditorView.test.tsx index 09cb69200..d5ef2c343 100644 --- a/src/features/instance/applications/components/SchemaEditorView/SchemaEditorView.test.tsx +++ b/src/features/instance/applications/components/SchemaEditorView/SchemaEditorView.test.tsx @@ -13,6 +13,7 @@ import { readSessionValue, writeSessionValue } from '@/lib/storage/sessionStorag import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import type { AxiosInstance } from 'axios'; +import { toast } from 'sonner'; import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; const server = vi.hoisted(() => { @@ -72,7 +73,7 @@ vi.mock('@/features/instance/applications/components/TextEditorView', async () = useListener('SaveFile', () => { if (openedEntry && content !== undefined) { const file = openedEntry.path.split('/').slice(1).join('/'); - saveFile({ ...server.params, file, payload: content, project: openedEntry.project }, openedEntry.path); + saveFile({ ...server.params, file, payload: content, project: openedEntry.project }); } }, [openedEntry, content, saveFile]); return ( @@ -108,9 +109,11 @@ const brokenSchema: FileEntry = { name: 'broken.graphql', path: 'punch/schemas/b let queryClient: QueryClient; let open: (entry: DirectoryEntry | FileEntry) => void; +let view: ReturnType; function Opener() { - open = useEditorView().setOpenedEntry; + view = useEditorView(); + open = view.setOpenedEntry; return null; } @@ -389,4 +392,52 @@ describe('SchemaEditorView: a schema file only ever holds its own contents (#192 'type Thing @table(database: "punch") @export @sealed {\n\tid: ID @primaryKey\n}\n', ); }); + + it('says a Save pressed before the save in flight lands was not made, and makes the next one', async () => { + const notice = vi.spyOn(toast, 'info'); + renderEditor(); + await openEntry(newSchema, 'No tables yet.'); + fireEvent.click(screen.getByRole('button', { name: /add table/i })); + type('Table (type) name', 'Thing'); + let finishWrite!: () => void; + server.state.pendingWrite = new Promise(resolve => (finishWrite = resolve)); + act(() => emitToListeners('SaveFile', true)); + await waitFor(() => expect(view.isSavingFile).toBe(true)); + type('Database', 'punch'); + + act(() => emitToListeners('SaveFile', true)); + + expect(notice).toHaveBeenCalledWith('thing.graphql is still saving', expect.anything()); + await act(async () => finishWrite()); + await waitFor(() => expect(server.state.saves).toHaveLength(1)); + expect(server.state.saves[0].payload).toBe('type Thing @table @export @sealed {\n\tid: ID @primaryKey\n}\n'); + expect((await save()).payload).toBe( + 'type Thing @table(database: "punch") @export @sealed {\n\tid: ID @primaryKey\n}\n', + ); + }); + + it('keeps a schema put back to the saved one while another saves, and says that Save was not made', async () => { + const notice = vi.spyOn(toast, 'info'); + const saved = 'type Thing @table @export @sealed {\n\tid: ID @primaryKey\n}\n'; + renderEditor(); + server.state.files['schemas/thing.graphql'] = saved; + await openEntry(newSchema, 'Thing'); + fireEvent.click(screen.getByRole('button', { name: /Thing/ })); + type('Database', 'punch'); + let finishWrite!: () => void; + server.state.pendingWrite = new Promise(resolve => (finishWrite = resolve)); + act(() => emitToListeners('SaveFile', true)); + await waitFor(() => expect(view.isSavingFile).toBe(true)); + fireEvent.change(screen.getByLabelText('Database'), { target: { value: '' } }); + await waitFor(() => expect(buffered(newSchema.path)).toBe(saved)); + + act(() => emitToListeners('SaveFile', true)); + expect(notice).toHaveBeenCalledWith('thing.graphql is still saving', expect.anything()); + await act(async () => finishWrite()); + await waitFor(() => expect(view.isSavingFile).toBe(false)); + + expect(screen.getByLabelText('Database').value).toBe(''); + expect(buffered(newSchema.path)).toBe(saved); + expect((await save()).payload).toBe(saved); + }); }); diff --git a/src/features/instance/applications/components/SchemaEditorView/index.tsx b/src/features/instance/applications/components/SchemaEditorView/index.tsx index 1ce6a901a..de5f1a269 100644 --- a/src/features/instance/applications/components/SchemaEditorView/index.tsx +++ b/src/features/instance/applications/components/SchemaEditorView/index.tsx @@ -99,14 +99,17 @@ function LoadedSchemaEditorView({ if (showSource || readOnly) { return; } - setContent(serialized === savedContents ? undefined : serialized); - }, [serialized, showSource, readOnly, savedContents, setContent]); + // While a save is in flight the saved contents are about to change, so a schema back at the old ones + // is an edit to keep; cleared, the reconcile would replace it with what lands. + setContent(serialized === savedContents && !isSavingFile ? undefined : serialized); + }, [serialized, showSource, readOnly, savedContents, isSavingFile, setContent]); useListener( 'SaveFile', () => { - // The schema on screen is what is saved, not the shared buffer, which can trail it. - if (showSource || readOnly || !openedEntry || isSavingFile || serialized === savedContents) { + // The schema on screen is what is saved, not the shared buffer, which can trail it. Unchanged from + // the saved contents can still differ from a save in flight, which `saveFile` then says it refused. + if (showSource || readOnly || !openedEntry || (serialized === savedContents && !isSavingFile)) { return; } // Never write a schema the visual editor knows is invalid — it would break @@ -114,17 +117,14 @@ function LoadedSchemaEditorView({ if (validateSchema(doc).length > 0) { return; } - saveFile( - { - ...instanceParams, - file: openedEntry.path.split('/').slice(1).join('/'), - payload: serialized, - project: openedEntry.project, - }, - openedEntry.path, - ); + saveFile({ + ...instanceParams, + file: openedEntry.path.split('/').slice(1).join('/'), + payload: serialized, + project: openedEntry.project, + }); }, - [showSource, readOnly, openedEntry, savedContents, instanceParams, isSavingFile, doc, serialized], + [showSource, readOnly, openedEntry, savedContents, isSavingFile, instanceParams, doc, serialized, saveFile], ); useListener( diff --git a/src/features/instance/applications/components/TextEditorView/index.tsx b/src/features/instance/applications/components/TextEditorView/index.tsx index 9a6689c8e..e9586c637 100644 --- a/src/features/instance/applications/components/TextEditorView/index.tsx +++ b/src/features/instance/applications/components/TextEditorView/index.tsx @@ -154,19 +154,16 @@ export function TextEditorView() { useListener( 'SaveFile', () => { - if (openedEntry && !isSavingFile && updatedFileContent !== undefined) { - saveFile( - { - ...instanceParams, - file: openedEntry.path.split('/').slice(1).join('/'), - payload: updatedFileContent, - project: openedEntry.project, - }, - openedEntry.path, - ); + if (openedEntry && updatedFileContent !== undefined) { + saveFile({ + ...instanceParams, + file: openedEntry.path.split('/').slice(1).join('/'), + payload: updatedFileContent, + project: openedEntry.project, + }); } }, - [openedEntry, instanceParams, updatedFileContent], + [openedEntry, instanceParams, updatedFileContent, saveFile], ); useListener( diff --git a/src/features/instance/applications/context/EditorViewContext.tsx b/src/features/instance/applications/context/EditorViewContext.tsx index 5f6678a20..7c750891e 100644 --- a/src/features/instance/applications/context/EditorViewContext.tsx +++ b/src/features/instance/applications/context/EditorViewContext.tsx @@ -30,8 +30,12 @@ export type EditorViewContextValue = { selectedItems: TreeItemIndex[]; setSelectedItems: (entries: TreeItemIndex[] | ((prevState: TreeItemIndex[]) => TreeItemIndex[])) => void; - saveFile: (data: SetComponentFileRequest, filePath: string) => void; + /** Refused while a save of the same file is in flight; another file saves alongside it. */ + saveFile: (data: SetComponentFileRequest) => void; + /** `saveFile` for a caller that reports the outcome itself: rejects when refused or failed. */ + writeFile: (data: SetComponentFileRequest) => Promise; isSavingFile: boolean; + isSavingAnyFile: boolean; }; export const EditorViewContext = createContext(null); diff --git a/src/features/instance/applications/context/EditorViewProvider.contents.test.tsx b/src/features/instance/applications/context/EditorViewProvider.contents.test.tsx index ee7274e68..781edbc1c 100644 --- a/src/features/instance/applications/context/EditorViewProvider.contents.test.tsx +++ b/src/features/instance/applications/context/EditorViewProvider.contents.test.tsx @@ -8,6 +8,11 @@ import type { DirectoryEntry } from '@/features/instance/applications/context/di import { useEditorFileContent } from '@/features/instance/applications/context/editorFileContent'; import type { FileEntry } from '@/features/instance/applications/context/fileEntry'; import { useEditorView } from '@/features/instance/applications/hooks/useEditorView'; +import { startRename } from '@/features/instance/applications/lib/editorSaveInterlock'; +import { + getComponentFileQueryKey, + type GetComponentFileResponse, +} from '@/integrations/api/instance/applications/getComponentFile'; import { clearSessionStorage } from '@/lib/storage/clearSessionStorage'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { act, cleanup, render, waitFor } from '@testing-library/react'; @@ -23,8 +28,10 @@ const server = vi.hoisted(() => { const instanceClient = { post: async (_url: string, body: { operation: string; file?: string; payload?: string }) => { if (body.operation === 'get_component_file') { + // As the file was when the read reached the server. + const message = files[body.file!]; await reads.pending; - return { data: { message: files[body.file!], size: files[body.file!].length } }; + return { data: { message, size: message.length, mtime: 'when it was read' } }; } if (body.operation === 'set_component_file') { await writes.pending; @@ -102,7 +109,9 @@ function SuspendWhileOpen({ path }: { path: string }) { return null; } -function renderProvider(extra?: ReactNode) { +let queryClient: QueryClient; + +function renderProvider(extra?: ReactNode, client = new QueryClient()) { Object.assign(server.instances['instance-1'].files, { 'schemas/README.md': README, 'resources.js': SCRIPT, @@ -111,16 +120,18 @@ function renderProvider(extra?: ReactNode) { server.instances['instance-2'].files['resources.js'] = OTHER_SCRIPT; server.current = server.instances['instance-1']; renders = []; - const tree = ( - + queryClient = client; + // A new element each time: React skips rendering an element it was handed before. + const tree = () => ( + {extra} ); - const view = render(tree); - return { rerender: () => view.rerender(tree) }; + const view = render(tree()); + return { rerender: () => view.rerender(tree()) }; } async function openEntry(entry: DirectoryEntry | FileEntry, contents: string) { @@ -134,7 +145,7 @@ function startSave(payload: string) { const { files, params } = server.current; const entry = editorView.openedEntry!; const file = entry.path.split('/').slice(1).join('/'); - act(() => editorView.saveFile({ ...params, file, payload, project: entry.project }, entry.path)); + act(() => editorView.saveFile({ ...params, file, payload, project: entry.project })); return async () => { await act(async () => finishWrite()); await waitFor(() => expect(files[file]).toBe(payload)); @@ -142,6 +153,26 @@ function startSave(payload: string) { }; } +function cachedFile(entry: FileEntry) { + const file = entry.path.split('/').slice(1).join('/'); + return queryClient.getQueryData( + getComponentFileQueryKey({ ...server.current.params, project: entry.project, file }), + ); +} + +function cachedContents(entry: FileEntry) { + return cachedFile(entry)?.message; +} + +function writeScript(payload: string) { + return editorView.writeFile({ ...server.current.params, file: 'resources.js', payload, project: 'punch' }); +} + +function bufferedContents(entry: FileEntry) { + const key = Object.keys(sessionStorage).find(k => k.endsWith(`/${entry.path}`)); + return key && JSON.parse(sessionStorage.getItem(key)!); +} + afterEach(() => { cleanup(); clearSessionStorage(); @@ -237,8 +268,178 @@ describe('EditorViewProvider openedEntryContents', () => { await openEntry(newSchema, ''); await finishSave(); - const key = Object.keys(sessionStorage).find(k => k.endsWith(`/${script.path}`)); - expect(key && JSON.parse(sessionStorage.getItem(key)!)).toBe(left); + expect(bufferedContents(script)).toBe(left); + }); + + it('settles a save that lands after the page has gone', async () => { + renderProvider(); + await openEntry(script, SCRIPT); + act(() => buffer.setContent(SAVED_SCRIPT)); + const finishSave = startSave(SAVED_SCRIPT); + + cleanup(); + await finishSave(); + + expect(cachedContents(script)).toBe(SAVED_SCRIPT); + expect(bufferedContents(script)).toBeUndefined(); + }); + + it('caches the size that was written and no time, which the save does not report', async () => { + renderProvider(); + await openEntry(script, SCRIPT); + expect(cachedFile(script)).toMatchObject({ size: SCRIPT.length, mtime: 'when it was read' }); + const accented = 'export const café = 43;\n'; + + await startSave(accented)(); + + expect(cachedFile(script)).toMatchObject({ message: accented, size: accented.length + 1 }); + expect(cachedFile(script)?.mtime).toBeUndefined(); + }); + + it('writes through the same save, and settles it before the write resolves', async () => { + renderProvider(); + await openEntry(script, SCRIPT); + act(() => buffer.setContent(SAVED_SCRIPT)); + let finishWrite!: () => void; + server.writes.pending = new Promise(resolve => (finishWrite = resolve)); + let written!: Promise; + act(() => { + written = writeScript(SAVED_SCRIPT); + }); + await waitFor(() => expect(editorView.isSavingFile).toBe(true)); + const cachedOnceWritten = written.then(() => cachedContents(script)); + + await act(async () => finishWrite()); + + expect(await cachedOnceWritten).toBe(SAVED_SCRIPT); + expect(bufferedContents(script)).toBeUndefined(); + }); + + it.each([ + ['while that file is saving', 'is still saving'], + ['while a rename is in flight', 'was not saved'], + ])('refuses a write %s, and says why', async (when, reason) => { + renderProvider(); + await openEntry(script, SCRIPT); + const endRename = when.includes('rename') ? startRename(server.current.params.entityId) : undefined; + if (!endRename) { + startSave(SAVED_SCRIPT); + await waitFor(() => expect(editorView.isSavingFile).toBe(true)); + } + + await expect(writeScript('export const answer = 44;\n')).rejects.toThrow(`resources.js ${reason}`); + + endRename?.(); + expect(server.current.files['resources.js']).toBe(SCRIPT); + }); + + it('leaves nothing in the caches when sign-out clears them while the save cancels a read', async () => { + renderProvider(); + await openEntry(script, SCRIPT); + const finishSave = startSave(SAVED_SCRIPT); + cleanup(); + const cancel = queryClient.cancelQueries.bind(queryClient); + vi.spyOn(queryClient, 'cancelQueries').mockImplementation(filters => { + const cancelled = cancel(filters); + queryClient.getMutationCache().clear(); + queryClient.getQueryCache().clear(); + return cancelled; + }); + + await finishSave(); + + expect(vi.mocked(queryClient.cancelQueries)).toHaveBeenCalledOnce(); + expect(cachedContents(script)).toBeUndefined(); + }); + + it('leaves nothing of a save that lands after sign-out in the cleared caches', async () => { + renderProvider(); + await openEntry(script, SCRIPT); + const finishSave = startSave(SAVED_SCRIPT); + + cleanup(); + act(() => { + queryClient.getMutationCache().clear(); + queryClient.getQueryCache().clear(); + }); + await finishSave(); + + expect(cachedContents(script)).toBeUndefined(); + }); + + it("leaves the next session's read alone when a save from before sign-out lands", async () => { + renderProvider(); + await openEntry(script, SCRIPT); + const finishSave = startSave(SAVED_SCRIPT); + cleanup(); + act(() => { + queryClient.getMutationCache().clear(); + queryClient.getQueryCache().clear(); + }); + let finishRead!: () => void; + server.reads.pending = new Promise(resolve => (finishRead = resolve)); + renderProvider(undefined, queryClient); + act(() => editorView.setOpenedEntry(script)); + await waitFor(() => expect(queryClient.isFetching()).toBe(1)); + + await finishSave(); + await act(async () => finishRead()); + + await waitFor(() => expect(editorView.openedEntryContents).toBe(SCRIPT)); + }); + + it('keeps what was saved when a read already in flight lands after the save', async () => { + renderProvider(); + await openEntry(script, SCRIPT); + let finishRead!: () => void; + server.reads.pending = new Promise(resolve => (finishRead = resolve)); + act(() => void queryClient.invalidateQueries({ queryKey: [server.current.params.entityId, 'get_component_file'] })); + await waitFor(() => expect(queryClient.isFetching()).toBe(1)); + + await startSave(SAVED_SCRIPT)(); + await act(async () => finishRead()); + + expect(cachedContents(script)).toBe(SAVED_SCRIPT); + expect(editorView.openedEntryContents).toBe(SAVED_SCRIPT); + }); + + it('keeps the unsaved copy, and lets it be saved again, when the save fails', async () => { + renderProvider(); + await openEntry(script, SCRIPT); + act(() => buffer.setContent(SAVED_SCRIPT)); + let failWrite!: () => void; + server.writes.pending = new Promise(( + _, + reject, + ) => (failWrite = () => reject(new Error('Request failed with status code 503')))); + act(() => + editorView.saveFile({ ...server.current.params, file: 'resources.js', payload: SAVED_SCRIPT, project: 'punch' }) + ); + await waitFor(() => expect(editorView.isSavingFile).toBe(true)); + + await act(async () => failWrite()); + + await waitFor(() => expect(editorView.isSavingFile).toBe(false)); + expect(bufferedContents(script)).toBe(SAVED_SCRIPT); + expect(cachedContents(script)).toBe(SCRIPT); + server.writes.pending = undefined; + await startSave(SAVED_SCRIPT)(); + expect(bufferedContents(script)).toBeUndefined(); + }); + + it('does not hold up the same path on another instance while it saves', async () => { + const { rerender } = renderProvider(); + await openEntry(script, SCRIPT); + const finishSave = startSave(SAVED_SCRIPT); + await waitFor(() => expect(editorView.isSavingFile).toBe(true)); + + server.current = server.instances['instance-2']; + rerender(); + await waitFor(() => expect(editorView.openedEntryContents).toBe(OTHER_SCRIPT)); + + expect(editorView.isSavingFile).toBe(false); + expect(editorView.isSavingAnyFile).toBe(false); + await finishSave(); }); it('ignores a contents setter kept from an entry that is no longer open', async () => { diff --git a/src/features/instance/applications/context/EditorViewProvider.test.tsx b/src/features/instance/applications/context/EditorViewProvider.test.tsx index e016778b6..d47f68c2f 100644 --- a/src/features/instance/applications/context/EditorViewProvider.test.tsx +++ b/src/features/instance/applications/context/EditorViewProvider.test.tsx @@ -9,7 +9,6 @@ vi.mock('@/config/useInstanceClient', () => ({ useInstanceClientIdParams: () => ({ entityId: 'instance-1', entityType: 'instance' }), })); vi.mock('@/features/instance/applications/context/editorFileContent', () => ({ - useEditorFileContentId: () => 'instance-1', editorFileContentKey: (id: string, path: string) => `EditorFileContent/${id}}/${path}`, discardEditorFileContentIfSaved: vi.fn(), })); @@ -23,6 +22,7 @@ vi.mock('@/integrations/api/instance/applications/setComponentFile', () => ({ vi.mock('@/lib/events/listener', () => ({ useListener: vi.fn() })); vi.mock('@tanstack/react-query', () => ({ queryOptions: (options: unknown) => options, + useMutationState: () => [], useQuery: () => ({ data: undefined }), useQueryClient: () => ({ fetchQuery: vi.fn(), setQueryData: vi.fn() }), })); diff --git a/src/features/instance/applications/context/EditorViewProvider.tsx b/src/features/instance/applications/context/EditorViewProvider.tsx index f123d9028..df9e0f67b 100644 --- a/src/features/instance/applications/context/EditorViewProvider.tsx +++ b/src/features/instance/applications/context/EditorViewProvider.tsx @@ -7,8 +7,12 @@ import { newApplication } from '@/features/instance/applications/components/Appl import { discardEditorFileContentIfSaved, editorFileContentKey, - useEditorFileContentId, } from '@/features/instance/applications/context/editorFileContent'; +import { + editorSaveKey, + editorSavesInFlight, + isRenaming, +} from '@/features/instance/applications/lib/editorSaveInterlock'; import { parseReadMe } from '@/features/instance/applications/lib/parseReadMe'; import { useSessionStorage } from '@/hooks/useSessionStorage'; import { @@ -24,10 +28,11 @@ import { import { useListener } from '@/lib/events/listener'; import { setWatchedValue } from '@/lib/events/watcher'; import { isBinaryFile } from '@/lib/string/binaryFileType'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useMutationState, useQuery, useQueryClient } from '@tanstack/react-query'; import { useNavigate, useSearch } from '@tanstack/react-router'; import { PropsWithChildren, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { TreeItemIndex } from 'react-complex-tree/src/types'; +import { toast } from 'sonner'; import { DirectoryEntry } from './directoryEntry'; import { EditorViewContext, EditorViewContextValue } from './EditorViewContext'; import { FileEntry } from './fileEntry'; @@ -45,6 +50,18 @@ function entryKeyOf(entityId: string, path: string | undefined) { return path === undefined ? undefined : `${entityId}:${path}`; } +interface SaveRefusal { + id: string; + title: string; + description: string; + /** A save of the same text is already in flight, so nothing is lost. */ + quiet: boolean; +} + +function savedEntryKeyOf({ entityId, project, file }: SetComponentFileRequest) { + return entryKeyOf(entityId, `${project}/${file}`); +} + export function EditorViewProvider({ children }: PropsWithChildren) { const navigate = useNavigate(); const instanceParams = useInstanceClientIdParams(); @@ -69,7 +86,6 @@ export function EditorViewProvider({ children }: PropsWithChildren) { : contents, })); }, [openedEntryKey]); - const editorFileContentId = useEditorFileContentId(); const baseURL = useEntityRestURL(); const queryClient = useQueryClient(); const { open }: { open?: string } = useSearch({ strict: false }); @@ -170,11 +186,6 @@ export function EditorViewProvider({ children }: PropsWithChildren) { // risks choking the editor. Leave the file undefined to disable the query. const isBinaryEntry = !!openedEntry && !isDirectory(openedEntry) && isBinaryFile(openedEntry.name); const fileToLoad = isBinaryEntry ? undefined : pathToLoad?.split('/').slice(1).join('/'); - const fileQueryKey = getComponentFileQueryKey({ - file: fileToLoad, - project: projectToLoad, - ...instanceParams, - }); const { data: getComponentFileQueryData, isError: openedEntryReadFailed } = useQuery( getComponentFileQueryOptions( { @@ -203,35 +214,97 @@ export function EditorViewProvider({ children }: PropsWithChildren) { /* Save changes. */ - const { mutate: saveComponentFile, isPending: isSavingFile } = useSetComponentFile(); + const settleSave = useCallback(async (_response: unknown, saved: SetComponentFileRequest) => { + const { entityId, instanceClient, project, file, payload } = saved; + if (payload === undefined) { + return; + } + // Sign-out clears the mutation cache, and nothing from before it may reach the next session's caches. + const signedOut = () => + !queryClient.getMutationCache().getAll().some(mutation => mutation.state.variables === saved); + if (signedOut()) { + return; + } + const queryKey = getComponentFileQueryKey({ entityId, instanceClient, project, file }); + // A read sent before the save landed would otherwise put the old contents back. + await queryClient.cancelQueries({ queryKey, exact: true }); + if (signedOut()) { + return; + } + // With the file's identity, which a cache entry that was never read lacks. The response reports no + // size or time: the size is what was written, in UTF-8 as Harper writes it, and the time is unknown. + queryClient.setQueryData( + queryKey, + previous => + ({ + ...previous, + project, + file, + message: payload, + size: new TextEncoder().encode(payload).byteLength, + mtime: undefined, + }) as GetComponentFileResponse, + ); + discardEditorFileContentIfSaved(editorFileContentKey(entityId, `${project}/${file}`), payload); + const savedKey = savedEntryKeyOf(saved); + if (openedEntryKeyRef.current === savedKey) { + // '' included: an emptied file is loaded, not unknown. + setLoadedContents({ entryKey: savedKey, contents: payload }); + } + }, [queryClient]); + const { mutate: saveComponentFile, mutateAsync: writeComponentFile } = useSetComponentFile({ + mutationKey: editorSaveKey, + onSuccess: settleSave, + }); + const savingEntryKeys = useMutationState({ + filters: { mutationKey: editorSaveKey, status: 'pending' }, + select: mutation => savedEntryKeyOf(mutation.state.variables as SetComponentFileRequest), + }); + const isSavingFile = openedEntryKey !== undefined && savingEntryKeys.includes(openedEntryKey); + const isSavingAnyFile = savingEntryKeys.some(key => key?.startsWith(`${instanceParams.entityId}:`)); + const refusalOf = useCallback((data: SetComponentFileRequest): SaveRefusal | undefined => { + const savedKey = savedEntryKeyOf(data); + const name = data.file.split('/').at(-1); + if (isRenaming(data.entityId)) { + return { + id: `save-during-rename:${savedKey}`, + title: `${name} was not saved`, + description: 'A rename is in progress. Save again once it finishes.', + quiet: false, + }; + } + const inFlight = editorSavesInFlight(queryClient).filter(saving => savedEntryKeyOf(saving) === savedKey); + if (inFlight.length > 0) { + return { + id: `save-in-flight:${savedKey}`, + title: `${name} is still saving`, + description: 'Your newer changes were not saved. Save again once this save finishes.', + quiet: inFlight.some(saving => saving.payload === data.payload), + }; + } + }, [queryClient]); const saveFile = useCallback( - (data: SetComponentFileRequest, filePath: string) => { - const savedKey = entryKeyOf(instanceParams.entityId, filePath); - const bufferKey = editorFileContentKey(editorFileContentId, filePath); - saveComponentFile(data, { - onSuccess: () => { - const { payload } = data; - if (payload === undefined) { - return; - } - // With the file's identity, which a cache entry that was never read lacks. - queryClient.setQueryData( - fileQueryKey, - previous => - ({ ...previous, project: data.project, file: data.file, message: payload }) as GetComponentFileResponse, - ); - discardEditorFileContentIfSaved(bufferKey, payload); - if (openedEntryKeyRef.current === savedKey) { - // '' included: an emptied file is loaded, not unknown. - setLoadedContents({ entryKey: savedKey, contents: payload }); - } - }, - onError: (error) => { - console.error('Error saving file:', error); - }, - }); + (data: SetComponentFileRequest) => { + const refusal = refusalOf(data); + if (refusal) { + if (!refusal.quiet) { + toast.info(refusal.title, { id: refusal.id, description: refusal.description }); + } + return; + } + saveComponentFile(data); + }, + [refusalOf, saveComponentFile], + ); + const writeFile = useCallback( + async (data: SetComponentFileRequest) => { + const refusal = refusalOf(data); + if (refusal) { + throw new Error(`${refusal.title}. ${refusal.description}`); + } + await writeComponentFile(data); }, - [editorFileContentId, fileQueryKey, instanceParams.entityId, queryClient, saveComponentFile], + [refusalOf, writeComponentFile], ); const restrictPackageModification = useMemo(() => isProtectedEntry(openedEntry), [openedEntry]); @@ -261,7 +334,9 @@ export function EditorViewProvider({ children }: PropsWithChildren) { openedEntryReadFailed, saveFile, + writeFile, isSavingFile, + isSavingAnyFile, }; }, [ mappedData.rootEntries, @@ -284,7 +359,9 @@ export function EditorViewProvider({ children }: PropsWithChildren) { openedEntryReadFailed, saveFile, + writeFile, isSavingFile, + isSavingAnyFile, ]); return {children}; } diff --git a/src/features/instance/applications/editorPathReuse.test.tsx b/src/features/instance/applications/editorPathReuse.test.tsx index 060bef878..33024992c 100644 --- a/src/features/instance/applications/editorPathReuse.test.tsx +++ b/src/features/instance/applications/editorPathReuse.test.tsx @@ -21,6 +21,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { act, cleanup, configure, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { AxiosError, type AxiosInstance, type InternalAxiosRequestConfig } from 'axios'; import type { ReactNode } from 'react'; +import { toast } from 'sonner'; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; const harness = vi.hoisted(() => { @@ -535,6 +536,7 @@ function bufferAt(path: string, entity = 'ins-1') { afterEach(() => { cleanup(); + vi.restoreAllMocks(); }); describe('a deleted file re-created at the same path', () => { @@ -808,6 +810,231 @@ describe('a file opened while another is saving', () => { }); }); +describe('a file saved while another is saving', () => { + it('takes typing and saves without waiting for the other', async () => { + seed({ 'app/a.js': 'saved a', 'app/b.js': 'saved b' }); + renderEditor(); + await openFile('app/a.js', 'saved a'); + type('edits to a'); + const landed = hold('set_component_file', 'app/a.js'); + save(); + await waitFor(() => expect(view.isSavingFile).toBe(true)); + await openFile('app/b.js', 'saved b'); + + await waitFor(() => expect(editor().readOnly).toBe(false)); + expect(view.isSavingAnyFile).toBe(true); + type('edits to b'); + save(); + + await waitFor(() => expect(files.get('app/b.js')).toBe('edits to b')); + expect(files.get('app/a.js')).toBe('saved a'); + await landed(); + await waitFor(() => expect(files.get('app/a.js')).toBe('edits to a')); + }); +}); + +describe('Save pressed again while that file is saving', () => { + it('sends nothing more, and says nothing, when there is nothing newer to save', async () => { + const notice = vi.spyOn(toast, 'info'); + seed({ 'app/a.js': 'saved a' }); + renderEditor(); + await openFile('app/a.js', 'saved a'); + type('edits to a'); + const landed = hold('set_component_file', 'app/a.js'); + save(); + await waitFor(() => expect(view.isSavingFile).toBe(true)); + + save(); + await landed(); + + await waitFor(() => expect(files.get('app/a.js')).toBe('edits to a')); + expect(writesOf('app/a.js')).toEqual(['edits to a']); + expect(notice).not.toHaveBeenCalled(); + }); +}); + +describe('a rename while a file is saving', () => { + it('waits for the save to land, from any way of opening it', async () => { + seed({ 'app/a.js': 'saved a' }); + renderEditor(); + await openFile('app/a.js', 'saved a'); + type('edits to a'); + const landed = hold('set_component_file', 'app/a.js'); + save(); + await waitFor(() => expect(view.isSavingFile).toBe(true)); + + act(() => setWatchedValue('ShowRenameFileModal', true)); + const input = await screen.findByLabelText('Name'); + fireEvent.change(input, { target: { value: 'c.js' } }); + fireEvent.submit(input.closest('form')!); + await act(async () => {}); + + expect(screen.getByRole('button', { name: 'Rename' })).toHaveProperty('disabled', true); + expect(writesOf('app/c.js')).toEqual([]); + await landed(); + await waitFor(() => expect(screen.getByRole('button', { name: 'Rename' })).toHaveProperty('disabled', false)); + fireEvent.submit(input.closest('form')!); + await waitFor(() => expect(files.get('app/c.js')).toBe('edits to a')); + expect(files.has('app/a.js')).toBe(false); + }); +}); + +describe('a save while a rename is in flight', () => { + it('is refused, so the edits move with the file instead of being lost with the original', async () => { + const notice = vi.spyOn(toast, 'info'); + seed({ 'app/a.js': 'saved a' }); + renderEditor(); + await openFile('app/a.js', 'saved a'); + type('edits to a'); + const copied = hold('set_component_file', 'app/c.js'); + + act(() => setWatchedValue('ShowRenameFileModal', true)); + const input = await screen.findByLabelText('Name'); + fireEvent.change(input, { target: { value: 'c.js' } }); + fireEvent.submit(input.closest('form')!); + await waitFor(() => expect(harness.holds.size).toBe(0)); + save(); + await copied(); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + + expect(notice).toHaveBeenCalledWith('a.js was not saved', expect.anything()); + expect(writesOf('app/a.js')).toEqual([]); + expect(files.has('app/a.js')).toBe(false); + expect(bufferAt('app/c.js')).toBe('edits to a'); + }); +}); + +describe('a save while a renamed folder is dropped', () => { + it('is refused until the old folder is gone', async () => { + const notice = vi.spyOn(toast, 'info'); + seed({ 'app/lib/x.js': 'saved x', 'app/b.js': 'saved b' }); + renderEditor(); + await open('app/lib'); + const dropped = hold('drop_component', 'app/lib'); + + act(() => setWatchedValue('ShowRenameFileModal', true)); + const input = await screen.findByLabelText('Name'); + fireEvent.change(input, { target: { value: 'src' } }); + fireEvent.submit(input.closest('form')!); + await waitFor(() => expect(harness.holds.size).toBe(0)); + act(() => view.saveFile({ ...harness.instanceParams, project: 'app', file: 'b.js', payload: 'edits to b' })); + + expect(notice).toHaveBeenCalledWith('b.js was not saved', expect.anything()); + await dropped(); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(writesOf('app/b.js')).toEqual([]); + }); +}); + +describe('a move while a file is saving', () => { + it('is refused until the save lands', async () => { + const notice = vi.spyOn(toast, 'info'); + seed({ 'app/a.js': 'saved a', 'app/lib/z.js': 'saved z' }); + renderEditor(); + await openFile('app/a.js', 'saved a'); + type('edits to a'); + const landed = hold('set_component_file', 'app/a.js'); + save(); + await waitFor(() => expect(view.isSavingFile).toBe(true)); + + await act(() => harness.tree.onDrop!([{ index: 'app/a.js' }], { targetType: 'item', targetItem: 'app/lib' })); + + expect(notice).toHaveBeenCalledWith('Nothing was renamed', expect.anything()); + expect(files.has('app/lib/a.js')).toBe(false); + await landed(); + await waitFor(() => expect(view.isSavingAnyFile).toBe(false)); + await act(() => harness.tree.onDrop!([{ index: 'app/a.js' }], { targetType: 'item', targetItem: 'app/lib' })); + expect(files.get('app/lib/a.js')).toBe('edits to a'); + }); + + it('goes ahead on another instance', async () => { + const notice = vi.spyOn(toast, 'info'); + seed({ 'app/a.js': 'saved a', 'app/lib/z.js': 'saved z' }); + const { rerender } = renderEditor(); + await openFile('app/a.js', 'saved a'); + type('edits to a'); + const landed = hold('set_component_file', 'app/a.js'); + save(); + await waitFor(() => expect(view.isSavingFile).toBe(true)); + act(() => switchInstance(rerender, 'ins-2')); + await waitFor(() => expect(view.entryExists('app/lib')).toBe(true)); + + await act(() => harness.tree.onDrop!([{ index: 'app/a.js' }], { targetType: 'item', targetItem: 'app/lib' })); + + expect(notice).not.toHaveBeenCalledWith('Nothing was renamed', expect.anything()); + await waitFor(() => expect(files.has('app/lib/a.js')).toBe(true)); + await landed(); + }); +}); + +describe('a .env file written from the secrets view', () => { + async function addSecret(key: string, value: string) { + fireEvent.click(screen.getByRole('button', { name: /add/i })); + fireEvent.change(await screen.findByLabelText('Key'), { target: { value: key } }); + fireEvent.change(screen.getByLabelText('Value'), { target: { value } }); + const submit = screen.getByRole('button', { name: /add secret/i }); + await waitFor(() => expect(submit.hasAttribute('disabled')).toBe(false)); + fireEvent.click(submit); + } + + it('counts as a save, so a move waits for it', async () => { + const notice = vi.spyOn(toast, 'info'); + seed({ 'app/.env': 'API_KEY=secret123\n', 'app/lib/z.js': 'saved z' }); + renderEditor(); + await open('app/.env'); + await screen.findByText('API_KEY'); + const landed = hold('set_component_file', 'app/.env'); + + await addSecret('NEW_KEY', 'value'); + await waitFor(() => expect(harness.holds.size).toBe(0)); + await waitFor(() => expect(view.isSavingFile).toBe(true)); + await act(() => harness.tree.onDrop!([{ index: 'app/.env' }], { targetType: 'item', targetItem: 'app/lib' })); + + expect(notice).toHaveBeenCalledWith('Nothing was renamed', expect.anything()); + expect(files.has('app/lib/.env')).toBe(false); + await landed(); + await waitFor(() => expect(files.get('app/.env')).toBe('API_KEY=secret123\nNEW_KEY=value\n')); + }); + + it('is refused during a rename, with the reason', async () => { + const failure = vi.spyOn(toast, 'error'); + seed({ 'app/.env': 'API_KEY=secret123\n', 'app/lib/z.js': 'saved z' }); + renderEditor(); + await open('app/.env'); + await screen.findByText('API_KEY'); + const copied = hold('set_component_file', 'app/lib/.env'); + let move: Promise | undefined; + act(() => { + move = harness.tree.onDrop!([{ index: 'app/.env' }], { targetType: 'item', targetItem: 'app/lib' }); + }); + await waitFor(() => expect(harness.holds.size).toBe(0)); + + await addSecret('NEW_KEY', 'value'); + + await waitFor(() => expect(failure).toHaveBeenCalledWith(expect.stringContaining('.env was not saved'))); + expect(writesOf('app/.env')).toEqual([]); + await copied(); + await act(() => move!); + }); +}); + +describe('Save pressed twice at once', () => { + it('sends one write', async () => { + seed({ 'app/a.js': 'saved a' }); + renderEditor(); + await openFile('app/a.js', 'saved a'); + type('edits to a'); + + act(() => { + emitToListeners('SaveFile', true); + emitToListeners('SaveFile', true); + }); + + await waitFor(() => expect(files.get('app/a.js')).toBe('edits to a')); + expect(writesOf('app/a.js')).toEqual(['edits to a']); + }); +}); + describe('a renamed file', () => { it('takes its unsaved edits to the new name and leaves none at the old one', async () => { seed({ 'app/a.js': 'saved a', 'app/b.js': 'saved b' }); diff --git a/src/features/instance/applications/hooks/useRenameFiles.tsx b/src/features/instance/applications/hooks/useRenameFiles.tsx index 0dabf1a8a..6bf1a5484 100644 --- a/src/features/instance/applications/hooks/useRenameFiles.tsx +++ b/src/features/instance/applications/hooks/useRenameFiles.tsx @@ -12,6 +12,7 @@ import { forgetReplacedPath, moveEditedPath, } from '@/features/instance/applications/lib/editorPathState'; +import { editorSavesInFlight, startRename } from '@/features/instance/applications/lib/editorSaveInterlock'; import { dropComponent } from '@/integrations/api/instance/applications/dropComponent'; import { getComponentFile } from '@/integrations/api/instance/applications/getComponentFile'; import { setComponentFile } from '@/integrations/api/instance/applications/setComponentFile'; @@ -39,6 +40,10 @@ export function useRenameFiles() { if (actualChanges.length === 0) { return true; } + if (editorSavesInFlight(queryClient).some(saving => saving.entityId === instanceParams.entityId)) { + toast.info('Nothing was renamed', { description: 'A save is in progress. Rename again once it finishes.' }); + return false; + } const toastTitle = `Renaming ${pluralize(actualChanges.length, 'File', 'Files')}`; const toastConfig: ExternalToast = { @@ -70,6 +75,7 @@ export function useRenameFiles() { }); } + const endRename = startRename(instanceParams.entityId); try { for (const change of actualChanges) { const oldParts = change.from.split('/'); @@ -142,6 +148,8 @@ export function useRenameFiles() { }); void reloadRootEntries(); return false; + } finally { + endRename(); } if (currentStep >= totalSteps) { diff --git a/src/features/instance/applications/lib/editorSaveInterlock.ts b/src/features/instance/applications/lib/editorSaveInterlock.ts new file mode 100644 index 000000000..9ca015317 --- /dev/null +++ b/src/features/instance/applications/lib/editorSaveInterlock.ts @@ -0,0 +1,28 @@ +/** The rules are in `../DESIGN.md`, under "Saving a file". */ +import type { SetComponentFileRequest } from '@/integrations/api/instance/applications/setComponentFile'; +import type { QueryClient } from '@tanstack/react-query'; + +export const editorSaveKey = ['set_component_file', 'editor'] as const; + +export function editorSavesInFlight(queryClient: QueryClient): SetComponentFileRequest[] { + return queryClient.getMutationCache().findAll({ mutationKey: editorSaveKey, status: 'pending' }) + .map(mutation => mutation.state.variables as SetComponentFileRequest); +} + +const renamesInFlight = new Map(); + +export function startRename(entityId: string): () => void { + renamesInFlight.set(entityId, (renamesInFlight.get(entityId) ?? 0) + 1); + return () => { + const remaining = (renamesInFlight.get(entityId) ?? 1) - 1; + if (remaining > 0) { + renamesInFlight.set(entityId, remaining); + } else { + renamesInFlight.delete(entityId); + } + }; +} + +export function isRenaming(entityId: string): boolean { + return renamesInFlight.has(entityId); +} diff --git a/src/features/instance/applications/modals/DownloadApplicationModal.test.tsx b/src/features/instance/applications/modals/DownloadApplicationModal.test.tsx index b641f7709..10b165e97 100644 --- a/src/features/instance/applications/modals/DownloadApplicationModal.test.tsx +++ b/src/features/instance/applications/modals/DownloadApplicationModal.test.tsx @@ -62,7 +62,9 @@ function mount(files: FileEntry[]) { selectedItems: [], setSelectedItems: () => {}, saveFile: () => {}, + writeFile: () => Promise.resolve(), isSavingFile: false, + isSavingAnyFile: false, } as unknown as EditorViewContextValue; return render( diff --git a/src/features/instance/applications/modals/RenameFileModal.tsx b/src/features/instance/applications/modals/RenameFileModal.tsx index fb286a4e9..c9d57e787 100644 --- a/src/features/instance/applications/modals/RenameFileModal.tsx +++ b/src/features/instance/applications/modals/RenameFileModal.tsx @@ -21,6 +21,7 @@ import { isDirectory } from '@/features/instance/applications/context/isDirector import { useEditorView } from '@/features/instance/applications/hooks/useEditorView'; import { useRenameFiles } from '@/features/instance/applications/hooks/useRenameFiles'; import { forgetDeletedPath } from '@/features/instance/applications/lib/editorPathState'; +import { startRename } from '@/features/instance/applications/lib/editorSaveInterlock'; import { dropComponent } from '@/integrations/api/instance/applications/dropComponent'; import { setComponentFile } from '@/integrations/api/instance/applications/setComponentFile'; import { isUnansweredWrite } from '@/integrations/api/unansweredWrite'; @@ -50,7 +51,7 @@ export function RenameFileModal() { attemptToRestoreFocus(trigger); }, [trigger]); - const { openedEntry, entryExists, reloadRootEntries } = useEditorView(); + const { openedEntry, entryExists, reloadRootEntries, isSavingAnyFile } = useEditorView(); const instanceParams = useInstanceClientIdParams(); const queryClient = useQueryClient(); const RenameFileSchema = z.object({ @@ -116,6 +117,8 @@ export function RenameFileModal() { // drop the old one. `relative` strips the leading project segment. if (renamed && isDir) { const relative = (path: string) => path.split('/').slice(1).join('/'); + // Saves still wait while the old folder is dropped: that drop is part of the rename. + const endRename = startRename(instanceParams.entityId); try { if (changes.length === 0) { await setComponentFile({ @@ -136,6 +139,8 @@ export function RenameFileModal() { toast.error(title, { description }); setIsPending(false); return; + } finally { + endRename(); } } @@ -209,6 +214,9 @@ export function RenameFileModal() { )} /> + {isSavingAnyFile && ( +

Rename once the save in progress finishes.

+ )}
@@ -218,7 +226,7 @@ export function RenameFileModal() { diff --git a/src/integrations/api/instance/applications/getComponentFile.ts b/src/integrations/api/instance/applications/getComponentFile.ts index 63f7780a2..dfe8db04b 100644 --- a/src/integrations/api/instance/applications/getComponentFile.ts +++ b/src/integrations/api/instance/applications/getComponentFile.ts @@ -13,7 +13,8 @@ export interface GetComponentFileResponse { file: string; birthtime: string; message: string; - mtime: string; + /** Unknown once an editor save has set the entry, since `set_component_file` doesn't report it. */ + mtime?: string; size: number; /** * Harper >= 5.2 marks secret-bearing `.env` files as protected: `message` is a value-free diff --git a/src/integrations/api/instance/applications/setComponentFile.ts b/src/integrations/api/instance/applications/setComponentFile.ts index eb1527b57..3dfd4a254 100644 --- a/src/integrations/api/instance/applications/setComponentFile.ts +++ b/src/integrations/api/instance/applications/setComponentFile.ts @@ -28,7 +28,7 @@ export async function setComponentFile({ } export function useSetComponentFile( - options?: Pick, 'onSuccess'>, + options?: Pick, 'onSuccess' | 'mutationKey'>, ) { return useMutation({ mutationFn: setComponentFile, From 7d1b955f0a1e5a1196bbc149a5aed23e5e507cde Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Wed, 14 Oct 2026 12:03:43 -0400 Subject: [PATCH 07/21] fix(applications): clear the open file's undo history when it is replaced with the same text The text editor dropped a model's history only when the text it was handed changed without being typed. Replacing the open file with the text it already showed (type B, save, upload B) changed nothing it was handed, and the reload can even land before the provider shows the contents missing. So Ctrl+Z brought back the earlier text and Save wrote it over the upload. editorPathState now counts each delete, replace and move away per path, and the text editor clears the attached model's history once the contents after a counted replacement are loaded, whatever the text. It tracks per model which file and count the history follows, so a switch to another file and back before the old model is disposed still clears it, and the same path on another instance, which shares the model, no longer undoes into this instance's file. Typing, saving and switching files keep their history. The editor also subscribes to its file's query for its read-only check: a reload that brings back the text the provider already holds changed nothing else it rendered from, which could leave it read-only. The test double now applies a `value` to a read-only editor with `setValue` even when the text is unchanged, as @monaco-editor/react 4.7.0 does, and a test on real Monaco pins that `setValue` with unchanged text drops the model's undo history. Closes #1951 Co-Authored-By: Claude Opus 5.5 --- src/features/instance/applications/DESIGN.md | 12 +- .../components/TextEditorView/index.tsx | 69 ++++++-- .../TextEditorView/setValueHistory.test.ts | 20 +++ .../applications/editorPathReuse.test.tsx | 167 ++++++++++++++++-- .../applications/lib/editorPathState.test.ts | 44 ++++- .../applications/lib/editorPathState.ts | 36 ++++ 6 files changed, 313 insertions(+), 35 deletions(-) create mode 100644 src/features/instance/applications/components/TextEditorView/setValueHistory.test.ts diff --git a/src/features/instance/applications/DESIGN.md b/src/features/instance/applications/DESIGN.md index 38fcb3bab..4329c37c2 100644 --- a/src/features/instance/applications/DESIGN.md +++ b/src/features/instance/applications/DESIGN.md @@ -31,11 +31,17 @@ assistant's, doesn't pull Monaco in. For the file that is open, `TextEditorView` a replace. Monaco ignores an undefined `value` and keeps showing the model's last text, and typing into that would save it here. The library also keeps an old `onChange` subscribed, and fires it when it writes `value` into a read-only editor, so the editor passes one handler for good. That handler reads the open file's setter and the - read-only state, which are set on commit. + read-only state, which are set on commit. The editor subscribes to the query for this, since a reload that brings + back the text the provider already holds changes nothing else it renders from. - When its `value` changes to text nobody typed, as when the file is replaced while open or moved edits land on it, the library writes it into the model as an undoable edit. The editor then sets the same text with `setValue`, which - drops the history, so Ctrl+Z can't bring the replaced file back for Save to write. Typing, and switching to another - file and back, keep their history. + drops the history, so Ctrl+Z can't bring the replaced file back for Save to write. A replacement with the same text + changes nothing the editor is handed, and its reload can land before the provider even shows the contents missing. + So `editorPathState` also counts each delete, replace and move away per path (`useReplacementCount`), and the + editor drops the history once the contents after a counted replacement are loaded, whatever the text. It records + per model which file and count its history follows, because a model taken back before its disposal keeps its + history, and the same path on another instance shares the model. Typing, saving, and switching to another file and + back keep their history; switching instances does not. Apart from the rename's copy, an operation whose outcome is unknown (an unanswered write) changes nothing, because discarding edits for a file that may still be there loses work. Call sites run these where an unmount can't skip them: diff --git a/src/features/instance/applications/components/TextEditorView/index.tsx b/src/features/instance/applications/components/TextEditorView/index.tsx index e9586c637..207c65b09 100644 --- a/src/features/instance/applications/components/TextEditorView/index.tsx +++ b/src/features/instance/applications/components/TextEditorView/index.tsx @@ -5,6 +5,7 @@ import { useEditorFileContent } from '@/features/instance/applications/context/e import { useApplicationTypeIntelligence } from '@/features/instance/applications/hooks/useApplicationTypeIntelligence'; import { useCodeNavigation } from '@/features/instance/applications/hooks/useCodeNavigation'; import { useEditorView } from '@/features/instance/applications/hooks/useEditorView'; +import { useReplacementCount } from '@/features/instance/applications/lib/editorPathState'; import { registerWithEditor } from '@/features/instance/applications/shortcuts'; import { useMonacoTheme } from '@/hooks/useMonacoTheme'; import { useInstanceBrowseManagePermission } from '@/hooks/usePermissions'; @@ -17,8 +18,8 @@ import { Editor } from '@/lib/monaco/MonacoEditor'; import { MAX_WORKER_MODEL_CHARS } from '@/lib/monaco/workerLimits'; import { parseFileExtension } from '@/lib/string/parseFileExtension'; import type { EditorProps, OnMount } from '@monaco-editor/react'; -import { useQueryClient } from '@tanstack/react-query'; -import { useCallback, useEffect, useInsertionEffect, useRef, useState } from 'react'; +import { hashKey, useQueryClient } from '@tanstack/react-query'; +import { useCallback, useEffect, useInsertionEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; import { DegradedIntelliSenseBanner, type IntelliSenseDegradation } from './DegradedIntelliSenseBanner'; import { configureHarperLanguageSupport } from './harper-language'; import './monaco-customizations.css'; @@ -66,6 +67,10 @@ function lookupEditorShortcuts(editor: Parameters[0], ids: string[]): R return labels; } +// Per model, which file and replacement its undo history follows. A model outlives a switch to another +// file, and one taken back before its disposal still holds the history from before the replacement. +const historyAsOf = new WeakMap(); + export function TextEditorView() { const instanceParams = useInstanceClientIdParams(); const queryClient = useQueryClient(); @@ -105,6 +110,32 @@ export function TextEditorView() { const extension = parseFileExtension(openedEntry?.path); const fileContent = updatedFileContent ?? openedEntryContents; + // Monaco keeps the model's last text for an undefined `value`, and the provider's contents trail the + // query by an effect after a replace; typing into either would save the old file's text here. + const openedFileHash = useMemo(() => + openedEntry && hashKey(getComponentFileQueryKey({ + ...instanceParams, + project: openedEntry.project, + file: openedEntry.path.slice(openedEntry.project.length + 1), + })), [instanceParams, openedEntry]); + const subscribeToOpenedFile = useCallback( + (listener: () => void) => + queryClient.getQueryCache().subscribe(event => { + if (event.query.queryHash === openedFileHash) { + listener(); + } + }), + [queryClient, openedFileHash], + ); + const readCachedContents = useCallback( + () => + openedFileHash + ? (queryClient.getQueryCache().get(openedFileHash)?.state.data as GetComponentFileResponse | undefined)?.message + : undefined, + [queryClient, openedFileHash], + ); + const cachedContents = useSyncExternalStore(subscribeToOpenedFile, readCachedContents); + const contentsBehind = updatedFileContent === undefined && cachedContents !== openedEntryContents; // A huge open file would feed its full text to the language worker the same // way sibling models do, overflowing the structured-clone buffer // ("DataCloneError: ... out of memory."). Render it as plaintext so no @@ -145,6 +176,29 @@ export function TextEditorView() { lastSeen.current = { model, text }; }, [mounted, fileContent]); + const openedPath = openedEntry?.path; + const replacement = useReplacementCount(instanceParams.entityId, openedPath); + useEffect(() => { + const model = mounted?.[0].getModel(); + if (!model) { + return; + } + const entry = `${instanceParams.entityId}:${openedPath}`; + const history = historyAsOf.get(model); + if (history === undefined) { + historyAsOf.set(model, { entry, replacement }); + return; + } + // Another instance's file at the same path shares the model, and so its history. + const current = history.entry === entry && history.replacement === replacement; + if (current || fileContent === undefined || contentsBehind) { + return; + } + model.setValue(fileContent); + historyAsOf.set(model, { entry, replacement }); + typed.current = undefined; + }, [instanceParams.entityId, openedPath, replacement, mounted, fileContent, contentsBehind]); + useEffect(() => { if (mounted && canManageBrowseInstance && !openedEntry?.package && !restrictPackageModification) { return registerWithEditor(mounted); @@ -205,17 +259,6 @@ export function TextEditorView() { } }, [mounted]); - // Monaco keeps the model's last text for an undefined `value`, and the provider's contents trail the - // query by an effect after a replace; typing into either would save the old file's text here. - const cached = openedEntry - && queryClient.getQueryData( - getComponentFileQueryKey({ - ...instanceParams, - project: openedEntry.project, - file: openedEntry.path.slice(openedEntry.project.length + 1), - }), - ); - const contentsBehind = updatedFileContent === undefined && cached?.message !== openedEntryContents; const readOnly = !openedEntry || isSavingFile || !!openedEntry.package || !canManageBrowseInstance || fileContent === undefined || contentsBehind; // Set on commit, before the library's effects can fire the handler, so a render React throws away diff --git a/src/features/instance/applications/components/TextEditorView/setValueHistory.test.ts b/src/features/instance/applications/components/TextEditorView/setValueHistory.test.ts new file mode 100644 index 000000000..394d10bfa --- /dev/null +++ b/src/features/instance/applications/components/TextEditorView/setValueHistory.test.ts @@ -0,0 +1,20 @@ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest'; + +// The text editor drops a model's undo history with `setValue`, even with the text the model already +// holds, and the test double in `editorPathReuse.test.tsx` is built on that. This pins it on real Monaco. +describe('Monaco model setValue', () => { + it('drops the undo history even when the text is unchanged', async () => { + const monaco = await import('@/lib/monaco/editorApi'); + const model = monaco.editor.createModel('A', 'plaintext', monaco.Uri.parse('file:///setValueHistory-test/a.txt')); + model.pushEditOperations([], [{ range: model.getFullModelRange(), text: 'B' }], () => null); + model.pushStackElement(); + expect(model.canUndo()).toBe(true); + + model.setValue('B'); + + expect(model.canUndo()).toBe(false); + expect(model.getValue()).toBe('B'); + model.dispose(); + }); +}); diff --git a/src/features/instance/applications/editorPathReuse.test.tsx b/src/features/instance/applications/editorPathReuse.test.tsx index 33024992c..62cafea93 100644 --- a/src/features/instance/applications/editorPathReuse.test.tsx +++ b/src/features/instance/applications/editorPathReuse.test.tsx @@ -151,10 +151,11 @@ vi.mock('@/features/instance/applications/modals/confirmOverwrite', () => ({ confirmOverwrite: () => Promise.resolve(true), })); vi.mock('@monaco-editor/react', () => ({ loader: { __getMonacoInstance: () => harness.monaco } })); -// A textarea over the path's model, with @monaco-editor/react's rules: an existing model is reused as -// it is, `value` seeds a new model and is applied only once it changes, never when undefined, and as -// an undoable edit unless the editor is read-only. Typing and Ctrl+Z are refused while read-only, and -// changes reach the last `onChange` subscribed, which a read-only editor's undefined prop doesn't drop. +// A textarea over the path's model, with @monaco-editor/react 4.7.0's rules: an existing model is reused +// as it is, `value` seeds a new model and is applied only once it changes, never when undefined. A +// read-only editor takes it with `setValue` even when the text is unchanged; an editable one takes a +// different text as an undoable edit. Typing and Ctrl+Z are refused while read-only, and changes reach +// the last `onChange` subscribed, which a read-only editor's undefined prop doesn't drop. vi.mock('@/lib/monaco/MonacoEditor', async () => { const { useEffect, useReducer, useRef } = await import('react'); function Editor({ path, value, onChange, onMount, options }: { @@ -207,12 +208,13 @@ vi.mock('@/lib/monaco/MonacoEditor', async () => { return; } appliedValue.current = value; - if (value !== undefined && shownModel.text !== value) { - if (live.current.readOnly) { - shownModel.setValue(value); - } else { - shownModel.applyValue(value); - } + if (value === undefined) { + return; + } + if (live.current.readOnly) { + shownModel.setValue(value); + } else if (shownModel.text !== value) { + shownModel.applyValue(value); } }, [value, shownModel]); // Declared after the `value` effect, as in @monaco-editor/react, so a commit that changes both hands @@ -465,6 +467,14 @@ function save() { act(() => emitToListeners('SaveFile', true)); } +async function upload(name: string, content: string) { + const input = document.getElementById('dropTarget') as HTMLInputElement; + await act(async () => { + fireEvent.change(input, { target: { files: [new File([content], name, { type: 'text/javascript' })] } }); + }); + await waitFor(() => expect(writesOf(`app/${name}`)).toContain(content)); +} + function cachedContents(path: string) { const [project, ...rest] = path.split('/'); return queryClient.getQueryData( @@ -1248,14 +1258,6 @@ describe('a renamed folder', () => { }); describe('an upload', () => { - async function upload(name: string, content: string) { - const input = document.getElementById('dropTarget') as HTMLInputElement; - await act(async () => { - fireEvent.change(input, { target: { files: [new File([content], name, { type: 'text/javascript' })] } }); - }); - await waitFor(() => expect(writesOf(`app/${name}`)).toContain(content)); - } - it('over a file with unsaved edits opens with the uploaded contents', async () => { seed({ 'app/a.js': 'saved a', 'app/b.js': 'saved b' }); renderEditor(); @@ -1298,3 +1300,132 @@ describe('an upload', () => { await waitFor(() => expect(editor().value).toBe('uploaded a')); }); }); + +describe('a file replaced while open with the text it already shows', () => { + async function typeAndSave(text: string) { + type(text); + save(); + await waitFor(() => expect(files.get('app/a.js')).toBe(text)); + await waitFor(() => expect(bufferAt('app/a.js')).toBeUndefined()); + } + + async function replacedAndLoaded(replace: () => Promise, text: string) { + const reads = readsOf('app/a.js'); + await replace(); + await waitFor(() => expect(readsOf('app/a.js')).toBeGreaterThan(reads)); + await loaded(text); + } + + async function loaded(text: string) { + await waitFor(() => expect(view.openedEntryContents).toBe(text)); + await waitFor(() => expect(editor().readOnly).toBe(false)); + await act(async () => {}); + } + + it.each([ + ['an upload', 'uploaded a', () => upload('a.js', 'uploaded a')], + ['New File', '', () => newFile('a.js')], + [ + 'a move onto it', + 'moved a', + async () => { + await act(() => harness.tree.onDrop!([{ index: 'app/lib/a.js' }], { targetType: 'item', targetItem: 'app' })); + await waitFor(() => expect(view.entryExists('app/lib/a.js')).toBe(false)); + }, + ], + ])('cannot be undone back to the replaced file after %s', async (_, text, replace) => { + seed({ 'app/a.js': 'saved a', 'app/lib/a.js': text }); + renderEditor(); + await openFile('app/a.js', 'saved a'); + await typeAndSave(text); + + await replacedAndLoaded(replace, text); + const writes = writesOf('app/a.js').length; + act(() => harness.editor.undo()); + save(); + + expect(editor().value).toBe(text); + expect(bufferAt('app/a.js')).toBeUndefined(); + expect(writesOf('app/a.js')).toHaveLength(writes); + expect(files.get('app/a.js')).toBe(text); + }); + + it('cannot be undone back when the new contents load before the editor ever sees them missing', async () => { + seed({ 'app/a.js': 'saved a' }); + renderEditor(); + await openFile('app/a.js', 'saved a'); + await typeAndSave('uploaded a'); + harness.shown.length = 0; + + await replacedAndLoaded(() => upload('a.js', 'uploaded a'), 'uploaded a'); + act(() => harness.editor.undo()); + + expect(harness.shown.filter(shown => shown.path === 'app/a.js').map(shown => shown.incoming)).not.toContain( + undefined, + ); + expect(editor().value).toBe('uploaded a'); + }); + + it('cannot be undone back when the editor sees the contents missing while they reload', async () => { + seed({ 'app/a.js': 'saved a' }); + renderEditor(); + await openFile('app/a.js', 'saved a'); + await typeAndSave('uploaded a'); + const reloaded = hold('get_component_file', 'app/a.js'); + harness.shown.length = 0; + + await upload('a.js', 'uploaded a'); + await waitFor(() => expect(view.openedEntryContents).toBeUndefined()); + await reloaded(); + await loaded('uploaded a'); + act(() => harness.editor.undo()); + + expect(harness.shown.filter(shown => shown.path === 'app/a.js').map(shown => shown.incoming)).toContain(undefined); + expect(editor().value).toBe('uploaded a'); + }); + + it('cannot be undone back when the editor went to another file and returned before the reload', async () => { + seed({ 'app/a.js': 'saved a', 'app/b.js': 'saved b' }); + renderEditor(); + await openFile('app/a.js', 'saved a'); + await typeAndSave('uploaded a'); + const reloaded = hold('get_component_file', 'app/a.js'); + + await upload('a.js', 'uploaded a'); + // Back before the model left on the switch is disposed, which waits a tick after it detaches. + act(() => view.setFocusedItem('app/b.js')); + act(() => view.setFocusedItem('app/a.js')); + expect(harness.models.get('app/a.js')?.disposed).toBe(false); + await reloaded(); + await loaded('uploaded a'); + act(() => harness.editor.undo()); + + expect(editor().value).toBe('uploaded a'); + }); + + it("cannot be undone into another instance's file at the same path", async () => { + seed({ 'app/a.js': 'saved a' }); + const { rerender } = renderEditor(); + await openFile('app/a.js', 'saved a'); + await typeAndSave('saved a, edited'); + + act(() => switchInstance(rerender, 'ins-2')); + await loaded('saved a, edited'); + act(() => harness.editor.undo()); + save(); + + expect(editor().value).toBe('saved a, edited'); + expect(bufferAt('app/a.js', 'ins-2')).toBeUndefined(); + }); + + it('still undoes typing and saving that came before nothing was replaced', async () => { + seed({ 'app/a.js': 'saved a' }); + renderEditor(); + await openFile('app/a.js', 'saved a'); + await typeAndSave('saved a, edited'); + + act(() => harness.editor.undo()); + + expect(editor().value).toBe('saved a'); + }); +}); diff --git a/src/features/instance/applications/lib/editorPathState.test.ts b/src/features/instance/applications/lib/editorPathState.test.ts index bdfa7c75d..6908b0898 100644 --- a/src/features/instance/applications/lib/editorPathState.test.ts +++ b/src/features/instance/applications/lib/editorPathState.test.ts @@ -75,7 +75,7 @@ vi.mock('@monaco-editor/react', () => ({ }, })); -import { forgetDeletedPath, forgetReplacedPath, moveEditedPath } from './editorPathState'; +import { forgetDeletedPath, forgetReplacedPath, moveEditedPath, useReplacementCount } from './editorPathState'; const entityId = 'ins-1'; @@ -344,6 +344,48 @@ describe("the text editor's kept models", () => { }); }); +describe('useReplacementCount', () => { + function counts(...paths: Array<[string, string?]>) { + const hooks = paths.map(([path, entity = entityId]) => renderHook(() => useReplacementCount(entity, path))); + const before = hooks.map(hook => hook.result.current); + return () => hooks.map((hook, i) => hook.result.current !== before[i]); + } + + it('changes for a replaced file, and for nothing beside it', () => { + const changed = counts(['app/a.js'], ['app/a.jsx'], ['app/b.js'], ['app/a.js', 'ins-2']); + + act(() => forgetReplacedPath(queryClient, entityId, 'app/a.js')); + + expect(changed()).toEqual([true, false, false, false]); + }); + + it('changes for every file under a deleted folder, but not a sibling whose name starts the same', () => { + const changed = counts(['app/lib/x.js'], ['app/lib/deep/y.js'], ['app/library.js'], ['app/lib.js']); + + act(() => forgetDeletedPath(queryClient, entityId, 'app/lib')); + + expect(changed()).toEqual([true, true, false, false]); + }); + + it('changes for the path a file was moved away from', () => { + const changed = counts(['app/a.js'], ['app/lib/a.js']); + + act(() => moveEditedPath(queryClient, entityId, 'app/a.js', 'app/lib/a.js')); + + expect(changed()).toEqual([true, false]); + }); + + it('changes again for each replacement', () => { + const { result } = renderHook(() => useReplacementCount(entityId, 'app/a.js')); + act(() => forgetReplacedPath(queryClient, entityId, 'app/a.js')); + const first = result.current; + + act(() => forgetReplacedPath(queryClient, entityId, 'app/a.js')); + + expect(result.current).not.toBe(first); + }); +}); + describe('discardEditorFileContentIfSaved', () => { it('drops a buffer that is exactly what was saved, and tells a mounted hook', () => { buffer({ 'app/a.js': 'saved text' }); diff --git a/src/features/instance/applications/lib/editorPathState.ts b/src/features/instance/applications/lib/editorPathState.ts index 5630ec0b3..aeeaead99 100644 --- a/src/features/instance/applications/lib/editorPathState.ts +++ b/src/features/instance/applications/lib/editorPathState.ts @@ -6,10 +6,12 @@ import { import { componentFilesAtOrUnder } from '@/integrations/api/instance/applications/getComponentFile'; import { loader } from '@monaco-editor/react'; import type { QueryClient } from '@tanstack/react-query'; +import { useCallback, useMemo, useSyncExternalStore } from 'react'; export function forgetDeletedPath(queryClient: QueryClient, entityId: string, path: string) { discardEditorFileContent(entityId, path); forgetFilesThatAreGone(queryClient, entityId, path); + countReplacement(entityId, path); disposeEditorModels(path); } @@ -17,6 +19,7 @@ export function forgetReplacedPath(queryClient: QueryClient, entityId: string, p discardEditorFileContent(entityId, path); // Reset rather than invalidate, so the replaced contents aren't handed out while the new ones load. void queryClient.resetQueries(componentFilesAtOrUnder(entityId, path)); + countReplacement(entityId, path); disposeEditorModels(path); } @@ -24,9 +27,42 @@ export function forgetReplacedPath(queryClient: QueryClient, entityId: string, p export function moveEditedPath(queryClient: QueryClient, entityId: string, from: string, to: string) { moveEditorFileContent(entityId, from, to); forgetFilesThatAreGone(queryClient, entityId, from); + countReplacement(entityId, from); disposeEditorModels(from); } +const replacements = new Map(); +const replacementListeners = new Set<() => void>(); +let replacementCount = 0; + +function countReplacement(entityId: string, path: string) { + replacements.set(`${entityId}:${path}`, ++replacementCount); + for (const listener of replacementListeners) { + listener(); + } +} + +function subscribeToReplacements(listener: () => void) { + replacementListeners.add(listener); + return () => void replacementListeners.delete(listener); +} + +/** Changes whenever the file at `path`, or a folder holding it, is deleted, replaced or moved away. */ +export function useReplacementCount(entityId: string, path: string | undefined) { + const keys = useMemo(() => { + const parts = path?.split('/') ?? []; + return parts.map((_, i) => `${entityId}:${parts.slice(0, i + 1).join('/')}`); + }, [entityId, path]); + const count = useCallback(() => { + let latest = 0; + for (const key of keys) { + latest = Math.max(latest, replacements.get(key) ?? 0); + } + return latest; + }, [keys]); + return useSyncExternalStore(subscribeToReplacements, count); +} + // The open file's query is still observed until the editor moves off it, and removing that one would // have the observer fetch a file that is gone at once, which fails into an error toast. Emptied in // place, it is fetched only if a refetch trigger such as window focus fires before the editor moves off. From d689810c44036099289898966b8094f18fb76db4 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Wed, 14 Oct 2026 14:50:15 -0400 Subject: [PATCH 08/21] feat(browse): ask in a Studio dialog instead of the browser's confirm() Browse asked four questions with window.confirm(): before a filter no index can answer, before Cleanup Orphan Blobs, before Delete Selected, and before the row editor's Previous/Next discards an unsaved draft. Each now asks in an AlertDialog. The full-scan one names the table(s) and the unindexed columns, with Scan anyway and Cancel. Nothing blocks while a dialog is open, so each question is a pending request holding what it would act on (the filter's conditions, the selected keys, the step's direction). In DatabaseTableView it also holds the view's context (instance, database, table, deep-link filters): the view stays mounted when the route moves to another table, and a request from elsewhere is cleared, not answered. The editor's question is cleared once its draft is gone, and Delete Selected's once the rows it named leave the screen. A yes clears the request and runs it once; Cancel or Escape leaves everything as it was. Applying or declining a filter now resets every filter input it doesn't name to empty. Left out of the reset, an input fell back to an earlier filter's value, which a declined deep link then sent with the next Apply. A filter an index can answer still applies at once, with no dialog and no change to applyFilters' synchronous shape. fullScanWarning becomes fullScanQuestion, which returns a title and a description. Co-Authored-By: Claude Opus 5.5 --- src/features/instance/databases/DESIGN.md | 10 + ...aseTableView.deepLinkRelationship.test.tsx | 13 +- .../DatabaseTableView.fullScanFilter.test.tsx | 115 +++++++++-- .../components/DatabaseTableView.test.tsx | 129 ++++++++++-- .../components/DatabaseTableView.tsx | 185 ++++++++++++++---- .../functions/fullScanFilter.test.ts | 17 +- .../databases/functions/fullScanFilter.ts | 11 +- .../modals/EditTableRowModal.test.tsx | 63 ++++-- .../databases/modals/EditTableRowModal.tsx | 78 +++++++- 9 files changed, 501 insertions(+), 120 deletions(-) diff --git a/src/features/instance/databases/DESIGN.md b/src/features/instance/databases/DESIGN.md index e715373da..2d870379d 100644 --- a/src/features/instance/databases/DESIGN.md +++ b/src/features/instance/databases/DESIGN.md @@ -156,6 +156,16 @@ so it is judged by that table's attribute. Typed filters and deep-linked ones bo carries `allowFullScan: true`, and so does a CSV export of that filter. A new way to apply a filter has to go through that function. +## Browse asks in a Studio dialog, and a question belongs to where it was asked + +The full-scan question, Cleanup Orphan Blobs, Delete Selected and the row editor's discard all ask in +an `AlertDialog`, not `window.confirm()` (#1924). Nothing blocks while it is open, so each question +is a pending request that carries what it would act on: the filter's conditions, the selected keys. +`DatabaseTableView` stays mounted when the route moves to another table, so its request also carries +the view's context (instance, database, table, deep-link filters) and is cleared, not answered, once +that changes. The editor's discard question is cleared once its draft is gone. A filter an index can +answer never asks and applies at once, as before. + ## An import refreshes Browse from the mutation, not from the modal Every Browse read has a 60s `staleTime`, so an import into the open table shows nothing new until diff --git a/src/features/instance/databases/components/DatabaseTableView.deepLinkRelationship.test.tsx b/src/features/instance/databases/components/DatabaseTableView.deepLinkRelationship.test.tsx index 8efefe665..15814b38b 100644 --- a/src/features/instance/databases/components/DatabaseTableView.deepLinkRelationship.test.tsx +++ b/src/features/instance/databases/components/DatabaseTableView.deepLinkRelationship.test.tsx @@ -4,7 +4,7 @@ import { InstanceDatabaseMap, InstanceTable } from '@/integrations/api/api.patch'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { act, cleanup, render, screen } from '@testing-library/react'; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { DatabaseTableView } from './DatabaseTableView'; vi.mock('@tanstack/react-router', () => { @@ -142,15 +142,10 @@ beforeAll(() => { } }); -const confirm = vi.fn<(message?: string) => boolean>(() => true); -beforeEach(() => { - vi.stubGlobal('confirm', confirm); -}); +const fullScanQuestion = () => screen.queryByRole('alertdialog'); afterEach(() => { cleanup(); - vi.unstubAllGlobals(); - confirm.mockClear(); instance.searches = []; instance.holdComponents = true; }); @@ -162,7 +157,7 @@ async function expectForeignKeySearch() { expect(instance.searches[0].conditions).toEqual([ { search_attribute: 'authorId', search_type: 'equals', search_value: 'a1' }, ]); - expect(confirm).not.toHaveBeenCalled(); + expect(fullScanQuestion()).toBeNull(); await screen.findByRole('cell', { name: 'b1' }, { timeout: 10_000 }); expect(screen.queryByRole('cell', { name: 'b2' })).toBeNull(); } @@ -203,7 +198,7 @@ describe('DatabaseTableView deep-linked filter on a relationship only the compon await vi.waitFor(() => expect(queryClient.getQueryData(['instance-1', 'schema-relationships'])).toBeDefined()); await act(async () => {}); expect(instance.searches).toEqual([]); - expect(confirm).not.toHaveBeenCalled(); + expect(fullScanQuestion()).toBeNull(); rerender(view(databaseMap)); diff --git a/src/features/instance/databases/components/DatabaseTableView.fullScanFilter.test.tsx b/src/features/instance/databases/components/DatabaseTableView.fullScanFilter.test.tsx index a1f97ebf2..d9bf8f8c1 100644 --- a/src/features/instance/databases/components/DatabaseTableView.fullScanFilter.test.tsx +++ b/src/features/instance/databases/components/DatabaseTableView.fullScanFilter.test.tsx @@ -4,7 +4,7 @@ import { InstanceTable } from '@/integrations/api/api.patch'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { cleanup, fireEvent, render, screen } from '@testing-library/react'; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { DatabaseTableView } from './DatabaseTableView'; const route = vi.hoisted(() => ({ search: {} as { filters?: Record } })); @@ -110,19 +110,16 @@ beforeAll(() => { } }); -const confirm = vi.fn<(message?: string) => boolean>(); -beforeEach(() => { - vi.stubGlobal('confirm', confirm); -}); - afterEach(() => { cleanup(); - vi.unstubAllGlobals(); - confirm.mockReset(); instance.searches = []; route.search = {}; }); +const scanQuestion = () => screen.queryByRole('alertdialog', { name: /^Scan every record in/ }); +const findScanQuestion = () => screen.findByRole('alertdialog', { name: /^Scan every record in/ }); +const answer = (name: 'Scan anyway' | 'Cancel') => fireEvent.click(screen.getByRole('button', { name })); + function renderView() { return render( @@ -152,21 +149,26 @@ describe('DatabaseTableView filtering a column no index covers', { timeout: 30_0 }); it('asks first, and runs nothing when the user declines', async () => { - confirm.mockReturnValue(false); renderView(); await filterBy('note', 'beta'); - expect(confirm).toHaveBeenCalledTimes(1); - expect(confirm.mock.calls[0][0]).toContain('every record in "Thing"'); + const question = await findScanQuestion(); + expect(question.querySelector('h2')!.textContent).toBe('Scan every record in "Thing"?'); + expect(question.textContent).toContain('this filter on "note"'); + expect(instance.searches).toEqual([]); + answer('Cancel'); + + expect(scanQuestion()).toBeNull(); await findCell('t3'); expect(instance.searches).toEqual([]); expect(screen.getByRole('button', { name: /apply filters/i })).toBeTruthy(); }); it('runs the search, marked as allowed to scan, once the user agrees', async () => { - confirm.mockReturnValue(true); renderView(); await filterBy('note', 'beta'); + await findScanQuestion(); + answer('Scan anyway'); await vi.waitFor(() => expect(instance.searches).toHaveLength(1)); expect(instance.searches[0]).toMatchObject({ @@ -182,17 +184,17 @@ describe('DatabaseTableView filtering a column no index covers', { timeout: 30_0 await filterBy('name', 'John'); await vi.waitFor(() => expect(instance.searches).toHaveLength(1)); - expect(confirm).not.toHaveBeenCalled(); + expect(scanQuestion()).toBeNull(); expect(instance.searches[0].allowFullScan).toBeUndefined(); }); it('reads *x as ends-with and *x* as contains, and asks before either', async () => { - confirm.mockReturnValue(true); renderView(); await filterBy('name', '*hn'); + expect((await findScanQuestion()).textContent).toContain('"name"'); + answer('Scan anyway'); await vi.waitFor(() => expect(instance.searches).toHaveLength(1)); - expect(confirm.mock.calls[0][0]).toContain('"name"'); expect(instance.searches[0]).toMatchObject({ conditions: [{ search_attribute: 'name', search_type: 'ends_with', search_value: 'hn' }], allowFullScan: true, @@ -200,8 +202,9 @@ describe('DatabaseTableView filtering a column no index covers', { timeout: 30_0 fireEvent.change(filterInput('name'), { target: { value: '*oh*' } }); fireEvent.keyDown(filterInput('name'), { key: 'Enter' }); + await findScanQuestion(); + answer('Scan anyway'); await vi.waitFor(() => expect(instance.searches).toHaveLength(2)); - expect(confirm).toHaveBeenCalledTimes(2); expect(instance.searches[1].conditions).toEqual([ { search_attribute: 'name', search_type: 'contains', search_value: 'oh' }, ]); @@ -234,21 +237,91 @@ describe('DatabaseTableView filtering a column no index covers', { timeout: 30_0 it('asks once for a deep link’s filter, and runs it on yes', async () => { route.search = { filters: { note: 'beta' } }; - confirm.mockReturnValue(true); renderView(); + await findScanQuestion(); + expect(instance.searches).toEqual([]); + answer('Scan anyway'); + await vi.waitFor(() => expect(instance.searches).toHaveLength(1)); await findCell('t2'); - expect(instance.searches).toHaveLength(1); expect(instance.searches[0].allowFullScan).toBe(true); - expect(confirm).toHaveBeenCalledTimes(1); + expect(scanQuestion()).toBeNull(); + await vi.waitFor(() => expect(screen.queryByRole('button', { name: /apply filters/i })).toBeNull()); + }); + + it('shows exactly the deep link’s filter once it runs, not one typed before it', async () => { + const { rerender } = renderView(); + await filterBy('name', 'John'); + await vi.waitFor(() => expect(instance.searches).toHaveLength(1)); + + route.search = { filters: { note: 'beta' } }; + rerender( + + + , + ); + await findScanQuestion(); + answer('Scan anyway'); + + await vi.waitFor(() => expect(instance.searches).toHaveLength(2)); + expect(instance.searches[1].conditions).toEqual([ + { search_attribute: 'note', search_type: 'equals', search_value: 'beta' }, + ]); + await vi.waitFor(() => expect(filterInput('note').value).toBe('beta')); + expect(filterInput('name').value).toBe(''); + expect(screen.queryByRole('button', { name: /apply filters/i })).toBeNull(); + }); + + it('keeps no earlier filter in the inputs when the deep link’s question is declined', async () => { + const { rerender } = renderView(); + await filterBy('name', 'John'); + await vi.waitFor(() => expect(instance.searches).toHaveLength(1)); + + route.search = { filters: { note: 'beta' } }; + rerender( + + + , + ); + await findScanQuestion(); + answer('Cancel'); + + await vi.waitFor(() => expect(filterInput('note').value).toBe('beta')); + expect(filterInput('name').value).toBe(''); + fireEvent.click(screen.getByRole('button', { name: /apply filters/i })); + await findScanQuestion(); + answer('Scan anyway'); + await vi.waitFor(() => expect(instance.searches).toHaveLength(2)); + expect(instance.searches[1].conditions).toEqual([ + { search_attribute: 'note', search_type: 'equals', search_value: 'beta' }, + ]); + }); + + // The view stays mounted across deep links, so a yes to one must not apply it to the next. + it('drops the question for a deep link the user has since left', async () => { + route.search = { filters: { note: 'beta' } }; + const { rerender } = renderView(); + await findScanQuestion(); + + route.search = {}; + rerender( + + + , + ); + + expect(scanQuestion()).toBeNull(); + await findCell('t3'); + expect(instance.searches).toEqual([]); }); it('asks before a deep link’s filter runs too', async () => { route.search = { filters: { note: 'beta' } }; - confirm.mockReturnValue(false); renderView(); + await findScanQuestion(); + fireEvent.keyDown(scanQuestion()!, { key: 'Escape' }); - await vi.waitFor(() => expect(confirm).toHaveBeenCalledTimes(1)); + expect(scanQuestion()).toBeNull(); await findCell('t3'); expect(instance.searches).toEqual([]); expect(filterInput('note').value).toBe('beta'); diff --git a/src/features/instance/databases/components/DatabaseTableView.test.tsx b/src/features/instance/databases/components/DatabaseTableView.test.tsx index 4b95e4963..66ca6702e 100644 --- a/src/features/instance/databases/components/DatabaseTableView.test.tsx +++ b/src/features/instance/databases/components/DatabaseTableView.test.tsx @@ -19,17 +19,15 @@ const permissionState = vi.hoisted(() => ({ canDeleteRecords: true, })); -vi.mock('@tanstack/react-router', () => { - // Stable references: the component keys effects off these objects' identity, and the real - // router hooks only produce a new one when params/search actually change. - const params = {}; - const search = {}; - return { - useParams: () => params, - useSearch: () => search, - Link: ({ children }: { children?: React.ReactNode }) => <>{children}, - }; -}); +// Stable references: the component keys effects off these objects' identity, and the real +// router hooks only produce a new one when params/search actually change. +const routerState = vi.hoisted(() => ({ params: {} as Record, search: {} })); + +vi.mock('@tanstack/react-router', () => ({ + useParams: () => routerState.params, + useSearch: () => routerState.search, + Link: ({ children }: { children?: React.ReactNode }) => <>{children}, +})); const instanceClientState = vi.hoisted(() => ({ entityId: 'instance-1' })); @@ -41,8 +39,15 @@ vi.mock('@/config/useInstanceClient', () => ({ }), })); +const staffState = vi.hoisted(() => ({ isInstanceOperator: false })); + vi.mock('@/hooks/useAuth', () => ({ - useStaffPermission: () => false, + useStaffPermission: () => staffState.isInstanceOperator, +})); + +const cleanupOrphanBlobs = vi.hoisted(() => ({ mutate: vi.fn() })); +vi.mock('@/integrations/api/instance/database/cleanupOrphanBlobs', () => ({ + useCleanupOrphanBlobsMutation: () => ({ mutate: cleanupOrphanBlobs.mutate, isPending: false }), })); vi.mock('@/hooks/usePermissions', () => ({ @@ -168,9 +173,15 @@ afterEach(() => { editorProps.syntheticAttributes = undefined; watchedValues.calls = []; deleteRecords.mutate.mockReset(); + cleanupOrphanBlobs.mutate.mockReset(); + routerState.params = {}; + staffState.isInstanceOperator = false; vi.restoreAllMocks(); }); +const question = (name: string | RegExp) => screen.queryByRole('alertdialog', { name }); +const answer = (name: string) => fireEvent.click(screen.getByRole('button', { name })); + const dogTable = { attributes: [{ attribute: 'id', type: 'string', is_primary_key: true, indexed: true }], primary_key: 'id', @@ -355,12 +366,15 @@ describe('DatabaseTableView bulk delete', () => { }); it('deletes every selected key once confirmed, then clears the selection', () => { - vi.spyOn(window, 'confirm').mockReturnValue(true); renderView(); selectRecords(['abc', 'def']); fireEvent.click(deleteSelectedButton()!); + expect(deleteRecords.mutate).not.toHaveBeenCalled(); + expect(question('Delete 2 records from "dog"?')).not.toBeNull(); + answer('Delete'); + expect(question(/^Delete/)).toBeNull(); expect(deleteRecords.mutate).toHaveBeenCalledTimes(1); const [payload, handlers] = deleteRecords.mutate.mock.calls[0]; // Raw primary-key values, not stringified row ids -- `delete` addresses records by the value @@ -373,12 +387,13 @@ describe('DatabaseTableView bulk delete', () => { }); it('does not delete anything when the confirmation is declined', () => { - vi.spyOn(window, 'confirm').mockReturnValue(false); renderView(); selectRecords(['abc']); fireEvent.click(deleteSelectedButton()!); + answer('Cancel'); + expect(question(/^Delete/)).toBeNull(); expect(deleteRecords.mutate).not.toHaveBeenCalled(); // The selection survives a declined confirmation -- the user backed out of the delete, not // out of the rows they had picked. @@ -396,7 +411,6 @@ describe('DatabaseTableView selection scope', () => { const deleteSelectedButton = () => screen.queryByRole('button', { name: /Delete Selected/ }); it('arms the delete with every key the grid reports as selectable', () => { - vi.spyOn(window, 'confirm').mockReturnValue(true); renderView(); // Select-all hands the grid's page keys straight through, so the delete is aimed at the same @@ -405,6 +419,7 @@ describe('DatabaseTableView selection scope', () => { expect(deleteSelectedButton()!.textContent).toContain('Delete Selected (3)'); fireEvent.click(deleteSelectedButton()!); + answer('Delete'); expect(deleteRecords.mutate.mock.calls[0][0]).toMatchObject({ hashValues: [1, 2, 3] }); @@ -472,13 +487,13 @@ describe('DatabaseTableView selection scope', () => { it('reports a delete the server only partly applied instead of claiming success', () => { // `delete` answers 200 while naming the records it couldn't address. Reporting that as a clean // success is what #1643 was about on the update path. - vi.spyOn(window, 'confirm').mockReturnValue(true); const warned = vi.spyOn(toast, 'error').mockImplementation(() => ''); const succeeded = vi.spyOn(toast, 'success').mockImplementation(() => ''); renderView(); act(() => tableViewSelection.current!.toggleAll(['a', 'b'], true)); fireEvent.click(deleteSelectedButton()!); + answer('Delete'); act(() => deleteRecords.mutate.mock.calls[0][1].onSuccess({ deleted_hashes: ['a'], skipped_hashes: ['b'] })); expect(succeeded).not.toHaveBeenCalled(); @@ -486,13 +501,13 @@ describe('DatabaseTableView selection scope', () => { }); it('treats an unreadable delete answer as unproven rather than successful', () => { - vi.spyOn(window, 'confirm').mockReturnValue(true); const warned = vi.spyOn(toast, 'error').mockImplementation(() => ''); const succeeded = vi.spyOn(toast, 'success').mockImplementation(() => ''); renderView(); act(() => tableViewSelection.current!.toggleRow('a')); fireEvent.click(deleteSelectedButton()!); + answer('Delete'); // Present but not an array: that responder does answer `delete`, and its answer is unreadable. act(() => deleteRecords.mutate.mock.calls[0][1].onSuccess({ skipped_hashes: null })); @@ -525,7 +540,6 @@ describe('DatabaseTableView selection follows the rows on screen', () => { }); it('deletes only the keys still on the page', () => { - vi.spyOn(window, 'confirm').mockReturnValue(true); const { rerender } = renderView({ rows: [{ id: 'abc' }, { id: 'def' }] }); act(() => tableViewSelection.current!.toggleAll(['abc', 'def'], true)); expect(deleteSelectedButton()!.textContent).toContain('Delete Selected (2)'); @@ -539,6 +553,7 @@ describe('DatabaseTableView selection follows the rows on screen', () => { expect(deleteSelectedButton()!.textContent).toContain('Delete Selected (1)'); fireEvent.click(deleteSelectedButton()!); + answer('Delete'); // 'abc' is still in the stored set, but it is no longer a row anyone can see. expect(deleteRecords.mutate.mock.calls[0][0]).toMatchObject({ hashValues: ['def'] }); @@ -598,3 +613,81 @@ describe('DatabaseTableView row editor write exclusions', () => { expect(editorProps.syntheticAttributes).toEqual(['tracks']); }); }); + +describe('DatabaseTableView asking before an action', () => { + it('cleans up orphan blobs only after the user agrees, once', () => { + staffState.isInstanceOperator = true; + routerState.params = { instanceId: 'instance-1' }; + renderView(); + + openTableOptions(); + fireEvent.click(screen.getByRole('menuitem', { name: 'Cleanup Orphan Blobs' })); + expect(question('Clean up orphan blobs?')).not.toBeNull(); + answer('Cancel'); + expect(cleanupOrphanBlobs.mutate).not.toHaveBeenCalled(); + // Opened from a menu: closing it must not leave the page unclickable. + expect(document.body.style.pointerEvents).not.toBe('none'); + + openTableOptions(); + fireEvent.click(screen.getByRole('menuitem', { name: 'Cleanup Orphan Blobs' })); + answer('Clean Up'); + + expect(cleanupOrphanBlobs.mutate).toHaveBeenCalledTimes(1); + expect(cleanupOrphanBlobs.mutate.mock.calls[0][0]).toMatchObject({ databaseName: 'data' }); + expect(document.body.style.pointerEvents).not.toBe('none'); + }); + + // The view stays mounted when the route moves to another table, so a question asked about one + // must not be answerable once the view shows the other. + it('drops a question once the view is showing another table, and does not bring it back', () => { + staffState.isInstanceOperator = true; + routerState.params = { instanceId: 'instance-1' }; + const { rerender } = renderView(); + const show = (databaseName: string, tableName: string) => + rerender( + + + , + ); + const ask = () => { + openTableOptions(); + fireEvent.click(screen.getByRole('menuitem', { name: 'Cleanup Orphan Blobs' })); + expect(question('Clean up orphan blobs?')).not.toBeNull(); + }; + + ask(); + show('data', 'cat'); + expect(question('Clean up orphan blobs?')).toBeNull(); + show('data', 'dog'); + expect(question('Clean up orphan blobs?')).toBeNull(); + + ask(); + show('other', 'dog'); + expect(question('Clean up orphan blobs?')).toBeNull(); + show('data', 'dog'); + + ask(); + instanceClientState.entityId = 'instance-2'; + show('data', 'dog'); + expect(question('Clean up orphan blobs?')).toBeNull(); + expect(cleanupOrphanBlobs.mutate).not.toHaveBeenCalled(); + }); + + it('drops the delete question once its rows leave the screen', () => { + const { rerender } = renderView({ rows: [{ id: 'abc' }, { id: 'def' }] }); + act(() => tableViewSelection.current!.toggleRow('abc')); + fireEvent.click(screen.getByRole('button', { name: /Delete Selected/ })); + expect(question('Delete 1 record from "dog"?')).not.toBeNull(); + + // A focus refetch, or another writer's record, moving the row onto the next page. + pageRows.current = [{ id: 'def' }, { id: 'ghi' }]; + rerender( + + + , + ); + + expect(question(/^Delete/)).toBeNull(); + expect(deleteRecords.mutate).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/instance/databases/components/DatabaseTableView.tsx b/src/features/instance/databases/components/DatabaseTableView.tsx index 5c3800fe0..e2089f7f1 100644 --- a/src/features/instance/databases/components/DatabaseTableView.tsx +++ b/src/features/instance/databases/components/DatabaseTableView.tsx @@ -1,4 +1,15 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alertDialog'; import { Button } from '@/components/ui/button'; +import { buttonVariants } from '@/components/ui/buttonVariants'; import { DropdownMenu, DropdownMenuContent, @@ -16,7 +27,7 @@ import { browsePrimaryKey, formatBrowseDataTableHeader, } from '@/features/instance/databases/functions/formatBrowseDataTableHeader'; -import { fullScanAttributes, fullScanWarning } from '@/features/instance/databases/functions/fullScanFilter'; +import { FullScan, fullScanAttributes, fullScanQuestion } from '@/features/instance/databases/functions/fullScanFilter'; import { randomizableAttributes } from '@/features/instance/databases/functions/generateRandomRecords'; import { buildSyntheticGetAttributes, @@ -107,6 +118,16 @@ const EMPTY_SELECTION: ReadonlySet = new Set(); const NO_ROWS: Record[] = []; const NO_EXTRA_ATTRIBUTES: ExtraRecordAttribute[] = []; +type FilterValues = Record; + +type PendingConfirmation = + & { context: string } + & ( + | { kind: 'fullScan'; conditions: SearchCondition[]; scans: FullScan[]; formValues: FilterValues } + | { kind: 'cleanupOrphanBlobs' } + | { kind: 'deleteSelected'; hashValues: unknown[] } + ); + export function DatabaseTableView({ instanceDatabaseMap, instanceDatabaseMapLoading = false, @@ -273,23 +294,47 @@ export function DatabaseTableView({ return conditions; }, [attributesMap, relationshipInfoMap]); + const { filters: urlFilters }: { filters?: Record } = useSearch({ strict: false }); + const urlFiltersKey = JSON.stringify([databaseName, tableName, urlFilters ?? null]); + const confirmationContext = JSON.stringify([instanceParams.entityId, urlFiltersKey]); + const [pendingConfirmation, setPendingConfirmation] = useState(null); + if (pendingConfirmation && pendingConfirmation.context !== confirmationContext) { + setPendingConfirmation(null); + } + + // Empty rather than left out: an input left out of a reset falls back to an earlier filter's value. + const { getValues: getFilterValues } = columnFiltersForm; + const onlyThese = useCallback( + (formValues: FilterValues) => ({ + ...Object.fromEntries(Object.keys(getFilterValues()).map((name) => [name, ''])), + ...formValues, + }), + [getFilterValues], + ); + const commitSearchConditions = useCallback( + (conditions: SearchCondition[], allowFullScan: boolean, formValues: FilterValues) => { + setFullScanAllowed(allowFullScan); + setAppliedSearchConditions(conditions.length ? conditions : null); + resetFiltersForm(onlyThese(formValues)); + }, + [setFullScanAllowed, setAppliedSearchConditions, onlyThese, resetFiltersForm], + ); + // The only way a filter is applied, typed or deep-linked, so one that no index can answer never runs // without the user agreeing to read the whole table. - const applySearchConditions = useCallback((conditions: SearchCondition[]) => { - const scanned = fullScanAttributes(conditions, attributesMap, relationshipInfoMap); - if (scanned.length && !confirm(fullScanWarning(tableName, scanned))) { + const applySearchConditions = useCallback((conditions: SearchCondition[], formValues: FilterValues) => { + const scans = fullScanAttributes(conditions, attributesMap, relationshipInfoMap); + if (scans.length) { + setPendingConfirmation({ kind: 'fullScan', context: confirmationContext, conditions, scans, formValues }); return false; } - setFullScanAllowed(scanned.length > 0); - setAppliedSearchConditions(conditions.length ? conditions : null); + commitSearchConditions(conditions, false, formValues); return true; - }, [attributesMap, relationshipInfoMap, tableName, setFullScanAllowed, setAppliedSearchConditions]); + }, [attributesMap, relationshipInfoMap, confirmationContext, commitSearchConditions]); const applyFilters = useCallback(() => { - if (applySearchConditions(translateFilterValues(columnFiltersValues))) { - resetFiltersForm({ ...columnFiltersValues }); - } - }, [applySearchConditions, translateFilterValues, resetFiltersForm, columnFiltersValues]); + applySearchConditions(translateFilterValues(columnFiltersValues), columnFiltersValues); + }, [applySearchConditions, translateFilterValues, columnFiltersValues]); const clearFilters = useCallback(() => { // Note sure why we need to resetFiltersForm twice here... resetFiltersForm({}, { keepValues: false, keepDirtyValues: false, keepDefaultValues: false }); @@ -311,8 +356,6 @@ export function DatabaseTableView({ // When the URL filters go away (navigating to another table, or back to the bare table), the filter // inputs are cleared too so they don't linger stale — `appliedSearchConditions` already resets on // table change, and the visible inputs should stay in step with it. - const { filters: urlFilters }: { filters?: Record } = useSearch({ strict: false }); - const urlFiltersKey = JSON.stringify([databaseName, tableName, urlFilters ?? null]); const appliedUrlFiltersKey = useRef(null); useEffect(function syncFiltersFromUrl() { if ( @@ -325,11 +368,9 @@ export function DatabaseTableView({ const isInitialSync = appliedUrlFiltersKey.current === null; appliedUrlFiltersKey.current = urlFiltersKey; if (urlFilters) { - if (applySearchConditions(translateFilterValues(urlFilters))) { - resetFiltersForm({ ...urlFilters }); - } else { - // Kept dirty, so Apply Filters stays on offer. - resetFiltersForm({ ...urlFilters }, { keepDefaultValues: true }); + if (!applySearchConditions(translateFilterValues(urlFilters), urlFilters)) { + // Kept dirty until the user agrees, so Apply Filters stays on offer if they don't. + resetFiltersForm(onlyThese(urlFilters), { keepDefaultValues: true }); } showFilters(); } else if (!isInitialSync) { @@ -344,6 +385,7 @@ export function DatabaseTableView({ schemaRelationshipsLoading, instanceDatabaseMapLoading, resetFiltersForm, + onlyThese, translateFilterValues, applySearchConditions, showFilters, @@ -637,11 +679,10 @@ export function DatabaseTableView({ [queryClient, instanceParams.entityId, databaseName, tableName], ); - const onCleanupOrphanBlobs = useCallback(async () => { - if (!confirm(`Are you sure you want to cleanup orphan blobs for database "${databaseName}"?`)) { - return; - } - + const onCleanupOrphanBlobs = useCallback(() => { + setPendingConfirmation({ kind: 'cleanupOrphanBlobs', context: confirmationContext }); + }, [confirmationContext]); + const cleanupOrphanBlobsNow = useCallback(() => { cleanupOrphanBlobs({ ...instanceParams, databaseName, @@ -785,13 +826,19 @@ export function DatabaseTableView({ // Bulk delete from the toolbar. Unlike the editor's single-record delete there is no record in // front of the user to check against, so it confirms first; both read the answer the same way. const onDeleteSelected = useCallback(() => { - const hashValues = visibleSelectedKeys; - if (!hashValues.length) { - return; - } - if (!confirm(`Permanently delete ${pluralize(hashValues.length, 'record', 'records')} from "${tableName}"?`)) { - return; + if (visibleSelectedKeys.length) { + setPendingConfirmation({ kind: 'deleteSelected', context: confirmationContext, hashValues: visibleSelectedKeys }); } + }, [visibleSelectedKeys, confirmationContext]); + // The question names the rows on screen; once a refetch moves them it names rows nobody can see. + if ( + pendingConfirmation?.kind === 'deleteSelected' + && (pendingConfirmation.hashValues.length !== visibleSelectedKeys.length + || pendingConfirmation.hashValues.some((key) => !visibleSelectedKeys.includes(key))) + ) { + setPendingConfirmation(null); + } + const deleteSelectedNow = useCallback((hashValues: unknown[]) => { deleteTableRecords( { ...instanceParams, @@ -815,15 +862,24 @@ export function DatabaseTableView({ }, }, ); - }, [ - deleteTableRecords, - instanceParams, - databaseName, - tableName, - refreshTable, - refreshOpenRecord, - visibleSelectedKeys, - ]); + }, [deleteTableRecords, instanceParams, databaseName, tableName, refreshTable, refreshOpenRecord]); + + const onConfirmed = () => { + const confirmation = pendingConfirmation; + setPendingConfirmation(null); + switch (confirmation?.kind) { + case 'fullScan': + commitSearchConditions(confirmation.conditions, true, confirmation.formValues); + return; + case 'cleanupOrphanBlobs': + cleanupOrphanBlobsNow(); + return; + case 'deleteSelected': + deleteSelectedNow(confirmation.hashValues); + return; + } + }; + const confirmationCopy = pendingConfirmation && describeConfirmation(pendingConfirmation, databaseName, tableName); // Point the editor at a record on the page the grid is showing. The row is kept as well as its // id because the editor falls back to it when the record can't be fetched by that id. @@ -1204,6 +1260,59 @@ export function DatabaseTableView({ isUpdateTableRecordsPending={isUpdateTableRecordsPending || isPutTableRecordsPending} isDeleteTableRecordsPending={isDeleteTableRecordsPending} /> + { + if (!open) { + setPendingConfirmation(null); + } + }} + > + {confirmationCopy && ( + + + {confirmationCopy.title} + {confirmationCopy.description} + + + Cancel + + {confirmationCopy.action} + + + + )} + ); } + +function describeConfirmation( + confirmation: PendingConfirmation, + databaseName: string, + tableName: string, +): { title: string; description: string; action: string; variant?: 'destructive' | 'warning' } { + switch (confirmation.kind) { + case 'fullScan': + return { ...fullScanQuestion(tableName, confirmation.scans), action: 'Scan anyway' }; + case 'cleanupOrphanBlobs': + return { + title: 'Clean up orphan blobs?', + description: `This starts an orphan blob cleanup for the "${databaseName}" database.`, + action: 'Clean Up', + variant: 'warning', + }; + case 'deleteSelected': { + const records = pluralize(confirmation.hashValues.length, 'record', 'records'); + return { + title: `Delete ${records} from "${tableName}"?`, + description: `The ${records} will be permanently deleted.`, + action: 'Delete', + variant: 'destructive', + }; + } + } +} diff --git a/src/features/instance/databases/functions/fullScanFilter.test.ts b/src/features/instance/databases/functions/fullScanFilter.test.ts index 631caa4c7..a21a0fc25 100644 --- a/src/features/instance/databases/functions/fullScanFilter.test.ts +++ b/src/features/instance/databases/functions/fullScanFilter.test.ts @@ -2,7 +2,7 @@ import { RelationshipAttributeInfo } from '@/features/instance/databases/functio import { InstanceAttribute } from '@/integrations/api/api.patch'; import { SearchCondition } from '@/integrations/api/instance/database/getSearchByConditions'; import { describe, expect, it } from 'vitest'; -import { fullScanAttributes, fullScanWarning } from './fullScanFilter'; +import { fullScanAttributes, fullScanQuestion } from './fullScanFilter'; // harperdb 4.7.40 describe_table for `type Thing @table { id: ID @primaryKey name: String @indexed // note: String category: String brand: Brand @relationship(from: brandId) }`: an indexed attribute @@ -66,19 +66,20 @@ describe('fullScanAttributes', () => { }); }); -describe('fullScanWarning', () => { +describe('fullScanQuestion', () => { it('names the attributes and the table they make Harper read', () => { - const message = fullScanWarning('Thing', [{ attribute: 'note' }, { attribute: 'category' }]); - expect(message).toContain('"note", "category"'); - expect(message).toContain('every record in "Thing" to'); - expect(message).toMatch(/Run it anyway\?$/); + const { title, description } = fullScanQuestion('Thing', [{ attribute: 'note' }, { attribute: 'category' }]); + expect(title).toBe('Scan every record in "Thing"?'); + expect(description).toContain('"note", "category"'); + expect(description).toContain('every record in "Thing" to'); }); it('names the related table for a join', () => { - const message = fullScanWarning('Thing', [ + const { title, description } = fullScanQuestion('Thing', [ { attribute: 'note' }, { attribute: 'brand.description', relatedTableName: 'Brand' }, ]); - expect(message).toContain('every record in "Thing" and "Brand"'); + expect(title).toBe('Scan every record in "Thing" and "Brand"?'); + expect(description).toContain('every record in "Thing" and "Brand"'); }); }); diff --git a/src/features/instance/databases/functions/fullScanFilter.ts b/src/features/instance/databases/functions/fullScanFilter.ts index ad8a9ae3d..8d5b0ad73 100644 --- a/src/features/instance/databases/functions/fullScanFilter.ts +++ b/src/features/instance/databases/functions/fullScanFilter.ts @@ -45,10 +45,17 @@ export function fullScanAttributes( return [...scans.values()]; } -export function fullScanWarning(tableName: string, scans: readonly FullScan[]): string { +export function fullScanQuestion( + tableName: string, + scans: readonly FullScan[], +): { title: string; description: string } { const attributes = scans.map(({ attribute }) => `"${attribute}"`).join(', '); const tables = [...new Set(scans.map((scan) => scan.relatedTableName ?? tableName))] .map((name) => `"${name}"`) .join(' and '); - return `No index can answer this filter on ${attributes}, so Harper will read every record in ${tables} to find the matches. On a large table that can take a long time and slow the server down for everyone using it.\n\nRun it anyway?`; + return { + title: `Scan every record in ${tables}?`, + description: + `No index can answer this filter on ${attributes}, so Harper will read every record in ${tables} to find the matches. On a large table that can take a long time and slow the server down for everyone using it.`, + }; } diff --git a/src/features/instance/databases/modals/EditTableRowModal.test.tsx b/src/features/instance/databases/modals/EditTableRowModal.test.tsx index e616efb55..221f459d6 100644 --- a/src/features/instance/databases/modals/EditTableRowModal.test.tsx +++ b/src/features/instance/databases/modals/EditTableRowModal.test.tsx @@ -556,6 +556,7 @@ describe('EditTableRowModal', () => { }); const previousButton = () => screen.getByRole('button', { name: 'Previous record' }); const nextButton = () => screen.getByRole('button', { name: 'Next record' }); + const discardQuestion = () => screen.queryByRole('alertdialog', { name: 'Discard your unsaved changes?' }); it('has no navigation when the caller has no result set to step through', () => { renderModal(); @@ -662,52 +663,88 @@ describe('EditTableRowModal', () => { // silently, the way Escape used to before it was blocked for the same reason. it('asks before a step discards unsaved edits, and stays put when refused', () => { const onNext = vi.fn(); - const confirmed = vi.spyOn(window, 'confirm').mockReturnValue(false); renderModal({ recordNavigation: navigation({ onNext }) }); edit('[{"name":"Grace"}]'); fireEvent.click(nextButton()); + expect(discardQuestion()).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'Keep Editing' })); - expect(confirmed).toHaveBeenCalled(); + expect(discardQuestion()).toBeNull(); expect(onNext).not.toHaveBeenCalled(); - confirmed.mockRestore(); + expect((screen.getByTestId('editor') as HTMLTextAreaElement).value).toBe('[{"name":"Grace"}]'); }); it('steps once the discard is confirmed', () => { const onNext = vi.fn(); - const confirmed = vi.spyOn(window, 'confirm').mockReturnValue(true); renderModal({ recordNavigation: navigation({ onNext }) }); edit('[{"name":"Grace"}]'); fireEvent.click(nextButton()); + fireEvent.click(screen.getByRole('button', { name: 'Discard Changes' })); expect(onNext).toHaveBeenCalledTimes(1); - confirmed.mockRestore(); + expect(discardQuestion()).toBeNull(); + }); + + it('asks once for the arrow keys too, and Escape answers no without closing the editor', () => { + const onNext = vi.fn(); + const setIsModalOpen = vi.fn(); + renderModal({ recordNavigation: navigation({ onNext }), setIsModalOpen }); + + edit('[{"name":"Grace"}]'); + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'ArrowRight' }); + const question = discardQuestion()!; + // The question's own keys stay with it rather than stepping the record behind it. + fireEvent.keyDown(question, { key: 'ArrowRight' }); + fireEvent.keyDown(question, { key: 'Escape' }); + + expect(discardQuestion()).toBeNull(); + expect(onNext).not.toHaveBeenCalled(); + expect(setIsModalOpen).not.toHaveBeenCalled(); + }); + + // A refetch of the record resets the draft the question was about, so a yes must not step on + // the strength of edits that are already gone. + it('drops the question once the record it was about is replaced', () => { + const onNext = vi.fn(); + const recordNavigation = navigation({ onNext }); + const { rerender } = renderAddressableModal({ recordNavigation, data: [{ id: 'a', name: 'A' }] }); + + edit('[{"id":"a","name":"edited A"}]'); + fireEvent.click(nextButton()); + expect(discardQuestion()).not.toBeNull(); + rerender(modal({ primaryKey: 'id', recordNavigation, data: [{ id: 'a', name: 'A, saved elsewhere' }] })); + expect(discardQuestion()).toBeNull(); + + // Edits typed after that are a new draft, which the old question never asked about. + edit('[{"id":"a","name":"edited again"}]'); + expect(discardQuestion()).toBeNull(); + expect(onNext).not.toHaveBeenCalled(); }); it('does not ask when there is nothing to discard', () => { - const confirmed = vi.spyOn(window, 'confirm').mockReturnValue(true); - renderModal({ recordNavigation: navigation() }); + const onNext = vi.fn(); + renderModal({ recordNavigation: navigation({ onNext }) }); fireEvent.click(nextButton()); - expect(confirmed).not.toHaveBeenCalled(); - confirmed.mockRestore(); + expect(discardQuestion()).toBeNull(); + expect(onNext).toHaveBeenCalledTimes(1); }); // The record arriving after a step is a different record, not this one changing underfoot — // the warning for that would be describing something that never happened. it('does not warn about a changed record when the user stepped to another one', () => { - const confirmed = vi.spyOn(window, 'confirm').mockReturnValue(true); const recordNavigation = navigation(); const { rerender } = renderModal({ recordNavigation, data: [{ id: 'a', name: 'A' }] }); edit('[{"id":"a","name":"edited A"}]'); fireEvent.click(nextButton()); + fireEvent.click(screen.getByRole('button', { name: 'Discard Changes' })); rerender(modal({ recordNavigation, data: [{ id: 'b', name: 'B' }] })); expect(screen.queryByText('This record changed while you were editing it')).toBeNull(); - confirmed.mockRestore(); }); }); @@ -784,7 +821,6 @@ describe('EditTableRowModal', () => { // further step must not stop to ask (Escape is refused on the same flag). it('leaves nothing to discard while the next record is still loading', () => { const onNext = vi.fn(); - const confirmed = vi.spyOn(window, 'confirm').mockReturnValue(true); const recordNavigation = { position: 1, total: 3, @@ -798,9 +834,8 @@ describe('EditTableRowModal', () => { rerender(modal({ primaryKey: 'id', recordNavigation, data: undefined })); fireEvent.click(screen.getByRole('button', { name: 'Next record' })); - expect(confirmed).not.toHaveBeenCalled(); + expect(screen.queryByRole('alertdialog')).toBeNull(); expect(onNext).toHaveBeenCalledTimes(1); - confirmed.mockRestore(); }); // The echo is recognised by "we just pushed this", not by "the text matches the record": a diff --git a/src/features/instance/databases/modals/EditTableRowModal.tsx b/src/features/instance/databases/modals/EditTableRowModal.tsx index a698fa85e..aa884fa96 100644 --- a/src/features/instance/databases/modals/EditTableRowModal.tsx +++ b/src/features/instance/databases/modals/EditTableRowModal.tsx @@ -1,6 +1,17 @@ import { Loading } from '@/components/Loading'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alertDialog'; import { Button } from '@/components/ui/button'; +import { buttonVariants } from '@/components/ui/buttonVariants'; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { primaryKeyMismatch } from '@/features/instance/databases/functions/primaryKeyMismatch'; import { @@ -180,14 +191,28 @@ export function EditTableRowModal({ // dealt with first (Escape is blocked for the same reason). Dropping the draft here rather than // leaving it for the reset above also keeps that reset's warning meaning what it says: the // stored record moved, not the user stepping away from it. - const stepToRecord = (step: () => void) => { - if (madeChanges && !confirm('Discard your unsaved changes to this record?')) { + const takeStep = (direction: 'previous' | 'next') => { + const allowed = direction === 'previous' ? recordNavigation?.hasPrevious : recordNavigation?.hasNext; + if (!recordNavigation || !allowed || isWritePending) { return; } setUpdatedTableRecordData(undefined); setMadeChanges(false); setDiscardedUnsavedEdits(false); - step(); + (direction === 'previous' ? recordNavigation.onPrevious : recordNavigation.onNext)(); + }; + // The question is about this draft. A draft that is gone (the record was replaced, or the dialog + // closed) takes the question with it, so a yes can't discard edits made after it was asked. + const [pendingStep, setPendingStep] = useState<'previous' | 'next'>(); + if (pendingStep && !(isModalOpen && madeChanges)) { + setPendingStep(undefined); + } + const stepToRecord = (direction: 'previous' | 'next') => { + if (madeChanges) { + setPendingStep(direction); + return; + } + takeStep(direction); }; const onNavigationKeyDown = (event: KeyboardEvent) => { if ( @@ -197,16 +222,16 @@ export function EditTableRowModal({ ) { return; } - const step = event.key === 'ArrowLeft' || event.key === 'ArrowUp' - ? (recordNavigation.hasPrevious ? recordNavigation.onPrevious : undefined) + const direction = event.key === 'ArrowLeft' || event.key === 'ArrowUp' + ? (recordNavigation.hasPrevious ? 'previous' : undefined) : event.key === 'ArrowRight' || event.key === 'ArrowDown' - ? (recordNavigation.hasNext ? recordNavigation.onNext : undefined) + ? (recordNavigation.hasNext ? 'next' : undefined) : undefined; - if (!step) { + if (!direction) { return; } event.preventDefault(); - stepToRecord(step); + stepToRecord(direction); }; return ( @@ -340,8 +365,8 @@ export function EditTableRowModal({ stepToRecord(recordNavigation.onPrevious)} - onNext={() => stepToRecord(recordNavigation.onNext)} + onPrevious={() => stepToRecord('previous')} + onNext={() => stepToRecord('next')} /> )}
@@ -455,6 +480,39 @@ export function EditTableRowModal({
+ {/* Outside the editor's content, so its keys never reach the record navigation. */} + { + if (!open) { + setPendingStep(undefined); + } + }} + > + + + Discard your unsaved changes? + + Moving to another record replaces the editor's contents, so your edits to this record will be lost. + + + + Keep Editing + { + const direction = pendingStep; + setPendingStep(undefined); + if (direction) { + takeStep(direction); + } + }} + > + Discard Changes + + + + ); } From 6ad4ba235651c6b33518fd834261acd0572ebf75 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Wed, 14 Oct 2026 15:58:45 -0400 Subject: [PATCH 09/21] fix(browse): keep a disabled Cleanup Orphan Blobs from running Cleanup Orphan Blobs is disabled while a cleanup is running, but its handler was on onClick, which Radix runs even for a disabled menu item; only CSS kept a pointer off it. A screen reader's activation or a synthetic click therefore still asked to start a second cleanup. The handler moves to onSelect, which Radix gates on disabled, as #1953 did for Export CSV. No other disabled-capable menu item under src/features/instance/databases uses onClick. Co-Authored-By: Claude Opus 5.5 --- .../components/DatabaseTableView.test.tsx | 21 +++++++++++++++++-- .../components/DatabaseTableView.tsx | 2 +- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/features/instance/databases/components/DatabaseTableView.test.tsx b/src/features/instance/databases/components/DatabaseTableView.test.tsx index 66ca6702e..754d1c89c 100644 --- a/src/features/instance/databases/components/DatabaseTableView.test.tsx +++ b/src/features/instance/databases/components/DatabaseTableView.test.tsx @@ -45,9 +45,9 @@ vi.mock('@/hooks/useAuth', () => ({ useStaffPermission: () => staffState.isInstanceOperator, })); -const cleanupOrphanBlobs = vi.hoisted(() => ({ mutate: vi.fn() })); +const cleanupOrphanBlobs = vi.hoisted(() => ({ mutate: vi.fn(), isPending: false })); vi.mock('@/integrations/api/instance/database/cleanupOrphanBlobs', () => ({ - useCleanupOrphanBlobsMutation: () => ({ mutate: cleanupOrphanBlobs.mutate, isPending: false }), + useCleanupOrphanBlobsMutation: () => ({ mutate: cleanupOrphanBlobs.mutate, isPending: cleanupOrphanBlobs.isPending }), })); vi.mock('@/hooks/usePermissions', () => ({ @@ -174,6 +174,7 @@ afterEach(() => { watchedValues.calls = []; deleteRecords.mutate.mockReset(); cleanupOrphanBlobs.mutate.mockReset(); + cleanupOrphanBlobs.isPending = false; routerState.params = {}; staffState.isInstanceOperator = false; vi.restoreAllMocks(); @@ -637,6 +638,22 @@ describe('DatabaseTableView asking before an action', () => { expect(document.body.style.pointerEvents).not.toBe('none'); }); + it('does not ask again while a cleanup is already running', () => { + staffState.isInstanceOperator = true; + routerState.params = { instanceId: 'instance-1' }; + cleanupOrphanBlobs.isPending = true; + renderView(); + + openTableOptions(); + const cleanup = screen.getByRole('menuitem', { name: 'Cleanup Orphan Blobs' }); + expect(cleanup.getAttribute('aria-disabled')).toBe('true'); + fireEvent.click(cleanup); + fireEvent.keyDown(cleanup, { key: 'Enter' }); + + expect(question('Clean up orphan blobs?')).toBeNull(); + expect(cleanupOrphanBlobs.mutate).not.toHaveBeenCalled(); + }); + // The view stays mounted when the route moves to another table, so a question asked about one // must not be answerable once the view shows the other. it('drops a question once the view is showing another table, and does not bring it back', () => { diff --git a/src/features/instance/databases/components/DatabaseTableView.tsx b/src/features/instance/databases/components/DatabaseTableView.tsx index e2089f7f1..2cba081ab 100644 --- a/src/features/instance/databases/components/DatabaseTableView.tsx +++ b/src/features/instance/databases/components/DatabaseTableView.tsx @@ -1157,7 +1157,7 @@ export function DatabaseTableView({ && !!instanceId && ( From 02dbf857bd2306649ce2ad30836ffe0160b3bd21 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Wed, 14 Oct 2026 15:59:32 -0400 Subject: [PATCH 10/21] fix(browse): don't offer Drop Table for a table the map no longer holds The tree and overview menus offered Drop Table whenever the database had more than one table, even for a table a refetch had just dropped from the map (another tab or user removed it while its menu was open). Like Add Records, Import and Export CSV since #1953, it now waits for the map to hold the table. The menu reads the map's own keys, so a table named `constructor` no longer finds Object.prototype's and counts as present. Co-Authored-By: Claude Opus 5.5 --- src/features/instance/databases/DESIGN.md | 6 +++++- .../components/TableContextMenuItems.test.tsx | 21 ++++++++++++++----- .../components/TableContextMenuItems.tsx | 12 +++++++---- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/src/features/instance/databases/DESIGN.md b/src/features/instance/databases/DESIGN.md index 2d870379d..f81bd4054 100644 --- a/src/features/instance/databases/DESIGN.md +++ b/src/features/instance/databases/DESIGN.md @@ -27,7 +27,11 @@ schema load. Add Records, Import and Export CSV need the key itself, so they wai that names one (`hasKnownPrimaryKey`, in the toolbar and in [`TableContextMenuItems`](components/TableContextMenuItems.tsx), the tree and overview menus). A table nothing has described yet gets the same answer as one without a key, rather than an Export -that then reports "no primary key found" (#1941). +that then reports "no primary key found" (#1941). The menus' Drop Table waits for the map to hold +the table too: one the map no longer holds may already have been dropped. + +A Radix menu item runs its `onClick` even while it is disabled, and only CSS keeps a pointer off it, +so an item that can be disabled takes its action through `onSelect`, which Radix gates. `$id` is not a stand-in key. These are live-probe observations on `harperdb:4.7.36` and `harper-pro:5.2.13` (2026-09), not covered by a test here: diff --git a/src/features/instance/databases/components/TableContextMenuItems.test.tsx b/src/features/instance/databases/components/TableContextMenuItems.test.tsx index d069bfa95..6ad7a1e98 100644 --- a/src/features/instance/databases/components/TableContextMenuItems.test.tsx +++ b/src/features/instance/databases/components/TableContextMenuItems.test.tsx @@ -32,12 +32,12 @@ function renderMenu(rolePermission: Record, dog: Record, instanceDatabaseMap: unknown) { +function renderMenuOver(rolePermission: Record, instanceDatabaseMap: unknown, tableName = 'dog') { permission.current = rolePermission as unknown as LocalRolePermission; const menu = (map: unknown) => ( - + ); @@ -109,9 +109,14 @@ describe('TableContextMenuItems on a table with no primary key', () => { }); describe('TableContextMenuItems on a table the map does not describe', () => { + const dropTable = () => screen.queryByRole('menuitem', { name: 'Drop Table' }); + // Two other tables, so the database's last table is not what withholds Drop Table. + const otherTables = { cat: keyedTable, cow: keyedTable }; + function expectNothingOffered() { expect(addRecords()).toBeNull(); expect(importData()).toBeNull(); + expect(dropTable()).toBeNull(); expect(exportCsv().hasAttribute('data-disabled')).toBe(true); fireEvent.click(exportCsv()); expect(exportCsvSpy).not.toHaveBeenCalled(); @@ -123,17 +128,23 @@ describe('TableContextMenuItems on a table the map does not describe', () => { }); it('offers nothing that needs its key when the map lacks the table', () => { - renderMenuOver({ super_user: true }, { data: { cat: keyedTable } }); + renderMenuOver({ super_user: true }, { data: otherTables }); + expectNothingOffered(); + }); + + it('reads only the map’s own tables, whatever the table is called', () => { + renderMenuOver({ super_user: true }, { data: otherTables }, 'constructor'); expectNothingOffered(); }); // Both menus list tables from the map, so an open one meets an undescribed table when a refetch // lands without it: another tab or user dropped it. it('withdraws them from an open menu once a refetched map drops the table', () => { - const { rerenderOver } = renderMenuOver({ super_user: true }, { data: { dog: keyedTable, cat: keyedTable } }); + const { rerenderOver } = renderMenuOver({ super_user: true }, { data: { dog: keyedTable, ...otherTables } }); expect(exportCsv().hasAttribute('data-disabled')).toBe(false); + expect(dropTable()).not.toBeNull(); - rerenderOver({ data: { cat: keyedTable } }); + rerenderOver({ data: otherTables }); expectNothingOffered(); }); }); diff --git a/src/features/instance/databases/components/TableContextMenuItems.tsx b/src/features/instance/databases/components/TableContextMenuItems.tsx index b2439aef4..116f0a0e5 100644 --- a/src/features/instance/databases/components/TableContextMenuItems.tsx +++ b/src/features/instance/databases/components/TableContextMenuItems.tsx @@ -28,8 +28,12 @@ export function TableContextMenuItems({ databaseName, tableName, instanceDatabas const canImport = useInstanceImportDataPermission(instanceId ?? clusterId, databaseName, tableName); const { exportCsv } = useExportTableCsv(); - const isLastTable = Object.keys(instanceDatabaseMap?.[databaseName] || {}).length <= 1; - const instanceTable = instanceDatabaseMap?.[databaseName]?.[tableName]; + const databaseTables = instanceDatabaseMap?.[databaseName]; + const isLastTable = Object.keys(databaseTables || {}).length <= 1; + // Own keys only: a table named `constructor` would otherwise find Object.prototype's. + const instanceTable = databaseTables && Object.hasOwn(databaseTables, tableName) + ? databaseTables[tableName] + : undefined; const primaryKey = browsePrimaryKey(instanceTable); // Same withdrawal as the table view's toolbar: rows added here couldn't be listed there. const hasKnownPrimaryKey = !!primaryKey; @@ -41,7 +45,7 @@ export function TableContextMenuItems({ databaseName, tableName, instanceDatabas primaryKey, conditions: null, instanceTable, - databaseTables: instanceDatabaseMap?.[databaseName], + databaseTables, }); }; @@ -63,7 +67,7 @@ export function TableContextMenuItems({ databaseName, tableName, instanceDatabas Export CSV - {canManage && !isLastTable && ( + {canManage && instanceTable && !isLastTable && ( <> Date: Wed, 14 Oct 2026 16:11:40 -0400 Subject: [PATCH 11/21] fix(browse): leave a dropped table even when it is named like an Object member resolveDatabasesRedirect sends the view away from a database or table the map no longer holds, and that redirect is what keeps the table view's toolbar from offering Drop Table for a dropped table. It looked the names up with a plain property read, so a table named `constructor` or `toString` found Object.prototype's member, counted as present, and the view stayed on a table that was gone. It now checks own keys. Co-Authored-By: Claude Opus 5.5 --- src/features/instance/databases/DESIGN.md | 4 +++- .../functions/resolveDatabasesRedirect.test.ts | 11 +++++++++++ .../databases/functions/resolveDatabasesRedirect.ts | 5 +++-- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/features/instance/databases/DESIGN.md b/src/features/instance/databases/DESIGN.md index f81bd4054..064c9172e 100644 --- a/src/features/instance/databases/DESIGN.md +++ b/src/features/instance/databases/DESIGN.md @@ -28,7 +28,9 @@ that names one (`hasKnownPrimaryKey`, in the toolbar and in [`TableContextMenuItems`](components/TableContextMenuItems.tsx), the tree and overview menus). A table nothing has described yet gets the same answer as one without a key, rather than an Export that then reports "no primary key found" (#1941). The menus' Drop Table waits for the map to hold -the table too: one the map no longer holds may already have been dropped. +the table too: one the map no longer holds may already have been dropped. The table view needs no +such gate, because `resolveDatabasesRedirect` leaves a table the map no longer holds. Both read the +map's own keys, so a table named like an `Object.prototype` member isn't found there by mistake. A Radix menu item runs its `onClick` even while it is disabled, and only CSS keeps a pointer off it, so an item that can be disabled takes its action through `onSelect`, which Radix gates. diff --git a/src/features/instance/databases/functions/resolveDatabasesRedirect.test.ts b/src/features/instance/databases/functions/resolveDatabasesRedirect.test.ts index d412e5d3a..d6f583ac4 100644 --- a/src/features/instance/databases/functions/resolveDatabasesRedirect.test.ts +++ b/src/features/instance/databases/functions/resolveDatabasesRedirect.test.ts @@ -44,6 +44,17 @@ describe('resolveDatabasesRedirect', () => { expect(resolveDatabasesRedirect(map, { databaseName: 'alpha', tableName: 'widgets' })).toBeNull(); }); + it('treats a database or table named like an Object.prototype member as missing unless the map has it', () => { + expect(resolveDatabasesRedirect(map, { databaseName: 'constructor' })).toEqual({ + databaseName: 'alpha', + tableName: undefined, + }); + expect(resolveDatabasesRedirect(map, { databaseName: 'alpha', tableName: 'toString' })).toEqual({ + databaseName: 'alpha', + tableName: undefined, + }); + }); + it('stays put on an empty database (shows its overview, no redirect loop)', () => { expect(resolveDatabasesRedirect(map, { databaseName: 'empty' })).toBeNull(); }); diff --git a/src/features/instance/databases/functions/resolveDatabasesRedirect.ts b/src/features/instance/databases/functions/resolveDatabasesRedirect.ts index 02639a13b..e2d739ded 100644 --- a/src/features/instance/databases/functions/resolveDatabasesRedirect.ts +++ b/src/features/instance/databases/functions/resolveDatabasesRedirect.ts @@ -17,7 +17,8 @@ export function resolveDatabasesRedirect( if (!instanceDatabaseMap) { return null; } - const databaseExists = !!(params.databaseName && instanceDatabaseMap[params.databaseName]); + // Own keys only: a database or table named `constructor` would otherwise find Object.prototype's. + const databaseExists = !!params.databaseName && Object.hasOwn(instanceDatabaseMap, params.databaseName); if (!databaseExists) { const firstDatabaseName = Object.keys(instanceDatabaseMap).sort()[0]; // No redirect when there are no databases at all, or the fallback would land on the same @@ -27,7 +28,7 @@ export function resolveDatabasesRedirect( } return null; } - if (params.tableName && !instanceDatabaseMap[params.databaseName!][params.tableName]) { + if (params.tableName && !Object.hasOwn(instanceDatabaseMap[params.databaseName!], params.tableName)) { return { databaseName: params.databaseName!, tableName: undefined }; } return null; From 81ee118a82a01cecfb65fdb5837ef3fba808c1c6 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Wed, 14 Oct 2026 16:19:01 -0400 Subject: [PATCH 12/21] fix(browse): read the schema maps by name through own-key helpers only Browse looked tables and databases up in the describe_all map and the schema-relationship map with plain property reads, so a name such as `constructor` or `toString` found Object.prototype's member. Import Data then took a new table called `constructor` for an existing one and said nothing about creating it, and a relationship lookup could be handed a function where it expected an array. describedTable.ts adds ownEntry, describedDatabase, describedTable and declaredRelationships, which read own keys only. The table menus, the table view, the overview, Import Data, Add Records, the CSV export, the relationship lookup and resolveDatabasesRedirect now go through them. The redirect also treats a database entry with no describe as missing instead of throwing on it. Co-Authored-By: Claude Opus 5.5 --- src/features/instance/databases/DESIGN.md | 6 ++-- .../components/DatabaseActionModals.tsx | 15 +++++++-- .../databases/components/DatabaseOverview.tsx | 3 +- .../components/DatabaseTableView.tsx | 11 +++++-- .../components/TableContextMenuItems.tsx | 8 ++--- .../functions/describedTable.test.ts | 33 +++++++++++++++++++ .../databases/functions/describedTable.ts | 29 ++++++++++++++++ .../functions/relationshipAttributes.ts | 3 +- .../resolveDatabasesRedirect.test.ts | 8 +++++ .../functions/resolveDatabasesRedirect.ts | 6 ++-- .../databases/hooks/useExportTableCsv.ts | 3 +- .../ImportDataModal.permissions.test.tsx | 9 +++++ .../databases/modals/ImportDataModal.tsx | 17 ++++++---- 13 files changed, 126 insertions(+), 25 deletions(-) create mode 100644 src/features/instance/databases/functions/describedTable.test.ts create mode 100644 src/features/instance/databases/functions/describedTable.ts diff --git a/src/features/instance/databases/DESIGN.md b/src/features/instance/databases/DESIGN.md index 064c9172e..364ab11af 100644 --- a/src/features/instance/databases/DESIGN.md +++ b/src/features/instance/databases/DESIGN.md @@ -29,8 +29,10 @@ that names one (`hasKnownPrimaryKey`, in the toolbar and in table nothing has described yet gets the same answer as one without a key, rather than an Export that then reports "no primary key found" (#1941). The menus' Drop Table waits for the map to hold the table too: one the map no longer holds may already have been dropped. The table view needs no -such gate, because `resolveDatabasesRedirect` leaves a table the map no longer holds. Both read the -map's own keys, so a table named like an `Object.prototype` member isn't found there by mistake. +such gate, because `resolveDatabasesRedirect` leaves a table the map no longer holds. Browse reads +the describe map and the schema-relationship map by database and table name only through +[`describedTable.ts`](functions/describedTable.ts), which takes their own keys, so a table named like +an `Object.prototype` member (`constructor`, `toString`) is never found there by mistake. A Radix menu item runs its `onClick` even while it is disabled, and only CSS keeps a pointer off it, so an item that can be disabled takes its action through `onSelect`, which Radix gates. diff --git a/src/features/instance/databases/components/DatabaseActionModals.tsx b/src/features/instance/databases/components/DatabaseActionModals.tsx index deb672de4..04131d47d 100644 --- a/src/features/instance/databases/components/DatabaseActionModals.tsx +++ b/src/features/instance/databases/components/DatabaseActionModals.tsx @@ -1,4 +1,9 @@ import { useInstanceClientIdParams } from '@/config/useInstanceClient'; +import { + declaredRelationships, + describedDatabase, + describedTable, +} from '@/features/instance/databases/functions/describedTable'; import { getSchemaRelationshipsQueryOptions } from '@/features/instance/databases/functions/schemaRelationships'; import { AddTableRowModal } from '@/features/instance/databases/modals/AddTableRowModal'; import { CreateNewTableModal } from '@/features/instance/databases/modals/CreateNewTableModal'; @@ -55,7 +60,7 @@ export function DatabaseActionModals({ instanceDatabaseMap }: { instanceDatabase ); const addTableFromMap = addTarget - ? instanceDatabaseMap?.[addTarget.databaseName]?.[addTarget.tableName] + ? describedTable(instanceDatabaseMap, addTarget.databaseName, addTarget.tableName) : undefined; const [describedAddTable, setDescribedAddTable] = useState<{ target: AddTarget; table: InstanceTable }>(); const addInstanceTable = addTableFromMap @@ -123,8 +128,12 @@ export function DatabaseActionModals({ instanceDatabaseMap }: { instanceDatabase { if (!open) { diff --git a/src/features/instance/databases/components/DatabaseOverview.tsx b/src/features/instance/databases/components/DatabaseOverview.tsx index dcdc4f10a..baec0d796 100644 --- a/src/features/instance/databases/components/DatabaseOverview.tsx +++ b/src/features/instance/databases/components/DatabaseOverview.tsx @@ -3,6 +3,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useInstanceClientIdParams } from '@/config/useInstanceClient'; import { TableRowContextMenu } from '@/features/instance/databases/components/TableRowContextMenu'; +import { describedDatabase } from '@/features/instance/databases/functions/describedTable'; import { summarizeDatabaseCounts } from '@/features/instance/databases/functions/summarizeDatabaseCounts'; import { useInstanceBrowseManagePermission, useInstanceImportOperationsPermission } from '@/hooks/usePermissions'; import { InstanceDatabaseMap } from '@/integrations/api/api.patch'; @@ -58,7 +59,7 @@ export function DatabaseOverview({ instanceDatabaseMap, databaseName }: { const canManage = useInstanceBrowseManagePermission(); const canImport = useInstanceImportOperationsPermission(); - const tables = instanceDatabaseMap?.[databaseName] ?? {}; + const tables = describedDatabase(instanceDatabaseMap, databaseName) ?? {}; const tableNames = Object.keys(tables).sort(); // Sizes/schema are already in the fast (count-skipping) map, so they render instantly. Row counts diff --git a/src/features/instance/databases/components/DatabaseTableView.tsx b/src/features/instance/databases/components/DatabaseTableView.tsx index 2cba081ab..d5115b628 100644 --- a/src/features/instance/databases/components/DatabaseTableView.tsx +++ b/src/features/instance/databases/components/DatabaseTableView.tsx @@ -18,6 +18,11 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdownMenu'; import { useInstanceClientIdParams } from '@/config/useInstanceClient'; +import { + declaredRelationships, + describedDatabase, + describedTable, +} from '@/features/instance/databases/functions/describedTable'; import { ExtraRecordAttribute, extraRecordAttributes, @@ -169,8 +174,8 @@ export function DatabaseTableView({ // describe_table backfills this table's count asynchronously. Render schema from whichever arrives first // -- the map is usually ready before describe_table -- so columns and records are never gated on the // (slower) count scan. describe_table wins once present: it's the per-table, refresh-invalidated copy. - const databaseTables = instanceDatabaseMap?.[databaseName]; - const tableFromMap = databaseTables?.[tableName]; + const databaseTables = describedDatabase(instanceDatabaseMap, databaseName); + const tableFromMap = describedTable(instanceDatabaseMap, databaseName, tableName); const instanceTable = describeTableData ?? tableFromMap; // What the empty state may actually invite, each gated on the source it would use rather than on @@ -195,7 +200,7 @@ export function DatabaseTableView({ const hasReplaceGrants = useInstanceTablePutPermission(instanceId ?? clusterId, databaseName, tableName); const replaceBlockedReason = replaceRecordsBlockedReason(registrationInfo?.version, hasReplaceGrants); const canReplaceRecords = replaceBlockedReason === undefined; - const schemaRelationships = schemaRelationshipMap?.[databaseName]?.[tableName]; + const schemaRelationships = declaredRelationships(schemaRelationshipMap, databaseName, tableName); // Read the attributes from the same map the modal's Random Data option reads (NOT the // describe-first `instanceTable`): where the two disagree, offering Seed reopens the empty-dropdown // dead end this gate exists to close. diff --git a/src/features/instance/databases/components/TableContextMenuItems.tsx b/src/features/instance/databases/components/TableContextMenuItems.tsx index 116f0a0e5..acc5270ef 100644 --- a/src/features/instance/databases/components/TableContextMenuItems.tsx +++ b/src/features/instance/databases/components/TableContextMenuItems.tsx @@ -1,4 +1,5 @@ import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/contextMenu'; +import { describedDatabase, describedTable } from '@/features/instance/databases/functions/describedTable'; import { browsePrimaryKey } from '@/features/instance/databases/functions/formatBrowseDataTableHeader'; import { useExportTableCsv } from '@/features/instance/databases/hooks/useExportTableCsv'; import { @@ -28,12 +29,9 @@ export function TableContextMenuItems({ databaseName, tableName, instanceDatabas const canImport = useInstanceImportDataPermission(instanceId ?? clusterId, databaseName, tableName); const { exportCsv } = useExportTableCsv(); - const databaseTables = instanceDatabaseMap?.[databaseName]; + const databaseTables = describedDatabase(instanceDatabaseMap, databaseName); const isLastTable = Object.keys(databaseTables || {}).length <= 1; - // Own keys only: a table named `constructor` would otherwise find Object.prototype's. - const instanceTable = databaseTables && Object.hasOwn(databaseTables, tableName) - ? databaseTables[tableName] - : undefined; + const instanceTable = describedTable(instanceDatabaseMap, databaseName, tableName); const primaryKey = browsePrimaryKey(instanceTable); // Same withdrawal as the table view's toolbar: rows added here couldn't be listed there. const hasKnownPrimaryKey = !!primaryKey; diff --git a/src/features/instance/databases/functions/describedTable.test.ts b/src/features/instance/databases/functions/describedTable.test.ts new file mode 100644 index 000000000..525eeb065 --- /dev/null +++ b/src/features/instance/databases/functions/describedTable.test.ts @@ -0,0 +1,33 @@ +import type { SchemaRelationshipMap } from '@/features/instance/databases/functions/schemaRelationships'; +import { InstanceDatabaseMap } from '@/integrations/api/api.patch'; +import { describe, expect, it } from 'vitest'; +import { declaredRelationships, describedDatabase, describedTable, ownEntry } from './describedTable'; + +const dog = { attributes: [] }; +const map = { data: { dog } } as unknown as InstanceDatabaseMap; +const relationships = [{ attribute: 'owner' }]; +const relationshipMap = { data: { dog: relationships } } as unknown as SchemaRelationshipMap; + +describe('describedDatabase and describedTable', () => { + it('return the map’s own entries', () => { + expect(describedDatabase(map, 'data')).toEqual({ dog }); + expect(describedTable(map, 'data', 'dog')).toBe(dog); + expect(declaredRelationships(relationshipMap, 'data', 'dog')).toBe(relationships); + }); + + it('return nothing for a name the map lacks, or before the map loads', () => { + expect(describedDatabase(undefined, 'data')).toBeUndefined(); + expect(describedTable(undefined, 'data', 'dog')).toBeUndefined(); + expect(describedTable(map, 'data', 'cat')).toBeUndefined(); + expect(describedTable(map, 'other', 'dog')).toBeUndefined(); + expect(declaredRelationships(undefined, 'data', 'dog')).toBeUndefined(); + expect(ownEntry(null, 'data')).toBeUndefined(); + }); + + it('never find an Object.prototype member', () => { + expect(describedDatabase(map, 'constructor')).toBeUndefined(); + expect(describedTable(map, 'data', 'toString')).toBeUndefined(); + expect(describedTable(map, 'constructor', 'name')).toBeUndefined(); + expect(declaredRelationships(relationshipMap, 'data', 'constructor')).toBeUndefined(); + }); +}); diff --git a/src/features/instance/databases/functions/describedTable.ts b/src/features/instance/databases/functions/describedTable.ts new file mode 100644 index 000000000..e7fc67686 --- /dev/null +++ b/src/features/instance/databases/functions/describedTable.ts @@ -0,0 +1,29 @@ +import { SchemaRelationship, SchemaRelationshipMap } from '@/features/instance/databases/functions/schemaRelationships'; +import { InstanceDatabaseMap, InstanceDatabaseTableMap, InstanceTable } from '@/integrations/api/api.patch'; + +export function ownEntry(record: Readonly> | null | undefined, key: string): T | undefined { + return record && Object.hasOwn(record, key) ? record[key] : undefined; +} + +export function describedDatabase( + instanceDatabaseMap: InstanceDatabaseMap | undefined, + databaseName: string, +): InstanceDatabaseTableMap | undefined { + return ownEntry(instanceDatabaseMap, databaseName); +} + +export function describedTable( + instanceDatabaseMap: InstanceDatabaseMap | undefined, + databaseName: string, + tableName: string, +): InstanceTable | undefined { + return ownEntry(describedDatabase(instanceDatabaseMap, databaseName), tableName); +} + +export function declaredRelationships( + schemaRelationshipMap: SchemaRelationshipMap | undefined, + databaseName: string, + tableName: string, +): SchemaRelationship[] | undefined { + return ownEntry(ownEntry(schemaRelationshipMap, databaseName), tableName); +} diff --git a/src/features/instance/databases/functions/relationshipAttributes.ts b/src/features/instance/databases/functions/relationshipAttributes.ts index a2809bea9..ac63ecb91 100644 --- a/src/features/instance/databases/functions/relationshipAttributes.ts +++ b/src/features/instance/databases/functions/relationshipAttributes.ts @@ -1,3 +1,4 @@ +import { ownEntry } from '@/features/instance/databases/functions/describedTable'; import { SchemaRelationship } from '@/features/instance/databases/functions/schemaRelationships'; import { InstanceAttribute, InstanceDatabaseTableMap, InstanceTable } from '@/integrations/api/api.patch'; @@ -152,7 +153,7 @@ export function getRelationshipInfoMap( } } for (const declared of schemaRelationships ?? []) { - const relatedTable = databaseTables?.[declared.relatedTableName]; + const relatedTable = ownEntry(databaseTables, declared.relatedTableName); const relatedPrimaryKey = relatedTable?.primary_key ?? relatedTable?.hash_attribute; if (!relatedTable || !relatedPrimaryKey || !databaseTables) { continue; diff --git a/src/features/instance/databases/functions/resolveDatabasesRedirect.test.ts b/src/features/instance/databases/functions/resolveDatabasesRedirect.test.ts index d6f583ac4..7fff12a14 100644 --- a/src/features/instance/databases/functions/resolveDatabasesRedirect.test.ts +++ b/src/features/instance/databases/functions/resolveDatabasesRedirect.test.ts @@ -55,6 +55,14 @@ describe('resolveDatabasesRedirect', () => { }); }); + it('treats a database with no describe as missing rather than throwing', () => { + const withHole = { alpha: {}, broken: null } as unknown as InstanceDatabaseMap; + expect(resolveDatabasesRedirect(withHole, { databaseName: 'broken', tableName: 'orders' })).toEqual({ + databaseName: 'alpha', + tableName: undefined, + }); + }); + it('stays put on an empty database (shows its overview, no redirect loop)', () => { expect(resolveDatabasesRedirect(map, { databaseName: 'empty' })).toBeNull(); }); diff --git a/src/features/instance/databases/functions/resolveDatabasesRedirect.ts b/src/features/instance/databases/functions/resolveDatabasesRedirect.ts index e2d739ded..6af7656d9 100644 --- a/src/features/instance/databases/functions/resolveDatabasesRedirect.ts +++ b/src/features/instance/databases/functions/resolveDatabasesRedirect.ts @@ -1,3 +1,4 @@ +import { describedDatabase, describedTable } from '@/features/instance/databases/functions/describedTable'; import { InstanceDatabaseMap } from '@/integrations/api/api.patch'; /** @@ -17,8 +18,7 @@ export function resolveDatabasesRedirect( if (!instanceDatabaseMap) { return null; } - // Own keys only: a database or table named `constructor` would otherwise find Object.prototype's. - const databaseExists = !!params.databaseName && Object.hasOwn(instanceDatabaseMap, params.databaseName); + const databaseExists = !!params.databaseName && !!describedDatabase(instanceDatabaseMap, params.databaseName); if (!databaseExists) { const firstDatabaseName = Object.keys(instanceDatabaseMap).sort()[0]; // No redirect when there are no databases at all, or the fallback would land on the same @@ -28,7 +28,7 @@ export function resolveDatabasesRedirect( } return null; } - if (params.tableName && !Object.hasOwn(instanceDatabaseMap[params.databaseName!], params.tableName)) { + if (params.tableName && !describedTable(instanceDatabaseMap, params.databaseName!, params.tableName)) { return { databaseName: params.databaseName!, tableName: undefined }; } return null; diff --git a/src/features/instance/databases/hooks/useExportTableCsv.ts b/src/features/instance/databases/hooks/useExportTableCsv.ts index 96fafe91b..c371f0348 100644 --- a/src/features/instance/databases/hooks/useExportTableCsv.ts +++ b/src/features/instance/databases/hooks/useExportTableCsv.ts @@ -1,4 +1,5 @@ import { useInstanceClientIdParams } from '@/config/useInstanceClient'; +import { declaredRelationships } from '@/features/instance/databases/functions/describedTable'; import { getSchemaRelationshipsQueryOptions } from '@/features/instance/databases/functions/schemaRelationships'; import { buildExportGetAttributes, @@ -78,7 +79,7 @@ export function useExportTableCsv() { records, instanceTable, databaseTables, - schemaRelationshipMap[databaseName]?.[tableName], + declaredRelationships(schemaRelationshipMap, databaseName, tableName), ); const blob = tableCsvBlob(records, columns); const url = URL.createObjectURL(blob); diff --git a/src/features/instance/databases/modals/ImportDataModal.permissions.test.tsx b/src/features/instance/databases/modals/ImportDataModal.permissions.test.tsx index 6123f3b5d..f89be1703 100644 --- a/src/features/instance/databases/modals/ImportDataModal.permissions.test.tsx +++ b/src/features/instance/databases/modals/ImportDataModal.permissions.test.tsx @@ -143,4 +143,13 @@ describe('ImportDataModal name fields', () => { expect(screen.getByText('Maximum 75 characters reached.')).toBeTruthy(); expect(screen.getByText('Maximum 250 characters reached.')).toBeTruthy(); }); + + it('says a table named like an Object.prototype member will be created, as for any new name', () => { + renderModal({ super_user: true }); + expect(screen.queryByText('New table')).toBeNull(); + + fireEvent.change(screen.getByLabelText('Table'), { target: { value: 'constructor' } }); + + expect(screen.getByText('New table')).toBeTruthy(); + }); }); diff --git a/src/features/instance/databases/modals/ImportDataModal.tsx b/src/features/instance/databases/modals/ImportDataModal.tsx index 6a55d6699..f0462a8ca 100644 --- a/src/features/instance/databases/modals/ImportDataModal.tsx +++ b/src/features/instance/databases/modals/ImportDataModal.tsx @@ -28,6 +28,11 @@ import { } from '@/components/ui/select'; import { Separator } from '@/components/ui/separator'; import { useInstanceClientIdParams } from '@/config/useInstanceClient'; +import { + declaredRelationships, + describedDatabase, + describedTable, +} from '@/features/instance/databases/functions/describedTable'; import { generateRandomRecords, randomizableAttributes, @@ -193,15 +198,15 @@ export function ImportDataModal({ const watchedTable = form.watch('table'); const selectedDataset = sampleDatasets.find((dataset) => dataset.id === datasetId); - const targetTable = instanceDatabaseMap?.[watchedDatabase || 'data']?.[watchedTable]; + const targetTable = describedTable(instanceDatabaseMap, watchedDatabase || 'data', watchedTable); const tableExists = !!targetTable; const tableWillBeCreated = !!instanceDatabaseMap && !!watchedTable && !tableExists; // Random data needs existing columns to model values on, so the option only appears // when the target table already has some beyond the primary key and system fields. const fillableAttributes = randomizableAttributes( targetTable?.attributes, - instanceDatabaseMap?.[watchedDatabase || 'data'], - schemaRelationshipMap?.[watchedDatabase || 'data']?.[watchedTable], + describedDatabase(instanceDatabaseMap, watchedDatabase || 'data'), + declaredRelationships(schemaRelationshipMap, watchedDatabase || 'data', watchedTable), ); const canGenerateRandom = fillableAttributes.length > 0; const isRandomDataset = datasetId === RANDOM_DATASET_ID; @@ -256,9 +261,9 @@ export function ImportDataModal({ let source: ImportSource; if (values.method === 'sample' && values.datasetId === RANDOM_DATASET_ID) { const attributes = randomizableAttributes( - instanceDatabaseMap?.[database]?.[table]?.attributes, - instanceDatabaseMap?.[database], - schemaRelationshipMap?.[database]?.[table], + describedTable(instanceDatabaseMap, database, table)?.attributes, + describedDatabase(instanceDatabaseMap, database), + declaredRelationships(schemaRelationshipMap, database, table), ); if (attributes.length === 0) { form.setError('datasetId', { From c419cb9a1c648b21b9a8207019232eb7f1a14869 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Wed, 14 Oct 2026 16:42:44 -0400 Subject: [PATCH 13/21] fix(browse): build the schema-relationship map without a prototype parseSchemaRelationships filled plain objects keyed by database and table names read from the instance's component schema files. A database named `__proto__` therefore wrote its tables onto Object.prototype, and one named `constructor` wrote them onto Object itself. Both levels of the map are now null-prototype objects, where those names are ordinary keys, and the query turns off structural sharing, which would rebuild a changed level as a plain object on the next refetch. ownEntry also returns undefined, not null, for an entry that holds nothing. The databases tree also reads each database's tables through describedDatabase, so every by-name read of the describe map in Browse goes through the own-key helpers, as DESIGN.md says. Listing works the same way: the tree, the overview, the redirect's fallback and both "last table" checks use describedDatabaseNames and describedTableNames, which name only entries that have a describe, so they agree with the lookups on an entry that has none. Co-Authored-By: Claude Opus 5.5 --- .../databases/components/DatabaseOverview.tsx | 4 +- .../components/DatabaseTableView.tsx | 5 +- .../DatabasesTree/buildItems.test.ts | 6 ++ .../components/DatabasesTree/buildItems.ts | 5 +- .../components/TableContextMenuItems.tsx | 8 ++- .../functions/describedTable.test.ts | 19 +++++- .../databases/functions/describedTable.ts | 14 ++++- .../resolveDatabasesRedirect.test.ts | 2 + .../functions/resolveDatabasesRedirect.ts | 8 ++- .../functions/schemaRelationships.test.ts | 62 ++++++++++++++++++- .../functions/schemaRelationships.ts | 9 ++- 11 files changed, 127 insertions(+), 15 deletions(-) diff --git a/src/features/instance/databases/components/DatabaseOverview.tsx b/src/features/instance/databases/components/DatabaseOverview.tsx index baec0d796..0615a8298 100644 --- a/src/features/instance/databases/components/DatabaseOverview.tsx +++ b/src/features/instance/databases/components/DatabaseOverview.tsx @@ -3,7 +3,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useInstanceClientIdParams } from '@/config/useInstanceClient'; import { TableRowContextMenu } from '@/features/instance/databases/components/TableRowContextMenu'; -import { describedDatabase } from '@/features/instance/databases/functions/describedTable'; +import { describedDatabase, describedTableNames } from '@/features/instance/databases/functions/describedTable'; import { summarizeDatabaseCounts } from '@/features/instance/databases/functions/summarizeDatabaseCounts'; import { useInstanceBrowseManagePermission, useInstanceImportOperationsPermission } from '@/hooks/usePermissions'; import { InstanceDatabaseMap } from '@/integrations/api/api.patch'; @@ -60,7 +60,7 @@ export function DatabaseOverview({ instanceDatabaseMap, databaseName }: { const canImport = useInstanceImportOperationsPermission(); const tables = describedDatabase(instanceDatabaseMap, databaseName) ?? {}; - const tableNames = Object.keys(tables).sort(); + const tableNames = describedTableNames(instanceDatabaseMap, databaseName); // Sizes/schema are already in the fast (count-skipping) map, so they render instantly. Row counts // aren't, so fetch them per-table via describe_table's cheap estimate path -- but only for the diff --git a/src/features/instance/databases/components/DatabaseTableView.tsx b/src/features/instance/databases/components/DatabaseTableView.tsx index d5115b628..77f86cdb7 100644 --- a/src/features/instance/databases/components/DatabaseTableView.tsx +++ b/src/features/instance/databases/components/DatabaseTableView.tsx @@ -22,6 +22,7 @@ import { declaredRelationships, describedDatabase, describedTable, + describedTableNames, } from '@/features/instance/databases/functions/describedTable'; import { ExtraRecordAttribute, @@ -252,8 +253,8 @@ export function DatabaseTableView({ // answers `false` when it simply doesn't know, which would offer an irreversible action on a // guess): dropping a table needs positive evidence that another one remains. const canDropTable = useMemo( - () => canManageBrowseInstance && !!databaseTables && Object.keys(databaseTables).length > 1, - [canManageBrowseInstance, databaseTables], + () => canManageBrowseInstance && describedTableNames(instanceDatabaseMap, databaseName).length > 1, + [canManageBrowseInstance, instanceDatabaseMap, databaseName], ); const { toggled: filtersToggled, toggleOn: showFilters, toggleOff: hideFilters } = useToggler(false); diff --git a/src/features/instance/databases/components/DatabasesTree/buildItems.test.ts b/src/features/instance/databases/components/DatabasesTree/buildItems.test.ts index bedcf4798..1add35ff7 100644 --- a/src/features/instance/databases/components/DatabasesTree/buildItems.test.ts +++ b/src/features/instance/databases/components/DatabasesTree/buildItems.test.ts @@ -37,6 +37,12 @@ describe('buildItems', () => { expect(items['empty'].children).toEqual([]); }); + it('lists only databases the map has a describe for', () => { + const { items } = buildItems({ ...map, hole: null } as unknown as InstanceDatabaseMap, { canManage: true }); + expect(items[rootId].children).toEqual([createTableId, 'alpha', 'beta', 'empty']); + expect(items['hole']).toBeUndefined(); + }); + it('gives tables composite ids so duplicate names across databases do not collide', () => { const { items } = buildItems(map, { canManage: true }); expect(items['alpha/shared'].data).toEqual({ kind: 'table', databaseName: 'alpha', tableName: 'shared' }); diff --git a/src/features/instance/databases/components/DatabasesTree/buildItems.ts b/src/features/instance/databases/components/DatabasesTree/buildItems.ts index 64576655e..8dc1e562c 100644 --- a/src/features/instance/databases/components/DatabasesTree/buildItems.ts +++ b/src/features/instance/databases/components/DatabasesTree/buildItems.ts @@ -1,3 +1,4 @@ +import { describedDatabaseNames, describedTableNames } from '@/features/instance/databases/functions/describedTable'; import { InstanceDatabaseMap } from '@/integrations/api/api.patch'; import type { TreeItem } from 'react-complex-tree'; import { createTableId, rootId } from './specialItems'; @@ -28,10 +29,10 @@ export function buildItems( { canManage }: { canManage: boolean }, ): { items: Record>; rootId: string } { const items: Record> = {}; - const databaseNames = Object.keys(instanceDatabaseMap || {}).sort(); + const databaseNames = describedDatabaseNames(instanceDatabaseMap); for (const databaseName of databaseNames) { - const tableNames = Object.keys(instanceDatabaseMap?.[databaseName] || {}).sort(); + const tableNames = describedTableNames(instanceDatabaseMap, databaseName); items[databaseName] = { index: databaseName, isFolder: true, diff --git a/src/features/instance/databases/components/TableContextMenuItems.tsx b/src/features/instance/databases/components/TableContextMenuItems.tsx index acc5270ef..d0aa53046 100644 --- a/src/features/instance/databases/components/TableContextMenuItems.tsx +++ b/src/features/instance/databases/components/TableContextMenuItems.tsx @@ -1,5 +1,9 @@ import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/contextMenu'; -import { describedDatabase, describedTable } from '@/features/instance/databases/functions/describedTable'; +import { + describedDatabase, + describedTable, + describedTableNames, +} from '@/features/instance/databases/functions/describedTable'; import { browsePrimaryKey } from '@/features/instance/databases/functions/formatBrowseDataTableHeader'; import { useExportTableCsv } from '@/features/instance/databases/hooks/useExportTableCsv'; import { @@ -30,7 +34,7 @@ export function TableContextMenuItems({ databaseName, tableName, instanceDatabas const { exportCsv } = useExportTableCsv(); const databaseTables = describedDatabase(instanceDatabaseMap, databaseName); - const isLastTable = Object.keys(databaseTables || {}).length <= 1; + const isLastTable = describedTableNames(instanceDatabaseMap, databaseName).length <= 1; const instanceTable = describedTable(instanceDatabaseMap, databaseName, tableName); const primaryKey = browsePrimaryKey(instanceTable); // Same withdrawal as the table view's toolbar: rows added here couldn't be listed there. diff --git a/src/features/instance/databases/functions/describedTable.test.ts b/src/features/instance/databases/functions/describedTable.test.ts index 525eeb065..670e1ea88 100644 --- a/src/features/instance/databases/functions/describedTable.test.ts +++ b/src/features/instance/databases/functions/describedTable.test.ts @@ -1,7 +1,14 @@ import type { SchemaRelationshipMap } from '@/features/instance/databases/functions/schemaRelationships'; import { InstanceDatabaseMap } from '@/integrations/api/api.patch'; import { describe, expect, it } from 'vitest'; -import { declaredRelationships, describedDatabase, describedTable, ownEntry } from './describedTable'; +import { + declaredRelationships, + describedDatabase, + describedDatabaseNames, + describedTable, + describedTableNames, + ownEntry, +} from './describedTable'; const dog = { attributes: [] }; const map = { data: { dog } } as unknown as InstanceDatabaseMap; @@ -22,6 +29,16 @@ describe('describedDatabase and describedTable', () => { expect(describedTable(map, 'other', 'dog')).toBeUndefined(); expect(declaredRelationships(undefined, 'data', 'dog')).toBeUndefined(); expect(ownEntry(null, 'data')).toBeUndefined(); + expect(ownEntry({ data: null }, 'data')).toBeUndefined(); + }); + + it('name the databases and tables that have a describe, sorted', () => { + const withHoles = { beta: { b: dog, a: dog, hole: null }, alpha: {}, hole: null } as unknown as InstanceDatabaseMap; + expect(describedDatabaseNames(withHoles)).toEqual(['alpha', 'beta']); + expect(describedDatabaseNames(undefined)).toEqual([]); + expect(describedTableNames(withHoles, 'beta')).toEqual(['a', 'b']); + expect(describedTableNames(withHoles, 'hole')).toEqual([]); + expect(describedTableNames(undefined, 'beta')).toEqual([]); }); it('never find an Object.prototype member', () => { diff --git a/src/features/instance/databases/functions/describedTable.ts b/src/features/instance/databases/functions/describedTable.ts index e7fc67686..ec7c4710b 100644 --- a/src/features/instance/databases/functions/describedTable.ts +++ b/src/features/instance/databases/functions/describedTable.ts @@ -2,7 +2,7 @@ import { SchemaRelationship, SchemaRelationshipMap } from '@/features/instance/d import { InstanceDatabaseMap, InstanceDatabaseTableMap, InstanceTable } from '@/integrations/api/api.patch'; export function ownEntry(record: Readonly> | null | undefined, key: string): T | undefined { - return record && Object.hasOwn(record, key) ? record[key] : undefined; + return record && Object.hasOwn(record, key) ? (record[key] ?? undefined) : undefined; } export function describedDatabase( @@ -12,6 +12,18 @@ export function describedDatabase( return ownEntry(instanceDatabaseMap, databaseName); } +export function describedDatabaseNames(instanceDatabaseMap: InstanceDatabaseMap | undefined): string[] { + return Object.keys(instanceDatabaseMap ?? {}).filter((name) => !!describedDatabase(instanceDatabaseMap, name)).sort(); +} + +export function describedTableNames( + instanceDatabaseMap: InstanceDatabaseMap | undefined, + databaseName: string, +): string[] { + const databaseTables = describedDatabase(instanceDatabaseMap, databaseName); + return Object.keys(databaseTables ?? {}).filter((name) => !!ownEntry(databaseTables, name)).sort(); +} + export function describedTable( instanceDatabaseMap: InstanceDatabaseMap | undefined, databaseName: string, diff --git a/src/features/instance/databases/functions/resolveDatabasesRedirect.test.ts b/src/features/instance/databases/functions/resolveDatabasesRedirect.test.ts index 7fff12a14..868406bc5 100644 --- a/src/features/instance/databases/functions/resolveDatabasesRedirect.test.ts +++ b/src/features/instance/databases/functions/resolveDatabasesRedirect.test.ts @@ -61,6 +61,8 @@ describe('resolveDatabasesRedirect', () => { databaseName: 'alpha', tableName: undefined, }); + const holeFirst = { aaa: null, beta: {} } as unknown as InstanceDatabaseMap; + expect(resolveDatabasesRedirect(holeFirst, {})).toEqual({ databaseName: 'beta', tableName: undefined }); }); it('stays put on an empty database (shows its overview, no redirect loop)', () => { diff --git a/src/features/instance/databases/functions/resolveDatabasesRedirect.ts b/src/features/instance/databases/functions/resolveDatabasesRedirect.ts index 6af7656d9..8e20c1069 100644 --- a/src/features/instance/databases/functions/resolveDatabasesRedirect.ts +++ b/src/features/instance/databases/functions/resolveDatabasesRedirect.ts @@ -1,4 +1,8 @@ -import { describedDatabase, describedTable } from '@/features/instance/databases/functions/describedTable'; +import { + describedDatabase, + describedDatabaseNames, + describedTable, +} from '@/features/instance/databases/functions/describedTable'; import { InstanceDatabaseMap } from '@/integrations/api/api.patch'; /** @@ -20,7 +24,7 @@ export function resolveDatabasesRedirect( } const databaseExists = !!params.databaseName && !!describedDatabase(instanceDatabaseMap, params.databaseName); if (!databaseExists) { - const firstDatabaseName = Object.keys(instanceDatabaseMap).sort()[0]; + const firstDatabaseName = describedDatabaseNames(instanceDatabaseMap)[0]; // No redirect when there are no databases at all, or the fallback would land on the same // (nonexistent) name we're already on -- both would otherwise loop. if (firstDatabaseName && firstDatabaseName !== params.databaseName) { diff --git a/src/features/instance/databases/functions/schemaRelationships.test.ts b/src/features/instance/databases/functions/schemaRelationships.test.ts index 5e27d110d..2339ccfe1 100644 --- a/src/features/instance/databases/functions/schemaRelationships.test.ts +++ b/src/features/instance/databases/functions/schemaRelationships.test.ts @@ -1,5 +1,6 @@ +import { QueryClient } from '@tanstack/react-query'; import { describe, expect, it } from 'vitest'; -import { parseSchemaRelationships } from './schemaRelationships'; +import { getSchemaRelationshipsQueryOptions, parseSchemaRelationships } from './schemaRelationships'; // The live anvils app schema this feature was built against. const anvilsSchema = `type Category @table @export @sealed { @@ -70,6 +71,65 @@ type Post @table(database: "blog") { ]); }); + it('keeps a database named __proto__ or constructor as its own entry, touching no prototype', () => { + const schemaIn = (database: string) => + `type Owner @table(database: "${database}") { + id: ID @primaryKey + pets: [Pet] @relationship(to: "ownerId") +} + +type Pet @table(database: "${database}") { + id: ID @primaryKey + ownerId: ID @indexed +} +`; + const map = parseSchemaRelationships([schemaIn('__proto__'), schemaIn('constructor')]); + + expect(Object.keys(map).sort()).toEqual(['__proto__', 'constructor']); + for (const database of ['__proto__', 'constructor']) { + expect(map[database].Owner[0]).toMatchObject({ attribute: 'pets', relatedTableName: 'Pet' }); + } + + const tableNamedProto = `type Owner @table(table: "__proto__") { + id: ID @primaryKey + pets: [Pet] @relationship(to: "ownerId") +} + +type Pet @table { + id: ID @primaryKey + ownerId: ID @indexed +} +`; + const tables = parseSchemaRelationships([tableNamedProto]).data; + expect(Object.keys(tables)).toEqual(['__proto__']); + expect(Object.getPrototypeOf(tables)).toBeNull(); + expect(({} as Record).Owner).toBeUndefined(); + expect((Object as unknown as Record).Owner).toBeUndefined(); + }); + + it('keeps a __proto__ database an entry across a refetch that changes the map', () => { + const ownerIn = (database: string, pets: string) => + `type Owner @table(database: "${database}") { + id: ID @primaryKey + ${pets}: [Pet] @relationship(to: "ownerId") +} + +type Pet @table(database: "${database}") { + id: ID @primaryKey + ownerId: ID @indexed +} +`; + const options = getSchemaRelationshipsQueryOptions({ entityId: 'instance-1' } as never); + const queryClient = new QueryClient(); + queryClient.getQueryCache().build(queryClient, options as never); + queryClient.setQueryData(options.queryKey, parseSchemaRelationships([ownerIn('__proto__', 'pets')])); + queryClient.setQueryData(options.queryKey, parseSchemaRelationships([ownerIn('__proto__', 'animals')])); + + const map = queryClient.getQueryData(options.queryKey)!; + expect(Object.hasOwn(map, '__proto__')).toBe(true); + expect(map['__proto__'].Owner[0]).toMatchObject({ attribute: 'animals' }); + }); + it('ignores unparseable sources and tables without relationships', () => { expect(parseSchemaRelationships(['type Broken @table {'])).toEqual({}); expect(parseSchemaRelationships(['type Plain @table { id: ID @primaryKey }'])).toEqual({}); diff --git a/src/features/instance/databases/functions/schemaRelationships.ts b/src/features/instance/databases/functions/schemaRelationships.ts index 5575c04be..f1dc745d0 100644 --- a/src/features/instance/databases/functions/schemaRelationships.ts +++ b/src/features/instance/databases/functions/schemaRelationships.ts @@ -41,6 +41,9 @@ export function getSchemaRelationshipsQueryOptions(params: InstanceClientIdConfi queryKey: [params.entityId, 'schema-relationships'] as const, staleTime: 60_000, retry: false, + // Structural sharing would rebuild changed levels as plain objects, where a `__proto__` key + // sets the prototype instead of staying an entry. + structuralSharing: false, enabled: params.enabled !== false, queryFn: async (): Promise => { // Best-effort: browse must keep working for users who can't read component files @@ -123,7 +126,9 @@ export function parseSchemaRelationships(sources: string[]): SchemaRelationshipM } } - const map: SchemaRelationshipMap = {}; + // Without a prototype, a database or table named `__proto__` or `constructor` is an ordinary key + // rather than a write into Object.prototype or onto Object itself. + const map: SchemaRelationshipMap = Object.create(null); for (const table of tables) { if (!table.relationships.length) { continue; @@ -140,7 +145,7 @@ export function parseSchemaRelationships(sources: string[]): SchemaRelationshipM } } if (resolved.length) { - (map[table.database] ??= {})[table.tableName] = resolved; + (map[table.database] ??= Object.create(null))[table.tableName] = resolved; } } return map; From a472f73271966bd3012910d911ad64be323b0c44 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Wed, 14 Oct 2026 18:27:18 -0400 Subject: [PATCH 14/21] fix(cluster): log a removed self-hosted cluster's sign-ins out of its servers, then forget them A self-hosted cluster's servers may outlive its removal from Fabric, so #1954 kept its remembered sign-ins rather than forget them with no logout. Removing one from Studio now ends those sessions instead. authStore.signOutEntities(ids) starts one best-effort logout per remembered id, built the way app sign-out builds them (the remembered credential, a 10s timeout, no response interceptors), forgets every id locally whatever the servers answer, and never rejects. terminateCluster calls it for a self-hosted cluster's own sign-in and each instance's once the DELETE succeeds, without making the removal wait. A Fabric cluster's terminate is unchanged, and a removed self-hosted cluster is still never cleared on render. Co-Authored-By: Claude Opus 5.5 --- src/features/auth/store/authStore.ts | 32 +++++-- .../auth/store/signOutEntities.test.ts | 86 +++++++++++++++++++ src/features/cluster/DESIGN.md | 7 +- .../cluster/terminatedClusterSignIns.ts | 13 +-- .../mutations/terminateCluster.test.ts | 56 +++++++++--- .../clusters/mutations/terminateCluster.ts | 16 +++- 6 files changed, 181 insertions(+), 29 deletions(-) create mode 100644 src/features/auth/store/signOutEntities.test.ts diff --git a/src/features/auth/store/authStore.ts b/src/features/auth/store/authStore.ts index bddaedbae..40dce30ba 100644 --- a/src/features/auth/store/authStore.ts +++ b/src/features/auth/store/authStore.ts @@ -703,12 +703,7 @@ class AuthStore { const instanceClient = this.buildLogoutClient(entityId); this.signOutLocally(entityId); if (instanceClient) { - logouts.push( - onInstanceLogoutSubmit({ entityId, instanceClient }).then( - () => undefined, - (err: unknown) => reportLogoutFailure(entityId, err), - ), - ); + logouts.push(this.sendBestEffortLogout(entityId, instanceClient)); } } // After the logout clients are built, which carry their credential in a header of their own. A @@ -761,6 +756,31 @@ class AuthStore { } } + /** For a server that outlives Studio's record of it. Never rejects. */ + public async signOutEntities(ids: Iterable): Promise { + const unique = [...new Set(ids)]; + const logouts = unique.map((entityId) => ({ + entityId, + instanceClient: this.potentiallyAuthenticated[entityId] ? this.buildLogoutClient(entityId) : null, + })); + this.signOutEntitiesLocally(unique); + await Promise.all( + logouts.map(({ entityId, instanceClient }) => + instanceClient ? this.sendBestEffortLogout(entityId, instanceClient) : Promise.resolve() + ), + ); + } + + private sendBestEffortLogout( + entityId: EntityIds, + instanceClient: ReturnType, + ): Promise { + return onInstanceLogoutSubmit({ entityId, instanceClient }).then( + () => undefined, + (err: unknown) => reportLogoutFailure(entityId, err), + ); + } + private clearSignIn(id: EntityIds): void { this.bumpSignOutGeneration(id); // That attempt will now reject; the next connect for this id must not join it. diff --git a/src/features/auth/store/signOutEntities.test.ts b/src/features/auth/store/signOutEntities.test.ts new file mode 100644 index 000000000..4b0c27203 --- /dev/null +++ b/src/features/auth/store/signOutEntities.test.ts @@ -0,0 +1,86 @@ +/** @vitest-environment jsdom */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { authStore } from './authStore'; + +const { onInstanceLogoutSubmit } = vi.hoisted(() => ({ onInstanceLogoutSubmit: vi.fn() })); +vi.mock('@/integrations/api/instance/auth/onInstanceLogoutSubmit', () => ({ onInstanceLogoutSubmit })); + +type SetArgs = Parameters; +const ENTITIES = ['clu-1', 'ins-1', 'clu-2']; + +function remember(id: string) { + const user = { username: 'admin' } as SetArgs[2]; + authStore.setUserForIdAndKey(id as SetArgs[0], `https://${id}.example.com:9925/` as SetArgs[1], user); + authStore.flagForBasicAuth(id, { username: 'admin', password: 'hunter2' }); +} + +function rememberedEntities() { + return ENTITIES.filter((id) => !!authStore.getOperationsUrl(id) || !!authStore.checkForBasicAuth(id)); +} + +function loggedOut() { + return onInstanceLogoutSubmit.mock.calls.map(([{ entityId, instanceClient }]) => ({ + entityId, + baseURL: instanceClient.defaults.baseURL, + timeout: instanceClient.defaults.timeout, + auth: instanceClient.defaults.auth, + })); +} + +beforeEach(() => { + localStorage.clear(); + ENTITIES.forEach(remember); + onInstanceLogoutSubmit.mockResolvedValue({ message: 'ok' }); + vi.spyOn(console, 'debug').mockImplementation(() => undefined); +}); + +afterEach(() => { + vi.resetAllMocks(); + vi.restoreAllMocks(); + ENTITIES.forEach((id) => authStore.signOutLocally(id)); +}); + +describe('authStore.signOutEntities', () => { + it('sends a logout for each remembered sign-in to its own server, with its credential and a short timeout, and forgets it', async () => { + await authStore.signOutEntities(['clu-1', 'ins-1', 'clu-1']); + + const credential = { username: 'admin', password: 'hunter2' }; + expect(loggedOut()).toEqual([ + { entityId: 'clu-1', baseURL: 'https://clu-1.example.com:9925/', timeout: 10_000, auth: credential }, + { entityId: 'ins-1', baseURL: 'https://ins-1.example.com:9925/', timeout: 10_000, auth: credential }, + ]); + expect(rememberedEntities()).toEqual(['clu-2']); + }); + + it('forgets the sign-ins, and resolves, when a logout fails', async () => { + const consoleError = vi.spyOn(console, 'error'); + onInstanceLogoutSubmit.mockRejectedValue(new Error('connect ECONNREFUSED')); + + await expect(authStore.signOutEntities(['clu-1', 'ins-1'])).resolves.toBeUndefined(); + + expect(rememberedEntities()).toEqual(['clu-2']); + expect(console.debug).toHaveBeenCalledWith(expect.stringContaining('clu-1'), 'connect ECONNREFUSED'); + expect(consoleError).not.toHaveBeenCalled(); + }); + + it('forgets the sign-ins before a slow logout answers', async () => { + let answer: () => void = () => undefined; + onInstanceLogoutSubmit.mockReturnValue(new Promise((resolve) => (answer = () => resolve({ message: 'ok' })))); + let settled = false; + + const signOut = authStore.signOutEntities(['clu-1']).then(() => (settled = true)); + + expect(rememberedEntities()).toEqual(['ins-1', 'clu-2']); + await new Promise((resolve) => setTimeout(resolve)); + expect(settled).toBe(false); + answer(); + await signOut; + expect(settled).toBe(true); + }); + + it('posts no logout for an id with nothing remembered', async () => { + await authStore.signOutEntities(['ins-unknown']); + + expect(onInstanceLogoutSubmit).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/cluster/DESIGN.md b/src/features/cluster/DESIGN.md index cf4ce1b2e..734552ef3 100644 --- a/src/features/cluster/DESIGN.md +++ b/src/features/cluster/DESIGN.md @@ -94,8 +94,11 @@ which is not final. Never from a route guard: guards also run on hover preloads, credentials. A sign-in left behind is not harmless. Each app sign-out posts a logout to every remembered host, and a stored Basic Auth credential would stay in localStorage for good. -A self-hosted cluster removed from Fabric keeps its sign-ins. Its servers may still run, and forgetting them locally -would leave their cookie sessions open; kept, the next app sign-out still logs them out. +A self-hosted cluster removed from Fabric is never cleared on render. Its servers may still run, and forgetting their +sign-ins locally would leave their cookie sessions open. Removing one from Studio sends each remembered sign-in's server a +logout the way app sign-out does (`signOutEntities`: one attempt, a 10s timeout, not awaited by the removal), and forgets +the sign-in without waiting for the answer. So a failed logout still forgets it, and a server unreachable at that moment +keeps its session. Removed elsewhere, its sign-ins stay until an app sign-out logs them out. None of the hidden pages can do anything there. Central manager's `assertClusterMutable` refuses every update to a cluster that isn't `RUNNING`, and the instances are torn down, along with the certificates issued on them. Nor is anything left diff --git a/src/features/cluster/terminatedClusterSignIns.ts b/src/features/cluster/terminatedClusterSignIns.ts index c5a5ae9ab..0e19fca33 100644 --- a/src/features/cluster/terminatedClusterSignIns.ts +++ b/src/features/cluster/terminatedClusterSignIns.ts @@ -1,13 +1,14 @@ import { Cluster } from '@/integrations/api/api.patch'; import { clusterIsSelfManaged } from '@/integrations/api/clusterIsSelfManaged'; +export function clusterSignInIds(cluster: Cluster): string[] { + return [cluster.id, ...(cluster.instances ?? []).map((instance) => instance.id)].filter(Boolean); +} + /** - * None for a self-hosted cluster removed from Fabric: its servers may still run, so its sign-ins stay remembered and an - * app sign-out still ends their sessions. + * None for a self-hosted cluster removed from Fabric: its servers may still run, and forgetting their sign-ins without a + * logout would leave their sessions open. */ export function terminatedClusterSignIns(cluster: Cluster): string[] { - if (clusterIsSelfManaged(cluster)) { - return []; - } - return [cluster.id, ...(cluster.instances ?? []).map((instance) => instance.id)].filter(Boolean); + return clusterIsSelfManaged(cluster) ? [] : clusterSignInIds(cluster); } diff --git a/src/features/clusters/mutations/terminateCluster.test.ts b/src/features/clusters/mutations/terminateCluster.test.ts index 4255b75db..0b3b0d845 100644 --- a/src/features/clusters/mutations/terminateCluster.test.ts +++ b/src/features/clusters/mutations/terminateCluster.test.ts @@ -2,8 +2,13 @@ import { authStore } from '@/features/auth/store/authStore'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const { apiDelete, apiGet } = vi.hoisted(() => ({ apiDelete: vi.fn(), apiGet: vi.fn() })); +const { apiDelete, apiGet, onInstanceLogoutSubmit } = vi.hoisted(() => ({ + apiDelete: vi.fn(), + apiGet: vi.fn(), + onInstanceLogoutSubmit: vi.fn(), +})); vi.mock('@/config/apiClient', () => ({ apiClient: { delete: apiDelete, get: apiGet } })); +vi.mock('@/integrations/api/instance/auth/onInstanceLogoutSubmit', () => ({ onInstanceLogoutSubmit })); const { terminateCluster } = await import('./terminateCluster'); @@ -12,6 +17,7 @@ const ENTITIES = ['clu-1', 'ins-1', 'ins-2', 'clu-2']; const DELETED = { data: { id: 'clu-1', deleted: true } }; // The organization's cluster list carries no instances; the cluster record does. const RECORD = { data: { id: 'clu-1', status: 'RUNNING', instances: [{ id: 'ins-1' }, { id: 'ins-2' }] } }; +const SELF_HOSTED = { data: { ...RECORD.data, plans: [{ planId: 'self-hosted-free' }] } }; function remember(id: string) { const user = { username: 'admin' } as SetArgs[2]; @@ -20,7 +26,11 @@ function remember(id: string) { } function rememberedEntities() { - return ENTITIES.filter((id) => !!authStore.getOperationsUrl(id) && !!authStore.checkForBasicAuth(id)); + return ENTITIES.filter((id) => !!authStore.getOperationsUrl(id) || !!authStore.checkForBasicAuth(id)); +} + +function loggedOut() { + return onInstanceLogoutSubmit.mock.calls.map(([{ entityId }]) => entityId); } beforeEach(() => { @@ -28,6 +38,7 @@ beforeEach(() => { ENTITIES.forEach(remember); apiDelete.mockResolvedValue(DELETED); apiGet.mockResolvedValue(RECORD); + onInstanceLogoutSubmit.mockResolvedValue({ message: 'ok' }); vi.spyOn(console, 'debug').mockImplementation(() => undefined); }); @@ -43,6 +54,7 @@ describe(terminateCluster, () => { await vi.waitFor(() => expect(rememberedEntities()).toEqual(['clu-2'])); expect(apiGet).toHaveBeenCalledWith('/Cluster/clu-1'); + expect(onInstanceLogoutSubmit).not.toHaveBeenCalled(); }); it('reads the instances back after the terminate when the read alongside it failed', async () => { @@ -61,24 +73,46 @@ describe(terminateCluster, () => { await vi.waitFor(() => expect(console.debug).toHaveBeenCalledTimes(2)); expect(rememberedEntities()).toEqual(ENTITIES); + expect(onInstanceLogoutSubmit).not.toHaveBeenCalled(); }); - it("keeps a removed self-hosted cluster's sign-ins, whose servers may still run", async () => { - apiGet.mockResolvedValue({ data: { ...RECORD.data, plans: [{ planId: 'self-hosted-free' }] } }); + it('forgets nothing when the terminate fails', async () => { + apiDelete.mockRejectedValue(new Error('Request failed with status code 400')); - await terminateCluster('clu-1'); + await expect(terminateCluster('clu-1')).rejects.toThrow('400'); await new Promise((resolve) => setTimeout(resolve)); - expect(apiGet).toHaveBeenCalled(); expect(rememberedEntities()).toEqual(ENTITIES); }); +}); - it('forgets nothing when the terminate fails', async () => { - apiDelete.mockRejectedValue(new Error('Request failed with status code 400')); +describe('removing a self-hosted cluster, whose servers may still run', () => { + beforeEach(() => { + apiGet.mockResolvedValue(SELF_HOSTED); + }); - await expect(terminateCluster('clu-1')).rejects.toThrow('400'); + it('sends the cluster and every instance a logout, and forgets them', async () => { + await terminateCluster('clu-1'); - await new Promise((resolve) => setTimeout(resolve)); - expect(rememberedEntities()).toEqual(ENTITIES); + await vi.waitFor(() => expect(rememberedEntities()).toEqual(['clu-2'])); + expect(loggedOut()).toEqual(['clu-1', 'ins-1', 'ins-2']); + }); + + it('still forgets them when a logout fails', async () => { + onInstanceLogoutSubmit.mockRejectedValue(new Error('timeout of 10000ms exceeded')); + + await expect(terminateCluster('clu-1')).resolves.toEqual(DELETED.data); + + await vi.waitFor(() => expect(rememberedEntities()).toEqual(['clu-2'])); + expect(loggedOut()).toEqual(['clu-1', 'ins-1', 'ins-2']); + }); + + it('finishes the removal without waiting for the logouts', async () => { + onInstanceLogoutSubmit.mockReturnValue(new Promise(() => undefined)); + + await expect(terminateCluster('clu-1')).resolves.toEqual(DELETED.data); + + await vi.waitFor(() => expect(loggedOut()).toEqual(['clu-1', 'ins-1', 'ins-2'])); + expect(rememberedEntities()).toEqual(['clu-2']); }); }); diff --git a/src/features/clusters/mutations/terminateCluster.ts b/src/features/clusters/mutations/terminateCluster.ts index edd6d7345..01d7245db 100644 --- a/src/features/clusters/mutations/terminateCluster.ts +++ b/src/features/clusters/mutations/terminateCluster.ts @@ -1,8 +1,9 @@ import { apiClient } from '@/config/apiClient'; import { authStore } from '@/features/auth/store/authStore'; import { getClusterInfo } from '@/features/cluster/queries/getClusterInfoQuery'; -import { terminatedClusterSignIns } from '@/features/cluster/terminatedClusterSignIns'; +import { clusterSignInIds } from '@/features/cluster/terminatedClusterSignIns'; import { Cluster } from '@/integrations/api/api.patch'; +import { clusterIsSelfManaged } from '@/integrations/api/clusterIsSelfManaged'; import { useMutation } from '@tanstack/react-query'; export async function terminateCluster(clusterId: string) { @@ -15,12 +16,19 @@ export async function terminateCluster(clusterId: string) { /** * The organization's cluster list carries no instances, so they come from the cluster record: read alongside the DELETE, * or read back after it, since central manager keeps a terminated cluster's record and its instances. Without either - * read nothing is forgotten, since the cluster might be self-hosted; `ClusterLayout` forgets them on the next visit. + * read nothing is forgotten, since the cluster might be self-hosted: a Fabric cluster's go on its next visit + * (`ClusterLayout`), a self-hosted one's at the next app sign-out. A removed self-hosted cluster's servers may still run, + * so they are sent a logout too. */ async function forgetSignIns(clusterId: string, before: Promise): Promise { const cluster = await before ?? await readCluster(clusterId); - if (cluster) { - authStore.signOutEntitiesLocally(terminatedClusterSignIns(cluster)); + if (!cluster) { + return; + } + if (clusterIsSelfManaged(cluster)) { + await authStore.signOutEntities(clusterSignInIds(cluster)); + } else { + authStore.signOutEntitiesLocally(clusterSignInIds(cluster)); } } From 0b14c61c682efc2365eb64b851f14dbc97a0929c Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Wed, 14 Oct 2026 18:50:49 -0400 Subject: [PATCH 15/21] fix(auth): keep a sign-in made while a best-effort logout was still out onInstanceLogoutSubmit clears the entity's Basic Auth credential and Fabric Connect state once the logout answers. App sign-out and cluster removal forget the entity before they send, so by the time a late answer lands, anything recorded for it is a newer sign-in, and the clear wiped it: a new Basic Auth credential (from this tab or another), a new Fabric Connect flag, or the token of a Fabric Connect still verifying. sendBestEffortLogout now snapshots what the answer would clear when it sends: the stored Basic Auth credential and Fabric Connect flag, read raw from localStorage so another tab's writes count, and the connection generation, which moves when a Fabric Connect token is claimed. It hands onInstanceLogoutSubmit a signedInSince check, which skips the clear once the snapshot has changed and clears again otherwise. Explicit sign-outs pass no check and clear as before. signOutEntities also reads the sign-in markers from storage, not only this tab's copy, so removing a self-hosted cluster logs out a sign-in another tab made after this one loaded, and drops that sign-in's marker from storage too. Co-Authored-By: Claude Opus 5.5 --- src/features/auth/store/authStore.ts | 64 +++++++++++-- .../auth/store/lateLogoutAnswer.test.ts | 96 +++++++++++++++++++ .../auth/store/signOutEntities.test.ts | 36 +++++++ .../mutations/terminateCluster.test.ts | 8 +- .../clusters/mutations/terminateCluster.ts | 3 +- .../instance/auth/onInstanceLogoutSubmit.ts | 8 +- 6 files changed, 201 insertions(+), 14 deletions(-) create mode 100644 src/features/auth/store/lateLogoutAnswer.test.ts diff --git a/src/features/auth/store/authStore.ts b/src/features/auth/store/authStore.ts index 40dce30ba..ffdb4c6b1 100644 --- a/src/features/auth/store/authStore.ts +++ b/src/features/auth/store/authStore.ts @@ -712,12 +712,12 @@ class AuthStore { await Promise.all(logouts); } - private buildLogoutClient(entityId: EntityIds): ReturnType | null { + private buildLogoutClient(entityId: EntityIds, operationsUrl?: string): ReturnType | null { if (entityId === OverallAppSignIn) { return null; } try { - const instanceClient = getInstanceClient({ id: entityId }); + const instanceClient = getInstanceClient({ id: entityId, operationsUrl }); instanceClient.defaults.timeout = LOGOUT_TIMEOUT_MS; // No gateway-error retries (5s + 10s + 20s) and no token recovery for a best-effort logout. instanceClient.interceptors.response.clear(); @@ -759,11 +759,19 @@ class AuthStore { /** For a server that outlives Studio's record of it. Never rejects. */ public async signOutEntities(ids: Iterable): Promise { const unique = [...new Set(ids)]; - const logouts = unique.map((entityId) => ({ - entityId, - instanceClient: this.potentiallyAuthenticated[entityId] ? this.buildLogoutClient(entityId) : null, - })); + // Another tab may have signed in since this one read the markers. + const remembered = { ...this.potentiallyAuthenticated, ...this.storedMarkers() }; + const logouts = unique.map((entityId) => { + const operationsUrl = remembered[entityId]; + return { + entityId, + instanceClient: typeof operationsUrl === 'string' && operationsUrl + ? this.buildLogoutClient(entityId, operationsUrl) + : null, + }; + }); this.signOutEntitiesLocally(unique); + this.forgetStoredMarkers(unique); await Promise.all( logouts.map(({ entityId, instanceClient }) => instanceClient ? this.sendBestEffortLogout(entityId, instanceClient) : Promise.resolve() @@ -771,16 +779,58 @@ class AuthStore { ); } + /** Sent after the caller has forgotten `entityId`, so whatever is recorded for it by the answer, in any tab, is newer. */ private sendBestEffortLogout( entityId: EntityIds, instanceClient: ReturnType, ): Promise { - return onInstanceLogoutSubmit({ entityId, instanceClient }).then( + const sentWith = this.signInSnapshot(entityId); + const signedInSince = () => this.signInSnapshot(entityId) !== sentWith; + return onInstanceLogoutSubmit({ entityId, instanceClient, signedInSince }).then( () => undefined, (err: unknown) => reportLogoutFailure(entityId, err), ); } + private storedMarkers(): Record { + try { + const markers: unknown = JSON.parse(localStorage.getItem(this.potentiallyAuthenticatedKey) || '{}'); + return markers && typeof markers === 'object' ? markers as Record : {}; + } catch { + return {}; + } + } + + /** `signOutLocally` drops only the markers this tab holds, so one another tab added would otherwise outlive its logout. */ + private forgetStoredMarkers(ids: EntityIds[]): void { + const stored = this.storedMarkers(); + if (!ids.some((id) => Object.hasOwn(stored, id))) { + return; + } + const remaining = Object.fromEntries(Object.entries(stored).filter(([id]) => !ids.includes(id))); + try { + localStorage.setItem(this.potentiallyAuthenticatedKey, JSON.stringify(remaining)); + } catch { + this.removeStoredItem(this.potentiallyAuthenticatedKey); + } + } + + /** Read raw, not through `readStoredItem`, because another tab may have written them. */ + private signInSnapshot(id: EntityIds): string { + const stored = (key: string) => { + try { + return localStorage.getItem(key); + } catch { + return null; + } + }; + return JSON.stringify([ + stored(this.basicAuthKeyPrefix + id), + stored(this.fabricConnectKeyPrefix + id), + this.getConnectionGeneration(id), + ]); + } + private clearSignIn(id: EntityIds): void { this.bumpSignOutGeneration(id); // That attempt will now reject; the next connect for this id must not join it. diff --git a/src/features/auth/store/lateLogoutAnswer.test.ts b/src/features/auth/store/lateLogoutAnswer.test.ts new file mode 100644 index 000000000..b8f714912 --- /dev/null +++ b/src/features/auth/store/lateLogoutAnswer.test.ts @@ -0,0 +1,96 @@ +/** @vitest-environment jsdom */ +import axios, { type AxiosAdapter, type AxiosResponse } from 'axios'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { authStore } from './authStore'; + +const { createInstanceAuthenticationTokens, getInstanceUserInfo } = vi.hoisted(() => ({ + createInstanceAuthenticationTokens: vi.fn(), + getInstanceUserInfo: vi.fn(), +})); +vi.mock('@/integrations/api/instance/auth/createInstanceAuthenticationTokens', () => ({ + createInstanceAuthenticationTokens, + refreshInstanceOperationToken: vi.fn(), +})); +vi.mock('@/integrations/api/instance/status/getInstanceUserInfo', () => ({ getInstanceUserInfo })); + +type SetArgs = Parameters; +const ID = 'ins-late'; +const URL = 'https://ins-late.example.com:9925/'; +const OLD = { username: 'admin', password: 'old' }; +const NEW = { username: 'admin', password: 'new' }; +const defaultAdapter = axios.defaults.adapter; +let answerLogout: (() => void) | undefined; + +beforeEach(() => { + localStorage.clear(); + answerLogout = undefined; + axios.defaults.adapter = ((config) => + new Promise((resolve) => { + answerLogout = () => resolve({ status: 200, statusText: 'OK', data: { message: 'ok' }, headers: {}, config }); + })) as AxiosAdapter; + authStore.setUserForIdAndKey(ID, URL as SetArgs[1], { username: 'admin' } as SetArgs[2]); + authStore.flagForBasicAuth(ID, OLD); + vi.spyOn(console, 'debug').mockImplementation(() => undefined); +}); + +afterEach(() => { + axios.defaults.adapter = defaultAdapter; + vi.resetAllMocks(); + vi.restoreAllMocks(); + authStore.signOutLocally(ID); +}); + +describe.each([ + { path: 'an app sign-out', signOut: () => authStore.signOutFromPotentiallyAuthenticatedInstances() }, + { path: 'removing its cluster', signOut: () => authStore.signOutEntities([ID]) }, +])('a logout from $path that answers after a new sign-in', ({ signOut }) => { + async function signInWhileTheLogoutIsOut(signIn: () => Promise | void) { + const done = signOut(); + await vi.waitFor(() => expect(answerLogout).toBeDefined()); + await signIn(); + answerLogout!(); + await done; + } + + it('keeps the new Basic Auth credential', async () => { + await signInWhileTheLogoutIsOut(() => authStore.flagForBasicAuth(ID, NEW)); + + expect(authStore.checkForBasicAuth(ID)).toEqual(NEW); + }); + + it('keeps a Basic Auth credential another tab stored', async () => { + await signInWhileTheLogoutIsOut(() => localStorage.setItem(`Studio:BasicAuth:${ID}`, btoa(JSON.stringify(NEW)))); + + expect(authStore.checkForBasicAuth(ID)).toEqual(NEW); + }); + + it('clears again on the answer when nothing replaced the sign-in', async () => { + const flagForBasicAuth = vi.spyOn(authStore, 'flagForBasicAuth'); + + await signInWhileTheLogoutIsOut(() => undefined); + + expect(flagForBasicAuth.mock.calls).toEqual([[ID, null], [ID, null]]); + }); + + it('keeps the new Fabric Connect flag', async () => { + await signInWhileTheLogoutIsOut(() => authStore.flagForFabricConnect(ID, true)); + + expect(authStore.checkForFabricConnect(ID)).toBe(true); + }); + + it('keeps the token of a Fabric Connect that is still verifying when the answer lands', async () => { + createInstanceAuthenticationTokens.mockResolvedValue({ operationToken: 'new-token' }); + let verify: (user: unknown) => void = () => undefined; + getInstanceUserInfo.mockReturnValue(new Promise((resolve) => (verify = resolve))); + let connect: Promise = Promise.resolve(); + + await signInWhileTheLogoutIsOut(async () => { + connect = authStore.establishFabricConnectAuth({ id: ID, operationsUrl: URL }); + await vi.waitFor(() => expect(getInstanceUserInfo).toHaveBeenCalled()); + }); + verify({ username: 'admin' }); + await connect; + + expect(authStore.getOperationToken(ID)).toBe('new-token'); + }); +}); diff --git a/src/features/auth/store/signOutEntities.test.ts b/src/features/auth/store/signOutEntities.test.ts index 4b0c27203..c19ef1e52 100644 --- a/src/features/auth/store/signOutEntities.test.ts +++ b/src/features/auth/store/signOutEntities.test.ts @@ -78,6 +78,42 @@ describe('authStore.signOutEntities', () => { expect(settled).toBe(true); }); + it('logs out a session-cookie sign-in, which stores no Basic Auth credential', async () => { + const user = { username: 'admin' } as SetArgs[2]; + authStore.setUserForIdAndKey( + 'ins-cookie' as SetArgs[0], + 'https://ins-cookie.example.com:9925/' as SetArgs[1], + user, + ); + + await authStore.signOutEntities(['ins-cookie']); + + expect(loggedOut()).toEqual([ + { entityId: 'ins-cookie', baseURL: 'https://ins-cookie.example.com:9925/', timeout: 10_000, auth: undefined }, + ]); + expect(authStore.getOperationsUrl('ins-cookie')).toBeUndefined(); + }); + + it('logs out, and forgets, a sign-in another tab made after this one read the markers', async () => { + const markers = JSON.parse(localStorage.getItem('Studio:PotentiallyAuthenticated') || '{}'); + localStorage.setItem( + 'Studio:PotentiallyAuthenticated', + JSON.stringify({ ...markers, 'ins-other-tab': 'https://ins-other-tab.example.com:9925/' }), + ); + + await authStore.signOutEntities(['ins-other-tab']); + + expect(loggedOut()).toEqual([ + { + entityId: 'ins-other-tab', + baseURL: 'https://ins-other-tab.example.com:9925/', + timeout: 10_000, + auth: undefined, + }, + ]); + expect(JSON.parse(localStorage.getItem('Studio:PotentiallyAuthenticated') || '{}')).toEqual(markers); + }); + it('posts no logout for an id with nothing remembered', async () => { await authStore.signOutEntities(['ins-unknown']); diff --git a/src/features/clusters/mutations/terminateCluster.test.ts b/src/features/clusters/mutations/terminateCluster.test.ts index 0b3b0d845..d36d6908d 100644 --- a/src/features/clusters/mutations/terminateCluster.test.ts +++ b/src/features/clusters/mutations/terminateCluster.test.ts @@ -29,6 +29,10 @@ function rememberedEntities() { return ENTITIES.filter((id) => !!authStore.getOperationsUrl(id) || !!authStore.checkForBasicAuth(id)); } +function fullyRememberedEntities() { + return ENTITIES.filter((id) => !!authStore.getOperationsUrl(id) && !!authStore.checkForBasicAuth(id)); +} + function loggedOut() { return onInstanceLogoutSubmit.mock.calls.map(([{ entityId }]) => entityId); } @@ -72,7 +76,7 @@ describe(terminateCluster, () => { await expect(terminateCluster('clu-1')).resolves.toEqual(DELETED.data); await vi.waitFor(() => expect(console.debug).toHaveBeenCalledTimes(2)); - expect(rememberedEntities()).toEqual(ENTITIES); + expect(fullyRememberedEntities()).toEqual(ENTITIES); expect(onInstanceLogoutSubmit).not.toHaveBeenCalled(); }); @@ -82,7 +86,7 @@ describe(terminateCluster, () => { await expect(terminateCluster('clu-1')).rejects.toThrow('400'); await new Promise((resolve) => setTimeout(resolve)); - expect(rememberedEntities()).toEqual(ENTITIES); + expect(fullyRememberedEntities()).toEqual(ENTITIES); }); }); diff --git a/src/features/clusters/mutations/terminateCluster.ts b/src/features/clusters/mutations/terminateCluster.ts index 01d7245db..ca7d72f4d 100644 --- a/src/features/clusters/mutations/terminateCluster.ts +++ b/src/features/clusters/mutations/terminateCluster.ts @@ -17,8 +17,7 @@ export async function terminateCluster(clusterId: string) { * The organization's cluster list carries no instances, so they come from the cluster record: read alongside the DELETE, * or read back after it, since central manager keeps a terminated cluster's record and its instances. Without either * read nothing is forgotten, since the cluster might be self-hosted: a Fabric cluster's go on its next visit - * (`ClusterLayout`), a self-hosted one's at the next app sign-out. A removed self-hosted cluster's servers may still run, - * so they are sent a logout too. + * (`ClusterLayout`), a self-hosted one's at the next app sign-out. */ async function forgetSignIns(clusterId: string, before: Promise): Promise { const cluster = await before ?? await readCluster(clusterId); diff --git a/src/integrations/api/instance/auth/onInstanceLogoutSubmit.ts b/src/integrations/api/instance/auth/onInstanceLogoutSubmit.ts index 12f71aa47..16b4fad2e 100644 --- a/src/integrations/api/instance/auth/onInstanceLogoutSubmit.ts +++ b/src/integrations/api/instance/auth/onInstanceLogoutSubmit.ts @@ -6,13 +6,15 @@ interface LogoutInfoResponse { } export async function onInstanceLogoutSubmit( - { instanceClient, entityId }: InstanceClientIdConfig, + { instanceClient, entityId, signedInSince }: InstanceClientIdConfig & { signedInSince?: () => boolean }, ): Promise { const { data } = await instanceClient.post('/', { operation: 'logout', }); - authStore.flagForBasicAuth(entityId, null); - authStore.flagForFabricConnect(entityId, false); + if (!signedInSince?.()) { + authStore.flagForBasicAuth(entityId, null); + authStore.flagForFabricConnect(entityId, false); + } if (data) { return data; } else { From 912c4209cb71ab4c3e7ae1dca032ed2ec3d64cef Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Wed, 14 Oct 2026 21:13:58 -0400 Subject: [PATCH 16/21] fix(auth): keep sign-ins another tab added when this tab writes its markers Each tab read Studio:PotentiallyAuthenticated once at startup and wrote that whole copy back on every sign-in and sign-out. That erased every marker another tab had added since. An app sign-out from the stale tab then sent those sign-ins no logout, and a sign-out of an entity only another tab held left its marker behind. - flagKeyAsSignedIn and flagKeyAsSignedOut now change one entity in the record as stored now, and leave the rest alone. A sign-in updates this tab's copy only once its write lands, so a refused write is retried at the next sign-in. - A passing check of a sign-in this tab already marked still writes nothing, and a failing one removes the marker only when it still holds the URL it checked. Neither undoes another tab's change. - When storage refuses to take a signed-out entity out of the record, this tab's later writes leave it out until one lands, instead of trusting or discarding the whole record. - App sign-out, the local full sign-out and signOutEntities act on this tab's markers and every stored one, and log out each URL an entity is marked at, since another tab may have signed in again at a newer one. - signOutEntities' forgetStoredMarkers is gone: flagKeyAsSignedOut now drops another tab's marker for the entity it signs out, so managed cluster termination, which signs out locally, reaches it too. - A stored record that is not an object is treated as unreadable. - DESIGN.md records what remains: storage has no atomic read-modify-write, so two tabs writing at once can still lose one change, and a marker removed elsewhere stays removed. Closes #1968 Co-Authored-By: Claude Opus 5.5 --- DESIGN.md | 10 + src/features/auth/store/authStore.ts | 137 ++++++++----- .../auth/store/crossTabMarkers.test.ts | 180 ++++++++++++++++++ .../store/localSignOutStorageFailure.test.ts | 127 ++++++++++++ .../mutations/terminateCluster.test.ts | 14 ++ 5 files changed, 420 insertions(+), 48 deletions(-) create mode 100644 src/features/auth/store/crossTabMarkers.test.ts diff --git a/DESIGN.md b/DESIGN.md index c3fec3c59..babd734f7 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -112,6 +112,16 @@ Central manager only ever sends the boolean, never the other organizations' ids; - **Remembered instance/cluster sign-ins are checked on demand**: `Studio:PotentiallyAuthenticated` spans every organization ever visited, so the root auth subscription checks only the app sign-in; an entity is checked by `useInstanceAuth`/`listenToEntity` or the instance-layout guard (`entityAuthCheckScope.test.ts`, HarperFast/studio#1329). +- **Every tab shares those markers, so a write changes one entity.** A sign-in or sign-out re-reads the record and adds + or drops only its own entity; app and full sign-outs act on every stored marker, not only this tab's, and log out + each URL an entity is marked at in this tab or storage. A check never undoes another tab's change: a passing one of + a sign-in this tab already marked writes nothing, and a failing one leaves a newer URL another tab stored + (`crossTabMarkers.test.ts`, HarperFast/studio#1968). So a marker removed elsewhere, or dropped with the whole record + when storage refused a write, stays removed even while this tab still holds that sign-in. Storage has no atomic + read-modify-write, so two tabs writing at the same moment can still lose one change. A missing marker looks signed + out on the next load and gets no logout from another tab's app sign-out; a lost sign-out leaves a marker that load + probes. When storage refuses to take a signed-out entity out, this tab's later writes leave it out until one lands + (`localSignOutStorageFailure.test.ts`); an unreadable record is replaced. - **`Dashboard` renders nothing under it without a signed-in user.** Route data loads in parallel with the page load's `/User/current` check (waiting for it costs +262/680 ms p75 on live org/cluster cold loads), and central manager answers a request with no session with 403, so a deep link on an expired session has its route data fail before the diff --git a/src/features/auth/store/authStore.ts b/src/features/auth/store/authStore.ts index ffdb4c6b1..0025b39b0 100644 --- a/src/features/auth/store/authStore.ts +++ b/src/features/auth/store/authStore.ts @@ -68,6 +68,8 @@ class AuthStore { // Storage can refuse a removal yet still answer reads, so a credential it kept belongs to the session // that ended. This tab reads such a key as absent until a write replaces it. private readonly unremovedKeys = new Set(); + // Signed-out entities storage still marks, because it refused this tab's write. The next one must leave them out. + private readonly unremovedMarkers = new Set(); private readonly potentiallyAuthenticated: Record; private readonly checkedAuthentication: Record = {}; private readonly allConnections: Record = {}; @@ -694,18 +696,19 @@ class AuthStore { } public async signOutFromPotentiallyAuthenticatedInstances() { - // Dooms connects for entities not yet in `potentiallyAuthenticated`, which the loop skips. + // Dooms connects for entities not yet marked, which the loop skips. this.bumpSignOutGeneration('*'); forgetAllEntitySettings(); - const logouts: Array> = []; - for (const entityId of Object.keys(this.potentiallyAuthenticated)) { - // The clears below drop the operations URL and token the client resolves from. - const instanceClient = this.buildLogoutClient(entityId); + const signIns = this.rememberedSignIns(); + // Built first: the clears below drop the credentials and token they are built with. + const clients = signIns.map(([entityId, operationsUrl]) => this.buildLogoutClient(entityId, operationsUrl)); + for (const entityId of new Set(signIns.map(([entityId]) => entityId))) { this.signOutLocally(entityId); - if (instanceClient) { - logouts.push(this.sendBestEffortLogout(entityId, instanceClient)); - } } + const logouts = signIns.flatMap(([entityId], index) => { + const instanceClient = clients[index]; + return instanceClient ? [this.sendBestEffortLogout(entityId, instanceClient)] : []; + }); // After the logout clients are built, which carry their credential in a header of their own. A // failed CM logout skips `signOutAllLocally`, so this is the only clear those connects get. this.dropFabricConnectState(); @@ -759,19 +762,14 @@ class AuthStore { /** For a server that outlives Studio's record of it. Never rejects. */ public async signOutEntities(ids: Iterable): Promise { const unique = [...new Set(ids)]; - // Another tab may have signed in since this one read the markers. - const remembered = { ...this.potentiallyAuthenticated, ...this.storedMarkers() }; - const logouts = unique.map((entityId) => { - const operationsUrl = remembered[entityId]; - return { + const signIns = this.rememberedSignIns(); + const logouts = unique.flatMap((entityId) => + signIns.filter(([id]) => id === entityId).map(([, operationsUrl]) => ({ entityId, - instanceClient: typeof operationsUrl === 'string' && operationsUrl - ? this.buildLogoutClient(entityId, operationsUrl) - : null, - }; - }); + instanceClient: this.buildLogoutClient(entityId, operationsUrl), + })) + ); this.signOutEntitiesLocally(unique); - this.forgetStoredMarkers(unique); await Promise.all( logouts.map(({ entityId, instanceClient }) => instanceClient ? this.sendBestEffortLogout(entityId, instanceClient) : Promise.resolve() @@ -792,29 +790,57 @@ class AuthStore { ); } - private storedMarkers(): Record { + /** Read raw, since other tabs write it too. Null when unreadable. */ + private readMarkers(): Record | null { try { const markers: unknown = JSON.parse(localStorage.getItem(this.potentiallyAuthenticatedKey) || '{}'); - return markers && typeof markers === 'object' ? markers as Record : {}; + if (!markers || typeof markers !== 'object' || Array.isArray(markers)) { + return null; + } + return Object.fromEntries( + Object.entries(markers).filter((entry): entry is [string, string] => + typeof entry[1] === 'string' && !!entry[1] + ), + ); } catch { - return {}; + return null; } } - /** `signOutLocally` drops only the markers this tab holds, so one another tab added would otherwise outlive its logout. */ - private forgetStoredMarkers(ids: EntityIds[]): void { - const stored = this.storedMarkers(); - if (!ids.some((id) => Object.hasOwn(stored, id))) { - return; + private storedMarkers(): Record | null { + const markers = this.readMarkers(); + if (markers) { + for (const id of this.unremovedMarkers) { + delete markers[id]; + } } - const remaining = Object.fromEntries(Object.entries(stored).filter(([id]) => !ids.includes(id))); - try { - localStorage.setItem(this.potentiallyAuthenticatedKey, JSON.stringify(remaining)); - } catch { - this.removeStoredItem(this.potentiallyAuthenticatedKey); + return markers; + } + + private writeMarkers(markers: Record): void { + this.writeStoredItem(this.potentiallyAuthenticatedKey, JSON.stringify(markers)); + this.unremovedMarkers.clear(); + } + + /** Fail closed: losing every marker only makes the other entities look signed out on the next load. */ + private removeMarkers(signedOut: EntityIds | undefined): void { + this.removeStoredItem(this.potentiallyAuthenticatedKey); + if (!this.unremovedKeys.has(this.potentiallyAuthenticatedKey)) { + this.unremovedMarkers.clear(); + } else if (signedOut !== undefined) { + this.unremovedMarkers.add(signedOut); } } + /** + * This tab's sign-ins and every stored one, raw, once per URL: another tab may have signed in again at a newer + * URL, and the older one's session is still open. A sweep that reaches more only signs more out. + */ + private rememberedSignIns(): Array<[EntityIds, AuthenticatedConnectionKey]> { + const signIns = [...Object.entries(this.potentiallyAuthenticated), ...Object.entries(this.readMarkers() ?? {})]; + return signIns.filter(([id, key], index) => signIns.findIndex(([i, k]) => i === id && k === key) === index); + } + /** Read raw, not through `readStoredItem`, because another tab may have written them. */ private signInSnapshot(id: EntityIds): string { const stored = (key: string) => { @@ -868,7 +894,7 @@ class AuthStore { this.bumpSignOutGeneration('*'); forgetAllEntitySettings(); // Snapshot keys first — signOutLocally mutates the flags as it goes. - for (const id of Object.keys(this.potentiallyAuthenticated)) { + for (const id of new Set(this.rememberedSignIns().map(([id]) => id))) { this.signOutLocally(id); } this.dropFabricConnectState(); @@ -965,24 +991,39 @@ class AuthStore { void this.updateListeners(id); } + // Writing this tab's copy would erase what other tabs signed into since it loaded, so both writes change one entity + // in the record as stored now. A check of a sign-in this tab already marked writes nothing, which leaves out a + // marker another tab's sign-out removed. private flagKeyAsSignedIn(id: EntityIds, key: AuthenticatedConnectionKey) { - if (this.potentiallyAuthenticated[id] !== key) { - this.potentiallyAuthenticated[id] = key; - localStorage.setItem(this.potentiallyAuthenticatedKey, JSON.stringify(this.potentiallyAuthenticated)); + if (this.potentiallyAuthenticated[id] === key) { + return; } + // Then this tab's copy: one that storage refused would make every later sign-in skip the write. + this.writeMarkers({ ...this.storedMarkers(), [id]: key }); + this.potentiallyAuthenticated[id] = key; } - private flagKeyAsSignedOut(id: EntityIds) { - if (this.potentiallyAuthenticated[id]) { - delete this.potentiallyAuthenticated[id]; - try { - localStorage.setItem(this.potentiallyAuthenticatedKey, JSON.stringify(this.potentiallyAuthenticated)); - } catch { - // Fail closed. A marker left behind makes the next page load probe this entity with its - // cookies, and a session that outlived the sign-out would sign it back in; losing every - // marker only makes the other entities look signed out on that load. - this.removeStoredItem(this.potentiallyAuthenticatedKey); - } + /** `checkedKey`: the URL a failed check used, which says nothing about a newer one another tab stored. */ + private flagKeyAsSignedOut(id: EntityIds, checkedKey?: AuthenticatedConnectionKey) { + delete this.potentiallyAuthenticated[id]; + const stored = this.storedMarkers(); + if (!stored) { + this.removeMarkers(id); + return; + } + const marked = Object.hasOwn(stored, id) && (checkedKey === undefined || stored[id] === checkedKey); + if (!marked && !this.unremovedMarkers.size) { + return; + } + if (marked) { + delete stored[id]; + } + try { + this.writeMarkers(stored); + } catch { + // A marker left behind makes the next page load probe this entity with its cookies, and a session that + // outlived the sign-out would sign it back in. + this.removeMarkers(marked ? id : undefined); } } @@ -1069,7 +1110,7 @@ class AuthStore { if (user) { this.flagKeyAsSignedIn(id, key); } else { - this.flagKeyAsSignedOut(id); + this.flagKeyAsSignedOut(id, key); } this.updateConnectionIfChanged(id, false, user); if (signOutHeard !== null && signOutHeard === this.appUser()) { diff --git a/src/features/auth/store/crossTabMarkers.test.ts b/src/features/auth/store/crossTabMarkers.test.ts new file mode 100644 index 000000000..79b3d8bf6 --- /dev/null +++ b/src/features/auth/store/crossTabMarkers.test.ts @@ -0,0 +1,180 @@ +/** @vitest-environment jsdom */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { authStore } from './authStore'; + +const { getInstanceUserInfo, onInstanceLogoutSubmit } = vi.hoisted(() => ({ + getInstanceUserInfo: vi.fn(), + onInstanceLogoutSubmit: vi.fn(), +})); +vi.mock('@/integrations/api/instance/status/getInstanceUserInfo', () => ({ getInstanceUserInfo })); +vi.mock('@/integrations/api/instance/auth/onInstanceLogoutSubmit', () => ({ onInstanceLogoutSubmit })); + +type SetArgs = Parameters; +const MARKERS = 'Studio:PotentiallyAuthenticated'; +const ENTITIES = ['ins-here', 'ins-other-tab']; +const url = (id: string) => `https://${id}.example.com:9925/`; + +function signInHere(id: string) { + authStore.setUserForIdAndKey(id as SetArgs[0], url(id) as SetArgs[1], { username: 'admin' } as SetArgs[2]); +} + +function stored(): Record { + return JSON.parse(localStorage.getItem(MARKERS) || '{}'); +} + +// What another tab's sign-in leaves: its marker and Basic Auth credential, written after this tab read its copy. +function signInInAnotherTab(id: string) { + localStorage.setItem(MARKERS, JSON.stringify({ ...stored(), [id]: url(id) })); + localStorage.setItem(`Studio:BasicAuth:${id}`, btoa(JSON.stringify({ username: 'admin', password: 'hunter2' }))); +} + +beforeEach(() => { + localStorage.clear(); + onInstanceLogoutSubmit.mockResolvedValue({ message: 'ok' }); + vi.spyOn(console, 'debug').mockImplementation(() => undefined); +}); + +afterEach(() => { + vi.resetAllMocks(); + vi.restoreAllMocks(); + ENTITIES.forEach((id) => authStore.signOutLocally(id)); + localStorage.clear(); +}); + +describe('sign-in markers another tab added', () => { + it('survive a sign-in in this tab', () => { + signInInAnotherTab('ins-other-tab'); + + signInHere('ins-here'); + + expect(stored()).toEqual({ 'ins-other-tab': url('ins-other-tab'), 'ins-here': url('ins-here') }); + }); + + it('survive a sign-out in this tab', () => { + signInHere('ins-here'); + signInInAnotherTab('ins-other-tab'); + + authStore.signOutLocally('ins-here'); + + expect(stored()).toEqual({ 'ins-other-tab': url('ins-other-tab') }); + }); + + it('go when this tab signs the same entity out', () => { + signInInAnotherTab('ins-other-tab'); + + authStore.signOutLocally('ins-other-tab'); + + expect(stored()).toEqual({}); + }); + + it('get a logout, and go, at an app sign-out from this tab', async () => { + signInHere('ins-here'); + signInInAnotherTab('ins-other-tab'); + + await authStore.signOutFromPotentiallyAuthenticatedInstances(); + + const loggedOut = onInstanceLogoutSubmit.mock.calls.map(([{ entityId, instanceClient }]) => [ + entityId, + instanceClient.defaults.baseURL, + ]); + expect(loggedOut).toEqual([['ins-here', url('ins-here')], ['ins-other-tab', url('ins-other-tab')]]); + expect(stored()).toEqual({}); + expect(authStore.checkForBasicAuth('ins-other-tab')).toBeUndefined(); + }); + + it('go, with their credentials, at a local full sign-out', () => { + signInInAnotherTab('ins-other-tab'); + + authStore.signOutAllLocally(); + + expect(stored()).toEqual({}); + expect(authStore.checkForBasicAuth('ins-other-tab')).toBeUndefined(); + }); + + it('stay out of a sign-in this tab makes when it cannot read them', () => { + signInHere('ins-here'); + localStorage.setItem(MARKERS, JSON.stringify({})); + const getItem = Storage.prototype.getItem; + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(function(this: Storage, key: string) { + if (key === MARKERS) { + throw new DOMException('Storage refused.', 'SecurityError'); + } + return getItem.call(this, key); + }); + + signInHere('ins-other-tab'); + vi.restoreAllMocks(); + + expect(stored()).toEqual({ 'ins-other-tab': url('ins-other-tab') }); + }); + + it('that are not a record get no logout at an app sign-out', async () => { + localStorage.setItem(MARKERS, JSON.stringify([url('ins-other-tab')])); + + await authStore.signOutFromPotentiallyAuthenticatedInstances(); + + expect(onInstanceLogoutSubmit).not.toHaveBeenCalled(); + }); +}); + +describe('a sign-in another tab made again at a newer URL', () => { + const newer = 'https://ins-here-2.example.com:9925/'; + + function loggedOutAt() { + return onInstanceLogoutSubmit.mock.calls.map(([{ instanceClient }]) => instanceClient.defaults.baseURL); + } + + beforeEach(() => { + signInHere('ins-here'); + localStorage.setItem(MARKERS, JSON.stringify({ 'ins-here': newer })); + }); + + it('gets a logout at both URLs at an app sign-out', async () => { + await authStore.signOutFromPotentiallyAuthenticatedInstances(); + + expect(loggedOutAt()).toEqual([url('ins-here'), newer]); + }); + + it('gets a logout at both URLs from signOutEntities', async () => { + await authStore.signOutEntities(['ins-here']); + + expect(loggedOutAt()).toEqual([url('ins-here'), newer]); + }); +}); + +describe('a check of a sign-in this tab remembers', () => { + beforeEach(() => { + getInstanceUserInfo.mockResolvedValue({ username: 'admin' }); + }); + + it('leaves out, when it passes, the marker of another tab that signed it out', async () => { + signInHere('ins-here'); + localStorage.setItem(MARKERS, JSON.stringify({})); + + await authStore.reloadUser('ins-here'); + + expect(stored()).toEqual({}); + }); + + it('keeps, when it passes, the operations URL another tab has signed in with since', async () => { + signInHere('ins-here'); + const newer = { 'ins-here': 'https://ins-here-2.example.com:9925/' }; + localStorage.setItem(MARKERS, JSON.stringify(newer)); + + await authStore.reloadUser('ins-here'); + + expect(stored()).toEqual(newer); + }); + + it("keeps that newer URL when it fails against this tab's older one", async () => { + signInHere('ins-here'); + const newer = { 'ins-here': 'https://ins-here-2.example.com:9925/' }; + localStorage.setItem(MARKERS, JSON.stringify(newer)); + getInstanceUserInfo.mockRejectedValue(new Error('Network Error')); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + + await authStore.reloadUser('ins-here'); + + expect(stored()).toEqual(newer); + }); +}); diff --git a/src/features/auth/store/localSignOutStorageFailure.test.ts b/src/features/auth/store/localSignOutStorageFailure.test.ts index f340278e8..083560210 100644 --- a/src/features/auth/store/localSignOutStorageFailure.test.ts +++ b/src/features/auth/store/localSignOutStorageFailure.test.ts @@ -142,6 +142,133 @@ describe('authStore local sign-out when storage writes throw', () => { expect(consoleError).toHaveBeenCalledWith(refusal); }); + describe('once storage works again after refusing to rewrite or remove the marker record', () => { + beforeEach(() => { + signIn('ins-p'); + signIn('ins-q'); + failEveryStorageWrite(); + vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => { + throw new DOMException('Storage refused.', 'SecurityError'); + }); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + authStore.signOutLocally('ins-p'); + vi.restoreAllMocks(); + }); + + afterEach(() => { + ['ins-q', 'ins-r', 'ins-s'].forEach((id) => authStore.signOutLocally(id)); + }); + + // What another tab's write leaves: the record as it reads it, which still marks the entity this tab signed out. + function writeInAnotherTab(change: (markers: Record) => void) { + const markers = JSON.parse(localStorage.getItem(MARKERS) || '{}'); + change(markers); + localStorage.setItem(MARKERS, JSON.stringify(markers)); + } + + it('leaves neither signed-out entity marked at the next sign-out', () => { + authStore.signOutLocally('ins-q'); + + const markers = JSON.parse(localStorage.getItem(MARKERS) || '{}'); + expect(markers).not.toHaveProperty('ins-p'); + expect(markers).not.toHaveProperty('ins-q'); + }); + + it('writes the sign-ins this tab holds, and not the one it signed out, at the next sign-in', () => { + signIn('ins-r'); + + const markers = JSON.parse(localStorage.getItem(MARKERS) || '{}'); + expect(markers).toMatchObject({ 'ins-q': 'https://ins-q', 'ins-r': 'https://ins-r' }); + expect(markers).not.toHaveProperty('ins-p'); + }); + + it('leaves out, at the next sign-in, a sign-in another tab has ended since', () => { + writeInAnotherTab((markers) => { + delete markers['ins-q']; + }); + + signIn('ins-r'); + + expect(JSON.parse(localStorage.getItem(MARKERS) || '{}')).not.toHaveProperty('ins-q'); + }); + + it('reaches, at a full sign-out, a sign-in only another tab has made since', () => { + writeInAnotherTab((markers) => { + markers['ins-s'] = 'https://ins-s'; + }); + localStorage.setItem('Studio:BasicAuth:ins-s', btoa(JSON.stringify({ username: 'u', password: 'p' }))); + + authStore.signOutAllLocally(); + + expect(authStore.checkForBasicAuth('ins-s')).toBeUndefined(); + expect(JSON.parse(localStorage.getItem(MARKERS) || '{}')).toEqual({}); + }); + + it('takes the entity out at the next sign-out of any other', () => { + authStore.signOutLocally('ins-never-signed-in'); + + expect(JSON.parse(localStorage.getItem(MARKERS) || '{}')).not.toHaveProperty('ins-p'); + }); + + it.each([ + ['a write', () => authStore.signOutLocally('ins-q')], + ['a removal', () => { + failEveryStorageWrite(); + authStore.signOutLocally('ins-q'); + vi.restoreAllMocks(); + }], + ])('keeps the entity once %s lands and another tab signs it back in', (_, land) => { + land(); + writeInAnotherTab((markers) => { + markers['ins-p'] = 'https://ins-p'; + }); + + signIn('ins-r'); + + expect(JSON.parse(localStorage.getItem(MARKERS) || '{}')).toHaveProperty('ins-p'); + }); + + it('reaches the entity at a full sign-out after another tab signs it back in', () => { + writeInAnotherTab((markers) => { + markers['ins-p'] = 'https://ins-p'; + }); + localStorage.setItem('Studio:BasicAuth:ins-p', btoa(JSON.stringify({ username: 'u', password: 'p' }))); + + authStore.signOutAllLocally(); + + // Raw: this tab still reads the credential it could not remove at the first sign-out as absent. + expect(localStorage.getItem('Studio:BasicAuth:ins-p')).toBeNull(); + }); + + it("keeps an entity it never marked when storage refuses that sign-out's write too", () => { + failEveryStorageWrite(); + vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => { + throw new DOMException('Storage refused.', 'SecurityError'); + }); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + authStore.signOutLocally('ins-never-signed-in'); + vi.restoreAllMocks(); + writeInAnotherTab((markers) => { + markers['ins-never-signed-in'] = 'https://ins-never-signed-in'; + }); + + signIn('ins-r'); + + expect(JSON.parse(localStorage.getItem(MARKERS) || '{}')).toHaveProperty('ins-never-signed-in'); + }); + }); + + it('writes a sign-in storage refused to record at the next one', () => { + failEveryStorageWrite(); + expect(() => signIn('ins-refused')).toThrow(); + vi.restoreAllMocks(); + + signIn('ins-refused'); + + expect(JSON.parse(localStorage.getItem(MARKERS) || '{}')).toHaveProperty('ins-refused'); + authStore.signOutLocally('ins-refused'); + }); + it('lets signOutAllLocally reach every entity and the cloud slot', () => { authStore.setUserForIdAndKey(OverallAppSignIn, OverallAppSignIn, { id: 'usr_a' } as SetArgs[2]); signIn('ins-a'); diff --git a/src/features/clusters/mutations/terminateCluster.test.ts b/src/features/clusters/mutations/terminateCluster.test.ts index d36d6908d..795cbc797 100644 --- a/src/features/clusters/mutations/terminateCluster.test.ts +++ b/src/features/clusters/mutations/terminateCluster.test.ts @@ -61,6 +61,20 @@ describe(terminateCluster, () => { expect(onInstanceLogoutSubmit).not.toHaveBeenCalled(); }); + it('forgets sign-ins only another tab remembers', async () => { + const markers = JSON.parse(localStorage.getItem('Studio:PotentiallyAuthenticated') || '{}'); + const otherTab = { 'clu-9': 'https://clu-9.example.com:9925/', 'ins-9': 'https://ins-9.example.com:9925/' }; + localStorage.setItem('Studio:PotentiallyAuthenticated', JSON.stringify({ ...markers, ...otherTab })); + apiDelete.mockResolvedValue({ data: { id: 'clu-9', deleted: true } }); + apiGet.mockResolvedValue({ data: { id: 'clu-9', status: 'RUNNING', instances: [{ id: 'ins-9' }] } }); + + await terminateCluster('clu-9'); + + await vi.waitFor(() => + expect(JSON.parse(localStorage.getItem('Studio:PotentiallyAuthenticated') || '{}')).toEqual(markers) + ); + }); + it('reads the instances back after the terminate when the read alongside it failed', async () => { apiGet.mockRejectedValueOnce(new Error('Network Error')); From dc715f00bf4718de4281d403dd6e1d0f90107749 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Wed, 14 Oct 2026 23:36:03 -0400 Subject: [PATCH 17/21] fix(auth): store each sign-in marker under its own key The markers lived in one shared Studio:PotentiallyAuthenticated record, so every write read and rewrote the whole of it. Two tabs writing at once could still lose a change, a refused write could take every marker with it, and a full sign-out rewrote the record once per entity. - Each marker now has its own key, Studio:PotentiallyAuthenticated:, holding the URL signed into. A sign-in or sign-out touches only its entity's key, and sweeps enumerate the keys by prefix. - On load, the legacy record is copied into per-entity keys once per storage, and a marker already stored per entity wins. A record that is not one (an array, or not JSON) is removed at every load, since the previous release cannot start on it. - For one release, every marker change is mirrored into the legacy record, and sign-out sweeps read it. A tab still on the previous release, or a rollback, then still sees this release's sign-ins and sign-outs, and an app sign-out here reaches a sign-in only such a tab recorded. The copy is never repeated: that tab writes its whole load-time record back, which would bring back what this release signed out. - A sign-in storage refuses to record no longer throws out of setUserForIdAndKey or a check. Its next sign-in or check records it. A marker storage would not remove is retried at every marker write. - A check's answer is dropped when this tab signed in again at another URL while the check was out. - Unit tests seed and read markers through signInMarkersTestHelpers, and the e2e anon specs through e2e/tests/signInMarkers.ts. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 4 +- DESIGN.md | 38 ++-- e2e/README.md | 2 +- e2e/tests/account-menu.anon.spec.ts | 56 ++++- e2e/tests/auth.setup.ts | 2 +- e2e/tests/cluster-form.anon.spec.ts | 5 +- e2e/tests/clusters.anon.spec.ts | 5 +- e2e/tests/global-search.anon.spec.ts | 5 +- e2e/tests/new-org.anon.spec.ts | 5 +- e2e/tests/organizations.anon.spec.ts | 5 +- e2e/tests/profile.anon.spec.ts | 5 +- e2e/tests/sidebar-help.anon.spec.ts | 5 +- e2e/tests/signInMarkers.ts | 9 + .../auth/handlers/signOutHandlers.test.ts | 5 +- .../auth/store/appSignOutElsewhere.test.ts | 12 +- src/features/auth/store/authStore.ts | 134 ++++++------ .../auth/store/crossTabMarkers.test.ts | 108 +++++----- .../auth/store/entityAuthCheckScope.test.ts | 16 +- .../auth/store/isKnownSignedOut.test.ts | 3 +- .../auth/store/legacySignInMarkers.test.ts | 195 ++++++++++++++++++ .../store/localSignOutStorageFailure.test.ts | 137 ++++-------- src/features/auth/store/signInMarkers.ts | 115 +++++++++++ .../auth/store/signInMarkersTestHelpers.ts | 13 ++ .../auth/store/signOutEntities.test.ts | 10 +- src/features/auth/store/signOutRaces.test.ts | 3 +- .../cluster/ClusterHome.signIns.test.tsx | 6 +- .../mutations/terminateCluster.test.ts | 9 +- src/router/__tests__/crossTabSignOut.test.tsx | 7 +- .../endedSessionRouteFailures.test.tsx | 3 +- .../rememberedClusterDeepLink.test.ts | 11 +- .../terminatedClusterDeepLink.test.tsx | 8 +- 31 files changed, 633 insertions(+), 308 deletions(-) create mode 100644 e2e/tests/signInMarkers.ts create mode 100644 src/features/auth/store/legacySignInMarkers.test.ts create mode 100644 src/features/auth/store/signInMarkers.ts create mode 100644 src/features/auth/store/signInMarkersTestHelpers.ts diff --git a/AGENTS.md b/AGENTS.md index e22d73dbf..2ba94b760 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -784,8 +784,8 @@ Three traps when reading this from RUM data: A boot-time redirect does **not** cost you that first view, and the deciding factor is whether `getAllConnections()` can answer synchronously. It synthesizes `{ user: null, isLoading: false }` -for `OverallAppSignIn` only when the `Studio:PotentiallyAuthenticated` localStorage record has no -entry for it; with an entry it returns the record untouched, so the key is simply **absent**. +for `OverallAppSignIn` only when no `Studio:PotentiallyAuthenticated:OverallAppSignIn` marker is +stored; with one it returns the record untouched, so the key is simply **absent**. `dashboardLayout.beforeLoad` guards on `auth && !auth.isLoading && !auth.user`, so those are two different outcomes: a signed-out deep link redirects _during_ the router's initial load and the root component never commits the deep-link location — one `startView`, named `/sign-in/` — while an diff --git a/DESIGN.md b/DESIGN.md index babd734f7..271355fe0 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -109,19 +109,27 @@ Central manager only ever sends the boolean, never the other organizations' ids; its own route: TanStack resolves it against the _current_ location, so a row that outlives a pending navigation re-resolves its href one level too deep (HarperFast/studio#1710, #1711). `EntityMenuItem.to` accepts only the helper's `AbsolutePath`, so a relative menu target fails `tsc`. -- **Remembered instance/cluster sign-ins are checked on demand**: `Studio:PotentiallyAuthenticated` spans every - organization ever visited, so the root auth subscription checks only the app sign-in; an entity is checked by +- **Remembered instance/cluster sign-ins are checked on demand**: the sign-in markers span every organization ever + visited, so the root auth subscription checks only the app sign-in; an entity is checked by `useInstanceAuth`/`listenToEntity` or the instance-layout guard (`entityAuthCheckScope.test.ts`, HarperFast/studio#1329). -- **Every tab shares those markers, so a write changes one entity.** A sign-in or sign-out re-reads the record and adds - or drops only its own entity; app and full sign-outs act on every stored marker, not only this tab's, and log out - each URL an entity is marked at in this tab or storage. A check never undoes another tab's change: a passing one of - a sign-in this tab already marked writes nothing, and a failing one leaves a newer URL another tab stored - (`crossTabMarkers.test.ts`, HarperFast/studio#1968). So a marker removed elsewhere, or dropped with the whole record - when storage refused a write, stays removed even while this tab still holds that sign-in. Storage has no atomic - read-modify-write, so two tabs writing at the same moment can still lose one change. A missing marker looks signed - out on the next load and gets no logout from another tab's app sign-out; a lost sign-out leaves a marker that load - probes. When storage refuses to take a signed-out entity out, this tab's later writes leave it out until one lands - (`localSignOutStorageFailure.test.ts`); an unreadable record is replaced. +- **Each sign-in marker has its own key, `Studio:PotentiallyAuthenticated:`, holding the URL signed into** + (`signInMarkers.ts`, HarperFast/studio#1968). A tab writes only the entity it signs in or out, so tabs can't erase or + restore each other's markers; app and full sign-outs act on every stored marker, at each URL an entity is marked at + here or in storage. A check never undoes another tab's change: a passing one of a sign-in this tab already marked + writes nothing, and a failing one leaves a newer URL another tab stored. So a marker removed elsewhere stays removed + even while this tab still holds that sign-in (`crossTabMarkers.test.ts`). A sign-in storage refuses to record still + signs in, and its next sign-in or check writes it; a marker storage won't remove is retried at every marker write + (`localSignOutStorageFailure.test.ts`). +- **The previous release's one shared record is kept in step for one more release.** Its tabs read + `Studio:PotentiallyAuthenticated` once at load and write their whole copy back, and know nothing of the per-entity + keys. So this release copies that record into the keys once per storage (a marker already stored per entity wins), + mirrors every marker change into it, and reads it in every sign-out sweep. A previous-release tab, or a rollback, + therefore still sees this release's sign-ins and sign-outs, and an app sign-out here reaches a sign-in only that tab + recorded. Its sign-in reaches this release's keys only at that first copy, and its sign-out of an entity doesn't + remove the entity's key, which matters only if its logout also failed; its full sign-out wipes both. The copy is + never repeated, because that tab's whole-copy writes would bring back what this release signed out + (`legacySignInMarkers.test.ts`, `account-menu.anon.spec.ts`). A record that isn't one is removed at every load: the + previous release can't start on it. The release after this one stops the mirror and removes the record. - **`Dashboard` renders nothing under it without a signed-in user.** Route data loads in parallel with the page load's `/User/current` check (waiting for it costs +262/680 ms p75 on live org/cluster cold loads), and central manager answers a request with no session with 403, so a deep link on an expired session has its route data fail before the @@ -142,9 +150,9 @@ Central manager only ever sends the boolean, never the other organizations' ids; from their next request. A tab follows only when the name is its own app user: a late 401 or event from user U's session must not sign out a tab that has since signed in as V. A tab whose check is still out remembers the user, and if the check comes back as them, asks again: central manager may have confirmed them just before the session ended. - Tabs follow that key, not the marker record, because a tab also drops the app sign-in from the record when its own - check merely fails (a timeout, a 5xx). A tab that follows writes no `localStorage`: its copy of the markers is a - snapshot from its own load, and writing it back could undo the other tab's next sign-in (`crossTabSignOut.test.tsx`). + Tabs follow that key, not the app sign-in's marker, because a tab also drops that marker when its own check merely + fails (a timeout, a 5xx). A tab that follows writes no `localStorage`: the other tab already wiped it, and a write + could undo that tab's next sign-in (`crossTabSignOut.test.tsx`). - **Data fetching**: TanStack React Query 5. Query keys must be instance-scoped when the request targets a specific instance. - **Polling**: every `refetchInterval` on a query that can reject against an instance or a cluster goes through [`pollUnlessForbidden`](src/react-query/pollUnlessForbidden.ts), which stops the timer on a 403 and backs off a sustained 400 or 5xx; a raw number polls a failing endpoint at full rate for as long as the page is open. When the interval depends on the data, compute it and hand it over per tick: `(query) => pollUnlessForbidden(base(query))(query)` (the back-off state is per query, not per wrapper). - **Forms**: react-hook-form with a zod `zodResolver`. A form whose submit button waits on `formState.isValid` must not use the default `onSubmit` mode, which keeps `isValid` current but shows errors only after a submit the disabled button never allows, so the user is blocked with no reason given (HarperFast/studio#1550). Use `mode: 'onTouched'`: a field's error appears once it is first left and then follows each change, while `isValid` still tracks every keystroke (`onBlur` recomputes `isValid` only on blur, so the button lags the text). Enter neither submits past a disabled button nor leaves the field, so give the `
` `onKeyDown={revealFieldErrorOnEnter(form)}`, which treats Enter in a text input as leaving it. A refine that compares two fields reports on one of them; re-check that field from the other's `onChange` once it has been touched (`getFieldState(name).isTouched`, then `trigger(name)`), because `rules={{ deps }}` fires on the other field's first blur, before the user reaches it. react-hook-form only updates the `formState` fields a render has read, and `disabled={!isDirty || !isValid}` skips `isValid` while the form is pristine, so where one change can complete the form (a single field, a paste, a checkbox) the button stays disabled on a valid form. Read the fields before the JSX (`const { isDirty, isValid } = form.formState;`) and pin it with a test that makes one change and checks the button. diff --git a/e2e/README.md b/e2e/README.md index b17b5fb1e..85a49b0ab 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -121,7 +121,7 @@ Naming drives which project runs a spec: `*.anon.spec.ts` = no session, - **Hash routing.** Routes are `/#/sign-in`, `/#/verifying`, `/#//users`. - **No test-ids** in these flows — drive by role/label/text (`getByRole`, `getByLabel`). The only `data-testid`s in the app are in instance analytics. -- **Auth is a cookie** (`POST /Login/`); the `Studio:PotentiallyAuthenticated` +- **Auth is a cookie** (`POST /Login/`); the `Studio:PotentiallyAuthenticated:` localStorage flag is only a hint. `storageState` captures the cookie. - **Sign-in and sign-up render every submit failure inline** (`p[role="alert"][data-slot="form-message"]`). **Forgot-password renders only CAPTCHA rejections diff --git a/e2e/tests/account-menu.anon.spec.ts b/e2e/tests/account-menu.anon.spec.ts index cfb838708..8f7b1820c 100644 --- a/e2e/tests/account-menu.anon.spec.ts +++ b/e2e/tests/account-menu.anon.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from '@playwright/test'; +import { appSignInMarker, rememberAppSignIn } from './signInMarkers'; const role = (name: string) => ({ organizationName: name, role: 'owner', permission: { super_user: true } }); const user = { @@ -18,9 +19,7 @@ test.beforeEach(async ({ page }) => { ['fetch', 'xhr'].includes(route.request().resourceType()) ? route.fulfill({ status: 404, json: { error: 'Unmocked test request' } }) : route.continue()); - await page.addInitScript(() => - localStorage.setItem('Studio:PotentiallyAuthenticated', JSON.stringify({ OverallAppSignIn: 'fixture' })) - ); + await rememberAppSignIn(page); await page.route('**/User/current', route => route.fulfill({ json: user })); await page.route('**/Organization/*', route => { const id = route.request().url().split('/').at(-1)!; @@ -87,7 +86,56 @@ test('account menu groups profile, organization switching, appearance and sign o await page.getByRole('menuitem', { name: 'Sign Out', exact: true }).click(); await expect(page).toHaveURL(/#\/sign-in/); expect(signedOut).toBe(true); - expect(await page.evaluate(() => localStorage.getItem('Studio:PotentiallyAuthenticated'))).toBeNull(); + expect(await page.evaluate((key) => localStorage.getItem(key), appSignInMarker)).toBeNull(); +}); + +test.describe('alongside a tab still on the previous release, which keeps every sign-in in one record', () => { + const legacyRecord = 'Studio:PotentiallyAuthenticated'; + + test('stays signed in on the sign-in that release recorded', async ({ page }) => { + await page.addInitScript(({ marker, legacy }) => { + if (!localStorage.getItem('Studio:PotentiallyAuthenticatedMigrated')) { + localStorage.removeItem(marker); + localStorage.setItem(legacy, JSON.stringify({ OverallAppSignIn: 'fixture' })); + } + }, { marker: appSignInMarker, legacy: legacyRecord }); + + await page.goto('/#/org-a'); + + await expect(page.getByRole('button', { name: 'Account menu' })).toBeVisible(); + expect(await page.evaluate((key) => localStorage.getItem(key), appSignInMarker)).toBeTruthy(); + }); + + test('signs out of a server only that tab signed into', async ({ page }) => { + const oldTabSignIn = 'https://ins-old.example.test:9925/'; + const instanceRequests: string[] = []; + await page.route(/ins-old\.example\.test/, route => { + instanceRequests.push(`${route.request().method()} ${route.request().postData() ?? ''}`); + return route.fulfill({ + json: { message: 'ok' }, + headers: { + 'Access-Control-Allow-Origin': route.request().headers()['origin'] ?? '*', + 'Access-Control-Allow-Credentials': 'true', + 'Access-Control-Allow-Headers': 'content-type', + }, + }); + }); + await page.route(/\/Logout\/?$/, route => route.fulfill({ json: {} })); + await page.goto('/#/org-a'); + await expect(page.getByRole('button', { name: 'Account menu' })).toBeVisible(); + // That tab writes its whole record back after this one has loaded. + await page.evaluate( + ({ legacy, url }) => + localStorage.setItem(legacy, JSON.stringify({ OverallAppSignIn: 'fixture', 'ins-old': url })), + { legacy: legacyRecord, url: oldTabSignIn }, + ); + + await page.getByRole('button', { name: 'Account menu' }).click(); + await page.getByRole('menuitem', { name: 'Sign Out', exact: true }).click(); + + await expect(page).toHaveURL(/#\/sign-in/); + expect(instanceRequests).toContain('POST {"operation":"logout"}'); + }); }); test('account controls remain reachable on a narrow screen with keyboard focus return', async ({ page }) => { diff --git a/e2e/tests/auth.setup.ts b/e2e/tests/auth.setup.ts index 69341f268..954014c37 100644 --- a/e2e/tests/auth.setup.ts +++ b/e2e/tests/auth.setup.ts @@ -14,7 +14,7 @@ const password = process.env.PLAYWRIGHT_USER_PASSWORD; /** * Logs in through the real UI once and persists the session (the cookie set by - * POST /Login/ — the localStorage `Studio:PotentiallyAuthenticated` flag is only + * POST /Login/ — the localStorage `Studio:PotentiallyAuthenticated:*` flags are only * a hint, so cookie-bearing storageState is what actually authenticates). * * Doubles as the sign-in happy-path smoke test. diff --git a/e2e/tests/cluster-form.anon.spec.ts b/e2e/tests/cluster-form.anon.spec.ts index 3c604e81d..69718415d 100644 --- a/e2e/tests/cluster-form.anon.spec.ts +++ b/e2e/tests/cluster-form.anon.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from '@playwright/test'; +import { rememberAppSignIn } from './signInMarkers'; const region = { id: 'us-1', region: 'US', latencyDescription: '83ms', instanceCount: 2 }; const plan = { @@ -33,9 +34,7 @@ const organizationWith = (clusterRecord: typeof cluster) => ({ }); test.beforeEach(async ({ page }) => { - await page.addInitScript(() => - localStorage.setItem('Studio:PotentiallyAuthenticated', JSON.stringify({ OverallAppSignIn: 'fixture' })) - ); + await rememberAppSignIn(page); await page.route( '**/*', route => diff --git a/e2e/tests/clusters.anon.spec.ts b/e2e/tests/clusters.anon.spec.ts index 83fa07c30..723b61116 100644 --- a/e2e/tests/clusters.anon.spec.ts +++ b/e2e/tests/clusters.anon.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from '@playwright/test'; +import { rememberAppSignIn } from './signInMarkers'; const clusters = [ { id: 'clu-production', name: 'Production', status: 'RUNNING', regionId: 'reg-east' }, @@ -26,9 +27,7 @@ const user = { }; test.beforeEach(async ({ page }) => { - await page.addInitScript(() => { - localStorage.setItem('Studio:PotentiallyAuthenticated', JSON.stringify({ OverallAppSignIn: 'fixture' })); - }); + await rememberAppSignIn(page); await page.route('**/User/current', route => route.fulfill({ json: user })); await page.route('**/Organization/org-fixture', route => route.fulfill({ json: organization })); await page.route('**/Cluster/clu-production', route => route.fulfill({ json: { ...clusters[0], instances: [] } })); diff --git a/e2e/tests/global-search.anon.spec.ts b/e2e/tests/global-search.anon.spec.ts index 24d33aa3b..69e252124 100644 --- a/e2e/tests/global-search.anon.spec.ts +++ b/e2e/tests/global-search.anon.spec.ts @@ -1,10 +1,9 @@ import { expect, test } from '@playwright/test'; +import { rememberAppSignIn } from './signInMarkers'; const role = (organizationName: string) => ({ organizationName, role: 'owner', permission: { super_user: true } }); test.beforeEach(async ({ page }) => { - await page.addInitScript(() => - localStorage.setItem('Studio:PotentiallyAuthenticated', JSON.stringify({ OverallAppSignIn: 'fixture' })) - ); + await rememberAppSignIn(page); await page.route( '**/*', route => diff --git a/e2e/tests/new-org.anon.spec.ts b/e2e/tests/new-org.anon.spec.ts index a80be010b..18ad24fdc 100644 --- a/e2e/tests/new-org.anon.spec.ts +++ b/e2e/tests/new-org.anon.spec.ts @@ -1,11 +1,10 @@ import { expect, test } from '@playwright/test'; +import { rememberAppSignIn } from './signInMarkers'; const user = { id: 'new-org-user', email: 'org@example.test', firstname: 'Taylor', lastname: 'River', roles: {} }; test.beforeEach(async ({ page }) => { - await page.addInitScript(() => - localStorage.setItem('Studio:PotentiallyAuthenticated', JSON.stringify({ OverallAppSignIn: 'fixture' })) - ); + await rememberAppSignIn(page); await page.route('**/*', route => ['fetch', 'xhr'].includes(route.request().resourceType()) ? route.fulfill({ status: 404, json: {} }) diff --git a/e2e/tests/organizations.anon.spec.ts b/e2e/tests/organizations.anon.spec.ts index c3b9a87a9..8d7976d40 100644 --- a/e2e/tests/organizations.anon.spec.ts +++ b/e2e/tests/organizations.anon.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from '@playwright/test'; +import { rememberAppSignIn } from './signInMarkers'; const member = (organizationName: string, role = 'owner') => ({ organizationName, @@ -19,9 +20,7 @@ const user = { }; test.beforeEach(async ({ page }) => { - await page.addInitScript(() => - localStorage.setItem('Studio:PotentiallyAuthenticated', JSON.stringify({ OverallAppSignIn: 'fixture' })) - ); + await rememberAppSignIn(page); await page.route( '**/*', route => diff --git a/e2e/tests/profile.anon.spec.ts b/e2e/tests/profile.anon.spec.ts index 338b2d4a7..88ac67900 100644 --- a/e2e/tests/profile.anon.spec.ts +++ b/e2e/tests/profile.anon.spec.ts @@ -1,11 +1,10 @@ import { expect, test } from '@playwright/test'; +import { rememberAppSignIn } from './signInMarkers'; const user = { id: 'profile-user', email: 'profile@example.test', firstname: 'Taylor', lastname: 'River', roles: {} }; test.beforeEach(async ({ page }) => { - await page.addInitScript(() => - localStorage.setItem('Studio:PotentiallyAuthenticated', JSON.stringify({ OverallAppSignIn: 'fixture' })) - ); + await rememberAppSignIn(page); await page.route( '**/*', route => diff --git a/e2e/tests/sidebar-help.anon.spec.ts b/e2e/tests/sidebar-help.anon.spec.ts index 5373b689e..ebf9e3d8e 100644 --- a/e2e/tests/sidebar-help.anon.spec.ts +++ b/e2e/tests/sidebar-help.anon.spec.ts @@ -1,9 +1,8 @@ import { expect, test } from '@playwright/test'; +import { rememberAppSignIn } from './signInMarkers'; test.beforeEach(async ({ page }) => { - await page.addInitScript(() => - localStorage.setItem('Studio:PotentiallyAuthenticated', JSON.stringify({ OverallAppSignIn: 'fixture' })) - ); + await rememberAppSignIn(page); await page.route( '**/User/current', route => diff --git a/e2e/tests/signInMarkers.ts b/e2e/tests/signInMarkers.ts new file mode 100644 index 000000000..857c0a828 --- /dev/null +++ b/e2e/tests/signInMarkers.ts @@ -0,0 +1,9 @@ +import type { Page } from '@playwright/test'; + +/** Studio records each sign-in under its own localStorage key (`src/features/auth/store/signInMarkers.ts`). */ +export const appSignInMarker = 'Studio:PotentiallyAuthenticated:OverallAppSignIn'; + +/** Marks the app signed in before the page loads, so it checks the mocked session instead of showing sign-in. */ +export function rememberAppSignIn(page: Page) { + return page.addInitScript((key) => localStorage.setItem(key, 'fixture'), appSignInMarker); +} diff --git a/src/features/auth/handlers/signOutHandlers.test.ts b/src/features/auth/handlers/signOutHandlers.test.ts index 4df256b09..dab49a4a3 100644 --- a/src/features/auth/handlers/signOutHandlers.test.ts +++ b/src/features/auth/handlers/signOutHandlers.test.ts @@ -1,5 +1,6 @@ /** @vitest-environment jsdom */ import { authStore, OverallAppSignIn } from '@/features/auth/store/authStore'; +import { seedSignInMarkers, storedSignInMarkers } from '@/features/auth/store/signInMarkersTestHelpers'; import { queryClient } from '@/react-query/queryClient'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { clearAuthStateAfterSignOutElsewhere, clearAuthStateLocally } from './clearAuthStateLocally'; @@ -244,7 +245,7 @@ describe('clearAuthStateAfterSignOutElsewhere', () => { it('signs this tab out and wipes both caches and its sessionStorage, but leaves the shared localStorage', () => { seedSignedInTab(); localStorage.clear(); - localStorage.setItem('Studio:PotentiallyAuthenticated', JSON.stringify({ OverallAppSignIn })); + seedSignInMarkers({ OverallAppSignIn }); clearAuthStateAfterSignOutElsewhere(); @@ -253,7 +254,7 @@ describe('clearAuthStateAfterSignOutElsewhere', () => { expectCachesEmpty(); expect(sessionStorage.length).toBe(0); expect(localStorage.length).toBe(1); - expect(localStorage.getItem('Studio:PotentiallyAuthenticated')).toBe(JSON.stringify({ OverallAppSignIn })); + expect(storedSignInMarkers()).toEqual({ OverallAppSignIn }); }); it('still wipes the caches and sessionStorage when the store teardown throws', () => { diff --git a/src/features/auth/store/appSignOutElsewhere.test.ts b/src/features/auth/store/appSignOutElsewhere.test.ts index 6f29eb750..bcfc7f55c 100644 --- a/src/features/auth/store/appSignOutElsewhere.test.ts +++ b/src/features/auth/store/appSignOutElsewhere.test.ts @@ -14,10 +14,12 @@ vi.mock('@/integrations/api/instance/status/getInstanceUserInfo', () => ({ getIn import { apiClient } from '@/config/apiClient'; import { authStore, OverallAppSignIn } from './authStore'; +import { signInMarkerKey } from './signInMarkers'; +import { seedSignInMarkers, storedSignInMarkers } from './signInMarkersTestHelpers'; type SetArgs = Parameters; -const MARKER = 'Studio:PotentiallyAuthenticated'; +const MARKER = signInMarkerKey(OverallAppSignIn); const SIGNAL = 'Studio:AppSignOut'; const DIRECT_URL = 'https://ins-direct.example.com:9925/'; @@ -56,8 +58,8 @@ describe('authStore.onAppSignedOutElsewhere', () => { const signedOut = vi.fn(); const stop = authStore.onAppSignedOutElsewhere(signedOut); - storageEventFromAnotherTab(MARKER, JSON.stringify({ OverallAppSignIn })); - storageEventFromAnotherTab(MARKER, '{}'); + storageEventFromAnotherTab(MARKER, OverallAppSignIn); + storageEventFromAnotherTab(MARKER, null); storageEventFromAnotherTab('Studio:ExplorerAuthEpoch', JSON.stringify({ '*': 1 })); storageEventFromAnotherTab(null, null); storageEventFromAnotherTab(SIGNAL, signOutOf('usr_v')); @@ -220,7 +222,7 @@ describe('authStore.forgetAppSignInEndedElsewhere', () => { authStore.setUserForIdAndKey('ins-a', 'https://ins-a.example.com', { username: 'u' } as SetArgs[2]); // What the other tab wrote after signing out: a sign-in, perhaps as someone else. localStorage.clear(); - localStorage.setItem(MARKER, JSON.stringify({ OverallAppSignIn })); + seedSignInMarkers({ OverallAppSignIn }); const writes = [ vi.spyOn(Storage.prototype, 'setItem'), vi.spyOn(Storage.prototype, 'removeItem'), @@ -234,7 +236,7 @@ describe('authStore.forgetAppSignInEndedElsewhere', () => { expect(authStore.getOperationsUrl('ins-a')).toBeUndefined(); const localWrites = writes.flatMap((spy) => spy.mock.contexts).filter((storage) => storage === localStorage); expect(localWrites).toEqual([]); - expect(localStorage.getItem(MARKER)).toBe(JSON.stringify({ OverallAppSignIn })); + expect(storedSignInMarkers()).toEqual({ OverallAppSignIn }); }); it('drops a direct-connect token, and a sign-in check still in flight', async () => { diff --git a/src/features/auth/store/authStore.ts b/src/features/auth/store/authStore.ts index 0025b39b0..692ff2b1b 100644 --- a/src/features/auth/store/authStore.ts +++ b/src/features/auth/store/authStore.ts @@ -22,6 +22,14 @@ import { isInstance } from '@/lib/types/isInstance'; import { getOperationsUrlForCluster } from '@/lib/urls/getOperationsUrlForCluster'; import { getOperationsUrlForInstance } from '@/lib/urls/getOperationsUrlForInstance'; import { isDirectOperationsUrl } from '@/lib/urls/isDirectOperationsUrl'; +import { + migrateLegacySignInMarkers, + mirrorLegacySignInMarker, + readLegacySignInMarkers, + readSignInMarkers, + signInMarkerKey, + signInMarkerPrefix, +} from './signInMarkers'; type AuthStoreListenerCleanup = () => void; @@ -61,15 +69,14 @@ class AuthStore { Array<(connection: AuthenticatedConnection, id: EntityIds) => void> > = {}; - private readonly potentiallyAuthenticatedKey = 'Studio:PotentiallyAuthenticated'; private readonly basicAuthKeyPrefix = 'Studio:BasicAuth:'; private readonly fabricConnectKeyPrefix = 'Studio:FabricConnect:'; private readonly lastConnectModeKeyPrefix = 'Studio:LastConnectMode:'; // Storage can refuse a removal yet still answer reads, so a credential it kept belongs to the session // that ended. This tab reads such a key as absent until a write replaces it. private readonly unremovedKeys = new Set(); - // Signed-out entities storage still marks, because it refused this tab's write. The next one must leave them out. - private readonly unremovedMarkers = new Set(); + // Sign-ins storage refused to record, which the next sign-in or check of them writes again. + private readonly unrecordedSignIns = new Set(); private readonly potentiallyAuthenticated: Record; private readonly checkedAuthentication: Record = {}; private readonly allConnections: Record = {}; @@ -113,7 +120,8 @@ class AuthStore { private signOutHeardDuringCheck: string | null = null; constructor() { - this.potentiallyAuthenticated = JSON.parse(localStorage.getItem(this.potentiallyAuthenticatedKey) || '{}'); + migrateLegacySignInMarkers(); + this.potentiallyAuthenticated = Object.fromEntries(readSignInMarkers()); } private readExplorerGenerations(): Record { @@ -790,54 +798,17 @@ class AuthStore { ); } - /** Read raw, since other tabs write it too. Null when unreadable. */ - private readMarkers(): Record | null { - try { - const markers: unknown = JSON.parse(localStorage.getItem(this.potentiallyAuthenticatedKey) || '{}'); - if (!markers || typeof markers !== 'object' || Array.isArray(markers)) { - return null; - } - return Object.fromEntries( - Object.entries(markers).filter((entry): entry is [string, string] => - typeof entry[1] === 'string' && !!entry[1] - ), - ); - } catch { - return null; - } - } - - private storedMarkers(): Record | null { - const markers = this.readMarkers(); - if (markers) { - for (const id of this.unremovedMarkers) { - delete markers[id]; - } - } - return markers; - } - - private writeMarkers(markers: Record): void { - this.writeStoredItem(this.potentiallyAuthenticatedKey, JSON.stringify(markers)); - this.unremovedMarkers.clear(); - } - - /** Fail closed: losing every marker only makes the other entities look signed out on the next load. */ - private removeMarkers(signedOut: EntityIds | undefined): void { - this.removeStoredItem(this.potentiallyAuthenticatedKey); - if (!this.unremovedKeys.has(this.potentiallyAuthenticatedKey)) { - this.unremovedMarkers.clear(); - } else if (signedOut !== undefined) { - this.unremovedMarkers.add(signedOut); - } - } - /** - * This tab's sign-ins and every stored one, raw, once per URL: another tab may have signed in again at a newer - * URL, and the older one's session is still open. A sweep that reaches more only signs more out. + * This tab's sign-ins, every stored one, and every one a previous-release tab recorded, once per URL: another tab + * may have signed in again at a newer URL, and the older one's session is still open. A sweep that reaches more + * only signs more out. */ private rememberedSignIns(): Array<[EntityIds, AuthenticatedConnectionKey]> { - const signIns = [...Object.entries(this.potentiallyAuthenticated), ...Object.entries(this.readMarkers() ?? {})]; + const signIns = [ + ...Object.entries(this.potentiallyAuthenticated), + ...readSignInMarkers(), + ...Object.entries(readLegacySignInMarkers() ?? {}), + ]; return signIns.filter(([id, key], index) => signIns.findIndex(([i, k]) => i === id && k === key) === index); } @@ -991,40 +962,59 @@ class AuthStore { void this.updateListeners(id); } - // Writing this tab's copy would erase what other tabs signed into since it loaded, so both writes change one entity - // in the record as stored now. A check of a sign-in this tab already marked writes nothing, which leaves out a - // marker another tab's sign-out removed. + /** Other tabs, and the next load, read a marker this tab couldn't remove, so every marker write tries again. */ + private retryUnremovedSignInMarkers(): void { + for (const key of this.unremovedKeys) { + if (key.startsWith(signInMarkerPrefix)) { + try { + localStorage.removeItem(key); + this.unremovedKeys.delete(key); + } catch { + // The next marker write tries again. + } + } + } + } + + // A check of a sign-in this tab already marked writes nothing, so it can't put back a marker another tab's + // sign-out removed. private flagKeyAsSignedIn(id: EntityIds, key: AuthenticatedConnectionKey) { - if (this.potentiallyAuthenticated[id] === key) { + if (this.potentiallyAuthenticated[id] === key && !this.unrecordedSignIns.has(id)) { return; } - // Then this tab's copy: one that storage refused would make every later sign-in skip the write. - this.writeMarkers({ ...this.storedMarkers(), [id]: key }); this.potentiallyAuthenticated[id] = key; + this.retryUnremovedSignInMarkers(); + try { + this.writeStoredItem(signInMarkerKey(id), key); + this.unrecordedSignIns.delete(id); + } catch (error) { + // Not thrown: the sign-in itself worked, and unrecorded it only looks signed out on the next load. + this.unrecordedSignIns.add(id); + console.debug('Could not record a sign-in', error); + } + mirrorLegacySignInMarker(id, key); } /** `checkedKey`: the URL a failed check used, which says nothing about a newer one another tab stored. */ private flagKeyAsSignedOut(id: EntityIds, checkedKey?: AuthenticatedConnectionKey) { delete this.potentiallyAuthenticated[id]; - const stored = this.storedMarkers(); - if (!stored) { - this.removeMarkers(id); - return; + this.unrecordedSignIns.delete(id); + this.retryUnremovedSignInMarkers(); + let stored: string | null = null; + try { + stored = localStorage.getItem(signInMarkerKey(id)); + } catch { + // Unreadable, so there is nothing this tab can remove. } - const marked = Object.hasOwn(stored, id) && (checkedKey === undefined || stored[id] === checkedKey); - if (!marked && !this.unremovedMarkers.size) { + if (checkedKey !== undefined && stored && stored !== checkedKey) { return; } - if (marked) { - delete stored[id]; - } - try { - this.writeMarkers(stored); - } catch { - // A marker left behind makes the next page load probe this entity with its cookies, and a session that - // outlived the sign-out would sign it back in. - this.removeMarkers(marked ? id : undefined); + if (stored) { + // Blanks or hides a marker storage won't remove: one left behind makes the next page load probe this + // entity with its cookies, and a session that outlived the sign-out would sign it back in. + this.removeStoredItem(signInMarkerKey(id)); } + mirrorLegacySignInMarker(id, null, checkedKey); } private async updateListeners(id: EntityIds): Promise { @@ -1107,6 +1097,10 @@ class AuthStore { if (this.getSignOutGeneration(id) !== signOutGeneration) { return null; } + if (this.potentiallyAuthenticated[id] !== key) { + // This tab signed in again at another URL while the check was out, so its answer is about a sign-in that ended. + return this.getConnectionById(id).user; + } if (user) { this.flagKeyAsSignedIn(id, key); } else { diff --git a/src/features/auth/store/crossTabMarkers.test.ts b/src/features/auth/store/crossTabMarkers.test.ts index 79b3d8bf6..1136526e0 100644 --- a/src/features/auth/store/crossTabMarkers.test.ts +++ b/src/features/auth/store/crossTabMarkers.test.ts @@ -1,6 +1,8 @@ /** @vitest-environment jsdom */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { authStore } from './authStore'; +import { legacySignInMarkersKey, signInMarkerKey } from './signInMarkers'; +import { seedSignInMarkers, storedSignInMarkers } from './signInMarkersTestHelpers'; const { getInstanceUserInfo, onInstanceLogoutSubmit } = vi.hoisted(() => ({ getInstanceUserInfo: vi.fn(), @@ -10,24 +12,26 @@ vi.mock('@/integrations/api/instance/status/getInstanceUserInfo', () => ({ getIn vi.mock('@/integrations/api/instance/auth/onInstanceLogoutSubmit', () => ({ onInstanceLogoutSubmit })); type SetArgs = Parameters; -const MARKERS = 'Studio:PotentiallyAuthenticated'; const ENTITIES = ['ins-here', 'ins-other-tab']; const url = (id: string) => `https://${id}.example.com:9925/`; +const newer = 'https://ins-here-2.example.com:9925/'; -function signInHere(id: string) { - authStore.setUserForIdAndKey(id as SetArgs[0], url(id) as SetArgs[1], { username: 'admin' } as SetArgs[2]); +function signInHere(id: string, at = url(id)) { + authStore.setUserForIdAndKey(id as SetArgs[0], at as SetArgs[1], { username: 'admin' } as SetArgs[2]); } -function stored(): Record { - return JSON.parse(localStorage.getItem(MARKERS) || '{}'); -} - -// What another tab's sign-in leaves: its marker and Basic Auth credential, written after this tab read its copy. function signInInAnotherTab(id: string) { - localStorage.setItem(MARKERS, JSON.stringify({ ...stored(), [id]: url(id) })); + seedSignInMarkers({ [id]: url(id) }); localStorage.setItem(`Studio:BasicAuth:${id}`, btoa(JSON.stringify({ username: 'admin', password: 'hunter2' }))); } +function loggedOut() { + return onInstanceLogoutSubmit.mock.calls.map(([{ entityId, instanceClient }]) => [ + entityId, + instanceClient.defaults.baseURL, + ]); +} + beforeEach(() => { localStorage.clear(); onInstanceLogoutSubmit.mockResolvedValue({ message: 'ok' }); @@ -47,7 +51,7 @@ describe('sign-in markers another tab added', () => { signInHere('ins-here'); - expect(stored()).toEqual({ 'ins-other-tab': url('ins-other-tab'), 'ins-here': url('ins-here') }); + expect(storedSignInMarkers()).toEqual({ 'ins-other-tab': url('ins-other-tab'), 'ins-here': url('ins-here') }); }); it('survive a sign-out in this tab', () => { @@ -56,7 +60,7 @@ describe('sign-in markers another tab added', () => { authStore.signOutLocally('ins-here'); - expect(stored()).toEqual({ 'ins-other-tab': url('ins-other-tab') }); + expect(storedSignInMarkers()).toEqual({ 'ins-other-tab': url('ins-other-tab') }); }); it('go when this tab signs the same entity out', () => { @@ -64,7 +68,7 @@ describe('sign-in markers another tab added', () => { authStore.signOutLocally('ins-other-tab'); - expect(stored()).toEqual({}); + expect(storedSignInMarkers()).toEqual({}); }); it('get a logout, and go, at an app sign-out from this tab', async () => { @@ -73,12 +77,8 @@ describe('sign-in markers another tab added', () => { await authStore.signOutFromPotentiallyAuthenticatedInstances(); - const loggedOut = onInstanceLogoutSubmit.mock.calls.map(([{ entityId, instanceClient }]) => [ - entityId, - instanceClient.defaults.baseURL, - ]); - expect(loggedOut).toEqual([['ins-here', url('ins-here')], ['ins-other-tab', url('ins-other-tab')]]); - expect(stored()).toEqual({}); + expect(loggedOut()).toEqual([['ins-here', url('ins-here')], ['ins-other-tab', url('ins-other-tab')]]); + expect(storedSignInMarkers()).toEqual({}); expect(authStore.checkForBasicAuth('ins-other-tab')).toBeUndefined(); }); @@ -87,46 +87,40 @@ describe('sign-in markers another tab added', () => { authStore.signOutAllLocally(); - expect(stored()).toEqual({}); + expect(storedSignInMarkers()).toEqual({}); expect(authStore.checkForBasicAuth('ins-other-tab')).toBeUndefined(); }); +}); + +describe("a sign-in's marker", () => { + it('is the only one its sign-in writes, beside the legacy record', () => { + signInInAnotherTab('ins-other-tab'); + const setItem = vi.spyOn(Storage.prototype, 'setItem'); - it('stay out of a sign-in this tab makes when it cannot read them', () => { signInHere('ins-here'); - localStorage.setItem(MARKERS, JSON.stringify({})); - const getItem = Storage.prototype.getItem; - vi.spyOn(Storage.prototype, 'getItem').mockImplementation(function(this: Storage, key: string) { - if (key === MARKERS) { - throw new DOMException('Storage refused.', 'SecurityError'); - } - return getItem.call(this, key); - }); - - signInHere('ins-other-tab'); - vi.restoreAllMocks(); - - expect(stored()).toEqual({ 'ins-other-tab': url('ins-other-tab') }); + + expect(setItem.mock.calls.map(([key]) => key)).toEqual([signInMarkerKey('ins-here'), legacySignInMarkersKey]); }); - it('that are not a record get no logout at an app sign-out', async () => { - localStorage.setItem(MARKERS, JSON.stringify([url('ins-other-tab')])); + it('is the only one its sign-out removes', () => { + signInHere('ins-here'); + signInInAnotherTab('ins-other-tab'); + const removeItem = vi.spyOn(Storage.prototype, 'removeItem'); - await authStore.signOutFromPotentiallyAuthenticatedInstances(); + authStore.signOutLocally('ins-here'); - expect(onInstanceLogoutSubmit).not.toHaveBeenCalled(); + expect(removeItem.mock.calls.map(([key]) => key)).not.toContain(signInMarkerKey('ins-other-tab')); }); }); describe('a sign-in another tab made again at a newer URL', () => { - const newer = 'https://ins-here-2.example.com:9925/'; - function loggedOutAt() { return onInstanceLogoutSubmit.mock.calls.map(([{ instanceClient }]) => instanceClient.defaults.baseURL); } beforeEach(() => { signInHere('ins-here'); - localStorage.setItem(MARKERS, JSON.stringify({ 'ins-here': newer })); + seedSignInMarkers({ 'ins-here': newer }); }); it('gets a logout at both URLs at an app sign-out', async () => { @@ -149,32 +143,50 @@ describe('a check of a sign-in this tab remembers', () => { it('leaves out, when it passes, the marker of another tab that signed it out', async () => { signInHere('ins-here'); - localStorage.setItem(MARKERS, JSON.stringify({})); + localStorage.removeItem(signInMarkerKey('ins-here')); await authStore.reloadUser('ins-here'); - expect(stored()).toEqual({}); + expect(storedSignInMarkers()).toEqual({}); }); it('keeps, when it passes, the operations URL another tab has signed in with since', async () => { signInHere('ins-here'); - const newer = { 'ins-here': 'https://ins-here-2.example.com:9925/' }; - localStorage.setItem(MARKERS, JSON.stringify(newer)); + seedSignInMarkers({ 'ins-here': newer }); await authStore.reloadUser('ins-here'); - expect(stored()).toEqual(newer); + expect(storedSignInMarkers()).toEqual({ 'ins-here': newer }); }); it("keeps that newer URL when it fails against this tab's older one", async () => { signInHere('ins-here'); - const newer = { 'ins-here': 'https://ins-here-2.example.com:9925/' }; - localStorage.setItem(MARKERS, JSON.stringify(newer)); + seedSignInMarkers({ 'ins-here': newer }); getInstanceUserInfo.mockRejectedValue(new Error('Network Error')); vi.spyOn(console, 'error').mockImplementation(() => undefined); await authStore.reloadUser('ins-here'); - expect(stored()).toEqual(newer); + expect(storedSignInMarkers()).toEqual({ 'ins-here': newer }); + }); + + it('leaves a sign-in this tab made at another URL while it was out', async () => { + signInHere('ins-here'); + let answer: (reason: unknown) => void = () => undefined; + getInstanceUserInfo.mockReturnValue( + new Promise((_, reject) => { + answer = reject; + }), + ); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + const check = authStore.reloadUser('ins-here'); + + signInHere('ins-here', newer); + answer(new Error('Network Error')); + await check; + + expect(authStore.getOperationsUrl('ins-here')).toBe(newer); + expect(authStore.getConnectionById('ins-here').user).toEqual({ username: 'admin' }); + expect(storedSignInMarkers()).toEqual({ 'ins-here': newer }); }); }); diff --git a/src/features/auth/store/entityAuthCheckScope.test.ts b/src/features/auth/store/entityAuthCheckScope.test.ts index 1cb3382aa..4333c2fa7 100644 --- a/src/features/auth/store/entityAuthCheckScope.test.ts +++ b/src/features/auth/store/entityAuthCheckScope.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // Transformed here, while the file is collected, which has no timeout, rather than inside the first test's 5s. // Each test still evaluates a fresh copy. import '@/features/instance/instanceLayoutRoute'; +import { seedSignInMarkers, storedSignInMarkers } from './signInMarkersTestHelpers'; const { get, getInstanceUserInfo, createInstanceAuthenticationTokens, onInstanceLogoutSubmit, routeParams } = vi .hoisted(() => ({ @@ -54,14 +55,11 @@ function guardParams(clusterId: string) { beforeEach(() => { localStorage.clear(); sessionStorage.clear(); - localStorage.setItem( - 'Studio:PotentiallyAuthenticated', - JSON.stringify({ - OverallAppSignIn: 'OverallAppSignIn', - [OTHER_ORG_CLUSTER]: OTHER_ORG_URL, - [THIS_ORG_CLUSTER]: THIS_ORG_URL, - }), - ); + seedSignInMarkers({ + OverallAppSignIn: 'OverallAppSignIn', + [OTHER_ORG_CLUSTER]: OTHER_ORG_URL, + [THIS_ORG_CLUSTER]: THIS_ORG_URL, + }); get.mockResolvedValue({ data: { id: 'cloud-user' } }); getInstanceUserInfo.mockResolvedValue({ username: 'admin' }); onInstanceLogoutSubmit.mockResolvedValue({ message: 'ok' }); @@ -137,7 +135,7 @@ describe('which remembered sign-ins a page load checks (#1329)', () => { expect(onInstanceLogoutSubmit.mock.calls.map(([{ entityId }]) => entityId).sort()) .toEqual([OTHER_ORG_CLUSTER, THIS_ORG_CLUSTER]); - expect(localStorage.getItem('Studio:PotentiallyAuthenticated')).toBe('{}'); + expect(storedSignInMarkers()).toEqual({}); }); it('checks the entity of the route a component is on when it names none', async () => { diff --git a/src/features/auth/store/isKnownSignedOut.test.ts b/src/features/auth/store/isKnownSignedOut.test.ts index 8686115b1..42f56850f 100644 --- a/src/features/auth/store/isKnownSignedOut.test.ts +++ b/src/features/auth/store/isKnownSignedOut.test.ts @@ -1,6 +1,7 @@ /** @vitest-environment jsdom */ // jsdom: authStore touches localStorage at module load and throughout. import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { seedSignInMarkers } from './signInMarkersTestHelpers'; const user = { id: 'usr_a', email: 'a@example.com', roles: {} }; @@ -26,7 +27,7 @@ describe('authStore.isKnownSignedOut', () => { }); it('knows at once when a sign-out clears the session', async () => { - localStorage.setItem('Studio:PotentiallyAuthenticated', JSON.stringify({ OverallAppSignIn: 'OverallAppSignIn' })); + seedSignInMarkers({ OverallAppSignIn: 'OverallAppSignIn' }); const { authStore, OverallAppSignIn } = await freshStore(); authStore.setUserForIdAndKey(OverallAppSignIn, OverallAppSignIn, user as never); diff --git a/src/features/auth/store/legacySignInMarkers.test.ts b/src/features/auth/store/legacySignInMarkers.test.ts new file mode 100644 index 000000000..4bced1b81 --- /dev/null +++ b/src/features/auth/store/legacySignInMarkers.test.ts @@ -0,0 +1,195 @@ +/** @vitest-environment jsdom */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { legacySignInMarkersKey, migrateLegacySignInMarkers } from './signInMarkers'; +import { seedSignInMarkers, storedSignInMarkers } from './signInMarkersTestHelpers'; + +const { getInstanceUserInfo, onInstanceLogoutSubmit } = vi.hoisted(() => ({ + getInstanceUserInfo: vi.fn(), + onInstanceLogoutSubmit: vi.fn(), +})); +vi.mock('@/integrations/api/instance/status/getInstanceUserInfo', () => ({ getInstanceUserInfo })); +vi.mock('@/integrations/api/instance/auth/onInstanceLogoutSubmit', () => ({ onInstanceLogoutSubmit })); + +type Store = Awaited>; +type SetArgs = Parameters; +const url = (id: string) => `https://${id}.example.com:9925/`; + +/** The store reads its markers at construction, so each load is a fresh one. */ +async function loadPage() { + vi.resetModules(); + return (await import('./authStore')).authStore; +} + +function legacy(): unknown { + const raw = localStorage.getItem(legacySignInMarkersKey); + return raw === null ? null : JSON.parse(raw); +} + +// What a tab on the previous release writes: its whole load-time copy, every time. +function writeFromPreviousRelease(markers: Record) { + localStorage.setItem(legacySignInMarkersKey, JSON.stringify(markers)); +} + +function signIn(store: Store, id: string) { + store.setUserForIdAndKey(id as SetArgs[0], url(id) as SetArgs[1], { username: 'admin' } as SetArgs[2]); +} + +beforeEach(() => { + localStorage.clear(); + onInstanceLogoutSubmit.mockResolvedValue({ message: 'ok' }); + vi.spyOn(console, 'debug').mockImplementation(() => undefined); +}); + +afterEach(() => { + vi.resetAllMocks(); + vi.restoreAllMocks(); +}); + +describe('the first load of this release', () => { + it("moves the previous release's markers into one key per entity", async () => { + writeFromPreviousRelease({ OverallAppSignIn: 'OverallAppSignIn', 'ins-a': url('ins-a') }); + + const store = await loadPage(); + + expect(storedSignInMarkers()).toEqual({ OverallAppSignIn: 'OverallAppSignIn', 'ins-a': url('ins-a') }); + expect(store.getOperationsUrl('ins-a')).toBe(url('ins-a')); + }); + + it('keeps a marker already stored for an entity over the legacy one', async () => { + seedSignInMarkers({ 'ins-a': 'https://ins-a-2.example.com:9925/' }); + writeFromPreviousRelease({ 'ins-a': url('ins-a') }); + + await loadPage(); + + expect(storedSignInMarkers()).toEqual({ 'ins-a': 'https://ins-a-2.example.com:9925/' }); + }); + + it('writes the same markers when another tab migrates at the same time', () => { + writeFromPreviousRelease({ 'ins-a': url('ins-a'), 'ins-b': url('ins-b') }); + + migrateLegacySignInMarkers(); + const once = storedSignInMarkers(); + localStorage.removeItem('Studio:PotentiallyAuthenticatedMigrated'); + migrateLegacySignInMarkers(); + + expect(storedSignInMarkers()).toEqual(once); + }); + + it.each([ + ['an array', JSON.stringify([url('ins-a')])], + ['not JSON', 'ins-a'], + ['null', 'null'], + ])('removes a legacy record that is %s, and moves nothing', async (_, junk) => { + localStorage.setItem(legacySignInMarkersKey, junk); + + await loadPage(); + + expect(localStorage.getItem(legacySignInMarkersKey)).toBeNull(); + expect(storedSignInMarkers()).toEqual({}); + }); +}); + +describe('a later load', () => { + it('brings back nothing a previous-release tab wrote back after this release signed it out', async () => { + writeFromPreviousRelease({ 'ins-a': url('ins-a') }); + const store = await loadPage(); + store.signOutLocally('ins-a'); + + writeFromPreviousRelease({ 'ins-a': url('ins-a') }); + await loadPage(); + + expect(storedSignInMarkers()).toEqual({}); + }); + + it('still removes a legacy record that is not one', async () => { + await loadPage(); + localStorage.setItem(legacySignInMarkersKey, JSON.stringify([url('ins-a')])); + + await loadPage(); + + expect(localStorage.getItem(legacySignInMarkersKey)).toBeNull(); + }); +}); + +describe('for tabs still on the previous release', () => { + it('mirrors each sign-in and sign-out into the legacy record, leaving its other markers', async () => { + writeFromPreviousRelease({ 'ins-old': url('ins-old') }); + const store = await loadPage(); + + signIn(store, 'ins-a'); + expect(legacy()).toEqual({ 'ins-old': url('ins-old'), 'ins-a': url('ins-a') }); + + store.signOutLocally('ins-a'); + expect(legacy()).toEqual({ 'ins-old': url('ins-old') }); + }); + + it('creates the legacy record when there is none, so a rollback still sees the sign-in', async () => { + const store = await loadPage(); + + signIn(store, 'ins-a'); + + expect(legacy()).toEqual({ 'ins-a': url('ins-a') }); + }); + + it("keeps a newer URL that tab recorded when a check of this tab's older one fails", async () => { + const store = await loadPage(); + signIn(store, 'ins-a'); + writeFromPreviousRelease({ 'ins-a': 'https://ins-a-2.example.com:9925/' }); + getInstanceUserInfo.mockRejectedValue(new Error('Network Error')); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + + await store.reloadUser('ins-a'); + + expect(legacy()).toEqual({ 'ins-a': 'https://ins-a-2.example.com:9925/' }); + }); + + it('takes the whole legacy record when it cannot mirror a sign-out', async () => { + writeFromPreviousRelease({ 'ins-a': url('ins-a'), 'ins-old': url('ins-old') }); + const store = await loadPage(); + const setItem = Storage.prototype.setItem; + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(function(this: Storage, key: string, value: string) { + if (key === legacySignInMarkersKey) { + throw new DOMException('The quota has been exceeded.', 'QuotaExceededError'); + } + setItem.call(this, key, value); + }); + + store.signOutLocally('ins-a'); + + expect(legacy()).toBeNull(); + }); + + it('gives an app sign-out a sign-in only a previous-release tab recorded, and forgets it', async () => { + const store = await loadPage(); + writeFromPreviousRelease({ 'ins-old': url('ins-old') }); + localStorage.setItem('Studio:BasicAuth:ins-old', btoa(JSON.stringify({ username: 'admin', password: 'hunter2' }))); + + await store.signOutFromPotentiallyAuthenticatedInstances(); + + expect(onInstanceLogoutSubmit.mock.calls.map(([{ entityId, instanceClient }]) => [ + entityId, + instanceClient.defaults.baseURL, + ])).toEqual([['ins-old', url('ins-old')]]); + expect(legacy()).toEqual({}); + expect(store.checkForBasicAuth('ins-old')).toBeUndefined(); + }); + + it('gives signOutEntities a sign-in only a previous-release tab recorded', async () => { + const store = await loadPage(); + writeFromPreviousRelease({ 'ins-old': url('ins-old') }); + + await store.signOutEntities(['ins-old']); + + expect(onInstanceLogoutSubmit.mock.calls.map(([{ entityId }]) => entityId)).toEqual(['ins-old']); + expect(legacy()).toEqual({}); + }); + + it('gives an app sign-out no entity from a legacy record that is not one', async () => { + const store = await loadPage(); + localStorage.setItem(legacySignInMarkersKey, JSON.stringify([url('ins-old')])); + + await store.signOutFromPotentiallyAuthenticatedInstances(); + + expect(onInstanceLogoutSubmit).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/auth/store/localSignOutStorageFailure.test.ts b/src/features/auth/store/localSignOutStorageFailure.test.ts index 083560210..c2b73e27b 100644 --- a/src/features/auth/store/localSignOutStorageFailure.test.ts +++ b/src/features/auth/store/localSignOutStorageFailure.test.ts @@ -2,6 +2,7 @@ import { readEntitySettings, writeEntitySettings } from '@/features/instance/apis/explorer/settings'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { authStore, OverallAppSignIn } from './authStore'; +import { seedSignInMarkers, storedSignInMarkers } from './signInMarkersTestHelpers'; const { createInstanceAuthenticationTokens, getInstanceUserInfo } = vi.hoisted(() => ({ createInstanceAuthenticationTokens: vi.fn(), @@ -15,8 +16,6 @@ vi.mock('@/integrations/api/instance/status/getInstanceUserInfo', () => ({ getIn type SetArgs = Parameters; -const MARKERS = 'Studio:PotentiallyAuthenticated'; - function signIn(id: string) { authStore.setUserForIdAndKey(id as SetArgs[0], `https://${id}` as SetArgs[1], { username: 'u' } as SetArgs[2]); } @@ -53,15 +52,16 @@ describe('authStore local sign-out when storage writes throw', () => { expect(readEntitySettings('ins-a')).toEqual({}); }); - it('drops the whole marker record rather than leave a signed-out entity marked', () => { + it("removes a signed-out entity's marker, and only it, while writes fail", () => { signIn('ins-a'); signIn('ins-b'); failEveryStorageWrite(); authStore.signOutLocally('ins-a'); - expect(localStorage.getItem(MARKERS)).toBeNull(); + expect(storedSignInMarkers()).toEqual({ 'ins-b': 'https://ins-b' }); expect(authStore.getOperationsUrl('ins-b')).toBe('https://ins-b'); + authStore.signOutLocally('ins-b'); }); it('drops the in-memory direct-connect token even when storage refuses removal', async () => { @@ -126,9 +126,8 @@ describe('authStore local sign-out when storage writes throw', () => { expect(authStore.checkForFabricConnect('ins-a')).toBe(true); }); - it('reports a marker record it could neither rewrite nor remove', () => { + it('reports a marker storage would neither remove nor blank', () => { signIn('ins-a'); - signIn('ins-b'); failEveryStorageWrite(); const refusal = new DOMException('Storage refused.', 'SecurityError'); vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => { @@ -138,14 +137,13 @@ describe('authStore local sign-out when storage writes throw', () => { expect(() => authStore.signOutLocally('ins-a')).not.toThrow(); - expect(JSON.parse(localStorage.getItem(MARKERS) ?? '{}')).toHaveProperty('ins-a'); + expect(storedSignInMarkers()).toHaveProperty('ins-a'); expect(consoleError).toHaveBeenCalledWith(refusal); }); - describe('once storage works again after refusing to rewrite or remove the marker record', () => { + describe("once storage works again after keeping a signed-out entity's marker", () => { beforeEach(() => { signIn('ins-p'); - signIn('ins-q'); failEveryStorageWrite(); vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => { throw new DOMException('Storage refused.', 'SecurityError'); @@ -156,117 +154,64 @@ describe('authStore local sign-out when storage writes throw', () => { }); afterEach(() => { - ['ins-q', 'ins-r', 'ins-s'].forEach((id) => authStore.signOutLocally(id)); - }); - - // What another tab's write leaves: the record as it reads it, which still marks the entity this tab signed out. - function writeInAnotherTab(change: (markers: Record) => void) { - const markers = JSON.parse(localStorage.getItem(MARKERS) || '{}'); - change(markers); - localStorage.setItem(MARKERS, JSON.stringify(markers)); - } - - it('leaves neither signed-out entity marked at the next sign-out', () => { - authStore.signOutLocally('ins-q'); - - const markers = JSON.parse(localStorage.getItem(MARKERS) || '{}'); - expect(markers).not.toHaveProperty('ins-p'); - expect(markers).not.toHaveProperty('ins-q'); + authStore.signOutLocally('ins-r'); }); - it('writes the sign-ins this tab holds, and not the one it signed out, at the next sign-in', () => { - signIn('ins-r'); - - const markers = JSON.parse(localStorage.getItem(MARKERS) || '{}'); - expect(markers).toMatchObject({ 'ins-q': 'https://ins-q', 'ins-r': 'https://ins-r' }); - expect(markers).not.toHaveProperty('ins-p'); - }); - - it('leaves out, at the next sign-in, a sign-in another tab has ended since', () => { - writeInAnotherTab((markers) => { - delete markers['ins-q']; - }); - - signIn('ins-r'); + it.each([ + ['sign-out of another entity', () => authStore.signOutLocally('ins-never-signed-in')], + ['sign-in', () => signIn('ins-r')], + ])('removes it at the next %s', (_, write) => { + write(); - expect(JSON.parse(localStorage.getItem(MARKERS) || '{}')).not.toHaveProperty('ins-q'); + expect(storedSignInMarkers()).not.toHaveProperty('ins-p'); }); - it('reaches, at a full sign-out, a sign-in only another tab has made since', () => { - writeInAnotherTab((markers) => { - markers['ins-s'] = 'https://ins-s'; - }); - localStorage.setItem('Studio:BasicAuth:ins-s', btoa(JSON.stringify({ username: 'u', password: 'p' }))); + it('reaches it at a full sign-out after another tab signs it back in', () => { + seedSignInMarkers({ 'ins-p': 'https://ins-p' }); + localStorage.setItem('Studio:BasicAuth:ins-p', btoa(JSON.stringify({ username: 'u', password: 'p' }))); authStore.signOutAllLocally(); - expect(authStore.checkForBasicAuth('ins-s')).toBeUndefined(); - expect(JSON.parse(localStorage.getItem(MARKERS) || '{}')).toEqual({}); - }); - - it('takes the entity out at the next sign-out of any other', () => { - authStore.signOutLocally('ins-never-signed-in'); - - expect(JSON.parse(localStorage.getItem(MARKERS) || '{}')).not.toHaveProperty('ins-p'); + // Raw: this tab still reads the credential it could not remove at the first sign-out as absent. + expect(localStorage.getItem('Studio:BasicAuth:ins-p')).toBeNull(); }); + }); - it.each([ - ['a write', () => authStore.signOutLocally('ins-q')], - ['a removal', () => { - failEveryStorageWrite(); - authStore.signOutLocally('ins-q'); - vi.restoreAllMocks(); - }], - ])('keeps the entity once %s lands and another tab signs it back in', (_, land) => { - land(); - writeInAnotherTab((markers) => { - markers['ins-p'] = 'https://ins-p'; - }); - - signIn('ins-r'); - - expect(JSON.parse(localStorage.getItem(MARKERS) || '{}')).toHaveProperty('ins-p'); + describe('a sign-in storage refused to record', () => { + afterEach(() => { + authStore.signOutLocally('ins-refused'); }); - it('reaches the entity at a full sign-out after another tab signs it back in', () => { - writeInAnotherTab((markers) => { - markers['ins-p'] = 'https://ins-p'; - }); - localStorage.setItem('Studio:BasicAuth:ins-p', btoa(JSON.stringify({ username: 'u', password: 'p' }))); + it('still signs in, without throwing', () => { + failEveryStorageWrite(); - authStore.signOutAllLocally(); + expect(() => signIn('ins-refused')).not.toThrow(); - // Raw: this tab still reads the credential it could not remove at the first sign-out as absent. - expect(localStorage.getItem('Studio:BasicAuth:ins-p')).toBeNull(); + expect(authStore.getConnectionById('ins-refused').user).toEqual({ username: 'u' }); }); - it("keeps an entity it never marked when storage refuses that sign-out's write too", () => { + it('is recorded at the next sign-in', () => { failEveryStorageWrite(); - vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => { - throw new DOMException('Storage refused.', 'SecurityError'); - }); - vi.spyOn(console, 'error').mockImplementation(() => undefined); - authStore.signOutLocally('ins-never-signed-in'); + signIn('ins-refused'); vi.restoreAllMocks(); - writeInAnotherTab((markers) => { - markers['ins-never-signed-in'] = 'https://ins-never-signed-in'; - }); - signIn('ins-r'); + signIn('ins-refused'); - expect(JSON.parse(localStorage.getItem(MARKERS) || '{}')).toHaveProperty('ins-never-signed-in'); + expect(storedSignInMarkers()).toHaveProperty('ins-refused'); }); - }); - it('writes a sign-in storage refused to record at the next one', () => { - failEveryStorageWrite(); - expect(() => signIn('ins-refused')).toThrow(); - vi.restoreAllMocks(); + it('is recorded at its next check, and a check that still cannot record it settles', async () => { + getInstanceUserInfo.mockResolvedValue({ username: 'u' }); + failEveryStorageWrite(); + signIn('ins-refused'); - signIn('ins-refused'); + await expect(authStore.reloadUser('ins-refused')).resolves.toEqual({ username: 'u' }); + expect(authStore.getConnectionById('ins-refused').isLoading).toBe(false); - expect(JSON.parse(localStorage.getItem(MARKERS) || '{}')).toHaveProperty('ins-refused'); - authStore.signOutLocally('ins-refused'); + vi.restoreAllMocks(); + await authStore.reloadUser('ins-refused'); + expect(storedSignInMarkers()).toHaveProperty('ins-refused'); + }); }); it('lets signOutAllLocally reach every entity and the cloud slot', () => { diff --git a/src/features/auth/store/signInMarkers.ts b/src/features/auth/store/signInMarkers.ts new file mode 100644 index 000000000..993815687 --- /dev/null +++ b/src/features/auth/store/signInMarkers.ts @@ -0,0 +1,115 @@ +import type { AuthenticatedConnectionKey, EntityIds } from './authStore'; + +/** + * Sign-in markers: one `localStorage` key per entity, holding the operations URL it was signed into. Each tab writes + * only the entity it signs in or out, so it can't erase or restore another tab's (HarperFast/studio#1968). + * + * The previous release kept every marker in one record, which its tabs still read once at load and write back whole. + * While those tabs can still be open, or a rollback can bring that release back, this one mirrors each change into the + * record too and reads it in sign-out sweeps (DESIGN.md). + */ +export const signInMarkerPrefix = 'Studio:PotentiallyAuthenticated:'; +export const legacySignInMarkersKey = 'Studio:PotentiallyAuthenticated'; +const legacyMigratedKey = 'Studio:PotentiallyAuthenticatedMigrated'; + +type SignIns = Record; + +export function signInMarkerKey(id: EntityIds): string { + return signInMarkerPrefix + id; +} + +/** Raw, since other tabs write them too. */ +export function readSignInMarkers(): Array<[EntityIds, AuthenticatedConnectionKey]> { + const markers: Array<[EntityIds, AuthenticatedConnectionKey]> = []; + try { + for (let index = 0; index < localStorage.length; index++) { + const key = localStorage.key(index); + const url = key?.startsWith(signInMarkerPrefix) ? localStorage.getItem(key) : null; + if (key && url) { + markers.push([key.slice(signInMarkerPrefix.length), url]); + } + } + } catch { + // Storage that can't be read holds no marker this tab could use. + } + return markers; +} + +/** Null when the record is absent, or is not one. */ +export function readLegacySignInMarkers(): SignIns | null { + try { + const raw = localStorage.getItem(legacySignInMarkersKey); + const markers: unknown = raw ? JSON.parse(raw) : null; + if (!markers || typeof markers !== 'object' || Array.isArray(markers)) { + return null; + } + return Object.fromEntries( + Object.entries(markers).filter((entry): entry is [string, string] => typeof entry[1] === 'string' && !!entry[1]), + ); + } catch { + return null; + } +} + +/** + * Copies the legacy record into per-entity keys once per storage, leaving any marker already stored per entity as it + * is. Later loads don't read it again: the previous release's tabs write their whole load-time copy back into it, so + * it would bring back what this release signed out. Two tabs migrating at once write the same values. + * + * A record that is not one is removed on every load: the previous release fails to start on it. + */ +export function migrateLegacySignInMarkers(): void { + try { + const legacy = readLegacySignInMarkers(); + if (!legacy && localStorage.getItem(legacySignInMarkersKey) !== null) { + localStorage.removeItem(legacySignInMarkersKey); + } + if (localStorage.getItem(legacyMigratedKey)) { + return; + } + for (const [id, url] of Object.entries(legacy ?? {})) { + if (!localStorage.getItem(signInMarkerKey(id))) { + localStorage.setItem(signInMarkerKey(id), url); + } + } + localStorage.setItem(legacyMigratedKey, '1'); + } catch { + // The next load tries again. + } +} + +/** + * Keeps the legacy record current for the previous release's tabs. Best effort: one that misses a sign-in only shows + * it signed out. A removal it can't write takes the whole record, as that release does itself, since a marker it kept + * would make its next load probe the entity with its cookies. `removing` limits a removal to the URL a failed check + * used. + */ +export function mirrorLegacySignInMarker( + id: EntityIds, + url: AuthenticatedConnectionKey | null, + removing?: AuthenticatedConnectionKey, +): void { + try { + const markers = readLegacySignInMarkers() ?? {}; + const unchanged = url === null + ? !Object.hasOwn(markers, id) || (removing !== undefined && markers[id] !== removing) + : markers[id] === url; + if (unchanged) { + return; + } + if (url === null) { + delete markers[id]; + } else { + markers[id] = url; + } + localStorage.setItem(legacySignInMarkersKey, JSON.stringify(markers)); + } catch { + if (url === null) { + try { + localStorage.removeItem(legacySignInMarkersKey); + } catch { + // Nothing left to try. + } + } + } +} diff --git a/src/features/auth/store/signInMarkersTestHelpers.ts b/src/features/auth/store/signInMarkersTestHelpers.ts new file mode 100644 index 000000000..532452215 --- /dev/null +++ b/src/features/auth/store/signInMarkersTestHelpers.ts @@ -0,0 +1,13 @@ +import { readSignInMarkers, signInMarkerKey } from './signInMarkers'; + +/** Stores sign-in markers the way a tab of this release records its sign-ins. */ +export function seedSignInMarkers(markers: Record): void { + for (const [id, url] of Object.entries(markers)) { + localStorage.setItem(signInMarkerKey(id), url); + } +} + +/** The stored sign-in markers, as one record. */ +export function storedSignInMarkers(): Record { + return Object.fromEntries(readSignInMarkers()); +} diff --git a/src/features/auth/store/signOutEntities.test.ts b/src/features/auth/store/signOutEntities.test.ts index c19ef1e52..0ba335f3c 100644 --- a/src/features/auth/store/signOutEntities.test.ts +++ b/src/features/auth/store/signOutEntities.test.ts @@ -1,6 +1,7 @@ /** @vitest-environment jsdom */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { authStore } from './authStore'; +import { seedSignInMarkers, storedSignInMarkers } from './signInMarkersTestHelpers'; const { onInstanceLogoutSubmit } = vi.hoisted(() => ({ onInstanceLogoutSubmit: vi.fn() })); vi.mock('@/integrations/api/instance/auth/onInstanceLogoutSubmit', () => ({ onInstanceLogoutSubmit })); @@ -95,11 +96,8 @@ describe('authStore.signOutEntities', () => { }); it('logs out, and forgets, a sign-in another tab made after this one read the markers', async () => { - const markers = JSON.parse(localStorage.getItem('Studio:PotentiallyAuthenticated') || '{}'); - localStorage.setItem( - 'Studio:PotentiallyAuthenticated', - JSON.stringify({ ...markers, 'ins-other-tab': 'https://ins-other-tab.example.com:9925/' }), - ); + const markers = storedSignInMarkers(); + seedSignInMarkers({ 'ins-other-tab': 'https://ins-other-tab.example.com:9925/' }); await authStore.signOutEntities(['ins-other-tab']); @@ -111,7 +109,7 @@ describe('authStore.signOutEntities', () => { auth: undefined, }, ]); - expect(JSON.parse(localStorage.getItem('Studio:PotentiallyAuthenticated') || '{}')).toEqual(markers); + expect(storedSignInMarkers()).toEqual(markers); }); it('posts no logout for an id with nothing remembered', async () => { diff --git a/src/features/auth/store/signOutRaces.test.ts b/src/features/auth/store/signOutRaces.test.ts index 1c60461d8..e30dfaeb0 100644 --- a/src/features/auth/store/signOutRaces.test.ts +++ b/src/features/auth/store/signOutRaces.test.ts @@ -17,6 +17,7 @@ vi.mock('@/integrations/api/instance/status/getInstanceUserInfo', () => ({ getIn import { getCurrentUser } from '@/features/auth/queries/getCurrentUser'; import { authStore, OverallAppSignIn } from './authStore'; +import { storedSignInMarkers } from './signInMarkersTestHelpers'; type SetArgs = Parameters; @@ -69,7 +70,7 @@ describe('auth resolutions that outlive a sign-out', () => { await expect(reload).resolves.toBeNull(); expectNothingRestored('ins-load'); - expect(localStorage.getItem('Studio:PotentiallyAuthenticated') ?? '').not.toContain('ins-load'); + expect(storedSignInMarkers()).not.toHaveProperty('ins-load'); }); it('getCurrentUser hands back its answer but does not sign the cloud slot back in', async () => { diff --git a/src/features/cluster/ClusterHome.signIns.test.tsx b/src/features/cluster/ClusterHome.signIns.test.tsx index fc255c013..ab0784b46 100644 --- a/src/features/cluster/ClusterHome.signIns.test.tsx +++ b/src/features/cluster/ClusterHome.signIns.test.tsx @@ -1,4 +1,5 @@ /** @vitest-environment jsdom */ +import { seedSignInMarkers } from '@/features/auth/store/signInMarkersTestHelpers'; import type { Cluster } from '@/integrations/api/api.patch'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { cleanup, render, screen } from '@testing-library/react'; @@ -58,10 +59,7 @@ async function renderOverview(status: string) { beforeEach(() => { localStorage.clear(); - localStorage.setItem( - 'Studio:PotentiallyAuthenticated', - JSON.stringify({ 'clu-1': 'https://clu-1.example.com:9925/' }), - ); + seedSignInMarkers({ 'clu-1': 'https://clu-1.example.com:9925/' }); getInstanceUserInfo.mockResolvedValue({ username: 'admin' }); }); diff --git a/src/features/clusters/mutations/terminateCluster.test.ts b/src/features/clusters/mutations/terminateCluster.test.ts index 795cbc797..d53e80efa 100644 --- a/src/features/clusters/mutations/terminateCluster.test.ts +++ b/src/features/clusters/mutations/terminateCluster.test.ts @@ -1,5 +1,6 @@ /** @vitest-environment jsdom */ import { authStore } from '@/features/auth/store/authStore'; +import { seedSignInMarkers, storedSignInMarkers } from '@/features/auth/store/signInMarkersTestHelpers'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const { apiDelete, apiGet, onInstanceLogoutSubmit } = vi.hoisted(() => ({ @@ -62,17 +63,15 @@ describe(terminateCluster, () => { }); it('forgets sign-ins only another tab remembers', async () => { - const markers = JSON.parse(localStorage.getItem('Studio:PotentiallyAuthenticated') || '{}'); + const markers = storedSignInMarkers(); const otherTab = { 'clu-9': 'https://clu-9.example.com:9925/', 'ins-9': 'https://ins-9.example.com:9925/' }; - localStorage.setItem('Studio:PotentiallyAuthenticated', JSON.stringify({ ...markers, ...otherTab })); + seedSignInMarkers(otherTab); apiDelete.mockResolvedValue({ data: { id: 'clu-9', deleted: true } }); apiGet.mockResolvedValue({ data: { id: 'clu-9', status: 'RUNNING', instances: [{ id: 'ins-9' }] } }); await terminateCluster('clu-9'); - await vi.waitFor(() => - expect(JSON.parse(localStorage.getItem('Studio:PotentiallyAuthenticated') || '{}')).toEqual(markers) - ); + await vi.waitFor(() => expect(storedSignInMarkers()).toEqual(markers)); }); it('reads the instances back after the terminate when the read alongside it failed', async () => { diff --git a/src/router/__tests__/crossTabSignOut.test.tsx b/src/router/__tests__/crossTabSignOut.test.tsx index f1222b5fe..f6848405d 100644 --- a/src/router/__tests__/crossTabSignOut.test.tsx +++ b/src/router/__tests__/crossTabSignOut.test.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { seedSignInMarkers, storedSignInMarkers } from '@/features/auth/store/signInMarkersTestHelpers'; import { type QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { cleanup, render, screen, waitFor } from '@testing-library/react'; import { AxiosError, type InternalAxiosRequestConfig } from 'axios'; @@ -295,7 +296,7 @@ function reportedAuthorizationFailures() { beforeEach(() => { localStorage.clear(); sessionStorage.clear(); - localStorage.setItem('Studio:PotentiallyAuthenticated', JSON.stringify({ OverallAppSignIn: 'OverallAppSignIn' })); + seedSignInMarkers({ OverallAppSignIn: 'OverallAppSignIn' }); window.history.replaceState(null, '', `#${PAGE}`); session = 'live'; requests.length = 0; @@ -445,13 +446,13 @@ describe('a sign-out in another tab', () => { const thisTab = await openClusterPage(); const otherTab = await openAnotherTab(); // A reload during a central-manager blip: that tab reads the failed check as signed out, and drops the app - // sign-in from the marker record both tabs share. + // sign-in's marker, which both tabs share. otherTab.apiClient.defaults.adapter = async (config) => { throw new AxiosError('timeout of 60000ms exceeded', AxiosError.ECONNABORTED, config); }; await otherTab.run(() => otherTab.authStore.reloadUser('OverallAppSignIn')); - expect(localStorage.getItem('Studio:PotentiallyAuthenticated')).toBe('{}'); + expect(storedSignInMarkers()).toEqual({}); await expectStillSignedIn(thisTab); }); diff --git a/src/router/__tests__/endedSessionRouteFailures.test.tsx b/src/router/__tests__/endedSessionRouteFailures.test.tsx index 92f0625d9..3e40f9b4f 100644 --- a/src/router/__tests__/endedSessionRouteFailures.test.tsx +++ b/src/router/__tests__/endedSessionRouteFailures.test.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { seedSignInMarkers } from '@/features/auth/store/signInMarkersTestHelpers'; import { type QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { cleanup, render, screen, waitFor } from '@testing-library/react'; import { AxiosError, type InternalAxiosRequestConfig } from 'axios'; @@ -177,7 +178,7 @@ function reportedAuthorizationFailures() { beforeEach(() => { localStorage.clear(); sessionStorage.clear(); - localStorage.setItem('Studio:PotentiallyAuthenticated', JSON.stringify({ OverallAppSignIn: 'OverallAppSignIn' })); + seedSignInMarkers({ OverallAppSignIn: 'OverallAppSignIn' }); // Not `location.hash =`: jsdom dispatches its popstate a task later, and a render that beats it gets a second // router load wherever that lands. Between the sign-out and the router hearing of it, that is the load the // last case starts on purpose. diff --git a/src/router/__tests__/rememberedClusterDeepLink.test.ts b/src/router/__tests__/rememberedClusterDeepLink.test.ts index ff683b50c..dd5852d9b 100644 --- a/src/router/__tests__/rememberedClusterDeepLink.test.ts +++ b/src/router/__tests__/rememberedClusterDeepLink.test.ts @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { seedSignInMarkers } from '@/features/auth/store/signInMarkersTestHelpers'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // Transformed here, while the file is collected, which has no timeout, rather than inside the first test's 5s. // Each test still evaluates a fresh copy. @@ -71,10 +72,7 @@ beforeEach(() => { cluster = { status: 'RUNNING' }; localStorage.clear(); sessionStorage.clear(); - localStorage.setItem( - 'Studio:PotentiallyAuthenticated', - JSON.stringify({ OverallAppSignIn: 'OverallAppSignIn', [CLUSTER_ID]: CLUSTER_URL }), - ); + seedSignInMarkers({ OverallAppSignIn: 'OverallAppSignIn', [CLUSTER_ID]: CLUSTER_URL }); get.mockImplementation(async (url: string) => { if (url === '/User/current') { return { data: cloudUser }; @@ -136,10 +134,7 @@ describe('a deep link into a remembered cluster that has been terminated since', }); function remember(entity: string, url: string) { - localStorage.setItem( - 'Studio:PotentiallyAuthenticated', - JSON.stringify({ OverallAppSignIn: 'OverallAppSignIn', [CLUSTER_ID]: CLUSTER_URL, [entity]: url }), - ); + seedSignInMarkers({ [entity]: url }); } it.each(remembered)('sends /$page to the overview before checking the sign-in or loading the page', async ({ diff --git a/src/router/__tests__/terminatedClusterDeepLink.test.tsx b/src/router/__tests__/terminatedClusterDeepLink.test.tsx index 1d0eff223..a1104660c 100644 --- a/src/router/__tests__/terminatedClusterDeepLink.test.tsx +++ b/src/router/__tests__/terminatedClusterDeepLink.test.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { seedSignInMarkers, storedSignInMarkers } from '@/features/auth/store/signInMarkersTestHelpers'; import { QueryClientProvider } from '@tanstack/react-query'; import { cleanup, render, screen } from '@testing-library/react'; import type { InternalAxiosRequestConfig } from 'axios'; @@ -116,7 +117,7 @@ async function openBookmark(path: string, cluster: object = {}) { } function rememberedEntities() { - const markers = JSON.parse(localStorage.getItem('Studio:PotentiallyAuthenticated') || '{}'); + const markers = storedSignInMarkers(); return Object.keys(SIGN_INS).filter((id) => markers[id] || localStorage.getItem(`Studio:BasicAuth:${id}`) || localStorage.getItem(`Studio:FabricConnect:${id}`) ); @@ -125,10 +126,7 @@ function rememberedEntities() { beforeEach(() => { localStorage.clear(); sessionStorage.clear(); - localStorage.setItem( - 'Studio:PotentiallyAuthenticated', - JSON.stringify({ OverallAppSignIn: 'OverallAppSignIn', ...SIGN_INS }), - ); + seedSignInMarkers({ OverallAppSignIn: 'OverallAppSignIn', ...SIGN_INS }); for (const id of Object.keys(SIGN_INS)) { localStorage.setItem(`Studio:BasicAuth:${id}`, btoa(JSON.stringify({ username: 'admin', password: 'hunter2' }))); localStorage.setItem(`Studio:FabricConnect:${id}`, 'true'); From 574b8c9623fc1721c59149c701c3cff9e8e1755f Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Wed, 14 Oct 2026 23:58:43 -0400 Subject: [PATCH 18/21] fix(auth): keep the sign-in marker migration from repeating, or failing silently - The sign-out wipe keeps the migration flag, so a previous-release tab writing its record back after a full sign-out isn't migrated again. - Until a migration lands, as when a full quota refuses it, the legacy record also seeds this tab's markers, so its sign-ins still check out. - Migration decides from one read, and also drops entries that aren't markers from the legacy record. A sign-in leaves a legacy record it can't read alone instead of replacing other tabs' markers. - A sign-in always records itself, so signing in again restores a marker another tab removed; only a check's passing answer skips it. - A check's answer is dropped when this tab signed in again while it was out, at the same URL or another, using a per-entity sign-in revision. - A marker storage refused to remove is retried only while it still holds the value it had, so another tab's sign-in there survives, and a marker that couldn't be read at sign-out is still removed. Co-Authored-By: Claude Opus 5.5 --- DESIGN.md | 15 +++-- e2e/tests/signInMarkers.ts | 2 +- src/features/auth/store/authStore.ts | 66 ++++++++++++------- .../auth/store/crossTabMarkers.test.ts | 30 +++++++++ .../auth/store/legacySignInMarkers.test.ts | 40 +++++++++++ .../store/localSignOutStorageFailure.test.ts | 24 +++++++ src/features/auth/store/signInMarkers.ts | 50 +++++++++----- .../auth/store/signInMarkersTestHelpers.ts | 1 - 8 files changed, 180 insertions(+), 48 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 271355fe0..bc0991b18 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -117,9 +117,10 @@ Central manager only ever sends the boolean, never the other organizations' ids; restore each other's markers; app and full sign-outs act on every stored marker, at each URL an entity is marked at here or in storage. A check never undoes another tab's change: a passing one of a sign-in this tab already marked writes nothing, and a failing one leaves a newer URL another tab stored. So a marker removed elsewhere stays removed - even while this tab still holds that sign-in (`crossTabMarkers.test.ts`). A sign-in storage refuses to record still - signs in, and its next sign-in or check writes it; a marker storage won't remove is retried at every marker write - (`localSignOutStorageFailure.test.ts`). + while this tab still holds that sign-in, until this tab signs in again, which always writes. A check's answer is + dropped when this tab signed in again while it was out (`crossTabMarkers.test.ts`). A sign-in storage refuses to + record still signs in, and its next sign-in or check writes it; a marker storage won't remove is retried at every + marker write, unless another tab has rewritten it since (`localSignOutStorageFailure.test.ts`). - **The previous release's one shared record is kept in step for one more release.** Its tabs read `Studio:PotentiallyAuthenticated` once at load and write their whole copy back, and know nothing of the per-entity keys. So this release copies that record into the keys once per storage (a marker already stored per entity wins), @@ -127,9 +128,11 @@ Central manager only ever sends the boolean, never the other organizations' ids; therefore still sees this release's sign-ins and sign-outs, and an app sign-out here reaches a sign-in only that tab recorded. Its sign-in reaches this release's keys only at that first copy, and its sign-out of an entity doesn't remove the entity's key, which matters only if its logout also failed; its full sign-out wipes both. The copy is - never repeated, because that tab's whole-copy writes would bring back what this release signed out - (`legacySignInMarkers.test.ts`, `account-menu.anon.spec.ts`). A record that isn't one is removed at every load: the - previous release can't start on it. The release after this one stops the mirror and removes the record. + never repeated, because that tab's whole-copy writes would bring back what this release signed out, so the sign-out + wipe keeps the flag that records it. Until the copy lands, as when a full quota refuses it, the record also seeds + this tab's markers (`legacySignInMarkers.test.ts`, `account-menu.anon.spec.ts`). Every load also removes a record + that isn't one, which the previous release can't start on, and drops entries that aren't markers; a sign-in leaves + one it can't read alone. The release after this one stops the mirror and removes the record. - **`Dashboard` renders nothing under it without a signed-in user.** Route data loads in parallel with the page load's `/User/current` check (waiting for it costs +262/680 ms p75 on live org/cluster cold loads), and central manager answers a request with no session with 403, so a deep link on an expired session has its route data fail before the diff --git a/e2e/tests/signInMarkers.ts b/e2e/tests/signInMarkers.ts index 857c0a828..07266ddae 100644 --- a/e2e/tests/signInMarkers.ts +++ b/e2e/tests/signInMarkers.ts @@ -1,6 +1,6 @@ import type { Page } from '@playwright/test'; -/** Studio records each sign-in under its own localStorage key (`src/features/auth/store/signInMarkers.ts`). */ +/** Must match `signInMarkerKey(OverallAppSignIn)` in `src/features/auth/store/signInMarkers.ts`, which e2e can't import. */ export const appSignInMarker = 'Studio:PotentiallyAuthenticated:OverallAppSignIn'; /** Marks the app signed in before the page loads, so it checks the mocked session instead of showing sign-in. */ diff --git a/src/features/auth/store/authStore.ts b/src/features/auth/store/authStore.ts index 692ff2b1b..1e48a5673 100644 --- a/src/features/auth/store/authStore.ts +++ b/src/features/auth/store/authStore.ts @@ -23,12 +23,12 @@ import { getOperationsUrlForCluster } from '@/lib/urls/getOperationsUrlForCluste import { getOperationsUrlForInstance } from '@/lib/urls/getOperationsUrlForInstance'; import { isDirectOperationsUrl } from '@/lib/urls/isDirectOperationsUrl'; import { + legacyMigratedKey, migrateLegacySignInMarkers, mirrorLegacySignInMarker, readLegacySignInMarkers, readSignInMarkers, signInMarkerKey, - signInMarkerPrefix, } from './signInMarkers'; type AuthStoreListenerCleanup = () => void; @@ -77,6 +77,10 @@ class AuthStore { private readonly unremovedKeys = new Set(); // Sign-ins storage refused to record, which the next sign-in or check of them writes again. private readonly unrecordedSignIns = new Set(); + // Markers storage refused to remove, by key, with the value they held then (null when it couldn't be read). + private readonly unremovedSignInMarkers = new Map(); + // Bumped by every sign-in, so a check can tell its answer is about one that has since been replaced. + private readonly signInRevisions = new Map(); private readonly potentiallyAuthenticated: Record; private readonly checkedAuthentication: Record = {}; private readonly allConnections: Record = {}; @@ -120,8 +124,9 @@ class AuthStore { private signOutHeardDuringCheck: string | null = null; constructor() { - migrateLegacySignInMarkers(); - this.potentiallyAuthenticated = Object.fromEntries(readSignInMarkers()); + // Until a migration lands (a full quota refuses one), the previous release's record still holds the sign-ins. + const unmigrated = migrateLegacySignInMarkers() ? {} : readLegacySignInMarkers(); + this.potentiallyAuthenticated = { ...unmigrated, ...Object.fromEntries(readSignInMarkers()) }; } private readExplorerGenerations(): Record { @@ -195,12 +200,12 @@ class AuthStore { /** * Keeps the explorer's sign-out generation: a tab that missed the sign-out compares its stored explorer - * credential against it when it reloads, so removing it would make that credential current again. Then, - * with room made, writes what a full store refused. + * credential against it when it reloads, so removing it would make that credential current again. Keeps the + * sign-in markers' migration flag too (`signInMarkers.ts`). Then, with room made, writes what a full store refused. */ public wipeLocalStorageForSignOut(): void { try { - clearLocalStorage([this.explorerAuthEpochKey]); + clearLocalStorage([this.explorerAuthEpochKey, legacyMigratedKey]); } finally { this.retrySignOutWritesAfterWipe(); } @@ -364,6 +369,7 @@ class AuthStore { user: AuthenticatedConnection['user'], ): void { if (user) { + this.signInRevisions.set(id, (this.signInRevisions.get(id) ?? 0) + 1); this.flagKeyAsSignedIn(id, key); this.updateConnectionIfChanged(id, false, user); } else { @@ -962,24 +968,30 @@ class AuthStore { void this.updateListeners(id); } - /** Other tabs, and the next load, read a marker this tab couldn't remove, so every marker write tries again. */ + /** + * Other tabs, and the next load, read a marker this tab couldn't remove, so every marker write tries again. One + * rewritten since is a newer sign-in, and is left alone. + */ private retryUnremovedSignInMarkers(): void { - for (const key of this.unremovedKeys) { - if (key.startsWith(signInMarkerPrefix)) { - try { + for (const [key, value] of this.unremovedSignInMarkers) { + try { + if (value === null || localStorage.getItem(key) === value) { localStorage.removeItem(key); - this.unremovedKeys.delete(key); - } catch { - // The next marker write tries again. } + this.unremovedSignInMarkers.delete(key); + this.unremovedKeys.delete(key); + } catch { + // Still refused. } } } - // A check of a sign-in this tab already marked writes nothing, so it can't put back a marker another tab's - // sign-out removed. - private flagKeyAsSignedIn(id: EntityIds, key: AuthenticatedConnectionKey) { - if (this.potentiallyAuthenticated[id] === key && !this.unrecordedSignIns.has(id)) { + /** + * `checked`: a check's passing answer for a sign-in this tab already marked writes nothing, so it can't put back a + * marker another tab's sign-out removed. A sign-in itself always writes. + */ + private flagKeyAsSignedIn(id: EntityIds, key: AuthenticatedConnectionKey, checked = false) { + if (checked && this.potentiallyAuthenticated[id] === key && !this.unrecordedSignIns.has(id)) { return; } this.potentiallyAuthenticated[id] = key; @@ -1000,19 +1012,24 @@ class AuthStore { delete this.potentiallyAuthenticated[id]; this.unrecordedSignIns.delete(id); this.retryUnremovedSignInMarkers(); + const markerKey = signInMarkerKey(id); let stored: string | null = null; + let unreadable = false; try { - stored = localStorage.getItem(signInMarkerKey(id)); + stored = localStorage.getItem(markerKey); } catch { - // Unreadable, so there is nothing this tab can remove. + unreadable = true; } if (checkedKey !== undefined && stored && stored !== checkedKey) { return; } - if (stored) { + if (stored || unreadable) { // Blanks or hides a marker storage won't remove: one left behind makes the next page load probe this // entity with its cookies, and a session that outlived the sign-out would sign it back in. - this.removeStoredItem(signInMarkerKey(id)); + this.removeStoredItem(markerKey); + if (this.unremovedKeys.has(markerKey)) { + this.unremovedSignInMarkers.set(markerKey, stored); + } } mirrorLegacySignInMarker(id, null, checkedKey); } @@ -1073,6 +1090,7 @@ class AuthStore { private async loadUser(id: EntityIds): Promise { const key = this.potentiallyAuthenticated[id]; const signOutGeneration = this.getSignOutGeneration(id); + const signInRevision = this.signInRevisions.get(id); this.updateConnectionIfChanged(id, true, null); let user: AuthenticatedConnection['user'] = null; try { @@ -1097,12 +1115,12 @@ class AuthStore { if (this.getSignOutGeneration(id) !== signOutGeneration) { return null; } - if (this.potentiallyAuthenticated[id] !== key) { - // This tab signed in again at another URL while the check was out, so its answer is about a sign-in that ended. + // The app sign-in's own check signs in through getCurrentUser, so only an entity's answer can be stale this way. + if (id !== OverallAppSignIn && this.signInRevisions.get(id) !== signInRevision) { return this.getConnectionById(id).user; } if (user) { - this.flagKeyAsSignedIn(id, key); + this.flagKeyAsSignedIn(id, key, true); } else { this.flagKeyAsSignedOut(id, key); } diff --git a/src/features/auth/store/crossTabMarkers.test.ts b/src/features/auth/store/crossTabMarkers.test.ts index 1136526e0..1c1a15cc2 100644 --- a/src/features/auth/store/crossTabMarkers.test.ts +++ b/src/features/auth/store/crossTabMarkers.test.ts @@ -113,6 +113,17 @@ describe("a sign-in's marker", () => { }); }); +describe('a sign-in this tab already remembers', () => { + it('records itself again when another tab signed it out', () => { + signInHere('ins-here'); + localStorage.removeItem(signInMarkerKey('ins-here')); + + signInHere('ins-here'); + + expect(storedSignInMarkers()).toEqual({ 'ins-here': url('ins-here') }); + }); +}); + describe('a sign-in another tab made again at a newer URL', () => { function loggedOutAt() { return onInstanceLogoutSubmit.mock.calls.map(([{ instanceClient }]) => instanceClient.defaults.baseURL); @@ -189,4 +200,23 @@ describe('a check of a sign-in this tab remembers', () => { expect(authStore.getConnectionById('ins-here').user).toEqual({ username: 'admin' }); expect(storedSignInMarkers()).toEqual({ 'ins-here': newer }); }); + + it('leaves a sign-in this tab made at the same URL while it was out', async () => { + signInHere('ins-here'); + let answer: (reason: unknown) => void = () => undefined; + getInstanceUserInfo.mockReturnValue( + new Promise((_, reject) => { + answer = reject; + }), + ); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + const check = authStore.reloadUser('ins-here'); + + signInHere('ins-here'); + answer(new Error('Network Error')); + await check; + + expect(authStore.getConnectionById('ins-here').user).toEqual({ username: 'admin' }); + expect(storedSignInMarkers()).toEqual({ 'ins-here': url('ins-here') }); + }); }); diff --git a/src/features/auth/store/legacySignInMarkers.test.ts b/src/features/auth/store/legacySignInMarkers.test.ts index 4bced1b81..02f4a9707 100644 --- a/src/features/auth/store/legacySignInMarkers.test.ts +++ b/src/features/auth/store/legacySignInMarkers.test.ts @@ -87,6 +87,25 @@ describe('the first load of this release', () => { expect(localStorage.getItem(legacySignInMarkersKey)).toBeNull(); expect(storedSignInMarkers()).toEqual({}); }); + + it("drops entries that aren't markers from the legacy record", async () => { + localStorage.setItem(legacySignInMarkersKey, JSON.stringify({ 'ins-a': url('ins-a'), 'ins-b': 123, 'ins-c': '' })); + + await loadPage(); + + expect(legacy()).toEqual({ 'ins-a': url('ins-a') }); + }); + + it("still signs in on the previous release's markers when storage refuses the migration", async () => { + writeFromPreviousRelease({ 'ins-a': url('ins-a') }); + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new DOMException('The quota has been exceeded.', 'QuotaExceededError'); + }); + + const store = await loadPage(); + + expect(store.getOperationsUrl('ins-a')).toBe(url('ins-a')); + }); }); describe('a later load', () => { @@ -101,6 +120,18 @@ describe('a later load', () => { expect(storedSignInMarkers()).toEqual({}); }); + it('brings back nothing written after a full sign-out, which keeps the record migrated', async () => { + writeFromPreviousRelease({ 'ins-a': url('ins-a') }); + const store = await loadPage(); + store.signOutAllLocally(); + store.wipeLocalStorageForSignOut(); + + writeFromPreviousRelease({ 'ins-a': url('ins-a') }); + await loadPage(); + + expect(storedSignInMarkers()).toEqual({}); + }); + it('still removes a legacy record that is not one', async () => { await loadPage(); localStorage.setItem(legacySignInMarkersKey, JSON.stringify([url('ins-a')])); @@ -143,6 +174,15 @@ describe('for tabs still on the previous release', () => { expect(legacy()).toEqual({ 'ins-a': 'https://ins-a-2.example.com:9925/' }); }); + it("leaves a legacy record it can't read alone at a sign-in", async () => { + const store = await loadPage(); + localStorage.setItem(legacySignInMarkersKey, 'not a record'); + + signIn(store, 'ins-a'); + + expect(localStorage.getItem(legacySignInMarkersKey)).toBe('not a record'); + }); + it('takes the whole legacy record when it cannot mirror a sign-out', async () => { writeFromPreviousRelease({ 'ins-a': url('ins-a'), 'ins-old': url('ins-old') }); const store = await loadPage(); diff --git a/src/features/auth/store/localSignOutStorageFailure.test.ts b/src/features/auth/store/localSignOutStorageFailure.test.ts index c2b73e27b..45ae6002d 100644 --- a/src/features/auth/store/localSignOutStorageFailure.test.ts +++ b/src/features/auth/store/localSignOutStorageFailure.test.ts @@ -166,6 +166,14 @@ describe('authStore local sign-out when storage writes throw', () => { expect(storedSignInMarkers()).not.toHaveProperty('ins-p'); }); + it('leaves it once another tab has signed in there again', () => { + seedSignInMarkers({ 'ins-p': 'https://ins-p-2' }); + + signIn('ins-r'); + + expect(storedSignInMarkers()).toHaveProperty('ins-p', 'https://ins-p-2'); + }); + it('reaches it at a full sign-out after another tab signs it back in', () => { seedSignInMarkers({ 'ins-p': 'https://ins-p' }); localStorage.setItem('Studio:BasicAuth:ins-p', btoa(JSON.stringify({ username: 'u', password: 'p' }))); @@ -177,6 +185,22 @@ describe('authStore local sign-out when storage writes throw', () => { }); }); + it('still removes a marker it cannot read at sign-out', () => { + signIn('ins-u'); + const getItem = Storage.prototype.getItem; + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(function(this: Storage, key: string) { + if (key === 'Studio:PotentiallyAuthenticated:ins-u') { + throw new DOMException('Storage refused.', 'SecurityError'); + } + return getItem.call(this, key); + }); + + authStore.signOutLocally('ins-u'); + vi.restoreAllMocks(); + + expect(storedSignInMarkers()).not.toHaveProperty('ins-u'); + }); + describe('a sign-in storage refused to record', () => { afterEach(() => { authStore.signOutLocally('ins-refused'); diff --git a/src/features/auth/store/signInMarkers.ts b/src/features/auth/store/signInMarkers.ts index 993815687..8f740641f 100644 --- a/src/features/auth/store/signInMarkers.ts +++ b/src/features/auth/store/signInMarkers.ts @@ -10,7 +10,8 @@ import type { AuthenticatedConnectionKey, EntityIds } from './authStore'; */ export const signInMarkerPrefix = 'Studio:PotentiallyAuthenticated:'; export const legacySignInMarkersKey = 'Studio:PotentiallyAuthenticated'; -const legacyMigratedKey = 'Studio:PotentiallyAuthenticatedMigrated'; +/** Survives the sign-out wipe: a previous-release tab writing its copy back afterwards must not be migrated again. */ +export const legacyMigratedKey = 'Studio:PotentiallyAuthenticatedMigrated'; type SignIns = Record; @@ -30,15 +31,14 @@ export function readSignInMarkers(): Array<[EntityIds, AuthenticatedConnectionKe } } } catch { - // Storage that can't be read holds no marker this tab could use. + // Unreadable storage reads as signed out. } return markers; } -/** Null when the record is absent, or is not one. */ -export function readLegacySignInMarkers(): SignIns | null { +/** Null when the record is absent or is not one; entries that aren't markers are dropped. */ +function parseLegacySignInMarkers(raw: string | null): SignIns | null { try { - const raw = localStorage.getItem(legacySignInMarkersKey); const markers: unknown = raw ? JSON.parse(raw) : null; if (!markers || typeof markers !== 'object' || Array.isArray(markers)) { return null; @@ -51,21 +51,34 @@ export function readLegacySignInMarkers(): SignIns | null { } } +export function readLegacySignInMarkers(): SignIns | null { + try { + return parseLegacySignInMarkers(localStorage.getItem(legacySignInMarkersKey)); + } catch { + return null; + } +} + /** * Copies the legacy record into per-entity keys once per storage, leaving any marker already stored per entity as it * is. Later loads don't read it again: the previous release's tabs write their whole load-time copy back into it, so - * it would bring back what this release signed out. Two tabs migrating at once write the same values. + * it would bring back what this release signed out. Two tabs migrating at once write the same values. Returns whether + * the record has been migrated, now or before. * - * A record that is not one is removed on every load: the previous release fails to start on it. + * Every load also cleans the record, since the previous release fails to start on one that isn't a record: it is + * removed, and entries that aren't markers are dropped from one that is. */ -export function migrateLegacySignInMarkers(): void { +export function migrateLegacySignInMarkers(): boolean { try { - const legacy = readLegacySignInMarkers(); - if (!legacy && localStorage.getItem(legacySignInMarkersKey) !== null) { + const raw = localStorage.getItem(legacySignInMarkersKey); + const legacy = parseLegacySignInMarkers(raw); + if (raw !== null && !legacy) { localStorage.removeItem(legacySignInMarkersKey); + } else if (legacy && JSON.stringify(legacy) !== raw) { + localStorage.setItem(legacySignInMarkersKey, JSON.stringify(legacy)); } if (localStorage.getItem(legacyMigratedKey)) { - return; + return true; } for (const [id, url] of Object.entries(legacy ?? {})) { if (!localStorage.getItem(signInMarkerKey(id))) { @@ -73,16 +86,17 @@ export function migrateLegacySignInMarkers(): void { } } localStorage.setItem(legacyMigratedKey, '1'); + return true; } catch { - // The next load tries again. + return false; } } /** * Keeps the legacy record current for the previous release's tabs. Best effort: one that misses a sign-in only shows * it signed out. A removal it can't write takes the whole record, as that release does itself, since a marker it kept - * would make its next load probe the entity with its cookies. `removing` limits a removal to the URL a failed check - * used. + * would make its next load probe the entity with its cookies. A sign-in leaves a record it can't read alone, rather + * than replace other tabs' markers with this one. `removing` limits a removal to the URL a failed check used. */ export function mirrorLegacySignInMarker( id: EntityIds, @@ -90,7 +104,11 @@ export function mirrorLegacySignInMarker( removing?: AuthenticatedConnectionKey, ): void { try { - const markers = readLegacySignInMarkers() ?? {}; + const raw = localStorage.getItem(legacySignInMarkersKey); + const markers = raw === null ? {} : parseLegacySignInMarkers(raw); + if (!markers) { + throw new Error('The legacy sign-in record is not one'); + } const unchanged = url === null ? !Object.hasOwn(markers, id) || (removing !== undefined && markers[id] !== removing) : markers[id] === url; @@ -108,7 +126,7 @@ export function mirrorLegacySignInMarker( try { localStorage.removeItem(legacySignInMarkersKey); } catch { - // Nothing left to try. + // Storage that refuses this too keeps the record, as the previous release's own fallback would. } } } diff --git a/src/features/auth/store/signInMarkersTestHelpers.ts b/src/features/auth/store/signInMarkersTestHelpers.ts index 532452215..f5c36886d 100644 --- a/src/features/auth/store/signInMarkersTestHelpers.ts +++ b/src/features/auth/store/signInMarkersTestHelpers.ts @@ -7,7 +7,6 @@ export function seedSignInMarkers(markers: Record): void { } } -/** The stored sign-in markers, as one record. */ export function storedSignInMarkers(): Record { return Object.fromEntries(readSignInMarkers()); } From 2c43658403383b61974d447b9a62b2241ec5b027 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Thu, 15 Oct 2026 00:16:29 -0400 Subject: [PATCH 19/21] fix(auth): treat failed checks and /User/current answers as checks - A failed check is a probe, not a sign-out: when it can't read the marker it leaves it, and a mirror write it can't make no longer takes the whole legacy record. - For the app sign-in, which every /User/current answer re-signs, those answers no longer write the marker, so they can't put back one another tab's failed check dropped. - A failing app check that was out during a new app sign-in is dropped, as an entity's already was. - This tab's own sign-in cancels a removal of its marker still owed from a refused write. - Migration checks its flag before the best-effort cleanup, so a refused cleanup on a migrated storage can't seed signed-out entities. The sign-out wipe marks the storage migrated, as nothing of the legacy record survives it. Co-Authored-By: Claude Opus 5.5 --- DESIGN.md | 7 +- .../auth/handlers/clearAuthStateLocally.ts | 4 +- .../auth/handlers/signOutHandlers.test.ts | 5 +- src/features/auth/store/authStore.ts | 16 +++-- .../auth/store/crossTabMarkers.test.ts | 59 ++++++++++++++++- .../auth/store/legacySignInMarkers.test.ts | 66 +++++++++++++++++++ .../store/localSignOutStorageFailure.test.ts | 18 +++++ src/features/auth/store/signInMarkers.ts | 37 ++++++++--- .../auth/store/signInMarkersTestHelpers.ts | 1 - 9 files changed, 189 insertions(+), 24 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index bc0991b18..430c7cea6 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -116,9 +116,10 @@ Central manager only ever sends the boolean, never the other organizations' ids; (`signInMarkers.ts`, HarperFast/studio#1968). A tab writes only the entity it signs in or out, so tabs can't erase or restore each other's markers; app and full sign-outs act on every stored marker, at each URL an entity is marked at here or in storage. A check never undoes another tab's change: a passing one of a sign-in this tab already marked - writes nothing, and a failing one leaves a newer URL another tab stored. So a marker removed elsewhere stays removed - while this tab still holds that sign-in, until this tab signs in again, which always writes. A check's answer is - dropped when this tab signed in again while it was out (`crossTabMarkers.test.ts`). A sign-in storage refuses to + writes nothing, and a failing one removes only the marker it checked. So a marker removed elsewhere stays removed + while this tab still holds that sign-in, until this tab signs into the entity again, which always writes; for the + app sign-in, which every `/User/current` answer re-signs, those answers count as checks. A check's answer is dropped + when this tab signed in again while it was out (`crossTabMarkers.test.ts`). A sign-in storage refuses to record still signs in, and its next sign-in or check writes it; a marker storage won't remove is retried at every marker write, unless another tab has rewritten it since (`localSignOutStorageFailure.test.ts`). - **The previous release's one shared record is kept in step for one more release.** Its tabs read diff --git a/src/features/auth/handlers/clearAuthStateLocally.ts b/src/features/auth/handlers/clearAuthStateLocally.ts index 4c8050046..fc56cafbb 100644 --- a/src/features/auth/handlers/clearAuthStateLocally.ts +++ b/src/features/auth/handlers/clearAuthStateLocally.ts @@ -5,8 +5,8 @@ import { queryClient } from '@/react-query/queryClient'; /** * Full LOCAL sign-out: clears all in-memory auth connections/tokens/flags, persisted storage, and BOTH * React Query caches, WITHOUT posting a logout to CM or instances. None of it may survive into a - * same-tab sign-in as another user, except the explorer's sign-out generation (see - * `authStore.wipeLocalStorageForSignOut`). + * same-tab sign-in as another user, except the explorer's sign-out generation and the sign-in markers' migration flag + * (see `authStore.wipeLocalStorageForSignOut`). */ export function clearAuthStateLocally(): void { runEvery([ diff --git a/src/features/auth/handlers/signOutHandlers.test.ts b/src/features/auth/handlers/signOutHandlers.test.ts index dab49a4a3..79386c803 100644 --- a/src/features/auth/handlers/signOutHandlers.test.ts +++ b/src/features/auth/handlers/signOutHandlers.test.ts @@ -89,7 +89,8 @@ describe.each( expect(authStore.getConnectionById(OverallAppSignIn).user).toBeNull(); expect(authStore.getConnectionById('ins-a').user).toBeNull(); expectCachesEmpty(); - expect(localStorageKeys()).toEqual(['Studio:ExplorerAuthEpoch']); + // The migration flag holds no account data, and keeps a previous-release tab's write-back from being migrated. + expect(localStorageKeys().sort()).toEqual(['Studio:ExplorerAuthEpoch', 'Studio:PotentiallyAuthenticatedMigrated']); expect(sessionStorage.length).toBe(0); }); @@ -235,7 +236,7 @@ describe.each( await signOut(); expectCachesEmpty(); - expect(localStorage.length).toBe(0); + expect(localStorageKeys()).toEqual(['Studio:PotentiallyAuthenticatedMigrated']); expect(sessionStorage.length).toBe(0); expect(consoleError).toHaveBeenCalledWith(failure); }); diff --git a/src/features/auth/store/authStore.ts b/src/features/auth/store/authStore.ts index 1e48a5673..58ff92aef 100644 --- a/src/features/auth/store/authStore.ts +++ b/src/features/auth/store/authStore.ts @@ -24,6 +24,7 @@ import { getOperationsUrlForInstance } from '@/lib/urls/getOperationsUrlForInsta import { isDirectOperationsUrl } from '@/lib/urls/isDirectOperationsUrl'; import { legacyMigratedKey, + markLegacySignInMarkersMigrated, migrateLegacySignInMarkers, mirrorLegacySignInMarker, readLegacySignInMarkers, @@ -206,6 +207,7 @@ class AuthStore { public wipeLocalStorageForSignOut(): void { try { clearLocalStorage([this.explorerAuthEpochKey, legacyMigratedKey]); + markLegacySignInMarkersMigrated(); } finally { this.retrySignOutWritesAfterWipe(); } @@ -370,7 +372,8 @@ class AuthStore { ): void { if (user) { this.signInRevisions.set(id, (this.signInRevisions.get(id) ?? 0) + 1); - this.flagKeyAsSignedIn(id, key); + // getCurrentUser re-signs the app in on every fetch, so for it this is a check. + this.flagKeyAsSignedIn(id, key, id === OverallAppSignIn); this.updateConnectionIfChanged(id, false, user); } else { // Clears the API explorer's stored credentials too, or the next user to sign into this entity @@ -981,7 +984,7 @@ class AuthStore { this.unremovedSignInMarkers.delete(key); this.unremovedKeys.delete(key); } catch { - // Still refused. + // Storage still refuses, so the entry stays queued. } } } @@ -995,6 +998,8 @@ class AuthStore { return; } this.potentiallyAuthenticated[id] = key; + // This sign-in replaces whatever removal of its marker was still owed. + this.unremovedSignInMarkers.delete(signInMarkerKey(id)); this.retryUnremovedSignInMarkers(); try { this.writeStoredItem(signInMarkerKey(id), key); @@ -1020,7 +1025,8 @@ class AuthStore { } catch { unreadable = true; } - if (checkedKey !== undefined && stored && stored !== checkedKey) { + // A failed check is a probe, not a sign-out: it removes only the marker it checked. + if (checkedKey !== undefined && (unreadable || (stored && stored !== checkedKey))) { return; } if (stored || unreadable) { @@ -1115,8 +1121,8 @@ class AuthStore { if (this.getSignOutGeneration(id) !== signOutGeneration) { return null; } - // The app sign-in's own check signs in through getCurrentUser, so only an entity's answer can be stale this way. - if (id !== OverallAppSignIn && this.signInRevisions.get(id) !== signInRevision) { + // A passing app check signs in itself, through getCurrentUser, so only its failure can be stale this way. + if (this.signInRevisions.get(id) !== signInRevision && (id !== OverallAppSignIn || !user)) { return this.getConnectionById(id).user; } if (user) { diff --git a/src/features/auth/store/crossTabMarkers.test.ts b/src/features/auth/store/crossTabMarkers.test.ts index 1c1a15cc2..38f1bf064 100644 --- a/src/features/auth/store/crossTabMarkers.test.ts +++ b/src/features/auth/store/crossTabMarkers.test.ts @@ -1,13 +1,15 @@ /** @vitest-environment jsdom */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { authStore } from './authStore'; +import { authStore, OverallAppSignIn } from './authStore'; import { legacySignInMarkersKey, signInMarkerKey } from './signInMarkers'; import { seedSignInMarkers, storedSignInMarkers } from './signInMarkersTestHelpers'; -const { getInstanceUserInfo, onInstanceLogoutSubmit } = vi.hoisted(() => ({ +const { getCurrentUser, getInstanceUserInfo, onInstanceLogoutSubmit } = vi.hoisted(() => ({ + getCurrentUser: vi.fn(), getInstanceUserInfo: vi.fn(), onInstanceLogoutSubmit: vi.fn(), })); +vi.mock('@/features/auth/queries/getCurrentUser', () => ({ getCurrentUser })); vi.mock('@/integrations/api/instance/status/getInstanceUserInfo', () => ({ getInstanceUserInfo })); vi.mock('@/integrations/api/instance/auth/onInstanceLogoutSubmit', () => ({ onInstanceLogoutSubmit })); @@ -41,7 +43,7 @@ beforeEach(() => { afterEach(() => { vi.resetAllMocks(); vi.restoreAllMocks(); - ENTITIES.forEach((id) => authStore.signOutLocally(id)); + [...ENTITIES, OverallAppSignIn].forEach((id) => authStore.signOutLocally(id)); localStorage.clear(); }); @@ -124,6 +126,39 @@ describe('a sign-in this tab already remembers', () => { }); }); +describe('the app sign-in', () => { + const signInApp = (user: object) => + authStore.setUserForIdAndKey(OverallAppSignIn, OverallAppSignIn, user as SetArgs[2]); + + it("isn't put back by a /User/current answer after another tab's failed check dropped it", () => { + signInApp({ id: 'usr_a' }); + localStorage.removeItem(signInMarkerKey(OverallAppSignIn)); + + signInApp({ id: 'usr_a' }); + + expect(storedSignInMarkers()).toEqual({}); + }); + + it('stays signed in when a check that was out during a new sign-in fails', async () => { + signInApp({ id: 'usr_a' }); + let answer: (reason: unknown) => void = () => undefined; + getCurrentUser.mockReturnValue( + new Promise((_, reject) => { + answer = reject; + }), + ); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + const check = authStore.reloadUser(OverallAppSignIn); + + signInApp({ id: 'usr_b' }); + answer(new Error('timeout of 60000ms exceeded')); + await check; + + expect(authStore.getConnectionById(OverallAppSignIn).user).toEqual({ id: 'usr_b' }); + expect(storedSignInMarkers()).toHaveProperty(OverallAppSignIn); + }); +}); + describe('a sign-in another tab made again at a newer URL', () => { function loggedOutAt() { return onInstanceLogoutSubmit.mock.calls.map(([{ instanceClient }]) => instanceClient.defaults.baseURL); @@ -201,6 +236,24 @@ describe('a check of a sign-in this tab remembers', () => { expect(storedSignInMarkers()).toEqual({ 'ins-here': newer }); }); + it("leaves the marker alone when it fails and can't read it", async () => { + signInHere('ins-here'); + getInstanceUserInfo.mockRejectedValue(new Error('Network Error')); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + const getItem = Storage.prototype.getItem; + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(function(this: Storage, key: string) { + if (key === signInMarkerKey('ins-here')) { + throw new DOMException('Storage refused.', 'SecurityError'); + } + return getItem.call(this, key); + }); + + await authStore.reloadUser('ins-here'); + vi.mocked(Storage.prototype.getItem).mockRestore(); + + expect(storedSignInMarkers()).toEqual({ 'ins-here': url('ins-here') }); + }); + it('leaves a sign-in this tab made at the same URL while it was out', async () => { signInHere('ins-here'); let answer: (reason: unknown) => void = () => undefined; diff --git a/src/features/auth/store/legacySignInMarkers.test.ts b/src/features/auth/store/legacySignInMarkers.test.ts index 02f4a9707..852cbf2a6 100644 --- a/src/features/auth/store/legacySignInMarkers.test.ts +++ b/src/features/auth/store/legacySignInMarkers.test.ts @@ -132,6 +132,53 @@ describe('a later load', () => { expect(storedSignInMarkers()).toEqual({}); }); + it('keeps the record migrated through a full sign-out whose writes storage refuses', async () => { + writeFromPreviousRelease({ 'ins-a': url('ins-a') }); + const store = await loadPage(); + store.signOutAllLocally(); + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new DOMException('The quota has been exceeded.', 'QuotaExceededError'); + }); + vi.spyOn(console, 'debug').mockImplementation(() => undefined); + store.wipeLocalStorageForSignOut(); + vi.restoreAllMocks(); + + writeFromPreviousRelease({ 'ins-a': url('ins-a') }); + await loadPage(); + + expect(storedSignInMarkers()).toEqual({}); + }); + + it("brings back nothing written after a full sign-out, even when the first migration didn't land", async () => { + writeFromPreviousRelease({ 'ins-a': url('ins-a') }); + const refused = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new DOMException('The quota has been exceeded.', 'QuotaExceededError'); + }); + const store = await loadPage(); + refused.mockRestore(); + store.signOutAllLocally(); + store.wipeLocalStorageForSignOut(); + + writeFromPreviousRelease({ 'ins-a': url('ins-a') }); + const next = await loadPage(); + + expect(next.getOperationsUrl('ins-a')).toBeUndefined(); + }); + + it("doesn't sign back in on a write-back when it can't clean the record", async () => { + writeFromPreviousRelease({ 'ins-a': url('ins-a') }); + const store = await loadPage(); + store.signOutLocally('ins-a'); + localStorage.setItem(legacySignInMarkersKey, JSON.stringify({ 'ins-a': url('ins-a'), junk: 1 })); + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new DOMException('The quota has been exceeded.', 'QuotaExceededError'); + }); + + const next = await loadPage(); + + expect(next.getOperationsUrl('ins-a')).toBeUndefined(); + }); + it('still removes a legacy record that is not one', async () => { await loadPage(); localStorage.setItem(legacySignInMarkersKey, JSON.stringify([url('ins-a')])); @@ -183,6 +230,25 @@ describe('for tabs still on the previous release', () => { expect(localStorage.getItem(legacySignInMarkersKey)).toBe('not a record'); }); + it("keeps the whole legacy record when it can't mirror a failed check", async () => { + writeFromPreviousRelease({ 'ins-old': url('ins-old') }); + const store = await loadPage(); + signIn(store, 'ins-a'); + getInstanceUserInfo.mockRejectedValue(new Error('Network Error')); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + const setItem = Storage.prototype.setItem; + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(function(this: Storage, key: string, value: string) { + if (key === legacySignInMarkersKey) { + throw new DOMException('The quota has been exceeded.', 'QuotaExceededError'); + } + setItem.call(this, key, value); + }); + + await store.reloadUser('ins-a'); + + expect(legacy()).toEqual({ 'ins-old': url('ins-old'), 'ins-a': url('ins-a') }); + }); + it('takes the whole legacy record when it cannot mirror a sign-out', async () => { writeFromPreviousRelease({ 'ins-a': url('ins-a'), 'ins-old': url('ins-old') }); const store = await loadPage(); diff --git a/src/features/auth/store/localSignOutStorageFailure.test.ts b/src/features/auth/store/localSignOutStorageFailure.test.ts index 45ae6002d..df440556c 100644 --- a/src/features/auth/store/localSignOutStorageFailure.test.ts +++ b/src/features/auth/store/localSignOutStorageFailure.test.ts @@ -185,6 +185,24 @@ describe('authStore local sign-out when storage writes throw', () => { }); }); + it('keeps a marker this tab signed back into while storage still refused its removal', () => { + signIn('ins-v'); + failEveryStorageWrite(); + vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => { + throw new DOMException('Storage refused.', 'SecurityError'); + }); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + vi.spyOn(console, 'debug').mockImplementation(() => undefined); + authStore.signOutLocally('ins-v'); + signIn('ins-v'); + vi.restoreAllMocks(); + + signIn('ins-w'); + + expect(storedSignInMarkers()).toHaveProperty('ins-v'); + ['ins-v', 'ins-w'].forEach((id) => authStore.signOutLocally(id)); + }); + it('still removes a marker it cannot read at sign-out', () => { signIn('ins-u'); const getItem = Storage.prototype.getItem; diff --git a/src/features/auth/store/signInMarkers.ts b/src/features/auth/store/signInMarkers.ts index 8f740641f..617436807 100644 --- a/src/features/auth/store/signInMarkers.ts +++ b/src/features/auth/store/signInMarkers.ts @@ -8,7 +8,7 @@ import type { AuthenticatedConnectionKey, EntityIds } from './authStore'; * While those tabs can still be open, or a rollback can bring that release back, this one mirrors each change into the * record too and reads it in sign-out sweeps (DESIGN.md). */ -export const signInMarkerPrefix = 'Studio:PotentiallyAuthenticated:'; +const signInMarkerPrefix = 'Studio:PotentiallyAuthenticated:'; export const legacySignInMarkersKey = 'Studio:PotentiallyAuthenticated'; /** Survives the sign-out wipe: a previous-release tab writing its copy back afterwards must not be migrated again. */ export const legacyMigratedKey = 'Studio:PotentiallyAuthenticatedMigrated'; @@ -69,17 +69,28 @@ export function readLegacySignInMarkers(): SignIns | null { * removed, and entries that aren't markers are dropped from one that is. */ export function migrateLegacySignInMarkers(): boolean { + let migrated = false; + let raw: string | null = null; + try { + migrated = !!localStorage.getItem(legacyMigratedKey); + raw = localStorage.getItem(legacySignInMarkersKey); + } catch { + return migrated; + } + const legacy = parseLegacySignInMarkers(raw); try { - const raw = localStorage.getItem(legacySignInMarkersKey); - const legacy = parseLegacySignInMarkers(raw); if (raw !== null && !legacy) { localStorage.removeItem(legacySignInMarkersKey); } else if (legacy && JSON.stringify(legacy) !== raw) { localStorage.setItem(legacySignInMarkersKey, JSON.stringify(legacy)); } - if (localStorage.getItem(legacyMigratedKey)) { - return true; - } + } catch { + // Cleaned at the next load instead. + } + if (migrated) { + return true; + } + try { for (const [id, url] of Object.entries(legacy ?? {})) { if (!localStorage.getItem(signInMarkerKey(id))) { localStorage.setItem(signInMarkerKey(id), url); @@ -92,11 +103,21 @@ export function migrateLegacySignInMarkers(): boolean { } } +/** After the sign-out wipe nothing of the legacy record is left, so a write-back after it is never migrated. */ +export function markLegacySignInMarkersMigrated(): void { + try { + localStorage.setItem(legacyMigratedKey, '1'); + } catch { + // The wipe kept the flag if it was already set. + } +} + /** * Keeps the legacy record current for the previous release's tabs. Best effort: one that misses a sign-in only shows * it signed out. A removal it can't write takes the whole record, as that release does itself, since a marker it kept * would make its next load probe the entity with its cookies. A sign-in leaves a record it can't read alone, rather - * than replace other tabs' markers with this one. `removing` limits a removal to the URL a failed check used. + * than replace other tabs' markers with this one. `removing` limits a removal to the URL a failed check used, and a + * failed check, being a probe rather than a sign-out, never takes the whole record. */ export function mirrorLegacySignInMarker( id: EntityIds, @@ -122,7 +143,7 @@ export function mirrorLegacySignInMarker( } localStorage.setItem(legacySignInMarkersKey, JSON.stringify(markers)); } catch { - if (url === null) { + if (url === null && removing === undefined) { try { localStorage.removeItem(legacySignInMarkersKey); } catch { diff --git a/src/features/auth/store/signInMarkersTestHelpers.ts b/src/features/auth/store/signInMarkersTestHelpers.ts index f5c36886d..9cf9cfed2 100644 --- a/src/features/auth/store/signInMarkersTestHelpers.ts +++ b/src/features/auth/store/signInMarkersTestHelpers.ts @@ -1,6 +1,5 @@ import { readSignInMarkers, signInMarkerKey } from './signInMarkers'; -/** Stores sign-in markers the way a tab of this release records its sign-ins. */ export function seedSignInMarkers(markers: Record): void { for (const [id, url] of Object.entries(markers)) { localStorage.setItem(signInMarkerKey(id), url); From 50eb684f632c790ffe90090660d0eb601af13208 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Thu, 15 Oct 2026 00:36:30 -0400 Subject: [PATCH 20/21] fix(auth): drop a passing app check's answer when a sign-in replaced it A page-load app check still out when the user signed in again applied its answer for the old session over the new sign-in: getCurrentUser synced it into the store, and loadUser applied it again. - The store's app check now fetches through fetchCurrentUser, which doesn't sync, so loadUser applies the answer itself, and the sign-in revision guard drops a stale passing answer as it drops a failing one. - getCurrentUser syncs its answer only if no sign-in landed while it was in flight, using the store's new getSignInRevision. - DESIGN.md links HarperFast/studio#1970 for removing the legacy mirror. Co-Authored-By: Claude Opus 5.5 --- DESIGN.md | 11 +++-- .../auth/queries/getCurrentUser.test.ts | 48 ++++++++++++------- src/features/auth/queries/getCurrentUser.ts | 24 +++++++--- src/features/auth/store/authStore.ts | 12 +++-- .../auth/store/crossTabMarkers.test.ts | 25 ++++++++-- 5 files changed, 83 insertions(+), 37 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 430c7cea6..4522c6b2b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -118,10 +118,11 @@ Central manager only ever sends the boolean, never the other organizations' ids; here or in storage. A check never undoes another tab's change: a passing one of a sign-in this tab already marked writes nothing, and a failing one removes only the marker it checked. So a marker removed elsewhere stays removed while this tab still holds that sign-in, until this tab signs into the entity again, which always writes; for the - app sign-in, which every `/User/current` answer re-signs, those answers count as checks. A check's answer is dropped - when this tab signed in again while it was out (`crossTabMarkers.test.ts`). A sign-in storage refuses to - record still signs in, and its next sign-in or check writes it; a marker storage won't remove is retried at every - marker write, unless another tab has rewritten it since (`localSignOutStorageFailure.test.ts`). + app sign-in, which every `/User/current` answer re-signs, those answers count as checks. A check's answer, or a + `/User/current` answer, is dropped when this tab signed in again while it was out (`crossTabMarkers.test.ts`, + `getCurrentUser.test.ts`). A sign-in storage refuses to record still signs in, and its next sign-in or check writes + it; a marker storage won't remove is retried at every marker write, unless another tab has rewritten it since + (`localSignOutStorageFailure.test.ts`). - **The previous release's one shared record is kept in step for one more release.** Its tabs read `Studio:PotentiallyAuthenticated` once at load and write their whole copy back, and know nothing of the per-entity keys. So this release copies that record into the keys once per storage (a marker already stored per entity wins), @@ -133,7 +134,7 @@ Central manager only ever sends the boolean, never the other organizations' ids; wipe keeps the flag that records it. Until the copy lands, as when a full quota refuses it, the record also seeds this tab's markers (`legacySignInMarkers.test.ts`, `account-menu.anon.spec.ts`). Every load also removes a record that isn't one, which the previous release can't start on, and drops entries that aren't markers; a sign-in leaves - one it can't read alone. The release after this one stops the mirror and removes the record. + one it can't read alone. The release after this one stops the mirror and removes the record (HarperFast/studio#1970). - **`Dashboard` renders nothing under it without a signed-in user.** Route data loads in parallel with the page load's `/User/current` check (waiting for it costs +262/680 ms p75 on live org/cluster cold loads), and central manager answers a request with no session with 403, so a deep link on an expired session has its route data fail before the diff --git a/src/features/auth/queries/getCurrentUser.test.ts b/src/features/auth/queries/getCurrentUser.test.ts index 88785247d..d85e3c31e 100644 --- a/src/features/auth/queries/getCurrentUser.test.ts +++ b/src/features/auth/queries/getCurrentUser.test.ts @@ -1,30 +1,27 @@ // @vitest-environment jsdom import { authStore, OverallAppSignIn } from '@/features/auth/store/authStore'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getCurrentUser } from './getCurrentUser'; -vi.mock('@/config/apiClient', () => ({ - apiClient: { - get: vi.fn().mockResolvedValue({ - data: { - id: 'usr-test', - email: 'admin@example.com', - fabricRole: 'fabric_admin', - roles: {}, - }, - }), - }, -})); +const { get } = vi.hoisted(() => ({ get: vi.fn() })); +vi.mock('@/config/apiClient', () => ({ apiClient: { get } })); + +const current = { id: 'usr-test', email: 'admin@example.com', fabricRole: 'fabric_admin', roles: {} }; + +function signIn(user: object) { + authStore.setUserForIdAndKey(OverallAppSignIn, OverallAppSignIn, user as never); +} + +beforeEach(() => { + get.mockResolvedValue({ data: current }); +}); describe('getCurrentUser', () => { it('syncs the fetched user into the auth store', async () => { // The /Login/ response seeds the store with a slimmer user (no // fabricRole); fetching /User/current must replace it so admin-only UI // appears without a page refresh. - authStore.setUserForIdAndKey(OverallAppSignIn, OverallAppSignIn, { - id: 'usr-test', - email: 'admin@example.com', - } as never); + signIn({ id: 'usr-test', email: 'admin@example.com' }); expect(authStore.getConnectionById(OverallAppSignIn).user).not.toHaveProperty('fabricRole'); const user = await getCurrentUser(); @@ -32,4 +29,21 @@ describe('getCurrentUser', () => { expect(user.fabricRole).toBe('fabric_admin'); expect(authStore.getConnectionById(OverallAppSignIn).user).toBe(user); }); + + it('leaves a sign-in made while it was in flight, whose session its answer may predate', async () => { + signIn({ id: 'usr-test' }); + let answer: (response: unknown) => void = () => undefined; + get.mockReturnValue( + new Promise((resolve) => { + answer = resolve; + }), + ); + const fetched = getCurrentUser(); + + signIn({ id: 'usr-other' }); + answer({ data: current }); + await fetched; + + expect(authStore.getConnectionById(OverallAppSignIn).user).toEqual({ id: 'usr-other' }); + }); }); diff --git a/src/features/auth/queries/getCurrentUser.ts b/src/features/auth/queries/getCurrentUser.ts index 1939e35f7..0a6134bd5 100644 --- a/src/features/auth/queries/getCurrentUser.ts +++ b/src/features/auth/queries/getCurrentUser.ts @@ -1,21 +1,31 @@ import { apiClient } from '@/config/apiClient'; -// Circular with authStore.ts (it calls getCurrentUser); safe because neither +// Circular with authStore.ts (it calls fetchCurrentUser); safe because neither // module touches the other's exports at module-evaluation time. import { authStore, OverallAppSignIn } from '@/features/auth/store/authStore'; import { User } from '@/integrations/api/api.patch'; import { queryOptions } from '@tanstack/react-query'; -export async function getCurrentUser(): Promise { - const signOutGeneration = authStore.getSignOutGeneration(OverallAppSignIn); +/** The signed-in user, without syncing it into the auth store: the store's own check applies it itself. */ +export async function fetchCurrentUser(): Promise { const { data } = await apiClient.get('/User/current' as '/User/{id}'); // TODO: The roles the API returns is a map instead of an array, so we have to map through unknown. - const user = data as unknown as User; + return data as unknown as User; +} + +export async function getCurrentUser(): Promise { + const signOutGeneration = authStore.getSignOutGeneration(OverallAppSignIn); + const signInRevision = authStore.getSignInRevision(OverallAppSignIn); + const user = await fetchCurrentUser(); // The /Login/ response seeds the auth store with a slimmer user than // /User/current returns (it lacks fabricRole, among other fields), so sync // the authoritative record into the store whenever we fetch it — unless a - // sign-out landed while it was in flight. Resolved, not rejected, then: a - // rejection would reach the query's global error toast. - if (authStore.getSignOutGeneration(OverallAppSignIn) === signOutGeneration) { + // sign-out landed while it was in flight, or a sign-in its answer may + // predate. Resolved, not rejected, then: a rejection would reach the + // query's global error toast. + if ( + authStore.getSignOutGeneration(OverallAppSignIn) === signOutGeneration + && authStore.getSignInRevision(OverallAppSignIn) === signInRevision + ) { authStore.setUserForEntity(OverallAppSignIn, user); } return user; diff --git a/src/features/auth/store/authStore.ts b/src/features/auth/store/authStore.ts index 58ff92aef..77bafe52c 100644 --- a/src/features/auth/store/authStore.ts +++ b/src/features/auth/store/authStore.ts @@ -1,6 +1,6 @@ import { isLocalStudio, localStudioDevUrl } from '@/config/constants'; import { getInstanceClient } from '@/config/getInstanceClient'; -import { getCurrentUser } from '@/features/auth/queries/getCurrentUser'; +import { fetchCurrentUser } from '@/features/auth/queries/getCurrentUser'; import { forgetAllEntitySettings, forgetEntitySettings, @@ -474,6 +474,11 @@ class AuthStore { * Moves on every sign-out of `id`, and of everything. An auth resolution reads it before its first * await and commits only if it has not moved, so a result that outlived its session is dropped. */ + /** Advances at every sign-in, so an answer fetched before one can tell it is about a session that ended. */ + public getSignInRevision(id: EntityIds): number { + return this.signInRevisions.get(id) ?? 0; + } + public getSignOutGeneration(id: EntityIds): number { return (this.signOutGenerations.get(id) ?? 0) + (this.signOutGenerations.get('*') ?? 0); } @@ -1104,7 +1109,7 @@ class AuthStore { if (isLocalStudio) { user = await getInstanceUserInfo({ instanceClient: getInstanceClient() }); } else { - user = await getCurrentUser(); + user = await fetchCurrentUser(); } } else if (id) { user = await getInstanceUserInfo({ instanceClient: getInstanceClient({ id }) }); @@ -1121,8 +1126,7 @@ class AuthStore { if (this.getSignOutGeneration(id) !== signOutGeneration) { return null; } - // A passing app check signs in itself, through getCurrentUser, so only its failure can be stale this way. - if (this.signInRevisions.get(id) !== signInRevision && (id !== OverallAppSignIn || !user)) { + if (this.signInRevisions.get(id) !== signInRevision) { return this.getConnectionById(id).user; } if (user) { diff --git a/src/features/auth/store/crossTabMarkers.test.ts b/src/features/auth/store/crossTabMarkers.test.ts index 38f1bf064..2540d534e 100644 --- a/src/features/auth/store/crossTabMarkers.test.ts +++ b/src/features/auth/store/crossTabMarkers.test.ts @@ -4,12 +4,12 @@ import { authStore, OverallAppSignIn } from './authStore'; import { legacySignInMarkersKey, signInMarkerKey } from './signInMarkers'; import { seedSignInMarkers, storedSignInMarkers } from './signInMarkersTestHelpers'; -const { getCurrentUser, getInstanceUserInfo, onInstanceLogoutSubmit } = vi.hoisted(() => ({ - getCurrentUser: vi.fn(), +const { fetchCurrentUser, getInstanceUserInfo, onInstanceLogoutSubmit } = vi.hoisted(() => ({ + fetchCurrentUser: vi.fn(), getInstanceUserInfo: vi.fn(), onInstanceLogoutSubmit: vi.fn(), })); -vi.mock('@/features/auth/queries/getCurrentUser', () => ({ getCurrentUser })); +vi.mock('@/features/auth/queries/getCurrentUser', () => ({ fetchCurrentUser })); vi.mock('@/integrations/api/instance/status/getInstanceUserInfo', () => ({ getInstanceUserInfo })); vi.mock('@/integrations/api/instance/auth/onInstanceLogoutSubmit', () => ({ onInstanceLogoutSubmit })); @@ -142,7 +142,7 @@ describe('the app sign-in', () => { it('stays signed in when a check that was out during a new sign-in fails', async () => { signInApp({ id: 'usr_a' }); let answer: (reason: unknown) => void = () => undefined; - getCurrentUser.mockReturnValue( + fetchCurrentUser.mockReturnValue( new Promise((_, reject) => { answer = reject; }), @@ -157,6 +157,23 @@ describe('the app sign-in', () => { expect(authStore.getConnectionById(OverallAppSignIn).user).toEqual({ id: 'usr_b' }); expect(storedSignInMarkers()).toHaveProperty(OverallAppSignIn); }); + + it("drops a passing check's answer about the session a sign-in made meanwhile replaced", async () => { + signInApp({ id: 'usr_a' }); + let answer: (user: unknown) => void = () => undefined; + fetchCurrentUser.mockReturnValue( + new Promise((resolve) => { + answer = resolve; + }), + ); + const check = authStore.reloadUser(OverallAppSignIn); + + signInApp({ id: 'usr_b' }); + answer({ id: 'usr_a' }); + await check; + + expect(authStore.getConnectionById(OverallAppSignIn).user).toEqual({ id: 'usr_b' }); + }); }); describe('a sign-in another tab made again at a newer URL', () => { From e7ae19ff7a14c549b90636a848554172ce979787 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Thu, 15 Oct 2026 00:48:36 -0400 Subject: [PATCH 21/21] fix(auth): make a /User/current sync a check, not a sign-in - getCurrentUser syncs through the new syncAppUser, which writes no marker this tab already holds and leaves the sign-in revision alone. A sync landing during the store's own app check therefore no longer makes that check drop its answer and skip its sign-out re-ask. - setUserForIdAndKey treats every sign-in, the app's included, as one that always records itself. - A removal storage still owes is cancelled up front only when the kept marker already holds the URL being signed in, and otherwise once the new marker is written. - A migration flag that can't be read is reported as migrated, so a previous-release write-back can't seed that load. - getSignInRevision sits under its own doc comment. Co-Authored-By: Claude Opus 5.5 --- src/features/auth/queries/getCurrentUser.ts | 2 +- src/features/auth/store/authStore.ts | 30 +++++++++++++------ .../auth/store/crossTabMarkers.test.ts | 29 +++++++++++++++++- .../auth/store/legacySignInMarkers.test.ts | 18 +++++++++++ .../store/localSignOutStorageFailure.test.ts | 25 ++++++++++++++++ src/features/auth/store/signInMarkers.ts | 3 +- 6 files changed, 95 insertions(+), 12 deletions(-) diff --git a/src/features/auth/queries/getCurrentUser.ts b/src/features/auth/queries/getCurrentUser.ts index 0a6134bd5..d0ffc88d8 100644 --- a/src/features/auth/queries/getCurrentUser.ts +++ b/src/features/auth/queries/getCurrentUser.ts @@ -26,7 +26,7 @@ export async function getCurrentUser(): Promise { authStore.getSignOutGeneration(OverallAppSignIn) === signOutGeneration && authStore.getSignInRevision(OverallAppSignIn) === signInRevision ) { - authStore.setUserForEntity(OverallAppSignIn, user); + authStore.syncAppUser(user); } return user; } diff --git a/src/features/auth/store/authStore.ts b/src/features/auth/store/authStore.ts index 77bafe52c..ee612bfcd 100644 --- a/src/features/auth/store/authStore.ts +++ b/src/features/auth/store/authStore.ts @@ -372,8 +372,7 @@ class AuthStore { ): void { if (user) { this.signInRevisions.set(id, (this.signInRevisions.get(id) ?? 0) + 1); - // getCurrentUser re-signs the app in on every fetch, so for it this is a check. - this.flagKeyAsSignedIn(id, key, id === OverallAppSignIn); + this.flagKeyAsSignedIn(id, key); this.updateConnectionIfChanged(id, false, user); } else { // Clears the API explorer's stored credentials too, or the next user to sign into this entity @@ -382,6 +381,15 @@ class AuthStore { } } + /** + * A `/User/current` answer: the current session's fuller record, not a new sign-in. So, like a check, it writes no + * marker this tab already holds and leaves the sign-in revision alone. + */ + public syncAppUser(user: User): void { + this.flagKeyAsSignedIn(OverallAppSignIn, OverallAppSignIn, true); + this.updateConnectionIfChanged(OverallAppSignIn, false, user); + } + public updateUserForEntity(entity: EntityTypes, changes: Partial): void { const id = this.calculateIdFromEntity(entity); if (!id) { @@ -470,15 +478,15 @@ class AuthStore { return value === 'fabric' || value === 'direct' ? value : undefined; } - /** - * Moves on every sign-out of `id`, and of everything. An auth resolution reads it before its first - * await and commits only if it has not moved, so a result that outlived its session is dropped. - */ /** Advances at every sign-in, so an answer fetched before one can tell it is about a session that ended. */ public getSignInRevision(id: EntityIds): number { return this.signInRevisions.get(id) ?? 0; } + /** + * Moves on every sign-out of `id`, and of everything. An auth resolution reads it before its first + * await and commits only if it has not moved, so a result that outlived its session is dropped. + */ public getSignOutGeneration(id: EntityIds): number { return (this.signOutGenerations.get(id) ?? 0) + (this.signOutGenerations.get('*') ?? 0); } @@ -1003,11 +1011,15 @@ class AuthStore { return; } this.potentiallyAuthenticated[id] = key; - // This sign-in replaces whatever removal of its marker was still owed. - this.unremovedSignInMarkers.delete(signInMarkerKey(id)); + const markerKey = signInMarkerKey(id); + // A marker storage refused to remove that already holds this URL is this sign-in's, even if the write below fails. + if (this.unremovedSignInMarkers.get(markerKey) === key) { + this.unremovedSignInMarkers.delete(markerKey); + } this.retryUnremovedSignInMarkers(); try { - this.writeStoredItem(signInMarkerKey(id), key); + this.writeStoredItem(markerKey, key); + this.unremovedSignInMarkers.delete(markerKey); this.unrecordedSignIns.delete(id); } catch (error) { // Not thrown: the sign-in itself worked, and unrecorded it only looks signed out on the next load. diff --git a/src/features/auth/store/crossTabMarkers.test.ts b/src/features/auth/store/crossTabMarkers.test.ts index 2540d534e..36f037dd4 100644 --- a/src/features/auth/store/crossTabMarkers.test.ts +++ b/src/features/auth/store/crossTabMarkers.test.ts @@ -134,11 +134,38 @@ describe('the app sign-in', () => { signInApp({ id: 'usr_a' }); localStorage.removeItem(signInMarkerKey(OverallAppSignIn)); - signInApp({ id: 'usr_a' }); + authStore.syncAppUser({ id: 'usr_a' } as never); expect(storedSignInMarkers()).toEqual({}); }); + it('records an explicit sign-in again, even while this tab still holds the marker', () => { + signInApp({ id: 'usr_a' }); + localStorage.removeItem(signInMarkerKey(OverallAppSignIn)); + + signInApp({ id: 'usr_a' }); + + expect(storedSignInMarkers()).toEqual({ [OverallAppSignIn]: OverallAppSignIn }); + }); + + it('still applies its own answer when a /User/current answer lands while it is out', async () => { + signInApp({ id: 'usr_a' }); + let answer: (user: unknown) => void = () => undefined; + fetchCurrentUser.mockReturnValue( + new Promise((resolve) => { + answer = resolve; + }), + ); + const check = authStore.reloadUser(OverallAppSignIn); + const checked = { id: 'usr_a', checked: true }; + + authStore.syncAppUser({ id: 'usr_a', synced: true } as never); + answer(checked); + await check; + + expect(authStore.getConnectionById(OverallAppSignIn).user).toBe(checked); + }); + it('stays signed in when a check that was out during a new sign-in fails', async () => { signInApp({ id: 'usr_a' }); let answer: (reason: unknown) => void = () => undefined; diff --git a/src/features/auth/store/legacySignInMarkers.test.ts b/src/features/auth/store/legacySignInMarkers.test.ts index 852cbf2a6..c87a363e8 100644 --- a/src/features/auth/store/legacySignInMarkers.test.ts +++ b/src/features/auth/store/legacySignInMarkers.test.ts @@ -179,6 +179,24 @@ describe('a later load', () => { expect(next.getOperationsUrl('ins-a')).toBeUndefined(); }); + it("doesn't sign back in on a write-back when it can't read the migration flag", async () => { + writeFromPreviousRelease({ 'ins-a': url('ins-a') }); + const store = await loadPage(); + store.signOutLocally('ins-a'); + writeFromPreviousRelease({ 'ins-a': url('ins-a') }); + const getItem = Storage.prototype.getItem; + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(function(this: Storage, key: string) { + if (key === 'Studio:PotentiallyAuthenticatedMigrated') { + throw new DOMException('Storage refused.', 'SecurityError'); + } + return getItem.call(this, key); + }); + + const next = await loadPage(); + + expect(next.getOperationsUrl('ins-a')).toBeUndefined(); + }); + it('still removes a legacy record that is not one', async () => { await loadPage(); localStorage.setItem(legacySignInMarkersKey, JSON.stringify([url('ins-a')])); diff --git a/src/features/auth/store/localSignOutStorageFailure.test.ts b/src/features/auth/store/localSignOutStorageFailure.test.ts index df440556c..b79181ff0 100644 --- a/src/features/auth/store/localSignOutStorageFailure.test.ts +++ b/src/features/auth/store/localSignOutStorageFailure.test.ts @@ -203,6 +203,31 @@ describe('authStore local sign-out when storage writes throw', () => { ['ins-v', 'ins-w'].forEach((id) => authStore.signOutLocally(id)); }); + it('still owes the removal of a marker this tab signed back into at another URL while writes were refused', async () => { + authStore.setUserForIdAndKey('ins-x' as SetArgs[0], 'https://ins-x' as SetArgs[1], { username: 'u' } as SetArgs[2]); + failEveryStorageWrite(); + vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => { + throw new DOMException('Storage refused.', 'SecurityError'); + }); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + vi.spyOn(console, 'debug').mockImplementation(() => undefined); + authStore.signOutLocally('ins-x'); + authStore.setUserForIdAndKey( + 'ins-x' as SetArgs[0], + 'https://ins-x-2' as SetArgs[1], + { username: 'u' } as SetArgs[2], + ); + vi.restoreAllMocks(); + getInstanceUserInfo.mockRejectedValue(new Error('Network Error')); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + await authStore.reloadUser('ins-x'); + + signIn('ins-y'); + + expect(storedSignInMarkers()).not.toHaveProperty('ins-x'); + authStore.signOutLocally('ins-y'); + }); + it('still removes a marker it cannot read at sign-out', () => { signIn('ins-u'); const getItem = Storage.prototype.getItem; diff --git a/src/features/auth/store/signInMarkers.ts b/src/features/auth/store/signInMarkers.ts index 617436807..76e5e169c 100644 --- a/src/features/auth/store/signInMarkers.ts +++ b/src/features/auth/store/signInMarkers.ts @@ -75,7 +75,8 @@ export function migrateLegacySignInMarkers(): boolean { migrated = !!localStorage.getItem(legacyMigratedKey); raw = localStorage.getItem(legacySignInMarkersKey); } catch { - return migrated; + // Reported as migrated: the record could be a previous-release write-back, which must not seed this load. + return true; } const legacy = parseLegacySignInMarkers(raw); try {