fix(copilot): read an artifact inside the transaction that revises it (#10647)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
AlexRV12
2026-08-12 02:13:29 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent a09df8a130
commit 66c0d1251d
3 changed files with 202 additions and 33 deletions
@@ -1,6 +1,6 @@
// Scoped by sessionId (fixed for the session's life), not chatId: a session follows its
// active chat's rotation, so chatId-keying would drop artifacts on each new conversation.
import { type DBSchema as IDBSchema, type IDBPObjectStore } from 'idb'
import { type DBSchema as IDBSchema, type IDBPObjectStore, type IDBPTransaction } from 'idb'
import { userScopedDb } from '$lib/userScopedDb'
export type ArtifactKind = 'md' | 'html'
@@ -135,6 +135,11 @@ export async function listArtifactsForSession(sessionId: string): Promise<Persis
}
}
export interface ArtifactEdit {
artifact: PersistedArtifact
snapshots: ArtifactVersion[]
}
/**
* Write an artifact and the snapshots that edit produced in one transaction.
*
@@ -151,11 +156,7 @@ export async function putArtifactWithVersions(
if (!db) return
try {
const tx = db.transaction(['items', 'versions'], 'readwrite')
const versions = tx.objectStore('versions')
await tx.objectStore('items').put(artifact)
for (const entry of snapshots) await versions.put(entry)
const newest = snapshots.at(-1)
if (newest) await pruneVersionsIn(versions, artifact.id, newest.content.length)
await writeEdit(tx.objectStore('items'), tx.objectStore('versions'), { artifact, snapshots })
await tx.done
} catch (err) {
// A rejected write (most likely QuotaExceededError) leaves the artifact usable for the
@@ -164,6 +165,97 @@ export async function putArtifactWithVersions(
}
}
async function writeEdit(
items: ItemsStore,
versions: VersionsStore,
edit: ArtifactEdit
): Promise<void> {
await items.put(edit.artifact)
for (const entry of edit.snapshots) await versions.put(entry)
const newest = edit.snapshots.at(-1)
if (newest) await pruneVersionsIn(versions, edit.artifact.id, newest.content.length)
}
/**
* Read an artifact and write it back in one transaction. `mutate` returns the edit to
* write, or undefined to leave the artifact alone and resolve to undefined. A store that
* fails is reported rather than thrown, so the edited row resolves either way — persisted
* where it could be, and usable for the session where it could not.
*/
export async function mutateArtifact(
id: string,
mutate: (existing: PersistedArtifact | undefined) => ArtifactEdit | undefined
): Promise<PersistedArtifact | undefined> {
const db = await getDB()
// `transaction()` throws on a connection closed since `getDB()` answered — another tab
// upgrading the schema, or a user switch releasing the handle.
let opened: IDBPTransaction<ArtifactsSchema, ('items' | 'versions')[], 'readwrite'> | undefined
try {
opened = db?.transaction(['items', 'versions'], 'readwrite')
} catch (err) {
console.error('Could not open an artifact write transaction', err)
}
// Not `return undefined`: `create` can hand out an artifact the store never took, and it
// stays revisable only if the edit is computed anyway.
if (!opened) return mutate(undefined)?.artifact
const tx = opened
// Attached before the first await: idb builds `done` eagerly and rejects it on abort, so
// attaching later would leave an unhandled rejection. Cleared before each deliberate
// abort below, whose own site reports the failure when there was one.
let reportFailure = true
const settled = tx.done.catch((err) => {
if (reportFailure) console.error('Could not persist artifact', err)
})
const abort = () => {
try {
tx.abort()
} catch {}
}
const items = tx.objectStore('items')
const versions = tx.objectStore('versions')
let existing: PersistedArtifact | undefined
try {
// Read outside this transaction, two tabs both see version N, both stamp N+1, and the
// later write silently replaces the earlier one — content and snapshot alike.
existing = await items.get(id)
} catch (err) {
console.error('Could not read the artifact being written', err)
reportFailure = false
abort()
await settled
return mutate(undefined)?.artifact
}
// Kept out of the store's own error handling: a mutator that fails is not the store
// failing, so its error is neither reported as one nor swallowed.
let edit: ArtifactEdit | undefined
try {
edit = mutate(existing)
} catch (err) {
reportFailure = false
abort()
await settled
throw err
}
if (!edit) {
reportFailure = false
abort()
await settled
return undefined
}
try {
await writeEdit(items, versions, edit)
} catch (err) {
// A request that errors aborts the transaction itself; one that throws before creating
// a request (DataCloneError) would otherwise commit the row without its snapshot.
// Logged here because `settled` sees only a cause-less AbortError.
console.error('Could not persist artifact', err)
reportFailure = false
abort()
}
await settled
return edit.artifact
}
async function pruneVersionsIn(
store: VersionsStore,
artifactId: string,
@@ -242,6 +334,8 @@ export async function deleteArtifactsForSession(sessionId: string): Promise<void
}
}
type ItemsStore = IDBPObjectStore<ArtifactsSchema, ('items' | 'versions')[], 'items', 'readwrite'>
type VersionsStore = IDBPObjectStore<
ArtifactsSchema,
('items' | 'versions')[],
@@ -6,6 +6,7 @@ import {
getArtifactVersion,
listArtifactVersions,
listArtifactsForSession,
mutateArtifact,
putArtifactWithVersions,
versionKey,
type ArtifactKind,
@@ -113,31 +114,38 @@ export class SessionArtifactsStore {
input: UpdateArtifactInput,
opts?: { sessionId?: string }
): Promise<PersistedArtifact | undefined> {
const existing = this.artifacts.find((a) => a.id === id) ?? (await getArtifact(id))
if (!existing) return undefined
if (opts?.sessionId !== undefined && existing.sessionId !== opts.sessionId) return undefined
// Only a content change earns a version: a rename or an identical rewrite would
// otherwise fill the picker with entries the user cannot tell apart.
const contentChanged = input.content !== undefined && input.content !== existing.content
const version = currentVersion(existing) + (contentChanged ? 1 : 0)
const updated: PersistedArtifact = {
...existing,
name: input.name ?? existing.name,
content: input.content ?? existing.content,
updatedAt: Date.now(),
version
}
const snapshots: ArtifactVersion[] = []
// An artifact written before history existed has no snapshot of its current content,
// so capture one on *any* update, not just a content change: this write stamps
// `version`, and nothing afterwards would recognise it as pre-history.
if (existing.version === undefined) {
snapshots.push(snapshotOf(existing, currentVersion(existing)))
}
if (contentChanged) {
snapshots.push(snapshotOf(updated, version, input.note))
}
await putArtifactWithVersions(updated, snapshots)
const updated = await mutateArtifact(id, (stored) => {
// Read inside the mutator: hoisted out, it would weigh a stale copy against a fresh one.
const existing = furtherAlong(
stored,
this.artifacts.find((a) => a.id === id)
)
if (!existing) return undefined
if (opts?.sessionId !== undefined && existing.sessionId !== opts.sessionId) return undefined
// Only a content change earns a version: a rename or an identical rewrite would
// otherwise fill the picker with entries the user cannot tell apart.
const contentChanged = input.content !== undefined && input.content !== existing.content
const version = currentVersion(existing) + (contentChanged ? 1 : 0)
const artifact: PersistedArtifact = {
...existing,
name: input.name ?? existing.name,
content: input.content ?? existing.content,
updatedAt: Date.now(),
version
}
const snapshots: ArtifactVersion[] = []
// An artifact written before history existed has no snapshot of its current content,
// so capture one on *any* update, not just a content change: this write stamps
// `version`, and nothing afterwards would recognise it as pre-history.
if (existing.version === undefined) {
snapshots.push(snapshotOf(existing, currentVersion(existing)))
}
if (contentChanged) {
snapshots.push(snapshotOf(artifact, version, input.note))
}
return { artifact, snapshots }
})
if (!updated) return undefined
if (updated.sessionId === this.#sessionId) {
this.#applyWrite(sortByUpdatedDesc(this.artifacts.map((a) => (a.id === id ? updated : a))))
}
@@ -187,6 +195,24 @@ export class SessionArtifactsStore {
}
}
/**
* Neither copy is authoritative: only the store carries an edit another tab made, and only
* memory carries one the store refused (quota) and `update` handed back unpersisted.
* Always preferring one side reverts the other's text on the next edit, so the later wins.
*/
function furtherAlong(
stored: PersistedArtifact | undefined,
held: PersistedArtifact | undefined
): PersistedArtifact | undefined {
if (!stored || !held) return stored ?? held
const heldVersion = currentVersion(held)
const storedVersion = currentVersion(stored)
// A rename earns no version, so at equal versions only the clock separates the two.
// Both are written by the same browser, which is what makes the stamps comparable.
if (heldVersion === storedVersion) return held.updatedAt > stored.updatedAt ? held : stored
return heldVersion > storedVersion ? held : stored
}
function snapshotOf(a: PersistedArtifact, version: number, note?: string): ArtifactVersion {
return {
key: versionKey(a.id, version),
@@ -143,6 +143,55 @@ describe('SessionArtifactsStore', () => {
expect(await store.update('nope', { content: 'x' })).toBeUndefined()
})
it('gives two tabs editing at once distinct versions, keeping both snapshots', async () => {
// Without the transactional read both tabs stamp the same next version, and one edit
// and its snapshot vanish under the other.
const other = new (await import('./artifactsState.svelte')).SessionArtifactsStore()
await store.setSession('s1')
const created = await store.create('s1', { name: 'Doc', content: 'v1' })
await other.setSession('s1')
await Promise.all([
store.update(created.id, { content: 'from A', note: 'A' }),
other.update(created.id, { content: 'from B', note: 'B' })
])
expect((await dbMod.listArtifactVersions(created.id)).map((v) => v.version)).toEqual([3, 2, 1])
expect((await dbMod.getArtifact(created.id))?.version).toBe(3)
})
it('keeps an edit the store refused when the next update lands', async () => {
await store.setSession('s1')
const created = await store.create('s1', { name: 'Doc', content: 'v1' })
// Refused, so v2 lives only in memory while the stored row stays behind at v1.
const quiet = vi.spyOn(console, 'error').mockImplementation(() => {})
const put = vi.spyOn(IDBObjectStore.prototype, 'put').mockImplementationOnce(() => {
throw new DOMException('quota', 'QuotaExceededError')
})
await store.update(created.id, { content: 'v2' })
put.mockRestore()
quiet.mockRestore()
expect((await dbMod.getArtifact(created.id))?.content).toBe('v1')
// Computed from the stored row, this rename would put v1's text back under the new name.
expect(await store.update(created.id, { name: 'Renamed' })).toMatchObject({
name: 'Renamed',
content: 'v2'
})
})
it('creates and then revises an artifact when there is no store to write to', async () => {
// No scoped user, so the database never opens. `create` hands the id out either way, so
// the document it named has to stay revisable rather than come back as an unknown one.
;(await import('$lib/stores')).userStore.set(undefined as never)
await store.setSession('s1')
const created = await store.create('s1', { name: 'A', content: 'x' })
expect((await store.update(created.id, { content: 'y' }))?.content).toBe('y')
expect(store.artifacts.map((a) => a.content)).toEqual(['y'])
})
it('get resolves from the in-memory list even when the DB lacks the record', async () => {
await store.setSession('s1')
const created = await store.create('s1', { name: 'A', content: 'x' })
@@ -213,7 +262,7 @@ describe('SessionArtifactsStore', () => {
await store.setSession('s1')
const created = await store.create('s1', { name: 'Doc', content: 'c1' })
const spy = vi.spyOn(dbMod, 'putArtifactWithVersions')
const spy = vi.spyOn(dbMod, 'mutateArtifact')
await store.update(created.id, { content: 'c2', note: 'second' })
// Split into two writes, a stamped version can outlive the snapshot that failed to
@@ -221,8 +270,8 @@ describe('SessionArtifactsStore', () => {
// synthesizes the current version from the row — right up until the next edit
// overwrites the row, which is the only copy of that content left.
expect(spy).toHaveBeenCalledTimes(1)
expect(spy.mock.calls[0][1].map((v) => v.version)).toEqual([2])
spy.mockRestore()
expect((await store.listVersions(created.id)).map((v) => v.version)).toEqual([2, 1])
const row = await dbMod.getArtifact(created.id)
const stored = await dbMod.listArtifactVersions(created.id)