Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -667,8 +667,13 @@

<!-- Pane Manager - positioned to span both tabs and content grid areas -->
<div class="pane-manager-wrapper">
<!-- PaneManager now renders content directly via PaneContent components -->
<PaneManager />
<!-- PaneManager now renders content directly via PaneContent components.
Mounted only once a database is selected: `databaseStore.load()`
points the routed clients at it before committing the selection,
so restored tabs never read from the daemon default first. -->
{#if databaseStore.activeDatabaseId !== null}
<PaneManager />
{/if}
</div>

<!-- Overlay chrome slot — registry-driven, floats top-right of the
Expand Down
159 changes: 102 additions & 57 deletions packages/desktop-app/src/lib/stores/database.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,14 @@ class DatabaseStore {
*/
private switchSeq = 0;

/**
* Number of `switchTo` calls between their `switchSeq` bump and settling.
* `load()` skips its startup routing while one is in flight: that switch
* may already have sent `set_active_database`, and a later send from
* `load()` would re-point routing away from the database the switch commits.
*/
private switchesInFlight = 0;

/** The database currently being viewed, or `null` if none is selected. */
get activeDatabase(): DatabaseInfo | null {
return this.databases.find((db) => db.id === this.activeDatabaseId) ?? null;
Expand Down Expand Up @@ -189,6 +197,7 @@ class DatabaseStore {
// A database named for *this launch* wins over the remembered one: it is
// set only when the user picked that database from the tray, which is a
// more specific instruction than "whatever you had open last time".
const seqAtStart = this.switchSeq;
const registered = (id: string | null): string | null =>
id !== null && this.databases.some((db) => db.id === id) ? id : null;

Expand All @@ -200,16 +209,39 @@ class DatabaseStore {
// Re-check after the awaits above. A second `load()` runs on every
// launch — the daemon-reconnect listener fires one — and both can pass
// the outer check before either assigns. Assigning unconditionally lets
// whichever finishes last overwrite a selection already made.
if (this.activeDatabaseId === null) {
this.activeDatabaseId = resolved;
// whichever finishes last overwrite a selection already made. A tray
// pick (`switchTo`) that started since this load began, or is still in
// flight, owns the selection and its routing, so defer to it too.
const superseded = (): boolean =>
this.activeDatabaseId !== null || this.switchSeq !== seqAtStart;
if (superseded() || this.switchesInFlight > 0) return;

// Point the routed gRPC clients (and the node-event watcher) at the
// restored database before committing the selection, as `switchTo`
// does. Otherwise the switcher and window pin show the restored
// database while node/import/agent requests still route to the daemon
// default. A switch that starts while this awaits sends its own
// `set_active_database` after this one, so it wins; the re-check
// below leaves its selection alone.
await invoke('set_active_database', { id: resolved });
if (superseded()) return;

this.activeDatabaseId = resolved;
if (resolved !== null) {
this.pinWindowDatabase(resolved);
this.activateProSync(resolved);
}

if (resolved !== null && resolved !== this.defaultDatabaseId) {
// The sidebar's boot-time loads went out before routing was set, so
// the daemon default answered them. Drop and reload them from the
// restored database. Workspace panes mount only once a database is
// selected, so restored tabs never read before this point.
this.evictAndReloadActiveDatabase();
} else {
// Hydrate the active database's DatabaseSettingsNode so the Pro-sync
// variant machine can read sync_enabled/auth_status.
this.refreshDatabaseSettings();
if (resolved !== null) {
this.pinWindowDatabase(resolved);
this.activateProSync(resolved);
}
}
}
} catch (err) {
Expand Down Expand Up @@ -387,6 +419,7 @@ class DatabaseStore {
}
this.error = null;
const seq = ++this.switchSeq;
this.switchesInFlight++;
try {
// Land any in-flight debounced saves in the database they were made
// against before the routed clients re-point.
Expand All @@ -408,15 +441,7 @@ class DatabaseStore {
// failure must not abort the already-committed routing switch.
this.activateProSync(id);

// Evict the previous database's cached data. `clearAll()` also bumps the
// store's database epoch, which closes the in-flight-read window: a read
// (e.g. loadChildren/getNode) dispatched against the previous database
// *before* this switch whose response resolves after this clear captured
// the old epoch and is dropped instead of writing the previous
// database's rows into the now-active store (see
// `sharedNodeStore.currentEpoch()`).
sharedNodeStore.clearAll();
structureTree.clear();
this.evictAndReloadActiveDatabase();

// Reset the workspace: open tabs referenced the previous database's
// nodes, so drop them and land on the new database's daily journal
Expand All @@ -434,53 +459,73 @@ class DatabaseStore {
},
true
);

// Reload the sidebar from the new database. The locally-created
// exemptions belong to the database being left — collection ids are
// derived from the name, so keeping them would wrongly un-hide a
// same-named empty collection in the new one.
collectionsData.forgetLocallyCreated();
// The per-collection member-node cache is keyed by collection id, which
// is name-derived and can collide across databases — without this, a
// same-named collection in the new database would render the *previous*
// database's cached member nodes as its own contents.
collectionsData.invalidateAllMembers();
collectionsData.loadCollections();
// Drop the sub-panel selection too: `collectionsState.selectedCollectionId`
// / `subPanelOpen` are not evicted by anything above, so a panel left open
// on a DB-A collection would otherwise keep rendering (now-stale) DB-A
// members against DB-B, including for a DB-B collection that happens to
// share the id.
collectionsState.reset();
// Same id-collision hazard as the member cache above, for the Pro
// membership roster/invites/requests cache (has_role edges are
// per-database — ADR-053).
membership.invalidateForDatabaseSwitch();
// As with collectionsData.forgetLocallyCreated() above: invalidate any
// in-flight loadSchemas before reloading, so its result can't land in
// a store that now represents a different database.
schemasData.invalidateForDatabaseSwitch();
schemasData.loadSchemas();
// As with collectionsData.forgetLocallyCreated() above: invalidate any
// in-flight "+ New chat" create before reloading, so its result can't
// land in a store that now represents a different database.
aiChatsData.invalidateForDatabaseSwitch();
aiChatsData.loadAiChats();
// Re-sync the schema plugin registry (hasTitleTemplate/titleTemplate)
// against the newly-active database's schemas — otherwise a custom type
// keeps resolving titles via the previous database's template (or, for a
// type unique to the new database, via no template at all) until the
// next app restart.
void resyncSchemaPluginsForDatabaseSwitch();
// Re-hydrate the new database's DatabaseSettingsNode (the previous one was
// evicted by clearAll) so the Pro-sync variant re-resolves for it.
this.refreshDatabaseSettings();
} catch (err) {
this.error = toError(err).message;
log.error('Failed to switch database', { id, error: err });
} finally {
this.switchesInFlight--;
}
}

/**
* Evict every per-database cache and reload the database-scoped stores
* from the currently-routed database. Used by `switchTo`, and by `load()`
* when the restored database is not the daemon default (reads issued
* before routing was set were answered by the default).
*/
private evictAndReloadActiveDatabase(): void {
// Evict the previous database's cached data. `clearAll()` also bumps the
// store's database epoch, which closes the in-flight-read window: a read
// (e.g. loadChildren/getNode) dispatched against the previous database
// *before* this switch whose response resolves after this clear captured
// the old epoch and is dropped instead of writing the previous
// database's rows into the now-active store (see
// `sharedNodeStore.currentEpoch()`).
sharedNodeStore.clearAll();
structureTree.clear();

// Reload the sidebar from the new database. The locally-created
// exemptions belong to the database being left — collection ids are
// derived from the name, so keeping them would wrongly un-hide a
// same-named empty collection in the new one.
collectionsData.forgetLocallyCreated();
// The per-collection member-node cache is keyed by collection id, which
// is name-derived and can collide across databases — without this, a
// same-named collection in the new database would render the *previous*
// database's cached member nodes as its own contents.
collectionsData.invalidateAllMembers();
collectionsData.loadCollections();
// Drop the sub-panel selection too: `collectionsState.selectedCollectionId`
// / `subPanelOpen` are not evicted by anything above, so a panel left open
// on a DB-A collection would otherwise keep rendering (now-stale) DB-A
// members against DB-B, including for a DB-B collection that happens to
// share the id.
collectionsState.reset();
// Same id-collision hazard as the member cache above, for the Pro
// membership roster/invites/requests cache (has_role edges are
// per-database — ADR-053).
membership.invalidateForDatabaseSwitch();
// As with collectionsData.forgetLocallyCreated() above: invalidate any
// in-flight loadSchemas before reloading, so its result can't land in
// a store that now represents a different database.
schemasData.invalidateForDatabaseSwitch();
schemasData.loadSchemas();
// As with collectionsData.forgetLocallyCreated() above: invalidate any
// in-flight "+ New chat" create before reloading, so its result can't
// land in a store that now represents a different database.
aiChatsData.invalidateForDatabaseSwitch();
aiChatsData.loadAiChats();
// Re-sync the schema plugin registry (hasTitleTemplate/titleTemplate)
// against the newly-active database's schemas — otherwise a custom type
// keeps resolving titles via the previous database's template (or, for a
// type unique to the new database, via no template at all) until the
// next app restart.
void resyncSchemaPluginsForDatabaseSwitch();
// Re-hydrate the new database's DatabaseSettingsNode (the previous one was
// evicted by clearAll) so the Pro-sync variant re-resolves for it.
this.refreshDatabaseSettings();
}

/**
* Force-refetch the active database's `DatabaseSettingsNode` into the shared
* store (bypassing the cache-first `ensureNode` path) so the Pro-sync variant
Expand Down
124 changes: 124 additions & 0 deletions packages/desktop-app/src/tests/stores/database.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,130 @@ describe('Database Store', () => {
expect(databaseStore.activeDatabaseId).toBe('b');
});

it('routes the gRPC clients to the restored database before committing the selection', async () => {
// A remembered (non-default) database must also become the routing
// target; otherwise the switcher shows it while every request still goes
// to the daemon default.
localStorage.setItem('nodespace.activeDatabaseId', 'a');
let activeWhenRouted: string | null | undefined;
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'list_databases') {
return Promise.resolve({
databases: [db('a'), db('b', { isDefault: true })],
defaultDatabaseId: 'b'
});
}
if (cmd === 'set_active_database') activeWhenRouted = databaseStore.activeDatabaseId;
return Promise.resolve(undefined);
});

await databaseStore.load();

expect(mockInvoke).toHaveBeenCalledWith('set_active_database', { id: 'a' });
expect(activeWhenRouted).toBeNull();
expect(databaseStore.activeDatabaseId).toBe('a');
expect(mockInvoke).toHaveBeenCalledWith('pin_window_database', { id: 'a' });
});

it('reloads the database-scoped stores when the restored database is not the default', async () => {
// The sidebar's boot-time loads went out unrouted (answered by the
// default), so they must be dropped and reloaded from the restored one.
localStorage.setItem('nodespace.activeDatabaseId', 'a');
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'list_databases') {
return Promise.resolve({
databases: [db('a'), db('b', { isDefault: true })],
defaultDatabaseId: 'b'
});
}
return Promise.resolve(undefined);
});

await databaseStore.load();

expect(clearAll).toHaveBeenCalledOnce();
expect(forgetLocallyCreated).toHaveBeenCalledOnce();
expect(loadCollections).toHaveBeenCalledOnce();
expect(schemasInvalidateForDatabaseSwitch).toHaveBeenCalledOnce();
expect(loadSchemas).toHaveBeenCalledOnce();
expect(invalidateForDatabaseSwitch).toHaveBeenCalledOnce();
expect(loadAiChats).toHaveBeenCalledOnce();
expect(mockGetNode).toHaveBeenCalledWith(DATABASE_SETTINGS_NODE_ID);
// Startup keeps the restored tabs; only a switch resets the workspace.
expect(clearAllTabs).not.toHaveBeenCalled();
});

it('does not reload the stores when the restored database is the default', async () => {
mockInvoke.mockResolvedValueOnce({
databases: [db('a'), db('b', { isDefault: true })],
defaultDatabaseId: 'b'
});

await databaseStore.load();

expect(mockInvoke).toHaveBeenCalledWith('set_active_database', { id: 'b' });
expect(clearAll).not.toHaveBeenCalled();
expect(loadCollections).not.toHaveBeenCalled();
expect(mockGetNode).toHaveBeenCalledWith(DATABASE_SETTINGS_NODE_ID);
});

it('lets a tray switch that lands while load() is routing win', async () => {
let releaseRouting: () => void = () => {};
mockInvoke.mockImplementation((cmd: string, args?: { id?: string }) => {
if (cmd === 'list_databases') {
return Promise.resolve({
databases: [db('a'), db('b', { isDefault: true })],
defaultDatabaseId: 'b'
});
}
if (cmd === 'set_active_database' && args?.id === 'b') {
return new Promise<void>((resolve) => (releaseRouting = resolve));
}
return Promise.resolve(undefined);
});

const loading = databaseStore.load();
await vi.waitFor(() =>
expect(mockInvoke).toHaveBeenCalledWith('set_active_database', { id: 'b' })
);
await databaseStore.switchTo('a');
releaseRouting();
await loading;

expect(databaseStore.activeDatabaseId).toBe('a');
});

it('does not route when a tray switch already sent its own routing call', async () => {
// The reverse ordering: switchTo('a') has sent set_active_database but
// not committed yet when load() resolves. A later send from load() would
// re-point routing to 'b' while the switch commits 'a'.
let releaseSwitch: () => void = () => {};
mockInvoke.mockImplementation((cmd: string, args?: { id?: string }) => {
if (cmd === 'list_databases') {
return Promise.resolve({
databases: [db('a'), db('b', { isDefault: true })],
defaultDatabaseId: 'b'
});
}
if (cmd === 'set_active_database' && args?.id === 'a') {
return new Promise<void>((resolve) => (releaseSwitch = resolve));
}
return Promise.resolve(undefined);
});
databaseStore.databases = [db('a'), db('b', { isDefault: true })];

const switching = databaseStore.switchTo('a');
await vi.waitFor(() =>
expect(mockInvoke).toHaveBeenCalledWith('set_active_database', { id: 'a' })
);
await databaseStore.load();
releaseSwitch();
await switching;

expect(mockInvoke).not.toHaveBeenCalledWith('set_active_database', { id: 'b' });
expect(databaseStore.activeDatabaseId).toBe('a');
});

it('records an error when the list fails', async () => {
mockInvoke.mockRejectedValueOnce(new Error('boom'));
await databaseStore.load();
Expand Down