From 8478b4ea98551701f868b28f17aed09b949055ae Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 15 Sep 2026 00:45:39 +0200 Subject: [PATCH] refactor: write the item a panel config stands for without becoming it Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015vn1jCHUcAdaG9C4NsUehF --- .../schedules/ScheduleEditorInner.svelte | 16 +- frontend/src/lib/itemStore.svelte.ts | 149 ++++----- frontend/src/lib/itemStore.test.ts | 315 +++--------------- 3 files changed, 113 insertions(+), 367 deletions(-) diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index b56e8db4e1..6fa9f0e50e 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -177,9 +177,9 @@ let session = $state(0) let fixedTemplate: ScheduleCfg | undefined = undefined const newTemplates = new Map() - // Temporary keys holding a config the caller supplied for a schedule that already exists: - // saving one updates that schedule rather than creating it. - const standsForDeployed = new Set() + // The deployed schedule a caller-supplied config describes: saving updates that schedule, and + // the item stays under its temporary key, so the caller's draft is not the schedule's own. + let standsFor: string | undefined = undefined const scheduleAdapter: ItemAdapter = { settles: true, @@ -206,8 +206,8 @@ throw err } }, - async write({ workspace, path, value, deployed }) { - const update = deployed !== undefined || standsForDeployed.has(path) + async write({ workspace, value, deployed, standsFor: stands }) { + const update = deployed !== undefined || stands !== undefined await writeScheduleCfg(value, update, workspace) return scheduleCfgAfterWrite(value, update, deployed) } @@ -215,7 +215,7 @@ const item = useItem( 'trigger_schedule', - () => ({ workspace: wsId, path: itemPath, session, template: fixedTemplate }), + () => ({ workspace: wsId, path: itemPath, session, template: fixedTemplate, standsFor }), scheduleAdapter ) @@ -327,11 +327,12 @@ mode = 'fixed' fixedTemplate = normalizeScheduleCfg({ ...defaultCfg, path: defaultCfg.path ?? ePath }) itemPath = newItemPath() - standsForDeployed.add(itemPath) + standsFor = ePath } else { mode = 'edit' fixedTemplate = undefined itemPath = ePath + standsFor = undefined } session++ } @@ -355,6 +356,7 @@ ? '' : (defaultValues?.path ?? (trigger?.isPrimary ? initialScriptPath : '')) fixedTemplate = undefined + standsFor = undefined const temporary = newItemPath() newTemplates.set(temporary, { workspace: wsId!, diff --git a/frontend/src/lib/itemStore.svelte.ts b/frontend/src/lib/itemStore.svelte.ts index 71856c58c7..e094669be2 100644 --- a/frontend/src/lib/itemStore.svelte.ts +++ b/frontend/src/lib/itemStore.svelte.ts @@ -53,6 +53,8 @@ export type ItemWriteContext = ItemKey & { value: V /** `undefined` when the write must create rather than update. */ deployed: V | undefined + /** The deployed item this value describes, when it is not the one at `path`. */ + standsFor: string | undefined meta: unknown } @@ -138,11 +140,6 @@ function errorMessage(e: unknown): string { * item's revision for another's. */ let nextRevision = 1 -/** Stamps every write and discard asked of an item, so the one asked last can be told from the - * one that reaches it last: a draft parked for a save moving onto a key arrives after asks the - * entry there has already recorded. */ -let nextAsk = 1 - const superseded = { ok: false, error: 'Another save of this item replaced this one' } as const class Entry { @@ -180,15 +177,15 @@ class Entry { settles = false absorbs: ((next: V, deployed: V) => boolean) | undefined + /** The deployed item this one's value describes, for an entry kept under a temporary key. */ + standsFor: string | undefined + /** How this item is read and written, from whoever opened it first. */ + adapter: ItemAdapter | undefined refs = 0 disposed = false /** Replaced by an entry that moved onto its key: it no longer owns the row there, and a write * reaching its turn does nothing, as its handles show the item that replaced it. */ retired = false - /** Discards asked and still waiting for their turn, and the last ask made of this item, stamped - * when it was made: of a write and a discard, the one asked later is the one that holds. */ - discardsAsked = 0 - lastAsk: { kind: 'write' | 'discard'; at: number } | undefined /** The command running now, past its turn: what a move onto this key waits for. */ running: Promise | undefined /** The entries a save of this entry waits for before moving: a move skips waiting for any @@ -240,12 +237,6 @@ class Entry { this.reconcile() } - /** Keep the latest of the asks made of this item, by when each was asked. */ - private recordAsk(kind: 'write' | 'discard', at: number): void { - if (at < (this.lastAsk?.at ?? 0)) return - this.lastAsk = { kind, at } - } - /** Mirror `dirty ? value : null` to the draft row. The only place a row is written. */ reconcile(): void { const key = this.key @@ -356,15 +347,15 @@ class Entry { }) } + /** Read the item again after someone else wrote it. The edits on screen are in the draft row, + * so they come back over what was written; one typed since the read was asked for stays. */ + reread(): Promise { + if (!this.loaded || this.retired || !this.adapter) return Promise.resolve({ ok: true }) + return this.load(this.adapter) + } + /** An outside write (the AI chat, another editor): a real divergence, never settling. */ - /** `askedAt`: when the write was made, for one that reaches this entry later than it was asked - * (parked while a move was heading here, or carried over from the entry a move replaced). */ - applyExternal(value: V, askedAt: number = nextAsk++): number { - // An ask older than the one that holds changes nothing, value included. - if (askedAt < (this.lastAsk?.at ?? 0)) return this.revision - // Before the unchanged-value return: re-writing the same draft is still an ask, and which - // ask came last is what outranks a discard still waiting for its turn. - this.recordAsk('write', askedAt) + applyExternal(value: V): number { if (this.loaded && serialize(value) === serialize(this.value)) return this.revision this.pristine = false this.removed = false @@ -400,9 +391,14 @@ class Entry { if (this.origin === 'deployed' && serialize(sent) === serialize(this.deployed)) { return { ok: true, path: from.path, moved: false } } - const to = (adapter.pathOf ?? ((v: V) => (v as { path: string }).path))(sent) - const moved = to !== from.path - const claim = moved ? this.store.claim({ ...from, path: to }, this) : undefined + // Where the write lands, and whether this entry becomes the item there: one that stands + // for a deployed item writes it and stays where it is. + const to = + this.standsFor ?? (adapter.pathOf ?? ((v: V) => (v as { path: string }).path))(sent) + const elsewhere = to !== from.path + const moved = elsewhere && this.standsFor === undefined + const claim = elsewhere ? this.store.claim({ ...from, path: to }, this) : undefined + let wrote = false try { await claim?.turn if (this.retired) return superseded @@ -414,12 +410,14 @@ class Entry { ...from, value: sent, deployed: this.origin === 'deployed' ? snapshot(this.deployed) : undefined, + standsFor: this.standsFor, meta: this.meta })) ?? sent } catch (e) { this.error = errorMessage(e) return { ok: false, error: this.error } } + wrote = true this.error = undefined if (held !== sent) this.adopt(sent, held) this.deployed = { ...held, ...this.patched } as V @@ -428,10 +426,13 @@ class Entry { if (moved) this.moveTo(to) this.reconcile() await this.settleRows(moved ? [from, this.key] : [this.key]) - return { ok: true, path: to, moved } } finally { + // Before telling the key: whoever re-reads it takes its turn there, which this holds. claim?.release() } + // The item at `to` has changed under whoever else is showing it. + if (elsewhere && wrote) await this.store.changed({ ...from, path: to }, this) + return { ok: true, path: to, moved } } if (!started) return this.run(body) const result = this.queue.then(() => this.turn(body)).finally(started) @@ -439,35 +440,6 @@ class Entry { return result } - /** Take over the edits of a deployed entry this one replaced at its key: each field its value - * changed from its own deployed side, so they stay a draft over what this entry deployed. - * Compared exactly, as a save is: a field the draft comparison ignores (run-as) is an edit. */ - carryEditsOf(old: Entry): void { - // A discard asked before the move landed wins over the edits it was asked to drop; had it - // run first, it would have dropped anything typed after it too. - if (old.value === undefined || this.value === undefined) return - if (old.discardsAsked > 0 && old.lastAsk?.kind !== 'write') return - // Not loaded yet (its read waits behind the move): only an outside write can have filled it, - // and nothing has persisted that write but this entry. - if (!old.loaded) { - this.applyExternal(old.value, old.lastAsk?.at) - return - } - if (old.origin !== 'deployed' || old.deployed === undefined) return - const edited = old.value as Record - const base = old.deployed as Record - const next = snapshot(this.value) as Record - let changed = false - for (const k of new Set([...Object.keys(edited), ...Object.keys(base)])) { - if (deepEqual(edited[k], base[k])) continue - next[k] = snapshot(edited[k]) - changed = true - } - if (!changed) return - this.pristine = false - this.replaceValue(next as V) - } - /** Give the value each field the server set otherwise than `sent` asked, unless the user has * changed that field since: left as sent, it would read as a draft of a change nobody made. */ private adopt(sent: V, held: V): void { @@ -485,13 +457,7 @@ class Entry { } discard(): Promise { - this.discardsAsked++ - this.recordAsk('discard', nextAsk++) return this.run(async () => { - this.discardsAsked-- - // A draft written again after this discard was asked outranks it, however the move it - // waited for turned out: the ask that came last is the one that holds. - if (this.lastAsk?.kind === 'write') return { removed: false } if (!this.loaded || this.retired) return { removed: false } if (this.origin === 'draft') { const kept = snapshot(this.value) @@ -653,6 +619,7 @@ type StoreInternals = { rekey(entry: Entry, from: ItemKey): void claim(key: ItemKey, claimant: Entry): { turn: Promise; release: () => void } moveSettled(entry: Entry): Promise + changed(key: ItemKey, by: Entry): Promise } export type ItemHandle = { @@ -693,6 +660,12 @@ export type ItemSpec = { template?: V /** Tells two openings of the same item apart: a new value lets an idle item be read afresh. */ session?: unknown + /** + * This item's value describes the deployed item at this path, from a config its caller holds + * (a runnable's trigger panel): saving writes that item without becoming it. Kept under its own + * temporary key, so the caller's draft stays the caller's and one entry owns the item's row. + */ + standsFor?: string valid?: () => boolean writable?: () => boolean } @@ -794,11 +767,6 @@ export function createItemStore(ports: ItemRowPort) { const entries = new Map>() /** The latest save moving onto a key, by that key, and when its move is done. */ const moves = new Map; done: Promise }>() - /** Drafts written from outside at a key while a save is moving onto it, stamped when they were - * written: newer than that write, so the entry arriving there takes them rather than clearing - * the row it finds, unless it has been asked something later still. */ - const arrivals = new Map() - const internals: StoreInternals = { release(entry) { entry.refs-- @@ -816,13 +784,6 @@ export function createItemStore(ports: ItemRowPort) { const displaced = entries.get(to) if (displaced && displaced !== entry) retire(displaced, entry) entries.set(to, entry) - const arrived = arrivals.get(to) - if (arrived !== undefined) { - arrivals.delete(to) - // It was asked when it was parked, not now: whichever of it and the asks this entry - // recorded meanwhile came last is the one that holds. - entry.applyExternal(arrived.value, arrived.at) - } }, /** * A save about to write the item at `key` and move onto it. It goes after the command @@ -852,12 +813,25 @@ export function createItemStore(ports: ItemRowPort) { turn: Promise.all(waits).then(() => (claimant.waitingOn = [])), release() { release() - if (moves.get(k) !== move) return - moves.delete(k) - arrivals.delete(k) + if (moves.get(k) === move) moves.delete(k) } } }, + /** + * The item at `key` was written by another entry. Whoever holds it re-reads it: the edits on + * screen there are in its draft row, so they come back over what was just written. With + * nobody there, the row that is left predates the write and goes. + */ + async changed(key, by) { + const holder = entries.get(keyString(key)) + if (holder === by) return + if (holder) { + await holder.reread() + return + } + ports.write(key, null) + await ports.flush(key) + }, async moveSettled(entry) { let move = moves.get(keyString(entry.key)) while (move && move.owner !== entry) { @@ -880,12 +854,11 @@ export function createItemStore(ports: ItemRowPort) { } /** - * An entry moved onto a key another live entry holds. One key has one row writer, so the - * old one stops writing and every handle on it moves to the entry that now is the item. Its - * deployed side is superseded by the write; the edits on screen over it are not. + * An entry moved onto a key another live entry holds: a create or a rename onto the path a + * draft-only item occupies, which the write supersedes. One key has one row writer, so the old + * one stops writing and every handle on it moves to the entry that now is the item. */ function retire(old: Entry, into: Entry): void { - into.carryEditsOf(old) old.retired = true old.stopWatch?.() for (const handle of old.handles) { @@ -924,6 +897,8 @@ export function createItemStore(ports: ItemRowPort) { entry = new Entry(key, ports, internals) entry.settles = adapter.settles ?? false entry.absorbs = adapter.absorbs + entry.adapter = adapter + entry.standsFor = spec.standsFor entries.set(k, entry) if (isTemporaryPath(key.path) && spec.template !== undefined) entry.initNew(spec.template) else void entry.load(adapter) @@ -981,12 +956,9 @@ export function createItemStore(ports: ItemRowPort) { seed(workspace: string, kind: UserDraftItemKind, path: string, value: unknown): boolean { if (value === undefined || value === null) return false const entry = find(workspace, kind, path) - if (!entry) { - // Persisted as usual, and handed to the save moving onto this key when it lands. - const k = keyString({ workspace, kind: kind as ItemKind, path }) - if (moves.has(k)) arrivals.set(k, { value: snapshot(value), at: nextAsk++ }) - return false - } + // With nobody holding the key, the caller persists it as a row; an entry arriving there + // reads it like any other draft. + if (!entry) return false entry.applyExternal(value) return true }, @@ -1001,9 +973,6 @@ export function createItemStore(ports: ItemRowPort) { return { value: entry.dirty && entry.origin !== 'new' ? snapshot(entry.value) : undefined } }, discard(workspace: string, kind: UserDraftItemKind, path: string): boolean { - // The discard is asked for the key, whoever holds it: a draft parked for a save moving - // onto that key goes with it, however it was parked. - arrivals.delete(keyString({ workspace, kind: kind as ItemKind, path })) const entry = find(workspace, kind, path) if (!entry) return false void entry.discard() diff --git a/frontend/src/lib/itemStore.test.ts b/frontend/src/lib/itemStore.test.ts index d8f44ac161..f75b751afc 100644 --- a/frontend/src/lib/itemStore.test.ts +++ b/frontend/src/lib/itemStore.test.ts @@ -500,284 +500,59 @@ describe('item store: one entry per key', () => { ]) }) - it('keeps what was typed in an editor a move replaces, as a draft over what it wrote', async () => { + it('writes the item it stands for without becoming it', async () => { const rows = fakeRows() const store = createItemStore(rows.port) - const gate = deferred() const b = { ...deployedRes, path: 'u/me/b' } + const temporary = newItemPath() + const a = adapter({}) + const { handle: panel } = store.acquire( + { workspace: 'w', kind: 'resource', path: temporary }, + { workspace: 'w', path: temporary, template: b, standsFor: 'u/me/b' }, + a + ) + await settle() + panel.value = { ...b, description: 'from the panel' } + + expect(await panel.save()).toMatchObject({ ok: true, path: 'u/me/b', moved: false }) + expect(a.writes[0].standsFor).toBe('u/me/b') + // Still its own item under its own key, and a temporary key never holds a row: the config + // it was opened on stays its caller's draft, not the item's. The only row it touches is the + // one left at the item it wrote, which predates that write. + expect(panel.key.path).toBe(temporary) + expect(rows.writes).toEqual([{ path: 'u/me/b', value: null }]) + }) + + it('has whoever holds an item re-read it when someone else writes it', async () => { + const rows = fakeRows() + const store = createItemStore(rows.port) + const b = { ...deployedRes, path: 'u/me/b' } + // What the server holds: the editor's own edits reach it as its draft row. + let deployed = b + let draft: Res | undefined const { handle: open } = store.acquire( { workspace: 'w', kind: 'resource', path: 'u/me/b' }, { workspace: 'w', path: 'u/me/b' }, - adapter({ deployed: b }) - ) - const temporary = newItemPath() - const { handle: moving } = store.acquire( - { workspace: 'w', kind: 'resource', path: temporary }, - { workspace: 'w', path: temporary, template: b }, - adapter({}, () => gate.promise) + adapter(async () => ({ deployed, draft })) ) await settle() - moving.value = { ...b, args: { a: 2 } } - const moved = moving.save() - await settle() - open.value = { ...b, description: 'typed while it moved' } + open.value = { ...b, description: 'typed in the open editor' } + draft = { ...b, description: 'typed in the open editor' } - gate.resolve() - expect(await moved).toMatchObject({ ok: true, moved: true }) - const kept = { ...b, args: { a: 2 }, description: 'typed while it moved' } + const temporary = newItemPath() + const { handle: panel } = store.acquire( + { workspace: 'w', kind: 'resource', path: temporary }, + { workspace: 'w', path: temporary, template: b, standsFor: 'u/me/b' }, + adapter({}, async (ctx) => void (deployed = { ...ctx.value })) + ) + await settle() + panel.value = { ...b, args: { a: 2 } } + expect(await panel.save()).toMatchObject({ ok: true, path: 'u/me/b' }) + + // The write landed under it, and its own edits came back over it from its row. expect(open.deployed).toEqual({ ...b, args: { a: 2 } }) - expect(open.value).toEqual(kept) - expect(rows.writes.at(-1)).toEqual({ path: 'u/me/b', value: kept }) - }) - - it('keeps a draft written from outside at the key a save is moving onto', async () => { - const rows = fakeRows() - const store = createItemStore(rows.port) - const gate = deferred() - const b = { ...deployedRes, path: 'u/me/b' } - const temporary = newItemPath() - const { handle: moving } = store.acquire( - { workspace: 'w', kind: 'resource', path: temporary }, - { workspace: 'w', path: temporary, template: b }, - adapter({}, () => gate.promise) - ) - await settle() - moving.value = { ...b, args: { a: 2 } } - const moved = moving.save() - await settle() - const chat = { ...b, description: 'written by the chat meanwhile' } - // Nobody holds the key yet, so the caller persists it itself. - expect(store.bridge.seed('w', 'resource', 'u/me/b', chat)).toBe(false) - - gate.resolve() - expect(await moved).toMatchObject({ ok: true, moved: true }) - expect(moving.deployed).toEqual({ ...b, args: { a: 2 } }) - expect(moving.value).toEqual(chat) - expect(rows.writes.at(-1)).toEqual({ path: 'u/me/b', value: chat }) - }) - - it('keeps a draft written from outside to an editor that opened during the move', async () => { - const rows = fakeRows() - const store = createItemStore(rows.port) - const gate = deferred() - const b = { ...deployedRes, path: 'u/me/b' } - const temporary = newItemPath() - const { handle: moving } = store.acquire( - { workspace: 'w', kind: 'resource', path: temporary }, - { workspace: 'w', path: temporary, template: b }, - adapter({}, () => gate.promise) - ) - await settle() - moving.value = { ...b, args: { a: 2 } } - const moved = moving.save() - await settle() - const { handle: opened } = store.acquire( - { workspace: 'w', kind: 'resource', path: 'u/me/b' }, - { workspace: 'w', path: 'u/me/b' }, - adapter({ deployed: b }) - ) - const chat = { ...b, description: 'written by the chat meanwhile' } - // The opened editor holds the key, so the store persists it. - expect(store.bridge.seed('w', 'resource', 'u/me/b', chat)).toBe(true) - - gate.resolve() - expect(await moved).toMatchObject({ ok: true, moved: true }) - expect(opened.value).toEqual(chat) - expect(rows.writes.at(-1)).toEqual({ path: 'u/me/b', value: chat }) - }) - - it('lets a discard asked before a move lands win over the edits it drops', async () => { - const rows = fakeRows() - const store = createItemStore(rows.port) - const gate = deferred() - const b = { ...deployedRes, path: 'u/me/b' } - const temporary = newItemPath() - const { handle: moving } = store.acquire( - { workspace: 'w', kind: 'resource', path: temporary }, - { workspace: 'w', path: temporary, template: b }, - adapter({}, () => gate.promise) - ) - await settle() - moving.value = { ...b, args: { a: 2 } } - const moved = moving.save() - await settle() - const { handle: opened } = store.acquire( - { workspace: 'w', kind: 'resource', path: 'u/me/b' }, - { workspace: 'w', path: 'u/me/b' }, - adapter({ deployed: b }) - ) - store.bridge.seed('w', 'resource', 'u/me/b', { ...b, description: 'written by the chat' }) - store.bridge.discard('w', 'resource', 'u/me/b') - - gate.resolve() - expect(await moved).toMatchObject({ ok: true, moved: true }) - expect(opened.value).toEqual({ ...b, args: { a: 2 } }) - expect(opened.dirty).toBe(false) - expect(rows.writes.at(-1)).toEqual({ path: 'u/me/b', value: null }) - }) - - it('keeps a draft written again after a discard, and drops one parked for the move', async () => { - const b = { ...deployedRes, path: 'u/me/b' } - const moveOnto = async ( - openBefore: boolean, - outside: ( - store: ReturnType, - open: () => void, - moving: ItemHandle - ) => void, - refuse?: string - ) => { - const rows = fakeRows() - const store = createItemStore(rows.port) - const gate = deferred() - const temporary = newItemPath() - const { handle: moving } = store.acquire( - { workspace: 'w', kind: 'resource', path: temporary }, - { workspace: 'w', path: temporary, template: b }, - adapter({}, async () => { - await gate.promise - if (refuse) throw new Error(refuse) - }) - ) - await settle() - moving.value = { ...b, args: { a: 2 } } - const moved = moving.save() - await settle() - let opened: ItemHandle | undefined - const open = () => { - opened = store.acquire( - { workspace: 'w', kind: 'resource', path: 'u/me/b' }, - { workspace: 'w', path: 'u/me/b' }, - adapter({ deployed: b }) - ).handle - } - if (openBefore) open() - outside(store, open, moving) - gate.resolve() - expect(await moved).toMatchObject(refuse ? { ok: false } : { ok: true, moved: true }) - await settle() - // A refused move leaves the item where it was, with the editor that opened on it. - return { moving, shown: refuse ? opened! : moving, rows } - } - // Deleted, then written again: the newer draft is the one that survives the move. - const again = { ...b, description: 'written again after the delete' } - const first = await moveOnto(true, (store) => { - store.bridge.seed('w', 'resource', 'u/me/b', { ...b, description: 'first draft' }) - store.bridge.discard('w', 'resource', 'u/me/b') - store.bridge.seed('w', 'resource', 'u/me/b', again) - }) - expect(first.moving.value).toEqual(again) - expect(first.rows.writes.at(-1)).toEqual({ path: 'u/me/b', value: again }) - - // The same draft written again is an ask of its own, unchanged value or not. - const same = await moveOnto(true, (store) => { - store.bridge.seed('w', 'resource', 'u/me/b', again) - store.bridge.discard('w', 'resource', 'u/me/b') - store.bridge.seed('w', 'resource', 'u/me/b', again) - }) - expect(same.moving.value).toEqual(again) - expect(same.rows.writes.at(-1)).toEqual({ path: 'u/me/b', value: again }) - - // The move failing changes none of that: the last ask is still the one that holds. - const failed = await moveOnto( - true, - (store) => { - store.bridge.seed('w', 'resource', 'u/me/b', { ...b, description: 'first draft' }) - store.bridge.discard('w', 'resource', 'u/me/b') - store.bridge.seed('w', 'resource', 'u/me/b', again) - }, - 'refused by the server' - ) - expect(failed.shown.value).toEqual(again) - expect(failed.rows.writes.at(-1)).toEqual({ path: 'u/me/b', value: again }) - - // A draft parked for the move, then Discard clicked on the editor doing the moving: the - // discard came last, so the parked draft does not come back with the item. - const discardedAfterParking = await moveOnto(false, (store, open, moving) => { - store.bridge.seed('w', 'resource', 'u/me/b', { ...b, description: 'parked draft' }) - open() - void moving.discard() - }) - expect(discardedAfterParking.moving.dirty).toBe(false) - expect(discardedAfterParking.rows.writes.at(-1)).toEqual({ path: 'u/me/b', value: null }) - - // The other way round: Discard first, then a draft parked for the move. The parked draft was - // asked last, so it is what the item arrives with. - const parkedAfterDiscard = await moveOnto(false, (store, _open, moving) => { - void moving.discard() - store.bridge.seed('w', 'resource', 'u/me/b', again) - }) - expect(parkedAfterDiscard.moving.value).toEqual(again) - expect(parkedAfterDiscard.rows.writes.at(-1)).toEqual({ path: 'u/me/b', value: again }) - - // A draft parked for the move, then a newer one written to the editor that opened over it: - // the newer one holds, and the parked one does not come back with the item. - const newerThanParked = { ...b, description: 'written after the parked one' } - const parkedThenWritten = await moveOnto(false, (store, open) => { - store.bridge.seed('w', 'resource', 'u/me/b', { ...b, description: 'parked draft' }) - open() - store.bridge.seed('w', 'resource', 'u/me/b', newerThanParked) - }) - expect(parkedThenWritten.moving.value).toEqual(newerThanParked) - expect(parkedThenWritten.rows.writes.at(-1)).toEqual({ path: 'u/me/b', value: newerThanParked }) - - // A draft written into the editor loading at the destination, then Discard on the mover: the - // discard came last, so the draft it carries over does not survive it either. - const writtenThenDiscarded = await moveOnto(false, (store, open, moving) => { - open() - store.bridge.seed('w', 'resource', 'u/me/b', { ...b, description: 'chat draft' }) - void moving.discard() - }) - expect(writtenThenDiscarded.moving.dirty).toBe(false) - expect(writtenThenDiscarded.rows.writes.at(-1)).toEqual({ path: 'u/me/b', value: null }) - - // Deleted last: the delete is what holds, however many drafts preceded it. - const deleted = await moveOnto(true, (store) => { - store.bridge.seed('w', 'resource', 'u/me/b', { ...b, description: 'first draft' }) - store.bridge.discard('w', 'resource', 'u/me/b') - store.bridge.seed('w', 'resource', 'u/me/b', again) - store.bridge.discard('w', 'resource', 'u/me/b') - }) - expect(deleted.moving.dirty).toBe(false) - expect(deleted.rows.writes.at(-1)).toEqual({ path: 'u/me/b', value: null }) - - // Parked for the move before anyone held the key, then deleted once an editor does: the - // discard is asked for the key, so the parked draft goes with it. - const second = await moveOnto(false, (store, open) => { - store.bridge.seed('w', 'resource', 'u/me/b', { ...b, description: 'parked draft' }) - open() - store.bridge.discard('w', 'resource', 'u/me/b') - }) - expect(second.moving.dirty).toBe(false) - expect(second.rows.writes.at(-1)).toEqual({ path: 'u/me/b', value: null }) - }) - - it('keeps a change the draft comparison ignores in an editor a move replaces', async () => { - type Sched = { path: string; summary: string; permissioned_as?: string } - const rows = fakeRows() - const store = createItemStore(rows.port) - const s: Sched = { path: 's', summary: '', permissioned_as: 'u/a' } - const { handle: open } = store.acquire( - { workspace: 'w', kind: 'trigger_schedule', path: 's' }, - { workspace: 'w', path: 's' }, - { - load: async () => ({ deployed: structuredClone(s) }), - write: async () => {} - } as ItemAdapter - ) - const temporary = newItemPath() - const { handle: moving } = store.acquire( - { workspace: 'w', kind: 'trigger_schedule', path: temporary }, - { workspace: 'w', path: temporary, template: s }, - { write: async () => {} } as ItemAdapter - ) - await settle() - open.value = { ...s, permissioned_as: 'u/b' } - expect(open.dirty).toBe(false) - moving.value = { ...s, summary: 'moved' } - - expect(await moving.save()).toMatchObject({ ok: true, moved: true }) - expect(open.value).toEqual({ ...s, summary: 'moved', permissioned_as: 'u/b' }) + expect(open.value).toEqual(draft) + expect(open.dirty).toBe(true) }) it('writes an item it moves onto after the saves queued there, and supersedes later ones', async () => { @@ -897,9 +672,9 @@ describe('item store: one entry per key', () => { expect(await secondMove).toMatchObject({ ok: true, moved: true }) expect(await later).toMatchObject({ ok: false }) expect(order).toEqual(['first', 'second']) + // Its handle follows the item to the entry that now holds the key. expect(first.deployed?.description).toBe('second') - // Not written, but not lost either: a draft over what the later move wrote. - expect(first.value?.description).toBe('saved again through the first') + expect(first.value?.description).toBe('second') }) })