From decf3150f9f60d02847c3373630128564aadb7c4 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Fri, 18 Sep 2026 01:05:58 +0200 Subject: [PATCH] fix(db-manager): discard a superseded relations read The manager is not remounted on every database change and a queued job cannot be recalled, so a slow read could resolve after the next database's and decorate its tables with the previous one's relations: runed stores whatever the fetcher returns, and this one ignored its abort signal. It now throws on a superseded run, the way the per-table foreign-key resource beside it already does, and the result carries the database it was read from so it is only ever shown alongside that one. The database is identified by a key string passed in rather than by the objects read from it: those are replaced whenever the manager re-reads, which says nothing about the database having changed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MXECAKye6KgN13wznsuRzP --- frontend/src/lib/components/DBManager.svelte | 47 +++++++++++++------ .../lib/components/DBManagerContent.svelte | 1 + 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/frontend/src/lib/components/DBManager.svelte b/frontend/src/lib/components/DBManager.svelte index 2b4dfcbff1..5e17587d00 100644 --- a/frontend/src/lib/components/DBManager.svelte +++ b/frontend/src/lib/components/DBManager.svelte @@ -77,6 +77,11 @@ export type DbManagerViewMode = 'data' | 'diagram' type Props = { + /** Identifies the database this is connected to, stable across reloads of + * it. A string rather than one of the objects read from it: those are + * replaced as the manager re-reads, which says nothing about the database + * having changed. */ + databaseKey?: string dbType: DbType dbSchema: DBSchema dbSupportsSchemas: boolean @@ -124,6 +129,7 @@ onImport?: (mode: 'schema_and_data' | 'schema_only') => void } let { + databaseKey, dbType, dbSchema, dbTableOpsFactory, @@ -670,27 +676,40 @@ }) // Fetched once for the whole database rather than per table: the diagram needs - // every relation at once, and the per-table query would be one job each. + // every relation at once, and the per-table query would be one job each. The + // result carries the database it was read from, and the metadata it was read + // beside — which is what the cards are built from. let relationsError = $state(undefined) - // Re-read only when the schema itself was reloaded, which is what a new - // `colDefs` identity means. Toggling back to the diagram must not queue the - // query again. - let relationsFetchedFor: Record | undefined let relations = resource( - [() => viewMode, () => colDefs], - async ([mode, defs], _prev, { data }): Promise => { - if (mode !== 'diagram' || (data && relationsFetchedFor === defs)) return data ?? [] + [() => viewMode, () => databaseKey, () => colDefs], + async ([mode, key, defs], _prev, { data, signal }) => { + // Re-read only when the database itself was reloaded: toggling back to + // the diagram must not queue the query again. + if (mode !== 'diagram' || (data?.databaseKey === key && data?.defs === defs)) return data relationsError = undefined + let read: DbRelation[] = [] + let error: string | undefined try { - const fetched = await dbSchemaOps.onFetchAllForeignKeys() - relationsFetchedFor = defs - return fetched + read = await dbSchemaOps.onFetchAllForeignKeys() } catch (e) { - relationsError = (e as any)?.body ?? (e as Error)?.message ?? String(e) - return [] + error = (e as any)?.body ?? (e as Error)?.message ?? String(e) } + // The manager is not remounted on every database change and a queued job + // cannot be recalled, so a slow read can land after the next database's. + // An AbortError keeps it out of `current`, where it would decorate that + // database's tables with this one's relations. + if (signal.aborted) throw new DOMException('Superseded', 'AbortError') + relationsError = error + return { databaseKey: key, defs, relations: read } } ) + // Relations are shown only alongside the database they were read from, so a + // result that is merely not superseded yet cannot decorate another one. + let currentRelations = $derived( + relations.current?.databaseKey === databaseKey && relations.current?.defs === colDefs + ? relations.current.relations + : [] + ) // Opening the diagram on an empty canvas would make it look broken, so the // current schema is drawn to start with — unless it is big enough that drawing @@ -1126,7 +1145,7 @@ {dbSchema} {colDefs} selectedTables={diagramTables} - relations={relations.current ?? []} + relations={currentRelations} loading={relations.loading} error={relationsError} onOpenTable={({ schema, table }) => { diff --git a/frontend/src/lib/components/DBManagerContent.svelte b/frontend/src/lib/components/DBManagerContent.svelte index a5368dac18..8b4a36e34b 100644 --- a/frontend/src/lib/components/DBManagerContent.svelte +++ b/frontend/src/lib/components/DBManagerContent.svelte @@ -302,6 +302,7 @@ {/if} Object.values(s)).length}