mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user