fix(frontend): mark a resource claim by edited_at, not its creator

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-08-17 16:33:05 +02:00
parent 0134899007
commit d3eb8ac0c4
3 changed files with 66 additions and 7 deletions
@@ -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')
})
})
@@ -499,12 +499,9 @@ async function writeResource(
value: Record<string, any>,
description: string
): Promise<Claims> {
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<string | undefined> {
const held = await ResourceService.getResource({ workspace: deps.workspace, path }).catch(
() => undefined
)
return held ? (held.edited_at ?? held.created_by ?? '') : undefined
}
/**
@@ -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
}