diff --git a/src/couch/couch-channel.test.ts b/src/couch/couch-channel.test.ts deleted file mode 100644 index 32e8f5a..0000000 --- a/src/couch/couch-channel.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { CouchChannel } from './couch-channel'; -import type { CouchSync } from './couch-sync'; - -// A CouchSync stand-in with spied delegation methods (the channel only calls these three). -const fakeSync = (): CouchSync => - ({ - tick: vi.fn(async () => {}), - pushFileLive: vi.fn(async (_p: string) => {}), - removeFile: vi.fn(async (_p: string) => {}), - }) as unknown as CouchSync; - -describe('CouchChannel', () => { - it('builds once per memory and reuses the live controller', () => { - const a = fakeSync(); - const build = vi.fn(() => a); - const ch = new CouchChannel(); - expect(ch.for('A', build)).toBe(a); - expect(ch.for('A', build)).toBe(a); // same memory -> no rebuild - expect(build).toHaveBeenCalledTimes(1); - expect(ch.active).toBe(true); - }); - - it('rebuilds on a memory switch', () => { - const a = fakeSync(); - const b = fakeSync(); - const ch = new CouchChannel(); - ch.for('A', () => a); - expect(ch.for('B', () => b)).toBe(b); - }); - - it('delegates tick/push/remove to the live controller', async () => { - const a = fakeSync(); - const ch = new CouchChannel(); - ch.for('A', () => a); - await ch.tick(); - await ch.pushFileLive('n.md'); - await ch.removeFile('o.md'); - expect(a.tick).toHaveBeenCalledOnce(); - expect(a.pushFileLive).toHaveBeenCalledWith('n.md'); - expect(a.removeFile).toHaveBeenCalledWith('o.md'); - }); - - // D2: after clear() a stale controller must not touch the previous memory's db. - it('clear tears the controller down so tick + handlers no-op, then a switch back rebuilds', async () => { - const a = fakeSync(); - const ch = new CouchChannel(); - ch.for('A', () => a); - ch.clear(); - expect(ch.active).toBe(false); - await ch.tick(); - await ch.pushFileLive('x.md'); - await ch.removeFile('y.md'); - expect(a.tick).not.toHaveBeenCalled(); - expect(a.pushFileLive).not.toHaveBeenCalled(); - expect(a.removeFile).not.toHaveBeenCalled(); - const a2 = fakeSync(); - expect(ch.for('A', () => a2)).toBe(a2); // memory A comes back -> fresh controller - }); -}); diff --git a/src/couch/couch-channel.ts b/src/couch/couch-channel.ts deleted file mode 100644 index b980369..0000000 --- a/src/couch/couch-channel.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { CouchSync } from './couch-sync'; - -// Holds the single live couch controller + which memory it replicates. A memory switch or a -// git-route sync tears the old controller down (`clear`) so its live vault handlers + the 2s -// tick no-op instead of pulling the previous memory's docs into (or pushing this vault's edits -// out to) the wrong db. The Obsidian side supplies the `build` factory and rebuilds on demand. -export class CouchChannel { - private sync?: CouchSync; - private memory?: string; - - /** The controller for `memory`, (re)built via `build` when the memory changed or none is live. */ - for(memory: string, build: () => CouchSync): CouchSync { - if (this.memory !== memory || !this.sync) { - this.sync = build(); - this.memory = memory; - } - return this.sync; - } - - /** Tear the controller down so later handlers + ticks no-op (memory switch / sign-out). */ - clear(): void { - this.sync = undefined; - this.memory = undefined; - } - - /** True while a controller is live; a switch back to its memory rebuilds via `for`. */ - get active(): boolean { - return !!this.sync; - } - - /** Queued outgoing changes (failed live pushes + deletes) waiting for the next tick; 0 when - * no controller is live. The honest count the sync popup can show without a network round-trip. */ - pendingCount(): number { - return this.sync?.pendingCount() ?? 0; - } - - tick(): Promise { - return this.sync?.tick() ?? Promise.resolve(); - } - pushFileLive(path: string): Promise { - return this.sync?.pushFileLive(path) ?? Promise.resolve(); - } - removeFile(path: string): Promise { - return this.sync?.removeFile(path) ?? Promise.resolve(); - } -} diff --git a/src/couch/couch-doc.test.ts b/src/couch/couch-doc.test.ts deleted file mode 100644 index dfa0b61..0000000 --- a/src/couch/couch-doc.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - chunkBody, - contentRev, - encodeFile, - fileId, - leafIdsOf, - pathOf, - sha256hex, -} from './couch-doc'; - -describe('sha256hex', () => { - it('is the standard, deterministic sha256 of the utf8 bytes', async () => { - // Known vector: sha256("") and sha256("abc"). - expect(await sha256hex('')).toBe( - 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' - ); - expect(await sha256hex('abc')).toBe( - 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad' - ); - expect(await sha256hex('abc')).toBe(await sha256hex('abc')); - }); -}); - -describe('fileId / pathOf', () => { - it('round-trips a path through the f: prefix', () => { - expect(fileId('notes/a.md')).toBe('f:notes/a.md'); - expect(pathOf(fileId('notes/a.md'))).toBe('notes/a.md'); - }); -}); - -describe('chunkBody', () => { - it('returns a single empty chunk for an empty body', () => { - expect(chunkBody('')).toEqual(['']); - }); - it('keeps a small body as one chunk', () => { - expect(chunkBody('hello')).toEqual(['hello']); - }); - it('splits a body larger than the 64KiB chunk into multiple chunks', () => { - const body = 'x'.repeat(64 * 1024 + 10); - const parts = chunkBody(body); - expect(parts).toHaveLength(2); - expect(parts[0]).toHaveLength(64 * 1024); - expect(parts[1]).toHaveLength(10); - expect(parts.join('')).toBe(body); - }); -}); - -describe('encodeFile', () => { - it('builds content-addressed leaf docs + a file doc listing them in order', async () => { - const { leaves, fileDoc } = await encodeFile('notes/a.md', 'hi'); - const h = await sha256hex('hi'); - expect(leaves).toEqual([{ _id: `h:${h}`, _rev: `1-${h.slice(0, 32)}`, data: 'hi' }]); - expect(fileDoc).toEqual({ - _id: 'f:notes/a.md', - type: 'file', - path: 'notes/a.md', - size: 2, - leaves: [`h:${h}`], - }); - }); - - it('leaf ids match leafIdsOf for the same body (the push-skip signature)', async () => { - const { fileDoc } = await encodeFile('notes/a.md', 'hello world'); - expect(fileDoc.leaves).toEqual(await leafIdsOf('hello world')); - }); -}); - -describe('contentRev', () => { - it('joins the ordered leaf ids into a deterministic rev', () => { - expect(contentRev({ leaves: ['h:a', 'h:b'] })).toBe('h:a,h:b'); - }); -}); diff --git a/src/couch/couch-doc.ts b/src/couch/couch-doc.ts deleted file mode 100644 index 86cf252..0000000 --- a/src/couch/couch-doc.ts +++ /dev/null @@ -1,71 +0,0 @@ -// Content-addressed doc model shared with the server bridge: a note becomes leaf docs -// h: plus a file doc f: that lists its leaves in order. Pure (Web -// Crypto only, no obsidian/node builtins) so it unit-tests in Node and stays mobile-safe. - -const CHUNK = 64 * 1024; - -// Web Crypto sha256 of the utf8 bytes - byte-identical to the server's node:crypto sha256, -// so the leaf id (which the bridge reads to reassemble) matches exactly. The leaf _rev only -// needs to be a deterministic 32-hex (couch rev shape); it need not match the server's md5 -// (identical content => same id; a differing rev is a harmless same-data leaf conflict). -export const sha256hex = async (s: string): Promise => { - const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s)); - return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join(''); -}; - -export const fileId = (p: string): string => `f:${p}`; -export const pathOf = (id: string): string => id.slice(2); - -export const chunkBody = (b: string): string[] => { - if (b.length === 0) return ['']; - const out: string[] = []; - for (let i = 0; i < b.length; i += CHUNK) out.push(b.slice(i, i + CHUNK)); - return out; -}; - -export interface LeafDoc { - _id: string; - _rev: string; - data: string; -} -export interface FileDoc { - _id: string; - _rev?: string; - type: 'file'; - path: string; - size: number; - leaves: string[]; - _conflicts?: string[]; - _deleted?: boolean; -} - -// The ordered leaf ids for a body - a deterministic, content-addressed signature of the -// file used both to build the file doc and to skip an unchanged push (see couch-sync). -export const leafIdsOf = async (body: string): Promise => - Promise.all(chunkBody(body).map(async (c) => `h:${await sha256hex(c)}`)); - -export const encodeFile = async ( - path: string, - body: string -): Promise<{ leaves: LeafDoc[]; fileDoc: FileDoc }> => { - const leaves = await Promise.all( - chunkBody(body).map(async (c) => { - const h = await sha256hex(c); - return { _id: `h:${h}`, _rev: `1-${h.slice(0, 32)}`, data: c }; - }) - ); - const ids = leaves.map((l) => l._id); - return { - leaves, - fileDoc: { _id: fileId(path), type: 'file', path, size: body.length, leaves: ids }, - }; -}; - -// The deterministic content rev used as the push-skip cache key (joined leaf ids). -export const contentRev = (fileDoc: Pick): string => fileDoc.leaves.join(','); - -// The content rev for a raw body, WITHOUT building a full file doc - the exact value pushFile -// caches (contentRev(fileDoc) === leafIdsOf(body).join(',')). Lets the preview compute the -// outgoing count from vault content alone, guaranteed equal to what pushAll would send. -export const contentRevOf = async (body: string): Promise => - (await leafIdsOf(body)).join(','); diff --git a/src/couch/couch-state.test.ts b/src/couch/couch-state.test.ts deleted file mode 100644 index 0b89e5d..0000000 --- a/src/couch/couch-state.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { CouchState } from './couch-state'; -import type { CouchMemoryState } from '../settings'; - -// A data.json-backed store: load reads the last saved blob, save records it (and counts writes). -const backing = () => { - let stored: CouchMemoryState | undefined; - const save = vi.fn(async (s: CouchMemoryState) => { - stored = s; - }); - return { load: () => stored, save, get: (): CouchMemoryState | undefined => stored }; -}; - -describe('CouchState - pull cursor', () => { - it('defaults to seq 0 and persists an advance', async () => { - const b = backing(); - const s = new CouchState(b.load, b.save); - expect(s.getCursor()).toBe('0'); - await s.setCursor('5'); - expect(s.getCursor()).toBe('5'); - expect(b.get()?.cursor).toBe('5'); - }); - - it('survives a simulated reload (a fresh instance rehydrates the saved cursor)', async () => { - const b = backing(); - await new CouchState(b.load, b.save).setCursor('42'); - const reloaded = new CouchState(b.load, b.save); - expect(reloaded.getCursor()).toBe('42'); - }); - - it('does not write when the cursor is unchanged', async () => { - const b = backing(); - const s = new CouchState(b.load, b.save); - await s.setCursor('0'); // same as default - expect(b.save).not.toHaveBeenCalled(); - }); -}); - -describe('CouchState - push-rev cache', () => { - it('sets, reads, and drops a rev, persisting each real change', async () => { - const b = backing(); - const s = new CouchState(b.load, b.save); - expect(s.revFor('a.md')).toBeUndefined(); - await s.setRev('a.md', 'r1'); - expect(s.revFor('a.md')).toBe('r1'); - expect(b.get()?.revs).toEqual({ 'a.md': 'r1' }); - await s.setRev('a.md', 'r1'); // no-op - expect(b.save).toHaveBeenCalledTimes(1); - await s.dropRev('a.md'); - expect(s.revFor('a.md')).toBeUndefined(); - await s.dropRev('a.md'); // absent -> no write - expect(b.save).toHaveBeenCalledTimes(2); - }); - - it('rehydrates the rev map on reload', async () => { - const b = backing(); - await new CouchState(b.load, b.save).setRev('a.md', 'r9'); - expect(new CouchState(b.load, b.save).revFor('a.md')).toBe('r9'); - }); - - it('knownPaths lists every path with a cached rev (the local-deletion oracle)', async () => { - const b = backing(); - const s = new CouchState(b.load, b.save); - expect(s.knownPaths()).toEqual([]); - await s.setRev('a.md', 'r1'); - await s.setRev('b.md', 'r2'); - expect(s.knownPaths().sort()).toEqual(['a.md', 'b.md']); - await s.dropRev('a.md'); - expect(s.knownPaths()).toEqual(['b.md']); - }); -}); - -describe('CouchState - pending pushes', () => { - it('enqueues without duplicates and dequeues, persisting real changes only', async () => { - const b = backing(); - const s = new CouchState(b.load, b.save); - await s.enqueue('a.md'); - await s.enqueue('a.md'); // dup -> no write - await s.enqueue('b.md'); - expect(s.pendingPaths().sort()).toEqual(['a.md', 'b.md']); - expect(b.save).toHaveBeenCalledTimes(2); - await s.dequeue('a.md'); - await s.dequeue('a.md'); // absent -> no write - expect(s.pendingPaths()).toEqual(['b.md']); - expect(b.save).toHaveBeenCalledTimes(3); - expect(b.get()?.pending).toEqual(['b.md']); - }); - - it('rehydrates pending on reload', async () => { - const b = backing(); - await new CouchState(b.load, b.save).enqueue('x.md'); - expect(new CouchState(b.load, b.save).pendingPaths()).toEqual(['x.md']); - }); -}); - -describe('CouchState - pending deletes', () => { - it('enqueues without duplicates and dequeues, persisting real changes only', async () => { - const b = backing(); - const s = new CouchState(b.load, b.save); - await s.enqueueDelete('a.md'); - await s.enqueueDelete('a.md'); // dup -> no write - await s.enqueueDelete('b.md'); - expect(s.pendingDeletePaths().sort()).toEqual(['a.md', 'b.md']); - expect(b.save).toHaveBeenCalledTimes(2); - await s.dequeueDelete('a.md'); - await s.dequeueDelete('a.md'); // absent -> no write - expect(s.pendingDeletePaths()).toEqual(['b.md']); - expect(b.save).toHaveBeenCalledTimes(3); - expect(b.get()?.pendingDeletes).toEqual(['b.md']); - }); - - it('rehydrates pending deletes on reload, independent of pending pushes', async () => { - const b = backing(); - const s = new CouchState(b.load, b.save); - await s.enqueue('push.md'); - await s.enqueueDelete('del.md'); - const reloaded = new CouchState(b.load, b.save); - expect(reloaded.pendingPaths()).toEqual(['push.md']); - expect(reloaded.pendingDeletePaths()).toEqual(['del.md']); - }); - - it('loads an old-shape snapshot (no pendingDeletes field) as an empty set', () => { - const stored: CouchMemoryState = { cursor: '3', revs: { 'a.md': 'r1' }, pending: ['b.md'] }; - const s = new CouchState( - () => stored, - async () => {} - ); - expect(s.getCursor()).toBe('3'); - expect(s.revFor('a.md')).toBe('r1'); - expect(s.pendingPaths()).toEqual(['b.md']); - expect(s.pendingDeletePaths()).toEqual([]); // absent field -> empty, no crash - }); -}); diff --git a/src/couch/couch-state.ts b/src/couch/couch-state.ts deleted file mode 100644 index afa0473..0000000 --- a/src/couch/couch-state.ts +++ /dev/null @@ -1,88 +0,0 @@ -import type { CouchMemoryState } from '../settings'; - -// Per-(host, memory) sync state, persisted through the plugin's saveData/loadData (the -// same channel settings use). Holds the resumable pull cursor (so a reload does not re-pull -// from seq 0), the path -> content-rev map (so an unchanged push skips the network), the -// pending-push set (paths whose live push failed), and the pending-delete set (paths whose -// live DELETE failed) - both retried on the next tick. Every mutation persists so the state -// survives a reload; a no-op mutation skips the write. - -export type LoadCouchState = () => CouchMemoryState | undefined; -export type SaveCouchState = (state: CouchMemoryState) => Promise; - -export class CouchState { - private cursor: string; - private readonly revs: Map; - private readonly pending: Set; - private readonly pendingDeletes: Set; - - constructor( - load: LoadCouchState, - private readonly save: SaveCouchState - ) { - const s = load() ?? {}; - this.cursor = s.cursor ?? '0'; - this.revs = new Map(Object.entries(s.revs ?? {})); - this.pending = new Set(s.pending ?? []); - this.pendingDeletes = new Set(s.pendingDeletes ?? []); - } - - getCursor(): string { - return this.cursor; - } - async setCursor(seq: string): Promise { - if (seq === this.cursor) return; - this.cursor = seq; - await this.persist(); - } - - revFor(path: string): string | undefined { - return this.revs.get(path); - } - // Paths we hold a content-rev for - "files we have synced". The disambiguator for a local - // deletion (known path absent from the vault) vs new remote content (unknown path). - knownPaths(): string[] { - return [...this.revs.keys()]; - } - async setRev(path: string, rev: string): Promise { - if (this.revs.get(path) === rev) return; - this.revs.set(path, rev); - await this.persist(); - } - async dropRev(path: string): Promise { - if (this.revs.delete(path)) await this.persist(); - } - - pendingPaths(): string[] { - return [...this.pending]; - } - async enqueue(path: string): Promise { - if (this.pending.has(path)) return; - this.pending.add(path); - await this.persist(); - } - async dequeue(path: string): Promise { - if (this.pending.delete(path)) await this.persist(); - } - - pendingDeletePaths(): string[] { - return [...this.pendingDeletes]; - } - async enqueueDelete(path: string): Promise { - if (this.pendingDeletes.has(path)) return; - this.pendingDeletes.add(path); - await this.persist(); - } - async dequeueDelete(path: string): Promise { - if (this.pendingDeletes.delete(path)) await this.persist(); - } - - private async persist(): Promise { - await this.save({ - cursor: this.cursor, - revs: Object.fromEntries(this.revs), - pending: [...this.pending], - pendingDeletes: [...this.pendingDeletes], - }); - } -} diff --git a/src/couch/couch-sync.test.ts b/src/couch/couch-sync.test.ts deleted file mode 100644 index 26fda8d..0000000 --- a/src/couch/couch-sync.test.ts +++ /dev/null @@ -1,554 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { - requestUrl, - TFile, - type RequestUrlParam, - type RequestUrlResponse, - type Vault, -} from 'obsidian'; -import { CouchSync, type CouchSyncConfig } from './couch-sync'; -import { CouchState } from './couch-state'; -import { contentRevOf } from './couch-doc'; -import type { CouchMemoryState } from '../settings'; - -// Only the requestUrl/TFile coupling is mocked; the doc model + state are the real modules. -vi.mock('obsidian', () => ({ requestUrl: vi.fn(), TFile: class TFile {} })); -const mockRequestUrl = vi.mocked(requestUrl); - -type Res = { status: number; json: unknown }; -const res = (status: number, json: unknown): Res => ({ status, json }); -type Handler = (url: string, method: string, body?: string) => Res; -let handler: Handler = () => res(404, {}); - -const mkFile = (path: string): TFile => - Object.assign(new TFile(), { path, extension: path.split('.').pop() ?? '' }) as unknown as TFile; - -// A minimal in-memory Vault - only the surface CouchSync touches. -class FakeVault { - private files = new Map(); - modifyCalls = 0; - createCalls = 0; - deleteCalls = 0; - constructor(init: Record = {}) { - for (const [p, c] of Object.entries(init)) this.files.set(p, { file: mkFile(p), content: c }); - } - getAbstractFileByPath(p: string): TFile | null { - return this.files.get(p)?.file ?? null; - } - async read(f: TFile): Promise { - return this.files.get(f.path)?.content ?? ''; - } - getMarkdownFiles(): TFile[] { - return [...this.files.values()].map((v) => v.file); - } - async modify(f: TFile, data: string): Promise { - this.modifyCalls++; - this.files.set(f.path, { file: f, content: data }); - } - async create(p: string, data: string): Promise { - this.createCalls++; - const file = mkFile(p); - this.files.set(p, { file, content: data }); - return file; - } - async createFolder(_p: string): Promise {} - async delete(f: TFile): Promise { - this.deleteCalls++; - this.files.delete(f.path); - } - content(p: string): string | undefined { - return this.files.get(p)?.content; - } -} - -const backing = () => { - let stored: CouchMemoryState | undefined; - return { - load: () => stored, - save: async (s: CouchMemoryState) => { - stored = s; - }, - get: (): CouchMemoryState | undefined => stored, - }; -}; - -const makeSync = ( - vault: FakeVault, - state: CouchState, - cfg: Partial = {} -): CouchSync => - new CouchSync( - vault as unknown as Vault, - { endpoint: 'http://couch.test', db: 'mem_x', ...cfg }, - async () => 'jwt', - vi.fn(), - state - ); - -const fileDoc = (path: string, rev: string, leaves: string[] = []) => ({ - _id: `f:${path}`, - _rev: rev, - type: 'file', - path, - size: 1, - leaves, -}); - -const changesUrls = (): string[] => - mockRequestUrl.mock.calls - .map((c) => (c[0] as RequestUrlParam).url) - .filter((u) => u.includes('/_changes')); - -beforeEach(() => { - mockRequestUrl.mockReset(); - mockRequestUrl.mockImplementation(((o: RequestUrlParam) => - Promise.resolve( - handler( - o.url, - o.method ?? 'GET', - o.body as string | undefined - ) as unknown as RequestUrlResponse - )) as unknown as typeof requestUrl); - // writeVault schedules a suppress-cleanup via window.setTimeout; no real timers in tests. - vi.stubGlobal('window', { setTimeout: () => 0 }); -}); -afterEach(() => vi.unstubAllGlobals()); - -describe('unchanged pushAll performs zero HTTP', () => { - it('skips the network entirely on the second pushAll when nothing changed', async () => { - const vault = new FakeVault({ 'notes/n.md': 'X' }); - const state = new CouchState( - () => undefined, - async () => {} - ); - const couch = makeSync(vault, state); - handler = (url, method) => { - if (url.includes('_bulk_docs')) return res(200, []); - if (url.includes('f%3A') && method === 'PUT') return res(200, { ok: true }); - return res(404, {}); // f: GET -> not on server yet - }; - - await couch.pushAll(); - expect(mockRequestUrl.mock.calls.length).toBeGreaterThan(0); - - mockRequestUrl.mockClear(); - await couch.pushAll(); - expect(mockRequestUrl).not.toHaveBeenCalled(); - }); -}); - -describe('a failed live push is queued and retried on the next tick', () => { - it('queues the path on a network error, then flushes it successfully on tick()', async () => { - const vault = new FakeVault({ 'notes/n.md': 'X' }); - const state = new CouchState( - () => undefined, - async () => {} - ); - const couch = makeSync(vault, state); - - handler = () => { - throw new Error('network down'); - }; - await couch.pushFileLive('notes/n.md'); - expect(state.pendingPaths()).toEqual(['notes/n.md']); - - handler = (url, method) => { - if (url.includes('/_changes')) return res(200, { results: [], last_seq: '0' }); - if (url.includes('_bulk_docs')) return res(200, []); - if (url.includes('f%3A') && method === 'PUT') return res(200, { ok: true }); - return res(404, {}); - }; - await couch.tick(); - - expect(state.pendingPaths()).toEqual([]); - const puts = mockRequestUrl.mock.calls.filter( - (c) => (c[0] as RequestUrlParam).method === 'PUT' - ); - expect(puts.length).toBe(1); - }); -}); - -describe('a couch-rejected push is queued, not silently cached', () => { - it('does not cache the rev on a non-2xx PUT, so the next tick retries it', async () => { - const vault = new FakeVault({ 'notes/n.md': 'X' }); - const state = new CouchState( - () => undefined, - async () => {} - ); - const couch = makeSync(vault, state); - - handler = (url, method) => { - if (url.includes('_bulk_docs')) return res(200, []); - if (url.includes('f%3A') && method === 'PUT') return res(500, {}); // couch rejects the file doc - return res(404, {}); // f: GET -> not on server yet - }; - await couch.pushFileLive('notes/n.md'); - expect(state.pendingPaths()).toEqual(['notes/n.md']); - expect(state.revFor('notes/n.md')).toBeUndefined(); // NOT cached -> stays retryable - - handler = (url, method) => { - if (url.includes('/_changes')) return res(200, { results: [], last_seq: '0' }); - if (url.includes('_bulk_docs')) return res(200, []); - if (url.includes('f%3A') && method === 'PUT') return res(200, { ok: true }); - return res(404, {}); - }; - await couch.tick(); - expect(state.pendingPaths()).toEqual([]); - expect(state.revFor('notes/n.md')).toBeDefined(); // cached only after couch accepted it - }); -}); - -describe('a per-leaf _bulk_docs error queues the push, no file doc PUT', () => { - it('throws on a reported leaf error so the file doc (missing leaf) is not written, then retries', async () => { - const vault = new FakeVault({ 'notes/n.md': 'X' }); - const state = new CouchState( - () => undefined, - async () => {} - ); - const couch = makeSync(vault, state); - - handler = (url, method) => { - // new_edits:false returns an entry ONLY for a leaf that failed: a genuine per-doc error. - if (url.includes('_bulk_docs')) return res(200, [{ id: 'h:bad', error: 'forbidden' }]); - if (url.includes('f%3A') && method === 'PUT') return res(200, { ok: true }); - return res(404, {}); // f: GET -> not on server yet - }; - await couch.pushFileLive('notes/n.md'); - expect(state.pendingPaths()).toEqual(['notes/n.md']); // queued, retryable - expect(state.revFor('notes/n.md')).toBeUndefined(); // NOT cached - const puts = mockRequestUrl.mock.calls.filter( - (c) => (c[0] as RequestUrlParam).method === 'PUT' - ); - expect(puts.length).toBe(0); // never PUT a file doc whose leaves may be missing - - handler = (url, method) => { - if (url.includes('/_changes')) return res(200, { results: [], last_seq: '0' }); - if (url.includes('_bulk_docs')) return res(200, []); // all leaves accepted now - if (url.includes('f%3A') && method === 'PUT') return res(200, { ok: true }); - return res(404, {}); - }; - await couch.tick(); - expect(state.pendingPaths()).toEqual([]); - expect(state.revFor('notes/n.md')).toBeDefined(); // cached only after the leaves landed - }); -}); - -describe('delete durability - a failed tombstone is queued and eventually lands', () => { - it('queues the deletion on a rejected DELETE, then flushes it on the next tick', async () => { - const vault = new FakeVault(); // file already gone locally - const state = new CouchState( - () => undefined, - async () => {} - ); - await state.setRev('gone.md', 'h:g1'); // we had synced this file -> couch holds the doc - const couch = makeSync(vault, state); - - handler = (url, method) => { - if (url.includes('f%3Agone.md') && method === 'DELETE') return res(500, {}); // couch rejects - if (url.includes('f%3Agone.md')) return res(200, fileDoc('gone.md', '3-r', ['h:g1'])); - return res(404, {}); - }; - await couch.removeFile('gone.md'); // never throws - expect(state.pendingDeletePaths()).toEqual(['gone.md']); - expect(state.revFor('gone.md')).toBe('h:g1'); // rev kept so the retry can disambiguate - - handler = (url, method) => { - if (url.includes('/_changes')) return res(200, { results: [], last_seq: '0' }); - if (url.includes('f%3Agone.md') && method === 'DELETE') return res(200, { ok: true }); - if (url.includes('f%3Agone.md')) return res(200, fileDoc('gone.md', '3-r', ['h:g1'])); - return res(404, {}); - }; - await couch.tick(); - expect(state.pendingDeletePaths()).toEqual([]); // tombstone landed -> dequeued - expect(state.revFor('gone.md')).toBeUndefined(); // and its rev cache dropped - }); -}); - -describe('local-deletion reconciliation via the rev cache', () => { - it('tombstones a known path absent from the vault, leaving present files untouched', async () => { - const vault = new FakeVault({ 'keep.md': 'K' }); // present - const state = new CouchState( - () => undefined, - async () => {} - ); - await state.setRev('gone.md', 'h:g1'); // known but absent -> a local deletion - const couch = makeSync(vault, state); - - const deleted: string[] = []; - handler = (url, method) => { - if (url.includes('_bulk_docs')) return res(200, []); - if (url.includes('f%3Agone.md') && method === 'DELETE') { - deleted.push('gone.md'); - return res(200, { ok: true }); - } - if (url.includes('f%3Agone.md')) return res(200, fileDoc('gone.md', '2-x', ['h:g1'])); - if (url.includes('f%3Akeep.md') && method === 'PUT') return res(200, { ok: true }); - return res(404, {}); // keep.md GET -> not on server yet - }; - await couch.pushAll(); - expect(deleted).toEqual(['gone.md']); // known-but-absent -> tombstoned - expect(state.revFor('gone.md')).toBeUndefined(); // rev-cache entry dropped - expect(state.revFor('keep.md')).toBeDefined(); // present file kept (pushed, not deleted) - }); -}); - -describe('pull-delete does not resurface as a phantom local deletion', () => { - it('a pull-applied delete drops the rev so the next pushAll issues no tombstone', async () => { - const vault = new FakeVault({ 'gone.md': 'BODY' }); - const state = new CouchState( - () => undefined, - async () => {} - ); - await state.setRev('gone.md', 'h:old'); // we had synced it - const couch = makeSync(vault, state); - - handler = (url) => { - if (url.includes('/_changes')) - return res(200, { results: [{ id: 'f:gone.md', deleted: true }], last_seq: '5' }); - return res(404, {}); - }; - await couch.pullOnce(); - expect(vault.content('gone.md')).toBeUndefined(); - expect(state.revFor('gone.md')).toBeUndefined(); // pull dropped the rev cache - - let deleteCalls = 0; - handler = (url, method) => { - if (url.includes('/_changes')) return res(200, { results: [], last_seq: '5' }); - if (method === 'DELETE') deleteCalls++; - return res(404, {}); - }; - await couch.pushAll(); - expect(deleteCalls).toBe(0); // not re-detected as a local deletion - }); -}); - -describe('tombstone-409 - edit wins over a stale delete', () => { - it('abandons the deletion on a 409 and lets the next pull restore the newer doc', async () => { - const vault = new FakeVault(); // file already gone locally - const state = new CouchState( - () => undefined, - async () => {} - ); - await state.setRev('race.md', 'h:v1'); // we knew v1 - const couch = makeSync(vault, state); - - handler = (url, method) => { - if (url.includes('f%3Arace.md') && method === 'DELETE') - return res(409, { error: 'conflict' }); - if (url.includes('f%3Arace.md')) return res(200, fileDoc('race.md', '2-a', ['h:v1'])); - return res(404, {}); - }; - await couch.pushAll(); - expect(state.pendingDeletePaths()).toEqual([]); // 409 is terminal -> never queued - expect(state.revFor('race.md')).toBeUndefined(); // rev dropped -> not re-detected - - handler = (url) => { - if (url.includes('/_changes')) - return res(200, { - results: [{ id: 'f:race.md', doc: fileDoc('race.md', '3-b', ['h:v2']) }], - last_seq: '7', - }); - if (url.includes('h%3Av2')) return res(200, { data: 'NEW' }); - return res(404, {}); - }; - await couch.pullOnce(); - expect(vault.content('race.md')).toBe('NEW'); // newer edit restored, not force-deleted - }); -}); - -describe('pre-DELETE content-rev mismatch - edit wins before any DELETE is issued', () => { - it('abandons the deletion with no DELETE request when couch already advanced the content', async () => { - const vault = new FakeVault(); // file already gone locally - const state = new CouchState( - () => undefined, - async () => {} - ); - await state.setRev('race.md', 'h:v1'); // we knew v1 - const couch = makeSync(vault, state); - - let deleteCalls = 0; - handler = (url, method) => { - if (url.includes('f%3Arace.md') && method === 'DELETE') { - deleteCalls++; - return res(200, { ok: true }); - } - if (url.includes('f%3Arace.md')) return res(200, fileDoc('race.md', '2-a', ['h:v2'])); - return res(404, {}); - }; - await couch.pushAll(); - expect(deleteCalls).toBe(0); // abandoned before any DELETE - content already moved on - expect(state.pendingDeletePaths()).toEqual([]); // never queued - expect(state.revFor('race.md')).toBeUndefined(); // rev dropped -> not re-detected - }); -}); - -describe('a locally-absent path deletes without error', () => { - it('treats a doc already absent on the server as an idempotent success', async () => { - const vault = new FakeVault(); - const state = new CouchState( - () => undefined, - async () => {} - ); - const couch = makeSync(vault, state); - handler = () => res(404, {}); // nothing on the server - await expect(couch.removeFile('gone.md')).resolves.toBeUndefined(); - expect(state.pendingDeletePaths()).toEqual([]); // not queued - }); -}); - -describe('resumable, paged pull cursor', () => { - it('pages _changes with successive since= values and persists the cursor', async () => { - const vault = new FakeVault(); - const b = backing(); - const state = new CouchState(b.load, b.save); - const couch = makeSync(vault, state, { pageLimit: 2 }); - handler = (url) => { - if (url.includes('/_changes') && url.includes('since=0')) - return res(200, { - results: [ - { - id: 'f:a.md', - doc: { _id: 'f:a.md', type: 'file', path: 'a.md', size: 3, leaves: ['h:a1'] }, - }, - { - id: 'f:b.md', - doc: { _id: 'f:b.md', type: 'file', path: 'b.md', size: 3, leaves: ['h:b1'] }, - }, - ], - last_seq: '2', - }); - if (url.includes('/_changes') && url.includes('since=2')) - return res(200, { results: [], last_seq: '2' }); - if (url.includes('h%3Aa1')) return res(200, { data: 'AAA' }); - if (url.includes('h%3Ab1')) return res(200, { data: 'BBB' }); - return res(404, {}); - }; - - await couch.pullOnce(); - - const changes = changesUrls(); - expect(changes).toHaveLength(2); - expect(changes[0]).toContain('since=0'); - expect(changes[1]).toContain('since=2'); - expect(changes[0]).toContain('limit=2'); - expect(vault.content('a.md')).toBe('AAA'); - expect(vault.content('b.md')).toBe('BBB'); - expect(state.getCursor()).toBe('2'); - expect(b.get()?.cursor).toBe('2'); // persisted -> survives a reload - }); -}); - -describe('a missing leaf never truncates the note', () => { - it('aborts the pull, leaves the file untouched, and does not advance the cursor', async () => { - const vault = new FakeVault({ 'notes/a.md': 'ORIGINAL' }); - const state = new CouchState( - () => undefined, - async () => {} - ); - const couch = makeSync(vault, state); - handler = (url) => { - if (url.includes('/_changes')) - return res(200, { - results: [ - { - id: 'f:notes/a.md', - doc: { - _id: 'f:notes/a.md', - type: 'file', - path: 'notes/a.md', - size: 5, - leaves: ['h:AAA', 'h:BBB'], - }, - }, - ], - last_seq: '3', - }); - if (url.includes('h%3AAAA')) return res(200, { _id: 'h:AAA', _rev: '1-x', data: 'hello' }); - return res(404, {}); // h:BBB (and anything else) is missing - }; - - await expect(couch.pullOnce()).rejects.toThrow('missing leaf'); - expect(vault.modifyCalls).toBe(0); - expect(vault.content('notes/a.md')).toBe('ORIGINAL'); - expect(state.getCursor()).toBe('0'); // cursor NOT advanced -> next tick retries - }); -}); - -describe('a non-2xx pull surfaces as a failing sync', () => { - it('throws on a non-2xx _changes feed instead of silently succeeding', async () => { - const vault = new FakeVault(); - const b = backing(); - const state = new CouchState(b.load, b.save); - const couch = makeSync(vault, state); - handler = (url) => - url.includes('/_changes') ? res(403, { error: 'forbidden' }) : res(404, {}); - await expect(couch.pullOnce()).rejects.toThrow('_changes 403'); - expect(state.getCursor()).toBe('0'); // cursor NOT advanced - }); - - it('tick logs a failing pull instead of throwing (fire-and-forget safe)', async () => { - const vault = new FakeVault(); - const state = new CouchState( - () => undefined, - async () => {} - ); - const couch = makeSync(vault, state); - handler = (url) => (url.includes('/_changes') ? res(500, {}) : res(404, {})); - await expect(couch.tick()).resolves.toBeUndefined(); - }); -}); - -describe('syncNow is resilient like tick()', () => { - it('records a pull failure instead of throwing, and reports push/pull status', async () => { - const vault = new FakeVault({ 'n.md': 'X' }); - const state = new CouchState( - () => undefined, - async () => {} - ); - const couch = makeSync(vault, state); - handler = (url, method) => { - if (url.includes('/_changes')) throw new Error('pull boom'); // pull fails - if (url.includes('_bulk_docs')) return res(200, []); - if (url.includes('f%3A') && method === 'PUT') return res(200, { ok: true }); - return res(404, {}); // f: GET -> not on server yet - }; - const result = await couch.syncNow(); // must not throw - expect(result.pushed).toBe(true); - expect(result.pulled).toBe(false); - expect(result.error).toBe('pull boom'); - }); -}); - -// The honest preview count: no network, no controller - just vault content vs the push-rev cache. -describe('CouchSync.countOutgoing', () => { - const freshState = (init?: CouchMemoryState): CouchState => - new CouchState( - () => init, - async () => {} - ); - - it('a fresh memory (empty cache) counts EVERY local md file - the first-sync bug fix', async () => { - const vault = new FakeVault({ 'a.md': 'A', 'b.md': 'B', 'notes/c.md': 'C' }); - expect(await CouchSync.countOutgoing(vault, freshState())).toBe(3); - }); - - it('reports 0 once every file is at its pushed content rev (post-full-sync)', async () => { - const files = { 'a.md': 'A', 'b.md': 'B' }; - const vault = new FakeVault(files); - const revs: Record = {}; - for (const [p, c] of Object.entries(files)) revs[p] = await contentRevOf(c); - expect(await CouchSync.countOutgoing(vault, freshState({ revs }))).toBe(0); - }); - - it('counts only files whose content changed since the last push', async () => { - const vault = new FakeVault({ 'a.md': 'A-new', 'b.md': 'B' }); - const revs = { 'a.md': await contentRevOf('A-old'), 'b.md': await contentRevOf('B') }; - expect(await CouchSync.countOutgoing(vault, freshState({ revs }))).toBe(1); // only a.md - }); - - it('counts a cached path removed from the vault as an outgoing delete', async () => { - const vault = new FakeVault({ 'a.md': 'A' }); - const revs = { 'a.md': await contentRevOf('A'), 'gone.md': 'stale-rev' }; - expect(await CouchSync.countOutgoing(vault, freshState({ revs }))).toBe(1); // the delete - }); -}); diff --git a/src/couch/couch-sync.ts b/src/couch/couch-sync.ts deleted file mode 100644 index efa8c81..0000000 --- a/src/couch/couch-sync.ts +++ /dev/null @@ -1,375 +0,0 @@ -import { requestUrl, TFile, type Vault } from 'obsidian'; -import { - contentRev, - contentRevOf, - encodeFile, - fileId, - leafIdsOf, - pathOf, - type FileDoc, - type LeafDoc, -} from './couch-doc'; -import type { CouchState } from './couch-state'; - -// CouchDB sync channel (thin client, the onepager's "thin client" option) - now THE account -// channel for couch-flagged memories. Replicates the vault's markdown to a per-memory CouchDB -// using the SAME content-addressed model the server bridge expects (leaf docs + a file doc); -// the bridge then commits every couch edit to git. Uses Obsidian's requestUrl (no CORS, no -// PouchDB bundle) + Web Crypto (mobile-safe, no node builtins). Echo-safe: a push/pull whose -// content already matches is skipped, so the vault<->couch<->git loop converges. - -const DEFAULT_PAGE = 200; // _changes page size so a big feed never lands in one response -const SUPPRESS_MS = 200; // vault 'modify' fires async after our write; hold the echo guard past it -const ok2xx = (status: number): boolean => status >= 200 && status < 300; - -export interface CouchSyncConfig { - endpoint: string; // discovered couch host, e.g. https://couch. - db: string; // discovered per-memory db name (mem_) - pageLimit?: number; // _changes limit; defaults to DEFAULT_PAGE -} - -// Mints/caches the couch JWT (CouchTokenClient.token); the header is always "Bearer ". -export type CouchAuthorize = () => Promise; - -// Outcome of a full syncNow round. Resilient like tick(): a push/pull failure is recorded -// here (and logged) rather than thrown, so callers can report success/failure without a catch. -export interface SyncResult { - pushed: boolean; // pushAll finished without throwing - pulled: boolean; // pullOnce finished without throwing - error?: string; // first error message when either side failed -} - -// The minimal read surface countOutgoing needs: the vault's md files + their content, and the -// persisted push-rev cache. Lets the preview compute the outgoing count with NO live controller -// and NO network (the same seam the tests drive). -export interface OutgoingVault { - getMarkdownFiles(): { path: string }[]; - read(file: { path: string }): Promise; -} -export interface OutgoingState { - revFor(path: string): string | undefined; - knownPaths(): string[]; -} - -export class CouchSync { - private pulling = false; - private suppress = new Set(); // paths being written by a pull (skip their push) - - // The honest "to send" count for the sync popup, computed from vault content alone (no network, - // no controller). A file counts when its current content-rev is absent-from / differs-from the - // push-rev cache (a push pushAll would send) - on a fresh memory (empty cache) that is EVERY md - // file. A cached path no longer present in the vault counts as a delete (mirrors - // reconcileDeletions). Reuses contentRevOf (the exact rev pushFile caches) so it can never drift - // from what pushAll actually sends. - static async countOutgoing(vault: OutgoingVault, state: OutgoingState): Promise { - const md = vault.getMarkdownFiles(); - const present = new Set(md.map((f) => f.path)); - let pushes = 0; - for (const f of md) { - const rev = await contentRevOf(await vault.read(f)); - if (state.revFor(f.path) !== rev) pushes++; - } - const deletes = state.knownPaths().filter((p) => !present.has(p)).length; - return pushes + deletes; - } - - constructor( - private vault: Vault, - private cfg: CouchSyncConfig, - private authorize: CouchAuthorize, // supplies a valid couch JWT (see CouchTokenClient) - private onUnauthorized: () => void, // drop the token cache so a 401 retry re-mints - private state: CouchState, // persisted cursor + push-rev cache + pending pushes - private log: (msg: string) => void = () => {} - ) {} - - private url(p: string): string { - return `${this.cfg.endpoint.replace(/\/+$/, '')}/${this.cfg.db}${p}`; - } - private async req( - p: string, - init: { method?: string; body?: string } = {} - ): Promise<{ status: number; json: unknown }> { - const send = async (): Promise<{ status: number; json: unknown }> => { - const jwt = await this.authorize(); - const r = await requestUrl({ - url: this.url(p), - method: init.method ?? 'GET', - headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' }, - body: init.body, - throw: false, - }); - let json: unknown = null; - try { - json = r.json; - } catch { - /* non-JSON */ - } - return { status: r.status, json }; - }; - const out = await send(); - if (out.status !== 401) return out; - this.onUnauthorized(); // JWT expired/rejected -> re-mint and retry once - return send(); - } - - private async getDoc(id: string, rev?: string): Promise<(FileDoc & Partial) | null> { - const r = await this.req( - `/${encodeURIComponent(id)}${rev ? `?rev=${encodeURIComponent(rev)}` : ''}` - ); - return r.status === 200 ? (r.json as FileDoc & Partial) : null; - } - // Mirror the server's decodeFile: a missing leaf THROWS, never substitutes an empty chunk - // (which would truncate the note). The caller aborts the pull round and keeps the cursor. - private async reassemble(fdoc: FileDoc): Promise { - const parts: string[] = []; - for (const id of fdoc.leaves) { - const leaf = await this.getDoc(id); - if (!leaf || typeof leaf.data !== 'string') - throw new Error(`couch pull: missing leaf ${id} for ${fdoc.path}`); - parts.push(leaf.data); - } - return parts.join(''); - } - - // -- push: vault -> couch -------------------------------------------------- - async pushFile(path: string): Promise { - if (this.suppress.has(path)) return; // we are mid-pull-writing this file - const af = this.vault.getAbstractFileByPath(path); - if (!(af instanceof TFile) || af.extension !== 'md') return; - const body = await this.vault.read(af); - // Same rev source countOutgoing uses, so the preview count can never drift from what we push. - const rev = await contentRevOf(body); - if (this.state.revFor(path) === rev) return; // this exact content is already pushed - const { leaves, fileDoc } = await encodeFile(path, body); - const cur = await this.getDoc(fileDoc._id); - if (cur && !cur._deleted && JSON.stringify(cur.leaves) === JSON.stringify(fileDoc.leaves)) { - await this.state.setRev(path, rev); // remote already converged; cache so we skip next time - return; - } - const bulk = await this.req('/_bulk_docs', { - method: 'POST', - body: JSON.stringify({ new_edits: false, docs: leaves }), - }); - if (!ok2xx(bulk.status)) throw new Error(`couch push leaves ${bulk.status} for ${path}`); - // new_edits:false lists an entry ONLY for a leaf that genuinely failed to write, so any - // reported error means the file doc would reference a missing leaf - throw, caller re-queues. - const leafErr = (Array.isArray(bulk.json) ? bulk.json : []).find( - (e): e is { id?: string; error: string } => - !!e && typeof e === 'object' && typeof (e as { error?: unknown }).error === 'string' - ); - if (leafErr) - throw new Error(`couch push leaf ${leafErr.error} (${leafErr.id ?? '?'}) for ${path}`); - const put: FileDoc = { ...fileDoc }; - if (cur && cur._rev) put._rev = cur._rev; // normal update so this device's edit wins - const r = await this.req(`/${encodeURIComponent(put._id)}`, { - method: 'PUT', - body: JSON.stringify(put), - }); - // Cache the pushed rev only after couch accepted it, so a rejected push stays retryable. - if (!ok2xx(r.status)) throw new Error(`couch push ${r.status} for ${path}`); - await this.state.setRev(path, rev); - this.log(`push ${path} -> ${r.status}`); - } - - // Live push (sync-on-save): a failure queues the path so the next tick retries it, and a - // success clears any prior queue entry. Never throws (the vault event handler is void). - async pushFileLive(path: string): Promise { - await this.state.dequeueDelete(path); // latest intent is a write - cancel any queued delete - try { - await this.pushFile(path); - await this.state.dequeue(path); - } catch (e) { - await this.state.enqueue(path); - this.log(`push ${path} failed, queued: ${(e as Error).message}`); - } - } - - // Tombstone the file doc at its current couch rev. Terminal outcomes (no retry needed): - // 'deleted' - couch accepted the tombstone, or the doc was already gone - // 'conflict' - couch advanced under us (a newer edit) - abandon the delete and let the - // next pull re-deliver that newer version (edit-wins-over-stale-delete). - // Throws only on a transport/couch failure so the caller can keep the deletion pending. - private async tombstone(path: string): Promise<'deleted' | 'conflict'> { - const cur = await this.getDoc(fileId(path)); - if (!cur?._rev || cur._deleted) { - await this.state.dropRev(path); - return 'deleted'; // already absent on couch - } - // Content moved on since we last synced it -> another device edited it, not the version we - // deleted. Never force-delete the fresh rev; drop our rev so pull re-delivers it. - const known = this.state.revFor(path); - if (known !== undefined && contentRev(cur) !== known) { - await this.state.dropRev(path); - return 'conflict'; - } - const del = await this.req( - `/${encodeURIComponent(fileId(path))}?rev=${encodeURIComponent(cur._rev)}`, - { method: 'DELETE' } - ); - if (del.status === 409) { - await this.state.dropRev(path); // rev went stale mid-flight -> let pull win - return 'conflict'; - } - if (!ok2xx(del.status)) throw new Error(`couch delete ${del.status} for ${path}`); - await this.state.dropRev(path); - this.log(`delete ${path}`); - return 'deleted'; - } - - // Live delete (mirror of pushFileLive): a transport failure queues the tombstone so the next - // tick retries it, a terminal outcome (deleted/conflict) clears any prior queue entry. Never - // throws (the vault event handler is void), so the fire-and-forget caller stays safe. - async removeFile(path: string): Promise { - await this.state.dequeue(path); // latest intent is a delete - cancel any queued push - try { - await this.tombstone(path); - await this.state.dequeueDelete(path); - } catch (e) { - await this.state.enqueueDelete(path); - this.log(`delete ${path} failed, queued: ${(e as Error).message}`); - } - } - - // A file we have synced (rev cache) but that is no longer in the vault's markdown set was - // deleted locally - issue its tombstone. A couch doc with no rev-cache entry is new remote - // content (pull writes it), so it is never mistaken for a local deletion. - private async reconcileDeletions(): Promise { - const present = new Set(this.vault.getMarkdownFiles().map((f) => f.path)); - for (const path of this.state.knownPaths()) { - if (!present.has(path)) await this.removeFile(path); - } - } - - async pushAll(): Promise { - for (const f of this.vault.getMarkdownFiles()) await this.pushFileLive(f.path); - await this.reconcileDeletions(); - } - - // -- pull: couch -> vault -------------------------------------------------- - async pullOnce(): Promise { - if (this.pulling) return; - this.pulling = true; - const limit = this.cfg.pageLimit ?? DEFAULT_PAGE; - try { - for (;;) { - const since = this.state.getCursor(); - const r = await this.req( - `/_changes?since=${encodeURIComponent(since)}&include_docs=true&style=all_docs&limit=${limit}` - ); - // A non-2xx feed (403 channel disabled / 404 db gone / 5xx) is a real failure, not an - // empty page: throw so tick() logs it and the cursor is NOT advanced (401 already - // re-minted once in req). Silently returning [] would mask a broken sync. - if (!ok2xx(r.status)) throw new Error(`couch pull _changes ${r.status}`); - const body = r.json as { - results?: Array<{ id: string; deleted?: boolean; doc?: FileDoc }>; - last_seq?: string; - } | null; - const results = body?.results ?? []; - // Apply the whole page first; reassemble THROWS on a missing leaf, aborting the round - // before writeVault so no truncated body is written and the cursor is not advanced. - for (const ch of results) { - if (!ch.id.startsWith('f:')) continue; - const path = pathOf(ch.id); - if (ch.deleted || ch.doc?._deleted) { - const af = this.vault.getAbstractFileByPath(path); - if (af) await this.vault.delete(af); - await this.state.dropRev(path); - continue; - } - if (ch.doc) await this.writeVault(path, await this.reassemble(ch.doc)); - } - // Page fully applied - only now advance (and persist) the cursor. - if (body?.last_seq != null) await this.state.setCursor(body.last_seq); - if (results.length < limit || body?.last_seq == null || body.last_seq === since) break; - } - } finally { - this.pulling = false; - } - } - - private async writeVault(path: string, body: string): Promise { - const af = this.vault.getAbstractFileByPath(path); - if (af instanceof TFile) { - if ((await this.vault.read(af)) === body) return; // echo guard: already in sync - this.suppress.add(path); - try { - await this.vault.modify(af, body); - } finally { - window.setTimeout(() => this.suppress.delete(path), SUPPRESS_MS); - } - this.log(`pull ${path} (modify)`); - } else { - const dir = path.split('/').slice(0, -1).join('/'); - if (dir && !this.vault.getAbstractFileByPath(dir)) { - await this.vault.createFolder(dir).catch(() => {}); - } - this.suppress.add(path); - try { - await this.vault.create(path, body); - } finally { - window.setTimeout(() => this.suppress.delete(path), SUPPRESS_MS); - } - this.log(`pull ${path} (create)`); - } - // Cache the applied content rev so a later push does not echo it back to couch. - await this.state.setRev(path, (await leafIdsOf(body)).join(',')); - } - - // Retry every queued push, then pull. The periodic tick calls this so a dropped live push - // is not lost. Resilient: a failing pull is logged, not thrown, so the next tick retries. - async flushPending(): Promise { - for (const path of this.state.pendingPaths()) { - try { - await this.pushFile(path); - await this.state.dequeue(path); - } catch { - /* keep queued for the next tick */ - } - } - for (const path of this.state.pendingDeletePaths()) { - try { - await this.tombstone(path); - await this.state.dequeueDelete(path); - } catch { - /* keep queued for the next tick */ - } - } - } - - // Queued outgoing changes: live pushes + deletes that failed and await the next tick's retry. - // The honest "to send" count for the sync popup (no network round-trip). - pendingCount(): number { - return this.state.pendingPaths().length + this.state.pendingDeletePaths().length; - } - - async tick(): Promise { - await this.flushPending(); - try { - await this.pullOnce(); - } catch (e) { - this.log(`pull failed (will retry): ${(e as Error).message}`); - } - } - - // Resilient like tick(): a push or pull failure is recorded, not thrown, so a one-shot sync - // never rejects and the caller still learns which side failed. pushAll first-syncs a non-empty - // vault (echo-safe; the push rev-cache skips already-converged files) and reconciles deletions. - async syncNow(): Promise { - const out: SyncResult = { pushed: false, pulled: false }; - try { - await this.pushAll(); - out.pushed = true; - } catch (e) { - out.error = (e as Error).message; - this.log(`push failed (will retry): ${(e as Error).message}`); - } - try { - await this.pullOnce(); - out.pulled = true; - } catch (e) { - out.error ??= (e as Error).message; - this.log(`pull failed (will retry): ${(e as Error).message}`); - } - return out; - } -} diff --git a/src/couch/couch-token.test.ts b/src/couch/couch-token.test.ts deleted file mode 100644 index 90fef43..0000000 --- a/src/couch/couch-token.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { CouchTokenClient, parseCouchToken, type CouchTokenPost } from './couch-token'; - -const ok = (jwt: string, expSec = 3600): { status: number; json: unknown } => ({ - status: 200, - json: { success: true, data: { jwt, db: 'mem_abc', sub: 'u/work', expSec } }, -}); - -describe('parseCouchToken', () => { - it('reads the { data } envelope and defaults expSec', () => { - expect(parseCouchToken({ data: { jwt: 'j', db: 'd', sub: 's', expSec: 60 } })).toEqual({ - jwt: 'j', - db: 'd', - sub: 's', - expSec: 60, - }); - expect(parseCouchToken({ data: { jwt: 'j' } }).expSec).toBe(3600); - }); - - it('throws on a missing data object or jwt', () => { - expect(() => parseCouchToken(null)).toThrow('missing data'); - expect(() => parseCouchToken({})).toThrow('missing data'); - expect(() => parseCouchToken({ data: {} })).toThrow('missing jwt'); - }); -}); - -describe('CouchTokenClient - mint + cache + refresh', () => { - const make = ( - post: CouchTokenPost, - bearer: string | null = 'oauth-bearer', - now: () => number = () => 0 - ): CouchTokenClient => - new CouchTokenClient( - 'https://auth.x/account/couch-token', - 'work', - post, - async () => bearer, - now - ); - - it('mints once and serves the cache within the skew window', async () => { - const post = vi.fn(async () => ok('jwt-1')); - const c = make(post); - expect(await c.token()).toBe('jwt-1'); - expect(await c.token()).toBe('jwt-1'); - expect(post).toHaveBeenCalledTimes(1); - // The mint body carries { memory } and the OAuth bearer, never a signed credential. - expect(post).toHaveBeenCalledWith( - 'https://auth.x/account/couch-token', - JSON.stringify({ memory: 'work' }), - 'oauth-bearer' - ); - }); - - it('re-mints ~60s before expiry (skew) and again after invalidate()', async () => { - let now = 0; - let n = 0; - const post = vi.fn(async () => ok(`jwt-${++n}`, 100)); // exp = now + 100s - const c = make(post, 'oauth-bearer', () => now); - expect(await c.token()).toBe('jwt-1'); - now = 39_000; // still >60s before the 100s expiry -> cache - expect(await c.token()).toBe('jwt-1'); - now = 41_000; // within 60s of expiry -> re-mint - expect(await c.token()).toBe('jwt-2'); - c.invalidate(); // e.g. a 401 from CouchDB - expect(await c.token()).toBe('jwt-3'); - expect(post).toHaveBeenCalledTimes(3); - }); - - it('throws a clear error on a 401 (bearer expired/revoked)', async () => { - const post = vi.fn(async () => ({ status: 401, json: null })); - await expect(make(post).token()).rejects.toThrow('unauthorized'); - }); - - it('throws on other non-2xx and when not signed in', async () => { - await expect(make(async () => ({ status: 503, json: null })).token()).rejects.toThrow( - 'HTTP 503' - ); - await expect(make(async () => ok('j'), null).token()).rejects.toThrow('not signed in'); - }); -}); diff --git a/src/couch/couch-token.ts b/src/couch/couch-token.ts deleted file mode 100644 index fca3212..0000000 --- a/src/couch/couch-token.ts +++ /dev/null @@ -1,75 +0,0 @@ -// Mints + caches the per-(user,memory) CouchDB JWT from the auth service. The auth service -// is the sole minter (owns COUCH_JWT_SECRET); the plugin POSTs its OAuth bearer to the -// resolved `couch_token_url` and gets back a short-lived JWT it presents to CouchDB as -// `Bearer `. Sync never signs a credential. DI'd post + bearer + clock so it -// unit-tests in Node without Obsidian or a live server. - -export interface CouchTokenData { - jwt: string; - db: string; - sub: string; - expSec: number; -} - -// requestUrl-backed POST in main; a mock in tests. Returns the parsed JSON body + status. -export type CouchTokenPost = ( - url: string, - body: string, - bearer: string -) => Promise<{ status: number; json: unknown }>; - -export type GetBearer = () => Promise; -export type Clock = () => number; - -// Re-mint this far before the JWT's stated expiry so an in-flight couch request never -// carries a token that expires mid-request. -const SKEW_MS = 60_000; - -// Server envelope: { success, data: { jwt, db, sub, expSec } } (auth /account/couch-token). -export function parseCouchToken(raw: unknown): CouchTokenData { - const data = (raw as { data?: unknown } | null)?.data; - if (!data || typeof data !== 'object') throw new Error('couch-token: missing data'); - const d = data as Record; - if (typeof d.jwt !== 'string' || !d.jwt) throw new Error('couch-token: missing jwt'); - const db = typeof d.db === 'string' ? d.db : ''; - const sub = typeof d.sub === 'string' ? d.sub : ''; - const expSec = typeof d.expSec === 'number' && d.expSec > 0 ? d.expSec : 3600; - return { jwt: d.jwt, db, sub, expSec }; -} - -export class CouchTokenClient { - private cached?: { jwt: string; expiresAt: number }; - - constructor( - private readonly tokenUrl: string, - private readonly memory: string, - private readonly post: CouchTokenPost, - private readonly getBearer: GetBearer, - private readonly now: Clock - ) {} - - /** A valid couch JWT, minting a fresh one when the cache is empty or within ~60s of expiry. */ - async token(): Promise { - const c = this.cached; - if (c && this.now() < c.expiresAt - SKEW_MS) return c.jwt; - return this.mint(); - } - - /** Drop the cache so the next token() re-mints (call on a 401 from CouchDB). */ - invalidate(): void { - this.cached = undefined; - } - - private async mint(): Promise { - const bearer = await this.getBearer(); - if (!bearer) throw new Error('couch-token: not signed in'); - const res = await this.post(this.tokenUrl, JSON.stringify({ memory: this.memory }), bearer); - // 401 = the bearer was genuinely rejected (expired/revoked); web#399 landed OAuth-bearer - // acceptance on /account/couch-token, so this is no longer a known server gap. - if (res.status === 401) throw new Error('couch-token: unauthorized (OAuth bearer rejected)'); - if (res.status < 200 || res.status >= 300) throw new Error(`couch-token: HTTP ${res.status}`); - const data = parseCouchToken(res.json); - this.cached = { jwt: data.jwt, expiresAt: this.now() + data.expSec * 1000 }; - return data.jwt; - } -} diff --git a/src/main.test.ts b/src/main.test.ts deleted file mode 100644 index 20b6978..0000000 --- a/src/main.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import type { App, PluginManifest } from 'obsidian'; -import type { CouchSync } from './couch/couch-sync'; -import type { CouchChannel } from './couch/couch-channel'; - -// Captured Notice text so a guard-path test can assert the user-facing message (hoisted so the -// vi.mock factory below can close over it). -const { noticeMessages } = vi.hoisted(() => ({ noticeMessages: [] as string[] })); - -// Only the Obsidian runtime surface the whole main.ts import graph touches at load time -// (class bases + Platform.isDesktopApp); everything else the plugin uses is injected below. -vi.mock('obsidian', () => ({ - FileSystemAdapter: class FileSystemAdapter {}, - Menu: class Menu {}, - Modal: class Modal {}, - Notice: class Notice { - constructor(message: string) { - noticeMessages.push(message); - } - }, - Platform: { isDesktopApp: true }, - Plugin: class Plugin {}, - PluginSettingTab: class PluginSettingTab {}, - FuzzySuggestModal: class FuzzySuggestModal {}, - Setting: class Setting {}, - TFile: class TFile {}, - requestUrl: vi.fn(), - normalizePath: (p: string) => p, - debounce: (fn: unknown) => fn, -})); - -// A CouchSync stand-in with spied delegation methods (the channel only calls these three). -const fakeSync = (): CouchSync => - ({ - tick: vi.fn(async () => {}), - pushFileLive: vi.fn(async (_p: string) => {}), - removeFile: vi.fn(async (_p: string) => {}), - }) as unknown as CouchSync; - -// onAuthChanged/refreshStatus early-return without a status bar; settingTab is undefined. -type Testable = { - auth: { isSignedIn: () => boolean }; - couchChannel: CouchChannel; - onAuthChanged: () => void; -}; - -const makePlugin = async (): Promise => { - const { default: AgentageMemoryPlugin } = await import('./main'); - // The mocked Plugin base ignores its ctor args; pass stubs to satisfy the Obsidian types. - const plugin = new AgentageMemoryPlugin({} as unknown as App, {} as unknown as PluginManifest); - return plugin as unknown as Testable; -}; - -describe('onAuthChanged tears the couch controller down on an auto sign-out', () => { - it('clears the live controller when signed out so a later tick no-ops', async () => { - const plugin = await makePlugin(); - const a = fakeSync(); - plugin.couchChannel.for('A', () => a); - expect(plugin.couchChannel.active).toBe(true); - - plugin.auth = { isSignedIn: () => false }; // dead-session clear routed here (not disconnect) - plugin.onAuthChanged(); - - expect(plugin.couchChannel.active).toBe(false); - await plugin.couchChannel.tick(); - await plugin.couchChannel.pushFileLive('x.md'); - expect(a.tick).not.toHaveBeenCalled(); - expect(a.pushFileLive).not.toHaveBeenCalled(); - // A different user on the same memory rebuilds a fresh controller (correct db) instead of reusing the stale one. - const b = fakeSync(); - expect(plugin.couchChannel.for('A', () => b)).toBe(b); - }); - - it('keeps the live controller while still signed in', async () => { - const plugin = await makePlugin(); - const a = fakeSync(); - plugin.couchChannel.for('A', () => a); - - plugin.auth = { isSignedIn: () => true }; - plugin.onAuthChanged(); - - expect(plugin.couchChannel.active).toBe(true); - await plugin.couchChannel.tick(); - expect(a.tick).toHaveBeenCalledOnce(); - }); -}); - -describe('syncNow guards a couch memory from a git-bound folder', () => { - // A resolution advertising `work` on the couch channel (git endpoint = https://sync.x/u). - const resolution = { - gitEndpoint: 'https://sync.x/u', - region: 'default', - vaults: [], - ttl: 3600, - couchEndpoint: 'https://couch.x', - couchTokenUrl: 'https://auth.x/account/couch-token', - couchVaults: [{ vault: 'work', db: 'mem_abc' }], - }; - const gitConfig = (memory: string) => - `[remote "origin"]\n\turl = https://sync.x/u/${memory}.git\n`; - - type Harness = { - auth: { getValidToken: () => Promise }; - resolver: { resolve: (t: string) => Promise }; - settings: { origin: { remote: string }; vault: string }; - app: { - vault: { - adapter: { exists: (p: string) => Promise; read: (p: string) => Promise }; - }; - }; - couchSyncNow: (ch: unknown, memory: string) => Promise<{ ok: boolean; message: string }>; - syncNow: () => Promise<{ ok: boolean; message: string }>; - }; - - const makeHarness = async (adapter: Harness['app']['vault']['adapter']): Promise => { - const { default: AgentageMemoryPlugin } = await import('./main'); - const plugin = new AgentageMemoryPlugin( - {} as unknown as App, - {} as unknown as PluginManifest - ) as unknown as Harness; - plugin.auth = { getValidToken: async () => 'tok' }; - plugin.resolver = { resolve: async () => resolution }; - plugin.settings = { origin: { remote: '' }, vault: 'work' }; - plugin.app = { vault: { adapter } }; - return plugin; - }; - - it('blocks with a clear notice and never engages couch when the folder is git-bound elsewhere', async () => { - noticeMessages.length = 0; - const plugin = await makeHarness({ - exists: async () => true, - read: async () => gitConfig('default'), - }); - const couchSpy = vi.fn(async () => ({ ok: true, message: 'engaged' })); - plugin.couchSyncNow = couchSpy; - - const r = await plugin.syncNow(); - - expect(couchSpy).not.toHaveBeenCalled(); - expect(r.ok).toBe(false); - expect(r.message).toContain('git memory (default)'); - expect(noticeMessages.at(-1)).toContain('git memory (default)'); - }); - - it('engages couch normally for a clean (git-free) folder', async () => { - noticeMessages.length = 0; - const plugin = await makeHarness({ - exists: async () => false, - read: async () => { - throw new Error('no .git'); - }, - }); - const couchSpy = vi.fn(async () => ({ ok: true, message: 'work: couch synced' })); - plugin.couchSyncNow = couchSpy; - - const r = await plugin.syncNow(); - - expect(couchSpy).toHaveBeenCalledWith(expect.anything(), 'work'); - expect(r).toEqual({ ok: true, message: 'work: couch synced' }); - }); -}); diff --git a/src/main.ts b/src/main.ts index 2c20826..6e34312 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,4 @@ -import { FileSystemAdapter, Menu, Notice, Platform, Plugin, TFile, requestUrl } from 'obsidian'; +import { FileSystemAdapter, Menu, Notice, Platform, Plugin, requestUrl } from 'obsidian'; import type { FsClient, MergeDriverCallback } from 'isomorphic-git'; import { type AgentageMemorySettings, @@ -29,20 +29,10 @@ import { createAuthJsonWriter, readAuthJsonState } from './auth/auth-json'; import { startLoopbackServer } from './auth/loopback-server'; import type { HttpPost } from './auth/oauth'; import type { GetJson } from './auth/discovery'; -import { - HostResolver, - buildRepoUrl, - channelForVault, - gitMemoryFromConfig, - type SyncResolution, -} from './resolve-host'; +import { HostResolver, buildRepoUrl, type SyncResolution } from './resolve-host'; import { openMemoryChooser } from './memory-chooser'; import { openActionsMenu, type PluginAction } from './actions-menu'; import { openSyncPreview, type SyncPreview } from './sync-preview-modal'; -import { CouchSync, type CouchAuthorize } from './couch/couch-sync'; -import { CouchChannel } from './couch/couch-channel'; -import { CouchState } from './couch/couch-state'; -import { CouchTokenClient, type CouchTokenPost } from './couch/couch-token'; // Single-host: every origin derives from the site FQDN. Precedence: the persisted // `siteFqdn` setting (non-empty) > the AGENTAGE_SITE_FQDN env var (desktop only, same @@ -101,11 +91,6 @@ export default class AgentageMemoryPlugin extends Plugin implements SettingsHost // if the OS keyring (secretStorage) is unavailable; persisted via secretStorage + // ~/.agentage/auth.json. Hydrated from auth.json on desktop at load. private readonly secretCache = new Map(); - // Couch channel (discovery-driven): holds the one live controller per couch-channel memory, - // rebuilt on a memory switch and torn down on a git-route sync / sign-out (see CouchChannel). - private readonly couchChannel = new CouchChannel(); - private couchWired = false; - private couchTokenPost!: CouchTokenPost; async onload(): Promise { await this.loadSettings(); @@ -152,117 +137,6 @@ export default class AgentageMemoryPlugin extends Plugin implements SettingsHost this.autoSyncOnReady(false); } - // Live replication for the couch channel: push on md edits, pull on an interval. Registered - // ONCE (Obsidian ties registerEvent/registerInterval to unload); handlers delegate to the - // current per-memory controller, so a memory switch repoints without re-registering. - private wireCouchEvents(): void { - if (this.couchWired) return; - this.couchWired = true; - const onMd = (cb: (path: string) => void) => (f: unknown) => { - if (f instanceof TFile && f.extension === 'md') cb(f.path); - }; - this.registerEvent( - this.app.vault.on( - 'modify', - onMd((p) => void this.couchChannel.pushFileLive(p)) - ) - ); - this.registerEvent( - this.app.vault.on( - 'create', - onMd((p) => void this.couchChannel.pushFileLive(p)) - ) - ); - this.registerEvent( - this.app.vault.on( - 'delete', - onMd((p) => void this.couchChannel.removeFile(p)) - ) - ); - this.registerEvent( - this.app.vault.on('rename', (f, oldPath) => { - if (f instanceof TFile && f.extension === 'md') { - void this.couchChannel.removeFile(oldPath); - void this.couchChannel.pushFileLive(f.path); - } - }) - ); - // Tick = flush any queued (failed) pushes/deletes, then pull. A torn-down channel no-ops. - this.registerInterval(window.setInterval(() => void this.couchChannel.tick(), 2000)); - } - - /** Sync a couch-channel memory: discovery already resolved endpoint/db/tokenUrl; mint the - * per-memory JWT on demand (cached, re-minted on expiry/401) and replicate the thin-client - * doc model against it. One live controller per memory; the server bridge commits couch -> git. */ - private async couchSyncNow( - ch: { endpoint: string; db: string; tokenUrl: string }, - memory: string - ): Promise<{ ok: boolean; message: string }> { - const couch = this.couchChannel.for(memory, () => this.buildCouchSync(ch, memory)); - this.wireCouchEvents(); - this.setStatusBar('syncing'); - // CouchSync.syncNow is resilient (records, never throws): a failed side comes back as `error`. - const r = await couch.syncNow(); - if (r.error) { - this.setStatusBar('error', r.error); - return { ok: false, message: r.error }; - } - this.setStatusBar('idle'); - return { ok: true, message: `${memory}: couch synced` }; - } - - /** The agentage git memory this vault folder's `.git` is bound to, or null when there is no - * `.git/config` or its origin remote is not on `gitEndpoint`. Reads via the raw adapter since - * `.git` is outside Obsidian's TFile index. Any read error degrades to null (do not block). */ - private async gitBoundMemory(gitEndpoint: string): Promise { - const adapter = this.app.vault.adapter; - try { - if (!(await adapter.exists('.git/config'))) return null; - return gitMemoryFromConfig(await adapter.read('.git/config'), gitEndpoint); - } catch { - return null; - } - } - - /** Build a CouchSync for `memory`: mint the per-memory JWT on demand (cached, re-minted on - * expiry/401) + persist the pull cursor + push-rev cache + pending queues per (host, memory) - * through data.json, so a reload resumes instead of re-pulling from seq 0. */ - private buildCouchSync( - ch: { endpoint: string; db: string; tokenUrl: string }, - memory: string - ): CouchSync { - const tokenClient = new CouchTokenClient( - ch.tokenUrl, - memory, - this.couchTokenPost, - () => this.auth.getValidToken(), - () => Date.now() - ); - const authorize: CouchAuthorize = () => tokenClient.token(); - const state = this.couchStateFor(memory); - return new CouchSync( - this.app.vault, - { endpoint: ch.endpoint, db: ch.db }, - authorize, - () => tokenClient.invalidate(), - state, - (m) => console.debug('[Agentage Couch]', m) - ); - } - - /** The persisted couch state for `memory`, keyed by (host, memory). Loaded the same way for - * the live controller and the offline preview count, so both read the same push-rev cache. */ - private couchStateFor(memory: string): CouchState { - const key = `${this.activeFqdn}:${memory}`; - return new CouchState( - () => this.settings.couchState[key], - async (s) => { - this.settings.couchState[key] = s; - await this.saveSettings(); - } - ); - } - // --- site host (SettingsHost) --- /** The host every origin is derived from this session. */ activeSiteFqdn(): string { @@ -366,18 +240,6 @@ export default class AgentageMemoryPlugin extends Plugin implements SettingsHost }, () => Date.now() ); - // Mint the per-memory couch JWT: POST the plugin's OAuth bearer to the resolved - // couch_token_url; the auth service is the sole minter (CouchTokenClient caches it). - this.couchTokenPost = async (url, body, bearer) => { - const res = await requestUrl({ - url, - method: 'POST', - headers: { Authorization: `Bearer ${bearer}`, 'Content-Type': 'application/json' }, - body, - throw: false, - }); - return { status: res.status, json: safeJson(res.text) }; - }; } private setStatusBar(s: SyncStatus, msg?: string): void { @@ -406,8 +268,6 @@ export default class AgentageMemoryPlugin extends Plugin implements SettingsHost } private onAuthChanged(): void { - // Auto sign-out funnels here (not disconnect); drop the live couch controller so a new user reuses no stale db. - if (!this.auth.isSignedIn()) this.couchChannel.clear(); this.settingTab?.display(); this.refreshStatus(); } @@ -630,8 +490,6 @@ export default class AgentageMemoryPlugin extends Plugin implements SettingsHost } async disconnect(): Promise { await this.auth.disconnect(); - // Stop any live couch controller so it can't keep replicating into a signed-out vault. - this.couchChannel.clear(); // Keep the selected memory across logout/login (persisted in data.json) so the next // sign-in resumes the same memory with no re-pick. A deleted/renamed memory is caught // at sync time (resolution + push), not by forgetting the choice here. @@ -688,29 +546,8 @@ export default class AgentageMemoryPlugin extends Plugin implements SettingsHost }; } syncedVault = chosen; - // Exactly one channel per memory: a resolution that advertises this memory on the couch - // channel routes to the couch controller; every other memory stays on the git path. - const ch = channelForVault(res, syncedVault); - if (ch.channel === 'couch') { - // A couch memory needs a git-free folder. If this folder is still a git working tree - // bound to a DIFFERENT git memory, couch-syncing into it silently mixes two memories in - // one folder - surface the cause instead of no-oping (never auto-delete the user's .git). - const gitMemory = await this.gitBoundMemory(res.gitEndpoint); - if (gitMemory && gitMemory !== syncedVault) { - const message = - `This folder is already synced as a git memory (${gitMemory}). A couch memory needs ` + - `its own (git-free) folder - open a new vault or remove the git binding.`; - new Notice(message); - return { ok: false, message }; - } - this.lastVault = syncedVault; - return this.couchSyncNow(ch, syncedVault); - } remote = buildRepoUrl(res.gitEndpoint, syncedVault); } - // This memory is NOT couch: tear down any live couch controller so its live handlers + the - // 2s tick stop replicating the previous couch memory into (or out of) this git sync. - this.couchChannel.clear(); this.lastVault = syncedVault; // so "Open dashboard" points at the vault we actually synced const ctrl = this.buildController(); try { @@ -732,10 +569,7 @@ export default class AgentageMemoryPlugin extends Plugin implements SettingsHost } } - // Non-mutating preview of the next sync for the popup, routed per channel like syncNow. - // Git memories preview both directions via the controller. Couch memories show the HONEST - // outgoing count - local md whose content-rev differs from (or is absent from) the push-rev - // cache (EVERY md file on a fresh memory); incoming is omitted (only known after a pull). + // Non-mutating preview of the next sync for the popup: both directions via the controller. private async previewSync(): Promise { const token = await this.auth.getValidToken(); if (!token) return { outgoing: 0, firstSync: true }; @@ -745,10 +579,6 @@ export default class AgentageMemoryPlugin extends Plugin implements SettingsHost if (managed && !chosen) return { outgoing: 0, firstSync: true }; if (managed) { const res = await this.resolver.resolve(token); - if (channelForVault(res, chosen).channel === 'couch') { - const outgoing = await CouchSync.countOutgoing(this.app.vault, this.couchStateFor(chosen)); - return { outgoing, firstSync: false }; - } remote = buildRepoUrl(res.gitEndpoint, chosen); } return this.buildController().preview({ url: remote, token }); @@ -797,8 +627,6 @@ export default class AgentageMemoryPlugin extends Plugin implements SettingsHost this.settings = { ...DEFAULT_SETTINGS, ...data }; this.settings.origin = { ...DEFAULT_SETTINGS.origin, ...this.settings.origin }; if (!Array.isArray(this.settings.mcp)) this.settings.mcp = [...DEFAULT_SETTINGS.mcp]; - // Fresh object so mutating couch state never aliases the shared DEFAULT_SETTINGS. - this.settings.couchState = { ...(data?.couchState ?? {}) }; } async saveSettings(): Promise { diff --git a/src/resolve-host.test.ts b/src/resolve-host.test.ts index 5a60198..667ff5e 100644 --- a/src/resolve-host.test.ts +++ b/src/resolve-host.test.ts @@ -1,11 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; -import { - HostResolver, - parseResolution, - buildRepoUrl, - channelForVault, - gitMemoryFromConfig, -} from './resolve-host'; +import { HostResolver, parseResolution, buildRepoUrl, gitMemoryFromConfig } from './resolve-host'; describe('resolve-host (R7 resolve + 1h cache)', () => { it('parses a well-formed resolution and defaults ttl/region/vaults', () => { @@ -63,62 +57,6 @@ describe('resolve-host (R7 resolve + 1h cache)', () => { }); }); -describe('resolve-host - couch channel parsing (additive)', () => { - const couchPayload = { - git_endpoint: 'https://sync.x/u', - vaults: ['notes'], - couch_endpoint: 'https://couch.x', - couch_token_url: 'https://auth.x/account/couch-token', - couch_vaults: [{ vault: 'work', db: 'mem_abc' }], - }; - - it('parses couch fields when endpoint + token url + vaults are all present', () => { - const r = parseResolution(couchPayload); - expect(r.vaults).toEqual(['notes']); - expect(r.couchEndpoint).toBe('https://couch.x'); - expect(r.couchTokenUrl).toBe('https://auth.x/account/couch-token'); - expect(r.couchVaults).toEqual([{ vault: 'work', db: 'mem_abc' }]); - }); - - it('degrades to git-only when the payload has no couch fields (old server)', () => { - const r = parseResolution({ git_endpoint: 'https://sync.x/u', vaults: ['notes', 'work'] }); - expect(r.couchEndpoint).toBeUndefined(); - expect(r.couchTokenUrl).toBeUndefined(); - expect(r.couchVaults).toBeUndefined(); - }); - - it('drops a partial couch advert (missing token url) rather than half-enabling it', () => { - const r = parseResolution({ ...couchPayload, couch_token_url: undefined }); - expect(r.couchEndpoint).toBeUndefined(); - expect(r.couchVaults).toBeUndefined(); - }); - - it('filters malformed couch_vault entries; empties collapse to git-only', () => { - const r = parseResolution({ - ...couchPayload, - couch_vaults: [{ vault: 'work' }, { db: 'x' }, 42, null], - }); - expect(r.couchVaults).toBeUndefined(); - expect(r.couchEndpoint).toBeUndefined(); - }); - - it('channelForVault routes a couch vault to couch and everything else to git', () => { - const r = parseResolution(couchPayload); - expect(channelForVault(r, 'work')).toEqual({ - channel: 'couch', - endpoint: 'https://couch.x', - db: 'mem_abc', - tokenUrl: 'https://auth.x/account/couch-token', - }); - expect(channelForVault(r, 'notes')).toEqual({ channel: 'git' }); - }); - - it('channelForVault is git for a git-only resolution', () => { - const r = parseResolution({ git_endpoint: 'https://sync.x/u', vaults: ['notes'] }); - expect(channelForVault(r, 'notes')).toEqual({ channel: 'git' }); - }); -}); - describe('gitMemoryFromConfig - the git memory a folder is bound to', () => { const cfg = (url: string) => `[core]\n\trepositoryformatversion = 0\n[remote "origin"]\n\turl = ${url}\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n`; diff --git a/src/resolve-host.ts b/src/resolve-host.ts index ab07d1d..71f5838 100644 --- a/src/resolve-host.ts +++ b/src/resolve-host.ts @@ -3,47 +3,18 @@ // for the response ttl (default 1h). fetch + clock are INJECTED so it unit-tests in // Node without Obsidian or a live server. -// A couch-channel memory advertised in the resolution: its per-memory db on the shared -// cluster. The JWT is NOT here - the client mints it from `couchTokenUrl` (the auth -// service is the sole minter). Mirrors @agentage/sync's CouchVault (server contract). -export interface CouchVaultResolution { - vault: string; - db: string; -} - export interface SyncResolution { gitEndpoint: string; region: string; vaults: string[]; ttl: number; - // Present only when the user has >=1 couch-channel memory. A vault appears in EITHER - // `vaults` (git) or `couchVaults` (couch), never both - one channel per memory. Absent - // => today's git-only shape, parsed exactly as before (old-server back-compat). - couchEndpoint?: string; - couchTokenUrl?: string; - couchVaults?: CouchVaultResolution[]; } -/** The sync channel a resolved memory uses. Exactly one per memory. */ -export type VaultChannel = - { channel: 'git' } | { channel: 'couch'; endpoint: string; db: string; tokenUrl: string }; - export type FetchJson = (url: string, token: string) => Promise<{ status: number; json: unknown }>; export type Clock = () => number; const WELL_KNOWN = '/.well-known/agentage-sync'; -// Server field names (packages/sync/src/resolution.ts): couch_endpoint, couch_token_url, -// couch_vaults[{ vault, db }]. Parsed only when all three are present + well-formed, so a -// partial/old payload degrades to git rather than half-advertising a couch channel. -function parseCouchVault(raw: unknown): CouchVaultResolution | null { - if (!raw || typeof raw !== 'object') return null; - const o = raw as Record; - if (typeof o.vault !== 'string' || !o.vault) return null; - if (typeof o.db !== 'string' || !o.db) return null; - return { vault: o.vault, db: o.db }; -} - export function parseResolution(raw: unknown): SyncResolution { if (!raw || typeof raw !== 'object') throw new Error('resolution: not an object'); const r = raw as Record; @@ -55,32 +26,7 @@ export function parseResolution(raw: unknown): SyncResolution { ? r.vaults.filter((v): v is string => typeof v === 'string') : []; const ttl = typeof r.ttl === 'number' && r.ttl > 0 ? r.ttl : 3600; - const base: SyncResolution = { gitEndpoint, region, vaults, ttl }; - const couchEndpoint = typeof r.couch_endpoint === 'string' ? r.couch_endpoint : ''; - const couchTokenUrl = typeof r.couch_token_url === 'string' ? r.couch_token_url : ''; - const couchVaults = Array.isArray(r.couch_vaults) - ? r.couch_vaults.map(parseCouchVault).filter((v): v is CouchVaultResolution => v !== null) - : []; - if (couchEndpoint && couchTokenUrl && couchVaults.length) { - base.couchEndpoint = couchEndpoint; - base.couchTokenUrl = couchTokenUrl; - base.couchVaults = couchVaults; - } - return base; -} - -/** Which sync channel a resolved memory uses. A vault named in `couchVaults` is on the - * couch channel (endpoint/db/tokenUrl attached); everything else defaults to git. */ -export function channelForVault(res: SyncResolution, vault: string): VaultChannel { - const couch = res.couchVaults?.find((v) => v.vault === vault); - if (couch && res.couchEndpoint && res.couchTokenUrl) - return { - channel: 'couch', - endpoint: res.couchEndpoint, - db: couch.db, - tokenUrl: res.couchTokenUrl, - }; - return { channel: 'git' }; + return { gitEndpoint, region, vaults, ttl }; } /** Build the per-vault git remote URL from a resolved endpoint. Token is NEVER here. */ @@ -89,8 +35,7 @@ export function buildRepoUrl(gitEndpoint: string, vault: string): string { } /** The agentage git memory a `.git/config` is bound to via its origin remote, or null when the - * folder has no origin remote on `gitEndpoint`. Inverse of buildRepoUrl - used to guard a - * couch-routed memory from silently syncing into a folder still bound to a git memory. */ + * folder has no origin remote on `gitEndpoint`. Inverse of buildRepoUrl. */ export function gitMemoryFromConfig(gitConfig: string, gitEndpoint: string): string | null { const url = originRemoteUrl(gitConfig); if (!url) return null; diff --git a/src/settings.ts b/src/settings.ts index 91e7a3b..710b9ec 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -35,22 +35,6 @@ export interface AgentageMemorySettings { /** Dev/testing host override, set directly in data.json (no on-page editor). Empty = env-or-prod. * Read once at load by resolveSiteFqdn; applies on the next Obsidian restart. */ siteFqdn: string; - /** Per-(host, memory) couch sync state (pull cursor + push-rev cache + pending pushes), - * keyed ":". Plugin-local; never written to vaults.json. */ - couchState: Record; -} - -/** Persisted couch sync state for one (host, memory). All fields optional so an older - * data.json (or a fresh memory) hydrates to sensible defaults. */ -export interface CouchMemoryState { - /** Last fully-applied _changes seq; resumes the pull after a reload. */ - cursor?: string; - /** path -> last pushed content rev (ordered leaf ids); skips an unchanged push. */ - revs?: Record; - /** Paths whose live push failed, retried on the next tick. */ - pending?: string[]; - /** Paths whose live DELETE failed, retried on the next tick. */ - pendingDeletes?: string[]; } export const MCP_ENDPOINT = 'https://memory.agentage.io/mcp'; @@ -87,7 +71,6 @@ export const DEFAULT_SETTINGS: AgentageMemorySettings = { configDir: '~/.agentage', writtenVaultName: '', siteFqdn: '', - couchState: {}, }; export const PROD_SITE_FQDN = 'agentage.io'; diff --git a/src/sync-preview-modal.ts b/src/sync-preview-modal.ts index 56d0c38..80a2192 100644 --- a/src/sync-preview-modal.ts +++ b/src/sync-preview-modal.ts @@ -1,14 +1,13 @@ import { type App, Modal, Setting } from 'obsidian'; export interface SyncPreview { - incoming?: number; // git channel: files to receive from the cloud; couch omits it (only known after a pull) - outgoing: number; // local changes to send up (couch: content differs from the push cache - honest count) + incoming?: number; // files to receive from the cloud + outgoing: number; // local changes to send up firstSync: boolean; // no memory chosen / not signed in yet - nothing to preview } -// The post-sign-in sync popup: shows what the next sync will move, then runs the sync and -// reports the result. Git memories show both directions; couch memories show only the honest -// outgoing count. Informational + non-blocking (the sync auto-starts). +// The post-sign-in sync popup: shows what the next sync will move (both directions), then runs +// the sync and reports the result. Informational + non-blocking (the sync auto-starts). class SyncPreviewModal extends Modal { constructor( app: App, diff --git a/test/fakes/boot.ts b/test/fakes/boot.ts index bfb9e08..15842da 100644 --- a/test/fakes/boot.ts +++ b/test/fakes/boot.ts @@ -6,7 +6,6 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { FakeVault } from './fake-vault'; import { fakeSecrets, type FakeSecrets } from './fake-secrets'; -import { FakeCouch } from './fake-couch'; import { FakeAuthServer, type FakeMemory } from './fake-auth-server'; import { Router } from './router'; import { requestUrlMock, makeFakeApp } from './obsidian'; @@ -20,24 +19,16 @@ export interface BootOptions { fqdn?: string; files?: Record; memories?: FakeMemory[]; - memoryName?: string; // the couch-channel memory the resolution advertises - couchDb?: string; - // Additional couch-channel memories, each with its own db name + fresh in-memory store, so a - // memory-switch test can prove the controller repoints to a new db without reusing the old one. - extraCouch?: Array<{ memory: string; db: string }>; desktop?: boolean; // Platform.isDesktopApp; false -> obsidian:// deep-link sign-in path } export interface Handles { plugin: import('../../src/main').default; - couch: FakeCouch; auth: FakeAuthServer; vault: FakeVault; router: Router; secrets: FakeSecrets; configDir: string; - /** Extra couch stores keyed by memory name (only when BootOptions.extraCouch was given). */ - extraCouch: Record; /** URLs passed to window.open (the authorize redirect). */ openedUrls: string[]; teardown: () => Promise; @@ -53,20 +44,11 @@ type WindowStub = { export async function bootPlugin(opts: BootOptions = {}): Promise { const fqdn = opts.fqdn ?? 'test.local'; - const memoryName = opts.memoryName ?? 'work'; - const couchDb = opts.couchDb ?? 'mem_work'; const vault = new FakeVault(opts.files ?? {}); const secrets = fakeSecrets(); - const couch = new FakeCouch(couchDb); - const auth = new FakeAuthServer({ memories: opts.memories ?? [], couchDb }); - const extra = (opts.extraCouch ?? []).map((e) => ({ - memory: e.memory, - couch: new FakeCouch(e.db), - })); - const extraCouch: Record = {}; - for (const e of extra) extraCouch[e.memory] = e.couch; - const router = new Router({ fqdn, auth, couch, memoryName, extraCouch: extra }); + const auth = new FakeAuthServer({ memories: opts.memories ?? [] }); + const router = new Router({ fqdn, auth }); requestUrlMock.mockImplementation(router.requestUrl); @@ -118,13 +100,11 @@ export async function bootPlugin(opts: BootOptions = {}): Promise { return { plugin, - couch, auth, vault, router, secrets, configDir, - extraCouch, openedUrls, teardown, }; diff --git a/test/fakes/fake-auth-server.ts b/test/fakes/fake-auth-server.ts index 1215dc4..7cff4e3 100644 --- a/test/fakes/fake-auth-server.ts +++ b/test/fakes/fake-auth-server.ts @@ -1,6 +1,6 @@ // In-memory Better Auth AS + management API: OAuth 2.1 discovery / DCR / token / refresh / -// revoke, the couch-token minter, and GET/POST /api/memories. Contracts mirror the real -// wire the plugin's src/auth/* + src/couch/couch-token + main.ts:listVaults speak. +// revoke, and GET/POST /api/memories. Contracts mirror the real wire the plugin's src/auth/* +// + main.ts:listVaults speak. export interface FakeMemory { name: string; @@ -28,13 +28,11 @@ export class FakeAuthServer { private clientSeq = 0; private tokenSeq = 0; readonly memories: FakeMemory[]; - readonly couchDb: string; private failNextStatus?: number; private failNextMgmtStatus?: number; - constructor(opts: { memories?: FakeMemory[]; couchDb?: string } = {}) { + constructor(opts: { memories?: FakeMemory[] } = {}) { this.memories = opts.memories ?? []; - this.couchDb = opts.couchDb ?? 'mem_default'; } failNext(status: number): void { @@ -53,7 +51,7 @@ export class FakeAuthServer { return { status, json: { error: { message: 'management endpoint unavailable' } } }; } - /** Is `access` a live (unrevoked) access token? Used by the api + couch-token guards. */ + /** Is `access` a live (unrevoked) access token? Used by the api guards. */ isValidAccess(access: string): boolean { return this.tokens.has(access); } @@ -97,7 +95,6 @@ export class FakeAuthServer { if (pathname.endsWith('/register')) return this.register(); if (pathname.endsWith('/token')) return this.token(body); if (pathname.endsWith('/revoke')) return { status: 200, json: {} }; - if (pathname.endsWith('/account/couch-token')) return this.couchToken(body); return { status: 404, json: { error: 'not_found' } }; } @@ -143,17 +140,6 @@ export class FakeAuthServer { return { status: 400, json: { error: 'unsupported_grant_type' } }; } - private couchToken(body?: string): AuthReply { - const memory = (JSON.parse(body ?? '{}') as { memory?: string }).memory ?? 'default'; - return { - status: 200, - json: { - success: true, - data: { jwt: `couch-jwt-${memory}`, db: this.couchDb, sub: `u/${memory}`, expSec: 3600 }, - }, - }; - } - /** GET /api/memories -> { data:[{name,entries,folderCount,updated}] }. */ listMemories(): AuthReply { return this.takeMgmtFailure() ?? { status: 200, json: { data: this.memories } }; diff --git a/test/fakes/fake-couch.ts b/test/fakes/fake-couch.ts deleted file mode 100644 index 66701df..0000000 --- a/test/fakes/fake-couch.ts +++ /dev/null @@ -1,197 +0,0 @@ -import type { FileDoc, LeafDoc } from '../../src/couch/couch-doc'; - -// An in-memory CouchDB matching the exact wire contract src/couch/couch-sync.ts speaks: -// - docs keyed by id: file docs `f:` + leaf docs `h:`, each with a _rev -// - an ordered changes log with a monotonic integer seq (stringified as last_seq) -// - GET / -> doc (200) or 404 -// - POST /_bulk_docs -> [] (leaves accepted) or [{id,error}] (scripted leaf failure) -// - PUT /f: -> upsert + bump _rev + append a change; 409 on a stale _rev -// - DELETE /f:?rev -> 409 if stale, else tombstone + a deleted change -// - GET /_changes?since=&limit= -> a page slice + last_seq -// Fault knobs (failNext / unauthorizeUntilRemint / dropLeaf / injectRemoteChange) are wired -// now for the PR-2 scenario fan-out, exercised by the smoke only through the happy path. - -type StoredDoc = (FileDoc | LeafDoc) & { _rev: string; _deleted?: boolean }; -interface ChangeRow { - seq: number; - id: string; - rev: string; - deleted: boolean; -} - -export interface CouchReply { - status: number; - json: unknown; -} - -const rev = (n: number, tag: string): string => `${n}-${tag.slice(0, 8).padStart(8, '0')}`; -const revNum = (r: string): number => Number(r.split('-')[0]) || 0; - -export class FakeCouch { - private docs = new Map(); - private changes: ChangeRow[] = []; - private seq = 0; - private failStatus?: number; - private unauthorizeOnce = false; - private droppedLeaves = new Set(); - private failLeaf?: { id: string; error: string }; - - constructor(readonly db: string) {} - - // -- fault knobs (PR-2) ----------------------------------------------------- - /** The next request (any) returns this status once, then normal service resumes. */ - failNext(status: number): void { - this.failStatus = status; - } - /** Return 401 once (as an expired couch JWT would), forcing the client to re-mint + retry. */ - unauthorizeUntilRemint(): void { - this.unauthorizeOnce = true; - } - /** Make a leaf GET 404 even though the file doc references it (truncation-guard test). */ - dropLeaf(id: string): void { - this.droppedLeaves.add(id); - } - /** Undo dropLeaf so a subsequent clean pull can reassemble the note (recovery-path test). */ - restoreLeaf(id: string): void { - this.droppedLeaves.delete(id); - } - /** Script the next _bulk_docs to report a per-leaf failure for `id`. */ - failLeafOnBulk(id: string, error = 'forbidden'): void { - this.failLeaf = { id, error }; - } - /** Seed a remote-origin change (as another device would) so a pull delivers it. */ - injectRemoteChange(path: string, body: string, leaves: LeafDoc[]): void { - for (const l of leaves) this.docs.set(l._id, { ...l, _rev: l._rev || rev(1, l._id.slice(2)) }); - const id = `f:${path}`; - const prev = this.docs.get(id); - const n = prev ? revNum(prev._rev) + 1 : 1; - const doc: StoredDoc = { - _id: id, - _rev: rev(n, path + body.length), - type: 'file', - path, - size: body.length, - leaves: leaves.map((l) => l._id), - }; - this.docs.set(id, doc); - this.append(id, doc._rev, false); - } - - /** Tombstone a doc as another device would (a remote-origin delete a pull then applies). */ - deleteRemote(path: string): void { - const id = `f:${path}`; - const cur = this.docs.get(id); - if (!cur || cur._deleted) return; - const n = revNum(cur._rev) + 1; - const tomb: StoredDoc = { ...cur, _rev: rev(n, id + 'del'), _deleted: true }; - this.docs.set(id, tomb); - this.append(id, tomb._rev, true); - } - - // -- assertion helpers ------------------------------------------------------ - fileDoc(path: string): FileDoc | undefined { - const d = this.docs.get(`f:${path}`); - return d && d._deleted !== true && (d as FileDoc).type === 'file' ? (d as FileDoc) : undefined; - } - hasLeaf(id: string): boolean { - return this.docs.has(id); - } - filePaths(): string[] { - return [...this.docs.values()] - .filter((d) => (d as FileDoc).type === 'file' && !d._deleted) - .map((d) => (d as FileDoc).path) - .sort(); - } - lastSeq(): number { - return this.seq; - } - - private append(id: string, r: string, deleted: boolean): void { - // Latest-wins per id (CouchDB collapses superseded rows), so drop any prior row for this id. - this.changes = this.changes.filter((c) => c.id !== id); - this.changes.push({ seq: ++this.seq, id, rev: r, deleted }); - } - - // -- request dispatch ------------------------------------------------------- - /** Handle one couch request; `path` is everything after `/`. */ - handle(method: string, path: string, body?: string): CouchReply { - if (this.unauthorizeOnce) { - this.unauthorizeOnce = false; - return { status: 401, json: { error: 'unauthorized' } }; - } - if (this.failStatus !== undefined) { - const status = this.failStatus; - this.failStatus = undefined; - return { status, json: { error: 'scripted failure' } }; - } - const [rawId, query = ''] = path.replace(/^\//, '').split('?'); - const id = decodeURIComponent(rawId); - if (id === '_bulk_docs') return this.bulkDocs(body); - if (id === '_changes') return this.changesFeed(query); - if (method === 'GET') return this.getDoc(id); - if (method === 'PUT') return this.putDoc(id, body); - if (method === 'DELETE') return this.deleteDoc(id, query); - return { status: 405, json: { error: 'method not allowed' } }; - } - - private getDoc(id: string): CouchReply { - if (id.startsWith('h:') && this.droppedLeaves.has(id)) return { status: 404, json: {} }; - const d = this.docs.get(id); - if (!d || d._deleted) return { status: 404, json: { error: 'not_found' } }; - return { status: 200, json: d }; - } - - private bulkDocs(body?: string): CouchReply { - const parsed = JSON.parse(body ?? '{}') as { docs?: LeafDoc[] }; - for (const leaf of parsed.docs ?? []) { - if (this.failLeaf && this.failLeaf.id === leaf._id) { - const err = this.failLeaf; - this.failLeaf = undefined; - return { status: 200, json: [{ id: err.id, error: err.error }] }; - } - if (!this.docs.has(leaf._id)) - this.docs.set(leaf._id, { ...leaf, _rev: leaf._rev || rev(1, leaf._id.slice(2)) }); - } - return { status: 200, json: [] }; // new_edits:false -> no per-doc rows on success - } - - private putDoc(id: string, body?: string): CouchReply { - const put = JSON.parse(body ?? '{}') as FileDoc; - const cur = this.docs.get(id); - if (cur && !cur._deleted && cur._rev !== put._rev) - return { status: 409, json: { error: 'conflict' } }; - const n = cur ? revNum(cur._rev) + 1 : 1; - const stored: StoredDoc = { ...put, _rev: rev(n, id + (put.leaves?.join('') ?? '')) }; - this.docs.set(id, stored); - this.append(id, stored._rev, false); - return { status: 201, json: { ok: true, id, rev: stored._rev } }; - } - - private deleteDoc(id: string, query: string): CouchReply { - const revParam = new URLSearchParams(query).get('rev'); - const cur = this.docs.get(id); - if (!cur || cur._deleted) return { status: 404, json: { error: 'not_found' } }; - if (revParam !== cur._rev) return { status: 409, json: { error: 'conflict' } }; - const n = revNum(cur._rev) + 1; - const tomb: StoredDoc = { ...cur, _rev: rev(n, id + 'del'), _deleted: true }; - this.docs.set(id, tomb); - this.append(id, tomb._rev, true); - return { status: 200, json: { ok: true, id, rev: tomb._rev } }; - } - - private changesFeed(query: string): CouchReply { - const q = new URLSearchParams(query); - const since = Number(q.get('since') ?? '0') || 0; - const limit = Number(q.get('limit') ?? '200') || 200; - const rows = this.changes.filter((c) => c.seq > since).sort((a, b) => a.seq - b.seq); - const page = rows.slice(0, limit); - const results = page.map((c) => { - const doc = this.docs.get(c.id) as FileDoc | undefined; - return c.deleted - ? { id: c.id, deleted: true, changes: [{ rev: c.rev }] } - : { id: c.id, changes: [{ rev: c.rev }], doc }; - }); - const last = page.length ? page[page.length - 1].seq : since; - return { status: 200, json: { results, last_seq: String(last) } }; - } -} diff --git a/test/fakes/fake-vault.ts b/test/fakes/fake-vault.ts index c4ddd74..4c16d4d 100644 --- a/test/fakes/fake-vault.ts +++ b/test/fakes/fake-vault.ts @@ -1,8 +1,8 @@ import { TFile } from 'obsidian'; -// Lifted from src/couch/couch-sync.test.ts and extended with a real event emitter so the -// assembled plugin's live handlers (vault.on 'create'/'modify'/'delete'/'rename') can be -// driven, and with getName/adapter so main.ts's vault-name + root-path helpers work. +// A minimal in-memory Vault fake with a real event emitter so an assembled-plugin test can +// drive vault.on('create'/'modify'/'delete'/'rename'), plus getName/adapter so main.ts's +// vault-name + root-path helpers work. export type VaultEvent = 'create' | 'modify' | 'delete' | 'rename'; type Listener = (file: TFile, oldPath?: string) => void; diff --git a/test/fakes/router.ts b/test/fakes/router.ts index 8b1c2a0..f8f89b4 100644 --- a/test/fakes/router.ts +++ b/test/fakes/router.ts @@ -1,20 +1,13 @@ import type { RequestUrlParam, RequestUrlResponse } from 'obsidian'; -import { FakeCouch } from './fake-couch'; import { FakeAuthServer } from './fake-auth-server'; // The single requestUrl seam: every HTTP call the assembled plugin makes goes through here. -// Dispatch by host to the right fake (sync. discovery, auth. AS + couch-token, api. memories, -// couch. the in-memory CouchDB). Counts calls so a test can assert "re-sync is zero-HTTP". +// Dispatch by host to the right fake (sync. discovery, auth. AS, api. memories). Counts calls +// so a test can assert "re-sync is zero-HTTP". export interface RouterOptions { fqdn: string; // active site fqdn, e.g. 'test.local' auth: FakeAuthServer; - couch: FakeCouch; - memoryName: string; // the couch-channel memory the resolution advertises - // Extra couch-channel memories, each backed by its OWN in-memory db (for a memory-switch test - // that must prove the controller repoints to a new db, never reusing the previous one). The - // primary { memoryName, couch } is always advertised first; these are appended. - extraCouch?: Array<{ memory: string; couch: FakeCouch }>; } export class Router { @@ -28,9 +21,6 @@ export class Router { get syncOrigin(): string { return `https://sync.${this.opts.fqdn}`; } - get couchOrigin(): string { - return `https://couch.${this.opts.fqdn}`; - } get authOrigin(): string { return `https://auth.${this.opts.fqdn}`; } @@ -56,29 +46,15 @@ export class Router { } as unknown as RequestUrlResponse; } - // The dual-channel resolution (GET sync./.well-known/agentage-sync): git_endpoint is - // always present (git = main channel); the couch fields advertise the couch-channel memories. + // The resolution (GET sync./.well-known/agentage-sync): git_endpoint is the sync host. private resolution(): unknown { - const couch_vaults = [ - { vault: this.opts.memoryName, db: this.opts.couch.db }, - ...(this.opts.extraCouch ?? []).map((e) => ({ vault: e.memory, db: e.couch.db })), - ]; return { git_endpoint: `https://sync.${this.opts.fqdn}`, region: 'default', ttl: 3600, - couch_endpoint: this.couchOrigin, - couch_token_url: `${this.authOrigin}/account/couch-token`, - couch_vaults, }; } - /** The FakeCouch backing a given db (primary or an extra), or undefined for an unknown db. */ - private couchForDb(db: string): FakeCouch | undefined { - if (db === this.opts.couch.db) return this.opts.couch; - return this.opts.extraCouch?.find((e) => e.couch.db === db)?.couch; - } - /** The vi.mock('obsidian').requestUrl implementation. */ requestUrl = async (param: RequestUrlParam | string): Promise => { const p = typeof param === 'string' ? { url: param } : param; @@ -90,17 +66,6 @@ export class Router { if (host === new URL(this.syncOrigin).host) return this.reply(200, this.resolution()); - if (host === new URL(this.couchOrigin).host) { - // `...//`; route to the FakeCouch that owns so a memory switch that repoints - // to a new db never lands on the previous memory's store. - const db = url.slice(this.couchOrigin.length + 1).split('/')[0]; - const target = this.couchForDb(db); - if (!target) return this.reply(404, { error: `no fake for db ${db}` }); - const rest = url.slice(`${this.couchOrigin}/${db}`.length); - const r = target.handle(method, rest, body); - return this.reply(r.status, r.json); - } - if (host === new URL(this.apiOrigin).host) { if (url.includes('/api/memories')) return method === 'POST' diff --git a/test/integration/_helpers.ts b/test/integration/_helpers.ts index 11a0ebb..130b7da 100644 --- a/test/integration/_helpers.ts +++ b/test/integration/_helpers.ts @@ -1,18 +1,13 @@ import { bootPlugin, signIn, type BootOptions, type Handles } from '../fakes/boot'; -import { encodeFile, type LeafDoc } from '../../src/couch/couch-doc'; // Shared scenario setup for the assembled-plugin integration tests. Boots the plugin against -// the fakes, signs in, and selects the couch-channel memory so a test starts at "ready to sync". -// Deterministic: no timers/network beyond the fakes; ticks are driven explicitly, never by sleep. +// the fakes and signs in so a test starts at "ready to sync". export const MEMORY = 'work'; -export const DB = 'mem_work'; -/** Boot + advertise one couch-channel memory named MEMORY (the wedge single-memory flow). */ +/** Boot + advertise one memory named MEMORY. */ export async function bootReady(opts: BootOptions = {}): Promise { return bootPlugin({ - memoryName: MEMORY, - couchDb: DB, memories: [{ name: MEMORY, entries: 0, folderCount: 0, updated: null }], ...opts, }); @@ -26,89 +21,10 @@ export async function bootSignedIn(opts: BootOptions = {}): Promise { return h; } -/** The content-addressed leaf docs the fake couch wants for injectRemoteChange (mirror encodeFile). */ -export async function leavesFor(body: string): Promise { - return (await encodeFile('x', body)).leaves; -} - -/** Seed a remote-origin note (as another device would) so a pull delivers it. */ -export async function seedRemote(h: Handles, path: string, body: string): Promise { - h.couch.injectRemoteChange(path, body, await leavesFor(body)); -} - -// The live couch controller + its 2s tick are reachable only through the plugin's private -// couchChannel; a test drives sync via the public syncNow()/tick surface below. -interface CouchChannelLike { - tick(): Promise; - pendingCount(): number; -} -interface WithCouchChannel { - couchChannel: CouchChannelLike; -} - -/** Drive one explicit couch tick (flush queued pushes/deletes, then pull). No wall-clock wait. */ -export async function tick(h: Handles): Promise { - await (h.plugin as unknown as WithCouchChannel).couchChannel.tick(); -} - -/** Queued outgoing changes (failed live pushes + deletes) the next tick will retry. */ -export function pendingCount(h: Handles): number { - return (h.plugin as unknown as WithCouchChannel).couchChannel.pendingCount(); -} - -/** True while a live couch controller is attached (a sign-out / non-couch route tears it down). */ -export function couchActive(h: Handles): boolean { - return (h.plugin as unknown as { couchChannel: { active: boolean } }).couchChannel.active; -} - // The private status field that drives the dot tone (idle/syncing -> green, error/conflict -> // red). The dot itself is a headless chainable no-op, so a test reads the state that computes it. export function syncStateOf(h: Handles): string { return (h.plugin as unknown as { syncState: string }).syncState; } -/** Run `turns` macrotask ticks unconditionally, letting a fire-and-forget handler run to - * completion even when its outcome is "no state change" (e.g. a delete the plugin abandons). */ -export async function drain(turns = 12): Promise { - for (let i = 0; i < turns; i++) await new Promise((r) => setTimeout(r, 0)); -} - -/** Fire a vault event through the plugin's live handlers AND mutate the in-memory vault. Note - * the vault mutation is synchronous but the plugin's push/delete handler is async - await - * settle()/drain() before asserting on couch state. */ -export function edit(h: Handles, path: string, content: string): void { - const create = h.vault.getAbstractFileByPath(path) === null; - h.vault.trigger(create ? 'create' : 'modify', path, content); -} -export function del(h: Handles, path: string): void { - h.vault.trigger('delete', path); -} - -/** Drain the microtask queue until `done()` holds so the plugin's fire-and-forget void handlers - * (pushFileLive/removeFile, each a bounded async chain: Web-Crypto digest -> getDoc -> _bulk_docs - * -> PUT/DELETE against the fakes) settle. Deterministic: the fakes never touch a real timer or - * socket, so the chain converges on microtasks alone - no sleep, no wall clock. Throws if it - * has not converged after `max` turns, surfacing a genuinely stuck handler instead of hanging. */ -export async function settle(done: () => boolean = () => true, max = 200): Promise { - // Yield to the macrotask queue too: Web-Crypto digest resolves off the microtask queue, so a - // pure Promise.resolve() drain would spin without ever running the push's hashing step. A 0ms - // timer fires on the next loop tick with no wall-clock wait, keeping the test deterministic. - for (let i = 0; i < max; i++) { - if (done()) return; - await new Promise((r) => setTimeout(r, 0)); - } - if (!done()) throw new Error('settle: handlers did not converge'); -} - -// The plugin's in-memory secret mirror (main.ts secretCache) is where the live access-token -// expiry lives; rewinding it is the deterministic stand-in for "the OAuth access token expired -// mid-session" without an injectable clock, forcing getValidToken down its refresh branch. -const EXPIRES_AT_SECRET = 'agentage-memory-token-expires-at'; -interface WithSecretCache { - secretCache: Map; -} -export function expireAccessToken(h: Handles): void { - (h.plugin as unknown as WithSecretCache).secretCache.set(EXPIRES_AT_SECRET, '1'); -} - -export { bootPlugin, signIn, type Handles }; +export { signIn, type Handles }; diff --git a/test/integration/delete-conflict.test.ts b/test/integration/delete-conflict.test.ts deleted file mode 100644 index f49301f..0000000 --- a/test/integration/delete-conflict.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, it, expect, afterEach, vi } from 'vitest'; -import { obsidianMockFactory } from '../fakes/obsidian'; -vi.mock('obsidian', () => obsidianMockFactory()); - -import type { LeafDoc } from '../../src/couch/couch-doc'; -import { bootSignedIn, seedRemote, del, settle, drain, tick, type Handles } from './_helpers'; - -// Data-loss guards - the highest-risk journeys. Every assertion checks BOTH vault state and -// fake-couch state so a truncation or a phantom deletion can't hide behind a green push count. - -describe('delete + conflict data-loss guards', () => { - let h: Handles; - afterEach(async () => h.teardown()); - - // A two-leaf remote note we can partially break: dropping one leaf makes reassemble throw. - const twoLeafNote = ( - path: string, - a: string, - b: string - ): { leaves: LeafDoc[]; full: string; leafB: string } => { - const leaves: LeafDoc[] = [ - { _id: 'h:leaf-a', _rev: '1-a', data: a }, - { _id: 'h:leaf-b', _rev: '1-b', data: b }, - ]; - return { leaves, full: a + b, leafB: 'h:leaf-b' }; - }; - - it('1. a missing-leaf pull never truncates: round aborts, cursor frozen, then converges', async () => { - h = await bootSignedIn(); - const note = twoLeafNote('big.md', 'AAAA', 'BBBB'); - h.couch.injectRemoteChange('big.md', note.full, note.leaves); - h.couch.dropLeaf(note.leafB); // the file doc references leaf-b but couch 404s it - - // The pull round hits the missing leaf, throws inside reassemble, and aborts BEFORE any write. - const before = h.couch.lastSeq(); - const r1 = await h.plugin.syncNow(); - expect(r1.ok).toBe(false); // surfaced as an error, never a silent empty pull - expect(h.vault.content('big.md')).toBeUndefined(); // no truncated body written - expect(h.vault.paths()).toEqual([]); - - // The cursor was NOT advanced (still at seq 0), so the same change stays pending for a retry. - void before; - const key = `${h.plugin.activeSiteFqdn()}:work`; - expect(h.plugin.settings.couchState[key]?.cursor ?? '0').toBe('0'); - - // Restore the leaf; the next clean pull re-reads the frozen cursor's page and converges. - h.couch.restoreLeaf(note.leafB); - const r2 = await h.plugin.syncNow(); - expect(r2.ok).toBe(true); - expect(h.vault.content('big.md')).toBe('AAAABBBB'); // full note, no truncation - }); - - it('2. delete -> tombstone (DELETE ?rev), and a pull-applied delete does not resurface locally', async () => { - h = await bootSignedIn({ files: { 'note.md': 'body' } }); - await h.plugin.syncNow(); - expect(h.couch.fileDoc('note.md')).toBeDefined(); - - // Local delete tombstones the file doc via a rev-scoped DELETE (the wire shape that matters). - h.router.reset(); - del(h, 'note.md'); - await settle(() => h.couch.fileDoc('note.md') === undefined); - const delCall = h.router.calls.find((c) => c.method === 'DELETE'); - expect(delCall).toBeDefined(); - expect(delCall?.url).toMatch(/note\.md\?rev=/); // DELETE ?rev=, not a blind delete - expect(h.couch.fileDoc('note.md')).toBeUndefined(); - - // A remote delete of a DIFFERENT file pulls in and removes it locally; it does NOT then get - // re-pushed as a phantom local deletion (the pull dropRev keeps it out of reconcileDeletions). - await seedRemote(h, 'remote.md', 'remote note'); - await h.plugin.syncNow(); - expect(h.vault.content('remote.md')).toBe('remote note'); - h.couch.deleteRemote('remote.md'); - h.router.reset(); - const r = await h.plugin.syncNow(); - expect(r.ok).toBe(true); - expect(h.vault.content('remote.md')).toBeUndefined(); // pull applied the remote delete - // No phantom: the pull-applied delete never bounces back out as an extra DELETE for remote.md. - const phantom = h.router.calls.filter( - (c) => c.method === 'DELETE' && c.url.includes('remote.md') - ); - expect(phantom).toEqual([]); - }); - - it('3. stale delete abandoned when content moved on: edit wins, next pull restores the newer doc', async () => { - h = await bootSignedIn({ files: { 'doc.md': 'v1' } }); - await h.plugin.syncNow(); // pushes doc.md@v1, rev-cache = v1's content rev - - // Another device advances the doc to v2 on couch (a newer edit than the one we knew). - await seedRemote(h, 'doc.md', 'v2-from-other-device'); - - // We delete doc.md locally. tombstone sees couch content-rev != our known rev -> abandons the - // delete (edit-wins-over-stale-delete), never force-deleting the fresher remote version. - del(h, 'doc.md'); // vault removal is synchronous; the tombstone attempt is async - await drain(); // let the abandon-on-conflict path run to completion - expect(h.couch.fileDoc('doc.md')).toBeDefined(); // the newer remote doc survived our stale delete - - // The next pull restores the newer doc into the vault (the edit won). - const r = await h.plugin.syncNow(); - expect(r.ok).toBe(true); - expect(h.vault.content('doc.md')).toBe('v2-from-other-device'); - }); -}); diff --git a/test/integration/first-sync.test.ts b/test/integration/first-sync.test.ts deleted file mode 100644 index 55c069a..0000000 --- a/test/integration/first-sync.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { obsidianMockFactory } from '../fakes/obsidian'; - -// The whole plugin import graph resolves 'obsidian' to the harness mock (class bases + -// Platform + the requestUrl spy). Everything else the plugin uses is injected via the fake -// app + the Router behind requestUrl - no real Obsidian binary, no network. -vi.mock('obsidian', () => obsidianMockFactory()); - -import { bootPlugin, signIn, type Handles } from '../fakes/boot'; - -describe('first-sync smoke: connect -> pick memory -> seed vault -> zero-HTTP re-sync', () => { - let h: Handles; - - beforeEach(async () => { - // The server already holds one couch-channel memory 'work' with a seeded note, so the - // first sync PULLS remote content into an empty local vault (the wedge first-run flow). - h = await bootPlugin({ - files: {}, - memoryName: 'work', - couchDb: 'mem_work', - memories: [{ name: 'work', entries: 1, folderCount: 0, updated: '2026-07-08' }], - }); - h.couch.injectRemoteChange('welcome.md', 'Hello from the cloud', [ - { _id: 'h:seed', _rev: '1-seed', data: 'Hello from the cloud' }, - ]); - }); - - afterEach(async () => { - await h.teardown(); - }); - - it('signs in, selects the memory, first-syncs the seed note, then re-syncs with no HTTP', async () => { - // 1. Not signed in at boot. - expect(h.plugin.isSignedIn()).toBe(false); - - // 2. Connect: discovery -> DCR -> authorize -> code exchange (all through the router). - await signIn(h); - expect(h.plugin.isSignedIn()).toBe(true); - - // 3. Pick the memory + run the first sync (selectVault triggers autoSyncOnReady). - await h.plugin.selectVault('work'); - const result = await h.plugin.syncNow(); - - // 4. First sync succeeded and seeded the local vault from couch. - expect(result.ok).toBe(true); - expect(h.vault.content('welcome.md')).toBe('Hello from the cloud'); - expect(h.vault.paths()).toEqual(['welcome.md']); - - // 5. Fake-couch state agrees: the seeded file doc + its leaf exist server-side. - expect(h.couch.filePaths()).toEqual(['welcome.md']); - expect(h.couch.hasLeaf('h:seed')).toBe(true); - - // 6. Re-sync with nothing changed does ZERO write HTTP: the push rev-cache + pull cursor - // are warm, so the only traffic is the single empty _changes poll (no PUT/POST/DELETE). - h.router.reset(); - const again = await h.plugin.syncNow(); - expect(again.ok).toBe(true); - const writes = h.router.calls.filter((c) => c.method !== 'GET'); - expect(writes).toEqual([]); - // The one GET is the empty _changes poll, and it advertises the caught-up cursor. - const changes = h.router.calls.filter((c) => c.url.includes('/_changes')); - expect(changes).toHaveLength(1); - expect(changes[0].url).toContain(`since=${h.couch.lastSeq()}`); - }); -}); diff --git a/test/integration/lifecycle.test.ts b/test/integration/lifecycle.test.ts index 3f15ec2..464470d 100644 --- a/test/integration/lifecycle.test.ts +++ b/test/integration/lifecycle.test.ts @@ -2,78 +2,15 @@ import { describe, it, expect, afterEach, vi } from 'vitest'; import { obsidianMockFactory } from '../fakes/obsidian'; vi.mock('obsidian', () => obsidianMockFactory()); -import { - bootReady, - bootSignedIn, - signIn, - edit, - settle, - tick, - couchActive, - syncStateOf, - MEMORY, - type Handles, -} from './_helpers'; +import { bootReady, signIn, syncStateOf, MEMORY, type Handles } from './_helpers'; -// Account lifecycle: a memory the server has not put on the couch channel, sign-out teardown, a -// memory switch that must repoint to a new db, and the connect flow. These guard the "never a -// silent no-op, never the wrong db" invariants. +// The connect flow: discovery -> DCR -> authorize -> token exchange, dot goes green. describe('account lifecycle', () => { let h: Handles; afterEach(async () => h.teardown()); - it('10. a memory not on the couch channel routes to git and never touches couch', async () => { - h = await bootSignedIn(); - // Dual-channel: a memory the resolution does NOT advertise on couch syncs via git. The - // fake serves no git smart-HTTP, so the git sync itself fails - what matters here is the - // couch side stays untouched and inert (no silent cross-channel write). - await h.plugin.selectVault('not-migrated'); - const r = await h.plugin.syncNow(); - - expect(r.ok).toBe(false); // the git path surfaced its own error (no git server in the fake) - expect(r.message).not.toMatch(/not on the new sync channel/i); // couch-only error is gone - expect(couchActive(h)).toBe(false); // the git route tears down any live couch controller - expect(h.couch.filePaths()).toEqual([]); // and crucially: nothing was written to couch - }); - - it('11. sign-out tears the controller down; a later tick no-ops (no couch traffic)', async () => { - h = await bootSignedIn(); - await h.plugin.syncNow(); - expect(couchActive(h)).toBe(true); - - await h.plugin.disconnect(); - expect(h.plugin.isSignedIn()).toBe(false); - expect(couchActive(h)).toBe(false); // controller cleared on sign-out - - h.router.reset(); - await tick(h); // the 2s interval keeps firing after sign-out - it must be inert - expect(h.router.calls).toEqual([]); // zero couch traffic from a torn-down channel - }); - - it('12. a memory switch repoints the controller to a new db; the old db is not reused', async () => { - h = await bootSignedIn({ extraCouch: [{ memory: 'work2', db: 'mem_work2' }] }); - await h.plugin.syncNow(); // build the live controller for the primary memory 'work' - - // Sync memory 'work' (db mem_work): an edit lands in the primary store. - edit(h, 'a.md', 'in work'); - await settle(() => h.couch.fileDoc('a.md') !== undefined); - await h.plugin.syncNow(); - expect(h.couch.filePaths()).toContain('a.md'); - - // Switch to 'work2' (db mem_work2), then sync so the controller rebuilds against the new db. - await h.plugin.selectVault('work2'); - await h.plugin.syncNow(); // repoints the live controller to mem_work2 - edit(h, 'b.md', 'in work2'); - await settle(() => h.extraCouch['work2'].fileDoc('b.md') !== undefined); - await h.plugin.syncNow(); - - // b.md landed in the NEW db only; the OLD db never saw it (no cross-db leakage). - expect(h.extraCouch['work2'].filePaths()).toContain('b.md'); - expect(h.couch.filePaths()).not.toContain('b.md'); - }); - - it('13. connect flow: discovery -> DCR -> authorize -> token exchange, dot goes green', async () => { + it('connect flow: discovery -> DCR -> authorize -> token exchange, dot goes green', async () => { h = await bootReady(); expect(h.plugin.isSignedIn()).toBe(false); // gray dot at boot diff --git a/test/integration/preview-count.test.ts b/test/integration/preview-count.test.ts deleted file mode 100644 index 412c43f..0000000 --- a/test/integration/preview-count.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { obsidianMockFactory } from '../fakes/obsidian'; - -// The plugin import graph resolves 'obsidian' to the harness mock; everything else is injected -// via the fake app + the Router behind requestUrl. See test/integration/first-sync.test.ts. -vi.mock('obsidian', () => obsidianMockFactory()); - -import { bootPlugin, signIn, type Handles } from '../fakes/boot'; -import type { SyncPreview } from '../../src/sync-preview-modal'; - -// The sync popup's count comes from the private previewSync(); expose it via a cast (same pattern -// the signIn helper uses to reach auth.handleCallback). -const preview = (h: Handles): Promise => - (h.plugin as unknown as { previewSync(): Promise }).previewSync(); - -describe('sync-preview count is honest on a FRESH memory (the first-sync bug)', () => { - let h: Handles; - - beforeEach(async () => { - // A fresh couch-channel memory 'work' the CLOUD side is EMPTY for, while the LOCAL vault - // already has three markdown notes - the exact repro: first sync must push all three. - h = await bootPlugin({ - files: { 'a.md': 'Alpha', 'b.md': 'Bravo', 'notes/c.md': 'Charlie' }, - memoryName: 'work', - couchDb: 'mem_work', - memories: [{ name: 'work', entries: 0, folderCount: 0, updated: null }], - }); - }); - - afterEach(async () => { - await h.teardown(); - }); - - it('reports N (not 0) before the first push, then 0 after a full sync', async () => { - await signIn(h); - await h.plugin.selectVault('work'); - - // BEFORE any sync: the honest outgoing count is every local md file, NOT 0 (the old bug read - // the failed-push retry queue, which is empty on a fresh pick and reported "0 to send"). - const before = await preview(h); - expect(before.firstSync).toBe(false); - expect(before.outgoing).toBe(3); - - // Run the real sync (pushes all three to the fake couch). - const result = await h.plugin.syncNow(); - expect(result.ok).toBe(true); - expect(h.couch.filePaths()).toEqual(['a.md', 'b.md', 'notes/c.md']); - - // AFTER a full sync: the push-rev cache is warm, so the preview honestly reports nothing to send. - const after = await preview(h); - expect(after.outgoing).toBe(0); - }); - - it('firstSync=true when no memory is chosen yet', async () => { - await signIn(h); - // Signed in but selectVault not called -> nothing to preview. - const p = await preview(h); - expect(p.firstSync).toBe(true); - expect(p.outgoing).toBe(0); - }); -}); diff --git a/test/integration/resilience.test.ts b/test/integration/resilience.test.ts deleted file mode 100644 index bd0538b..0000000 --- a/test/integration/resilience.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { describe, it, expect, afterEach, vi } from 'vitest'; -import { obsidianMockFactory } from '../fakes/obsidian'; -vi.mock('obsidian', () => obsidianMockFactory()); - -import { - bootSignedIn, - seedRemote, - edit, - settle, - drain, - tick, - pendingCount, - leavesFor, - expireAccessToken, - type Handles, -} from './_helpers'; - -// Fault-injection resilience: a 401 mid-replication, an expired OAuth token, a dropped live -// push, and a resumable cursor across a reload. Each drives the fault explicitly, then asserts -// the sync converges (no silent stall, no lost edit). - -describe('replication resilience', () => { - let h: Handles; - afterEach(async () => h.teardown()); - - it('6. couch 401 mid-replication -> re-mint the couch JWT + retry once, sync completes', async () => { - h = await bootSignedIn(); - await seedRemote(h, 'seed.md', 'seed body'); // give the pull something to deliver - - // The next couch request comes back 401 (an expired couch JWT); CouchSync must invalidate - // its token cache and re-mint before retrying the same request in the same round. - h.couch.unauthorizeUntilRemint(); - h.router.reset(); - const r = await h.plugin.syncNow(); - - expect(r.ok).toBe(true); // the retry succeeded - the 401 did not fail the round - expect(h.vault.content('seed.md')).toBe('seed body'); // pull still delivered after re-mint - // Re-mint = a second POST to the couch-token endpoint (the token cache was invalidated). - const mints = h.router.calls.filter( - (c) => c.method === 'POST' && c.url.includes('/account/couch-token') - ); - expect(mints.length).toBeGreaterThanOrEqual(1); - }); - - it('7. expired OAuth access token -> refresh + rotate before the couch JWT is minted', async () => { - h = await bootSignedIn(); - await h.plugin.syncNow(); // warm everything with the original token - - // The OAuth access token expired; the next getValidToken must refresh (grant=refresh_token) - // and rotate before couch-token mint / resolution reuse the bearer. - expireAccessToken(h); - h.router.reset(); - const r = await h.plugin.syncNow(); - - expect(r.ok).toBe(true); - const refreshes = h.router.calls.filter((c) => c.method === 'POST' && c.url.includes('/token')); - expect(refreshes.length).toBeGreaterThanOrEqual(1); // a refresh_token grant fired - expect(h.plugin.isSignedIn()).toBe(true); // the rotated token kept the session alive - }); - - it('8. a rejected live push queues; pendingCount reflects it; the next tick flushes it', async () => { - h = await bootSignedIn(); - await h.plugin.syncNow(); - - // Reject the leaf write on the live push's _bulk_docs (a leaf couch refused) so the push - // throws and queues. (A bare failNext(503) would land on the leading getDoc, which tolerates - // a non-200 as "doc absent" and pushes anyway - so we fault the actual write instead.) - const leaf = (await leavesFor('offline edit'))[0]._id; - h.couch.failLeafOnBulk(leaf, 'forbidden'); - edit(h, 'queued.md', 'offline edit'); - await settle(() => pendingCount(h) === 1); - expect(pendingCount(h)).toBe(1); // the honest "to send" count the sync popup shows - expect(h.couch.fileDoc('queued.md')).toBeUndefined(); // nothing reached couch yet - - // The 2s tick retries the queue; the scripted failure is one-shot, so the flush now lands it. - await tick(h); - await drain(); - expect(h.couch.fileDoc('queued.md')?.size).toBe('offline edit'.length); - expect(pendingCount(h)).toBe(0); // queue drained - }); - - it('9. paged pull cursor persists across a simulated reload (resumes, no re-pull from 0)', async () => { - h = await bootSignedIn(); - await seedRemote(h, 'one.md', 'first'); - await seedRemote(h, 'two.md', 'second'); - await h.plugin.syncNow(); - - const key = `${h.plugin.activeSiteFqdn()}:work`; - const savedCursor = h.plugin.settings.couchState[key]?.cursor; - expect(Number(savedCursor)).toBeGreaterThan(0); // the cursor advanced past the two changes - // The _changes poll is paged: it carries since + limit, so a large feed never lands at once. - const changes = h.router.calls.filter((c) => c.url.includes('/_changes')); - expect(changes.some((c) => /since=\d+/.test(c.url) && /limit=\d+/.test(c.url))).toBe(true); - - // Rebuild the plugin from the SAVED couchState (a reload): it must resume at the persisted - // cursor and pull ZERO changes, not re-fetch from seq 0. - const persisted = h.plugin.settings.couchState; - await h.teardown(); - h = await bootSignedIn({ files: { 'one.md': 'first', 'two.md': 'second' } }); - h.plugin.settings.couchState = persisted; // as loadSettings would hydrate from data.json - // Reattach the same server-side state so a fresh pull from the saved cursor is a no-op. - await seedRemote(h, 'one.md', 'first'); - await seedRemote(h, 'two.md', 'second'); - // The saved cursor is beyond the original changes; only a genuinely new change would apply. - h.router.reset(); - const again = await h.plugin.syncNow(); - expect(again.ok).toBe(true); - const resumed = h.router.calls.filter((c) => c.url.includes('/_changes')); - expect(resumed[0]?.url).toContain(`since=${persisted[key]?.cursor}`); - }); -}); diff --git a/test/integration/sync-roundtrip.test.ts b/test/integration/sync-roundtrip.test.ts deleted file mode 100644 index d610acf..0000000 --- a/test/integration/sync-roundtrip.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, it, expect, afterEach, vi } from 'vitest'; -import { obsidianMockFactory } from '../fakes/obsidian'; -vi.mock('obsidian', () => obsidianMockFactory()); - -import { bootSignedIn, seedRemote, edit, settle, drain, type Handles } from './_helpers'; - -// The two-way live loop: a local edit pushes leaf-then-file, a remote change pulls into the -// vault, and the just-pulled content does not echo straight back out as a redundant push. - -describe('live sync round-trip', () => { - let h: Handles; - afterEach(async () => h.teardown()); - - it('4. local edit -> live push writes leaves via _bulk_docs then the file doc via PUT', async () => { - h = await bootSignedIn(); - await h.plugin.syncNow(); // warm the controller + cursor - - h.router.reset(); - edit(h, 'note.md', 'fresh local body'); - await settle(() => h.couch.fileDoc('note.md') !== undefined); - - // The wire order that matters: leaves land first (_bulk_docs), then the file doc (PUT), so - // the file doc never references a leaf couch has not stored yet. - const writes = h.router.calls.filter((c) => c.method === 'POST' || c.method === 'PUT'); - const bulkIdx = writes.findIndex((c) => c.url.includes('/_bulk_docs')); - const putIdx = writes.findIndex((c) => c.method === 'PUT' && c.url.includes('note.md')); - expect(bulkIdx).toBeGreaterThanOrEqual(0); - expect(putIdx).toBeGreaterThan(bulkIdx); - - // Couch state agrees: the file doc + its single content leaf both exist server-side. - const doc = h.couch.fileDoc('note.md'); - expect(doc?.size).toBe('fresh local body'.length); - expect(doc?.leaves.every((id) => h.couch.hasLeaf(id))).toBe(true); - }); - - it('5. remote change pulls into the vault; the applied content does not echo back as a push', async () => { - h = await bootSignedIn(); - await seedRemote(h, 'incoming.md', 'from another device'); - - const r = await h.plugin.syncNow(); - expect(r.ok).toBe(true); - expect(h.vault.content('incoming.md')).toBe('from another device'); - - // A 'modify' event now fires for the just-pulled file with the SAME content (as the OS file - // watcher would after our own write). The suppress guard + the content-rev cache skip it: - // no _bulk_docs / PUT for incoming.md - the pull does not bounce back out. - h.router.reset(); - edit(h, 'incoming.md', 'from another device'); - await drain(); // give the (skipped) echo push every chance to reach the network - const echoes = h.router.calls.filter( - (c) => c.method !== 'GET' && c.url.includes('incoming.md') - ); - const bulk = h.router.calls.filter((c) => c.method === 'POST' && c.url.includes('_bulk_docs')); - expect(echoes).toEqual([]); - expect(bulk).toEqual([]); - }); -}); diff --git a/vitest.config.ts b/vitest.config.ts index 8dc6f52..f816814 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,18 +4,15 @@ export default defineConfig({ test: { // src unit tests + the assembled-plugin integration harness (test/**) run in one pass. include: ['src/**/*.test.ts', 'test/**/*.test.ts'], - testTimeout: 30000, // git smart-HTTP spawns git-http-backend; couch tests exercise retry/backoff + testTimeout: 30000, // git smart-HTTP spawns git-http-backend coverage: { provider: 'v8', include: ['src/**/*.ts'], // Excluded: Obsidian-runtime files (App/TFile/PluginSettingTab/setIcon/Vault + // window/crypto.subtle), the node-only test git server helper, and type/test files. - // The pure engine (merge-note, stream-utils, resolve-host, vaults-config), the couch - // discovery + token flow (resolve-host, couch-token), the couch doc model + persisted - // sync state (couch-doc, couch-state), the DI git-client (against a real local git - // server), and the requestUrl HttpClient adapter (against a mocked requestUrl) ARE - // unit/integration tested. couch-sync is the Vault/requestUrl-coupled replication driver - // (same bucket as vault-fs; exercised by couch-sync.test.ts); doc model matches the bridge. + // The pure engine (merge-note, stream-utils, resolve-host, vaults-config), the DI + // git-client (against a real local git server), and the requestUrl HttpClient adapter + // (against a mocked requestUrl) ARE unit/integration tested. exclude: [ '**/*.test.ts', '**/*.types.ts', @@ -25,7 +22,6 @@ export default defineConfig({ 'src/actions-menu.ts', 'src/git/vault-fs.ts', 'src/git/git-test-server.ts', - 'src/couch/couch-sync.ts', ], thresholds: { branches: 70, functions: 70, lines: 70, statements: 70 }, },