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
23 changes: 16 additions & 7 deletions packages/desktop-app/src/lib/components/layout/app-shell.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,28 @@
// first-launch wizard — see the `identityOnly` prop on OnboardingWizard.
let onboardingIdentityOnly = $state(false);

// Conflict journal (ADR-068): load on every startup, unconditionally —
// Conflict journal (ADR-068): load on every startup, whatever the tier —
// unlike the deleted Recovered Items log, this is NOT Pro-gated; the
// journal is designed to work on a purely local-only install. The inline
// per-node indicator and the Conflicts view both read `conflictsStore`
// directly; this only decides whether a one-time startup nudge is worth
// showing when open conflicts already exist.
//
// Started only once `databaseStore` has a selection: `list_conflicts` is a
// routed command, so issued any earlier it would be answered by the daemon
// default rather than the restored database. A load discarded by a database
// switch shows no notice — its count described no database in particular.
let conflictsNoticeStarted = false;
$effect(() => {
if (conflictsNoticeStarted || databaseStore.activeDatabaseId === null) return;
if (!(window as unknown as { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__) return;
conflictsNoticeStarted = true;
loadConflicts();
});

function loadConflicts(): void {
conflictsStore.load().then(() => {
conflictsStore.load().then((applied) => {
if (!applied) return;
const open = conflictsStore.records.filter((r) => r.status === 'open');
if (open.length === 0) return;
conflictNotifications.add({
Expand Down Expand Up @@ -246,11 +260,6 @@
.then((stop) => (cleanupProSync = stop))
.catch((e) => log.warn('proSync.start failed', { error: e }));

// Load the conflict journal on every startup — unconditional, not
// gated on Pro tier (ADR-068: the journal works on a purely
// local-only install, which is exactly the defect it fixes).
loadConflicts();

// Sync theme from backend preferences (overrides localStorage if different)
invoke<{ activeDatabasePath: string; display: { renderMarkdown: boolean; theme: string } }>('get_settings')
.then((settings) => {
Expand Down
63 changes: 53 additions & 10 deletions packages/desktop-app/src/lib/stores/conflicts.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,15 @@ class ConflictsStore {
records = $state<ConflictRecord[]>([]);
loaded = $state(false);

/**
* Bumped by `invalidateForDatabaseSwitch()`. `load`/`loadForNode` capture
* this before awaiting and drop their result if it changed, so a read
* issued against the previous database cannot write that database's
* journal into a store that now represents a different one (mirrors the
* `#generation` guard in `schemasStore` / `aiChatsData`).
*/
#generation = 0;

private openNodeIds = $derived(
new Set(
this.records.filter((r) => r.status === 'open').flatMap((r) => r.nodeIds)
Expand All @@ -69,26 +78,52 @@ class ConflictsStore {
return this.openNodeIds.has(nodeId);
}

/** Load every conflict record (used by the Conflicts view). */
async load(): Promise<void> {
/**
* Load every conflict record (used by the Conflicts view). Resolves `true`
* when the result was applied, `false` when a database switch landed while
* it was in flight and the result was discarded.
*/
async load(): Promise<boolean> {
const generation = this.#generation;
let records: ConflictRecord[];
try {
const records = await invoke<ConflictRecord[]>('list_conflicts', {
input: { status: null, kind: null, limit: null }
});
this.records = records ?? [];
records =
(await invoke<ConflictRecord[]>('list_conflicts', {
input: { status: null, kind: null, limit: null }
})) ?? [];
} catch (e) {
log.warn('Failed to load conflicts', { error: e });
this.records = [];
} finally {
this.loaded = true;
records = [];
}
if (generation !== this.#generation) {
log.debug('Discarding conflicts load that resolved after a database switch');
return false;
}
this.records = records;
this.loaded = true;
return true;
}

/**
* Forget the previous database's journal and invalidate any load still in
* flight against it. Call before reloading for a newly-active database.
* Clears `records` immediately rather than waiting on the reload: node ids
* can repeat across databases (date nodes are keyed by date), so stale
* records would otherwise flag the new database's nodes as conflicted.
*/
invalidateForDatabaseSwitch(): void {
this.#generation++;
this.records = [];
this.loaded = false;
}

/** Load only the records naming `nodeId` — used by the inline indicator so
* it doesn't have to wait on (or trigger) a full-list load. */
async loadForNode(nodeId: string): Promise<ConflictRecord[]> {
const generation = this.#generation;
try {
const records = await invoke<ConflictRecord[]>('conflicts_for_node', { nodeId });
if (generation !== this.#generation) return [];
// Merge into the shared cache by id so the full Conflicts view (if open
// in another pane) and this per-node lookup never disagree.
const byId = new Map(this.records.map((r) => [r.id, r]));
Expand All @@ -102,11 +137,16 @@ class ConflictsStore {
}

private async resolve(conflictId: string, resolution: Resolution): Promise<void> {
// Conflict ids are deterministic (derived from kind + node ids), so the
// same id can exist in another database — don't patch the new database's
// copy with the previous one's resolved record.
const generation = this.#generation;
try {
const updated = await invoke<ConflictRecord>('resolve_conflict', {
conflictId,
resolution
});
if (generation !== this.#generation) return;
this.records = this.records.map((r) => (r.id === conflictId ? updated : r));
} catch (e) {
log.warn('Failed to resolve conflict', { error: e, conflictId, resolution });
Expand Down Expand Up @@ -160,12 +200,15 @@ class ConflictsStore {
loserId: string,
conflictId?: string
): Promise<MergeOutcome> {
// Captured before the merge: a refresh issued after a database switch
// would read the survivor's records from the newly-active database.
const generation = this.#generation;
const outcome = await invoke<MergeOutcome>('merge_nodes', {
survivorId,
loserId,
conflictId: conflictId ?? null
});
if (conflictId) {
if (conflictId && generation === this.#generation) {
await this.loadForNode(survivorId);
}
return outcome;
Expand Down
5 changes: 5 additions & 0 deletions packages/desktop-app/src/lib/stores/database.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { structureTree } from '$lib/stores/reactive-structure-tree.svelte';
import { collectionsData, collectionsState } from '$lib/stores/collections.svelte';
import { schemasData } from '$lib/stores/schemas.svelte';
import { aiChatsData } from '$lib/stores/ai-chats.svelte';
import { conflictsStore } from '$lib/stores/conflicts.svelte';
import { membership } from '$lib/stores/membership.svelte';
import { resyncSchemaPluginsForDatabaseSwitch } from '$lib/plugins/schema-plugin-loader';
import {
Expand Down Expand Up @@ -515,6 +516,10 @@ class DatabaseStore {
// land in a store that now represents a different database.
aiChatsData.invalidateForDatabaseSwitch();
aiChatsData.loadAiChats();
// The conflict journal is per-database too (ADR-068): drop the previous
// database's records and any load still in flight against it, then reload.
conflictsStore.invalidateForDatabaseSwitch();
void conflictsStore.load();
// 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
Expand Down
98 changes: 97 additions & 1 deletion packages/desktop-app/src/tests/stores/conflicts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,109 @@ describe('conflicts store', () => {
it('clears records and marks loaded on failure, without throwing', async () => {
mockInvoke.mockRejectedValueOnce(new Error('daemon unreachable'));

await expect(conflictsStore.load()).resolves.toBeUndefined();
await expect(conflictsStore.load()).resolves.toBe(true);

expect(conflictsStore.records).toEqual([]);
expect(conflictsStore.loaded).toBe(true);
});
});

describe('invalidateForDatabaseSwitch()', () => {
it('clears the previous database records immediately', () => {
conflictsStore.records = [record()];
conflictsStore.loaded = true;

conflictsStore.invalidateForDatabaseSwitch();

expect(conflictsStore.records).toEqual([]);
expect(conflictsStore.loaded).toBe(false);
expect(conflictsStore.hasOpenFor('n1')).toBe(false);
});

it('drops a load issued before the switch that resolves after the reload', async () => {
let resolveStale!: (v: ConflictRecord[]) => void;
mockInvoke.mockImplementationOnce(
() => new Promise<ConflictRecord[]>((r) => (resolveStale = r))
);
const stale = conflictsStore.load();

conflictsStore.invalidateForDatabaseSwitch();
mockInvoke.mockResolvedValueOnce([record({ id: 'new-db', nodeIds: ['m1'] })]);
await expect(conflictsStore.load()).resolves.toBe(true);

resolveStale([record({ id: 'old-db', nodeIds: ['n1'] })]);
await expect(stale).resolves.toBe(false);

expect(conflictsStore.records.map((r) => r.id)).toEqual(['new-db']);
expect(conflictsStore.hasOpenFor('n1')).toBe(false);
});

it('drops a failed load issued before the switch instead of clearing the new records', async () => {
let rejectStale!: (e: Error) => void;
mockInvoke.mockImplementationOnce(
() => new Promise<ConflictRecord[]>((_, rej) => (rejectStale = rej))
);
const stale = conflictsStore.load();

conflictsStore.invalidateForDatabaseSwitch();
mockInvoke.mockResolvedValueOnce([record({ id: 'new-db' })]);
await conflictsStore.load();

rejectStale(new Error('daemon unreachable'));
await expect(stale).resolves.toBe(false);

expect(conflictsStore.records.map((r) => r.id)).toEqual(['new-db']);
});

it('drops a per-node load issued before the switch', async () => {
let resolveStale!: (v: ConflictRecord[]) => void;
mockInvoke.mockImplementationOnce(
() => new Promise<ConflictRecord[]>((r) => (resolveStale = r))
);
const stale = conflictsStore.loadForNode('n1');

conflictsStore.invalidateForDatabaseSwitch();
resolveStale([record({ id: 'old-db', nodeIds: ['n1'] })]);

await expect(stale).resolves.toEqual([]);
expect(conflictsStore.records).toEqual([]);
});

it('does not patch a same-id record in the new database with a pre-switch resolution', async () => {
let resolveStale!: (v: ConflictRecord) => void;
mockInvoke.mockImplementationOnce(
() => new Promise<ConflictRecord>((r) => (resolveStale = r))
);
const stale = conflictsStore.dismiss('c1');

conflictsStore.invalidateForDatabaseSwitch();
conflictsStore.records = [record({ id: 'c1' })];
resolveStale(record({ id: 'c1', status: 'dismissed' }));
await stale;

expect(conflictsStore.records[0].status).toBe('open');
});

it('skips the post-merge refresh when a switch landed during the merge', async () => {
let resolveMerge!: (v: unknown) => void;
mockInvoke.mockImplementationOnce(() => new Promise((r) => (resolveMerge = r)));
const merging = conflictsStore.merge('n1', 'n2', 'c1');

conflictsStore.invalidateForDatabaseSwitch();
resolveMerge({
survivorId: 'n1',
loserId: 'n2',
propertiesMerged: 0,
edgesRepointed: 0,
edgesDropped: 0
});
await merging;

expect(mockInvoke).toHaveBeenCalledTimes(1);
expect(mockInvoke).not.toHaveBeenCalledWith('conflicts_for_node', expect.anything());
});
});

describe('hasOpenFor()', () => {
it('is true for a node named by an OPEN record', () => {
conflictsStore.records = [record({ nodeIds: ['n1', 'n2'], status: 'open' })];
Expand Down
18 changes: 18 additions & 0 deletions packages/desktop-app/src/tests/stores/database.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,15 @@ vi.mock('$lib/stores/ai-chats.svelte', () => ({
}
}));

const loadConflicts = vi.fn((..._a: unknown[]) => Promise.resolve(true));
const conflictsInvalidateForDatabaseSwitch = vi.fn((..._a: unknown[]) => undefined);
vi.mock('$lib/stores/conflicts.svelte', () => ({
conflictsStore: {
load: (...a: unknown[]) => loadConflicts(...a),
invalidateForDatabaseSwitch: (...a: unknown[]) => conflictsInvalidateForDatabaseSwitch(...a)
}
}));

const clearAllTabs = vi.fn((..._a: unknown[]) => undefined);
const addTab = vi.fn((..._a: unknown[]) => undefined);
vi.mock('$lib/stores/navigation.svelte', () => ({
Expand Down Expand Up @@ -333,6 +342,8 @@ describe('Database Store', () => {
expect(loadSchemas).toHaveBeenCalledOnce();
expect(invalidateForDatabaseSwitch).toHaveBeenCalledOnce();
expect(loadAiChats).toHaveBeenCalledOnce();
expect(conflictsInvalidateForDatabaseSwitch).toHaveBeenCalledOnce();
expect(loadConflicts).toHaveBeenCalledOnce();
expect(mockGetNode).toHaveBeenCalledWith(DATABASE_SETTINGS_NODE_ID);
// Startup keeps the restored tabs; only a switch resets the workspace.
expect(clearAllTabs).not.toHaveBeenCalled();
Expand Down Expand Up @@ -495,6 +506,13 @@ describe('Database Store', () => {
expect(schemasInvalidateForDatabaseSwitch.mock.invocationCallOrder[0]).toBeLessThan(
loadSchemas.mock.invocationCallOrder[0]
);
// The conflict journal is per-database: its records and any in-flight
// load against the previous database are dropped before the reload.
expect(conflictsInvalidateForDatabaseSwitch).toHaveBeenCalledOnce();
expect(loadConflicts).toHaveBeenCalledOnce();
expect(conflictsInvalidateForDatabaseSwitch.mock.invocationCallOrder[0]).toBeLessThan(
loadConflicts.mock.invocationCallOrder[0]
);
// The per-collection member cache and the sub-panel selection
// are both keyed on a collection id that is name-derived and can collide
// across databases — both must be dropped so a same-named collection in
Expand Down