From d3eb8ac0c42bb2b22b9cd823204bca119eb9f254 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Mon, 17 Aug 2026 16:33:05 +0200 Subject: [PATCH] fix(frontend): mark a resource claim by edited_at, not its creator Co-Authored-By: Claude Opus 5 (1M context) --- .../addDataTableModel.test.ts | 47 +++++++++++++++++++ .../workspaceSettings/addDataTableModel.ts | 20 +++++--- .../workspaceSettings/setupClaims.ts | 6 ++- 3 files changed, 66 insertions(+), 7 deletions(-) diff --git a/frontend/src/lib/components/workspaceSettings/addDataTableModel.test.ts b/frontend/src/lib/components/workspaceSettings/addDataTableModel.test.ts index 1f0f35f1de..a4969c0d14 100644 --- a/frontend/src/lib/components/workspaceSettings/addDataTableModel.test.ts +++ b/frontend/src/lib/components/workspaceSettings/addDataTableModel.test.ts @@ -53,6 +53,11 @@ function nothingThere() { getResourceMock.mockRejectedValue(new Error('not found')) } +/** A resource that exists, with the timestamp the claim is marked by. */ +function resourceEditedAt(at: string) { + getResourceMock.mockResolvedValue({ path: 'p', created_by: 'alice', edited_at: at }) +} + /** A wizard about to create the Supabase project `later`, in the organization `acme`. */ function creating(): WizardState { const state = newWizardState({ name: 'main', projectName: 'later', folder: 'f/team' }) @@ -259,3 +264,45 @@ describe('newResourceParts', () => { expect(newResourceParts(state)?.host).toBe('db.example.com') }) }) + +// `created_by` survives an update, so it cannot tell an edit by somebody else from no edit at +// all. The claim is marked by `edited_at`, which moves on every write. +describe('runSetup writing over a resource', () => { + function ownResource(): WizardState { + const state = newWizardState({ name: 'main', projectName: 'x', folder: 'f/team' }) + state.provider = 'resource' + state.own.creating = true + state.review.resourceName = 'db' + state.own.fields = { + host: 'h', + port: 5432, + dbname: 'd', + user: 'u', + password: 'p', + sslmode: 'require' + } + return state + } + + beforeEach(() => { + vi.clearAllMocks() + existsVariableMock.mockResolvedValue(false) + getVariableMock.mockRejectedValue(new Error('not found')) + getSettingsMock.mockResolvedValue({ datatable: { datatables: {} } }) + editDataTableConfigMock.mockResolvedValue(undefined) + testDataTableConnectionMock.mockResolvedValue({ can_create_table: true }) + }) + + it('refuses a resource edited since this run claimed it', async () => { + resourceEditedAt('2026-01-02T00:00:00Z') + const result = await runSetup(ownResource(), { + workspace: 'w', + onProgress: () => {}, + // Claimed when it looked like this; someone has written to it since. + claims: [{ kind: 'resource' as const, path: 'f/team/db', mark: '2026-01-01T00:00:00Z' }], + username: 'alice' + } as any) + expect(result.ok).toBe(false) + expect(result.error).toContain('f/team/db') + }) +}) diff --git a/frontend/src/lib/components/workspaceSettings/addDataTableModel.ts b/frontend/src/lib/components/workspaceSettings/addDataTableModel.ts index 19f074bee1..0bd3604205 100644 --- a/frontend/src/lib/components/workspaceSettings/addDataTableModel.ts +++ b/frontend/src/lib/components/workspaceSettings/addDataTableModel.ts @@ -499,12 +499,9 @@ async function writeResource( value: Record, description: string ): Promise { - const held = await ResourceService.getResource({ workspace: deps.workspace, path }).catch( - () => undefined - ) + const held = await resourceMark(deps, path) if (held) { - if (!stillOurs(claims, 'resource', path, held.created_by)) - throw new Error(pathTakenLate('resource', path)) + if (!stillOurs(claims, 'resource', path, held)) throw new Error(pathTakenLate('resource', path)) await ResourceService.updateResource({ workspace: deps.workspace, path, @@ -516,7 +513,18 @@ async function writeResource( requestBody: { resource_type: 'postgresql', path, value, description } }) } - return claim(claims, 'resource', path, deps.username) + // Read back rather than claim the username: `created_by` survives an update, so it cannot + // tell an edit by somebody else from no edit at all. `edited_at` moves on every write, which + // is what makes the next attempt able to see one that happened in between. + return claim(claims, 'resource', path, (await resourceMark(deps, path)) ?? deps.username) +} + +/** `undefined` when nothing is there. */ +async function resourceMark(deps: RunDeps, path: string): Promise { + const held = await ResourceService.getResource({ workspace: deps.workspace, path }).catch( + () => undefined + ) + return held ? (held.edited_at ?? held.created_by ?? '') : undefined } /** diff --git a/frontend/src/lib/components/workspaceSettings/setupClaims.ts b/frontend/src/lib/components/workspaceSettings/setupClaims.ts index 5385604ace..e24e73da44 100644 --- a/frontend/src/lib/components/workspaceSettings/setupClaims.ts +++ b/frontend/src/lib/components/workspaceSettings/setupClaims.ts @@ -16,7 +16,11 @@ export type ClaimKind = 'secret' | 'resource' | 'row' export type Claim = { kind: ClaimKind path: string - /** Compared against the live object: who last edited a secret or resource, what a row points at. */ + /** + * Compared against the live object. It has to move whenever anyone else writes: `edited_by` + * for a secret, `edited_at` for a resource — whose `created_by` survives an edit and so + * cannot tell one from no edit at all — and the target for a row. + */ mark: string }