refactor: write the item a panel config stands for without becoming it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vn1jCHUcAdaG9C4NsUehF
This commit is contained in:
Ruben Fiszel
2026-09-15 00:45:39 +02:00
co-authored by Claude Opus 5
parent 866050aaf6
commit 8478b4ea98
3 changed files with 113 additions and 367 deletions
@@ -177,9 +177,9 @@
let session = $state(0)
let fixedTemplate: ScheduleCfg | undefined = undefined
const newTemplates = new Map<string, NewScheduleOptions>()
// 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<string>()
// 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<ScheduleCfg> = {
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<ScheduleCfg>(
'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!,
+59 -90
View File
@@ -53,6 +53,8 @@ export type ItemWriteContext<V> = 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<V> {
@@ -180,15 +177,15 @@ class Entry<V> {
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<V> | 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<unknown> | undefined
/** The entries a save of this entry waits for before moving: a move skips waiting for any
@@ -240,12 +237,6 @@ class Entry<V> {
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<V> {
})
}
/** 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<CommandOutcome> {
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<V> {
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<V> {
...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<V> {
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<V> {
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<V>): 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<string, unknown>
const base = old.deployed as Record<string, unknown>
const next = snapshot(this.value) as Record<string, unknown>
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<V> {
}
discard(): Promise<DiscardOutcome> {
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<any>, from: ItemKey): void
claim(key: ItemKey, claimant: Entry<any>): { turn: Promise<unknown>; release: () => void }
moveSettled(entry: Entry<any>): Promise<void>
changed(key: ItemKey, by: Entry<any>): Promise<void>
}
export type ItemHandle<V> = {
@@ -693,6 +660,12 @@ export type ItemSpec<V> = {
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<string, Entry<any>>()
/** The latest save moving onto a key, by that key, and when its move is done. */
const moves = new Map<string, { owner: Entry<any>; done: Promise<void> }>()
/** 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<string, { value: unknown; at: number }>()
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<any>, into: Entry<any>): 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<V>(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()
+45 -270
View File
@@ -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<typeof createItemStore>,
open: () => void,
moving: ItemHandle<Res>
) => 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<Res> | 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<Sched>
)
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<Sched>
)
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')
})
})