IN DEVELOPMENT⸺>Google Drive Graph Sync

Currently working on a project to create a platform for Google Drive integration to sync Logseq graphs via Google Drive.

Can see progress at the repo: GitHub - wanderduck/logseq-google-drive-graph-sync · GitHub

Any thoughts, tips/tricks, ides, et cetera welcome!

Putting current progress report in replies below (last reply is most recent update)…

CURRENT PROGRESS REPORT:

PROGRESS LOG

  • Live status and session handoff for docs/implementation-plan.md.
  • Update this at the end of every milestone and before any context clear.

Milestone Status

Milestone Status Notes
M0 Feasibility spike :white_check_mark: Done 2026-09-15 Gate: architecture B confirmed. Evidence in docs/spike-results.md
M1 Scaffold :white_check_mark: Done 2026-09-15 lint, test, build green; unpacked plugin loaded in Logseq 0.10.15 (toolbar icon, panel, close paths confirmed by the user; console [gdsync] … ready (host 0.10.15))
M2 UI shell :white_check_mark: Done 2026-09-15 lint, test (44), build green; all 8 in-Logseq demo checks passed (user-confirmed): 5 toolbar states, panel, conflict dialog, toasts, palette, settings, theme
M3 Google auth :white_check_mark: Done 2026-09-15 lint, test (105), build green; all 7 in-Logseq checks passed (user-confirmed): device-code sign-in, session file, connected state survived a restart, cancel, sign-out + revoke, invalid_client negative path
M4 Drive layer :white_check_mark: Done 2026-09-15 lint, test (158), build green; live smoke test against real Drive passed 13/13 (user-confirmed): layout bootstrap, multipart + resumable (3.1 MB, 1 MiB chunks) uploads, downloads hash-identical, appProperties merge/delete/query, content update, tree listing, changes.list, lock acquire/held/renew/release/break-expired, trash, cascading delete; throwaway folder removed
M5 Local file layer :white_large_square: Not started M5-C is not needed
M6 Sync engine core :white_large_square: Not started
M7 Manual two-way sync E2E :white_large_square: Not started Must force-save the editor before scan/write (spike §4.2)
M8 Snapshots, profile, restore :white_large_square: Not started
M9 Personal release :white_large_square: Not started

Decision / Deviation Log

  • 2026-09-14 — Plan approved (decisions D1–D11, C1, C2 in plan §1).
  • 2026-09-14 — Added a root .gitignore during M0 (M1 step 3 item, pulled forward so .env.local can never be committed).
  • 2026-09-15 — M0 gate: architecture B (host bridge) confirmed. M5-C dropped.
  • 2026-09-15 — M0 finding for plan §3.6 step 7 (“writes to the open page”): the host shows no prompt; an external write to the block being edited closes the editor and drops unsaved input. Executor policy: call Editor.exitEditingMode() (force-save) before the local scan and before any write, then wait for the flush. Not a deviation, it fills the placeholder the plan left for M0.
  • 2026-09-15 — HTTP transport (plan M3 step 4): iframe fetch for every Google endpoint; logseq.Request unused (cannot send multipart/binary, hides status/headers, and needs window.top anyway).
  • 2026-09-15 — Optional dot-root (lsp://) spike variant deferred to the public-release milestone (user decision).
  • 2026-09-15 — Approved plan amendment (§3.6 step 7): copy the previous local file to logseq/bak/gdsync/<ts>/ before every download-overwrite, not only before deletes, because Logseq’s own bak/ is only written when content is deleted (spike §4.3).
  • 2026-09-15 — M0 cleanup done: spikes/ deleted, spike FileStorage/settings removed, pristine test-graph tarball + reset script moved to ~/logseq-test-graphs/. Initial commit on master approved by the user.
  • 2026-09-15 — M1 scaffold written (plan M1 steps 1–5): Vite 8.3 / React 19.3 / TS 6.0.3 project refs / oxlint 1.83 / Vitest 5.0, @logseq/libs pinned exactly to 0.0.17, manifest with top-level main: dist/index.html, effect: true, logseq.id = logseq-google-drive-graph-sync. Hello-world: toolbar cloud button (src/logseq/toolbar.ts) toggles a React main-UI panel (src/ui/App.tsx) with Escape + click-outside close; M2 step 4 replaces the panel contents.
  • 2026-09-15 — M1 choices (no plan deviation): tests/ is type-checked by tsconfig.app.json (include: ["src","tests"], one program, no third tsconfig). publish.yml runs on Node 22 with npm ci and runs lint + test before the build. LICENSE = MIT © 2026 Wanderduck, copied from the sibling plugin because the release zip ships it; user to confirm the license choice.
  • 2026-09-15 — M1 pulled the §3.1 “host-version check” forward: src/logseq/hostVersion.ts (pure, unit-tested) + a warning toast at startup when the host is not 0.10.x. It is the first real unit under test; the sync-time preflight (plan §3.6 step 1) will reuse it.
  • 2026-09-15 — SDK facts found from source during M1 (recorded in Ref §5.2 and CLAUDE.md): ui:visible:changed is emitted before isMainUIVisible updates (hook reads the payload); the global logseq type lacks .version.
  • 2026-09-15 — npm audit: dompurify (critical) + lodash-es (high), all transitive under the pinned SDK 0.0.17; unfixable without breaking D3. Accepted; mention in the README at M9.
  • 2026-09-15 — M2 UI shell written (plan M2 steps 1–5), all on mock data:
    • Step 1 settings schema src/logseq/settings.ts: 10 items under 5 headings (root folder, device name, snapshot interval 24 h, retention 10/3, profile backup on, plugin-settings-in-profile on with the C1 warning in the description, sync-on-startup off, client ID/secret override). resolveSettings() is pure (defaults, type checks, clamps) and unit-tested; installSettings() feeds a Store<GdsyncSettings> from logseq.settings + onSettingsChanged.
    • Step 2 toolbar src/logseq/toolbar.ts: one template with data-state ∈ idle/syncing/conflict/error/signed-out + badge CSS via provideStyle (spinner for syncing). State changes re-register the item with the same key: verified in frontend/handler/plugin.cljs @0.10.15 (register-plugin-ui-item drops the existing entry with that key first). The Ref §5.2 note “don’t re-register” was wrong and is corrected.
    • Step 3 src/logseq/commands.ts: palette entries “Google Drive Sync: Sync now / Backup now / Open sync panel” (label prefix added for palette discoverability; keys gdsync-*; no keybindings).
    • Step 4 src/ui/StatusPanel.tsx: account row, running/error/skipped-conflict banners, facts (graph, last sync + summary, last snapshot, profile backup, remote status with Refresh), actions (Sync now, Backup now, Settings → closes the overlay then showSettingsUI()), footer. Remote check runs once when the panel opens (D9).
    • Step 5 src/ui/ConflictDialog.tsx (per-file keep local / keep remote / keep both / apply to all / skip; Esc = skip the rest, decisions kept) and src/logseq/toasts.ts (one sticky progress toast updated in place + summary/error toasts). Verified in logseq/sdk/ui.cljs + handler/notification.cljs @0.10.15: timeout: 0 = sticky, same key = replace in place, closeMsg = notification/clear!.
    • New pure layer src/sync/{store,status,conflict,controller}.ts: generic observable store, SyncStatus + deriveSyncState() (precedence signed-out > pending conflict > syncing > error > skipped-conflict > idle), conflict model + conflictCopyName() (name.conflict-<device>-<YYYYMMDD-HHmm>.ext, device name sanitized to [A-Za-z0-9_-], ≤ 32), and the SyncController interface the real engine implements in M7.
    • Temporary src/mock/mockSyncController.ts + src/ui/DemoControls.tsx (not in plan §3.1; M2-only): fake sync/backup/remote-check/connect flows with timers and a “Demo controls” <details> in the panel (run clean / with conflicts / failing; force any of the 5 states). Delete both in M7 when the engine lands.
    • Small additions pulled forward: src/logseq/theme.ts (panel follows the host’s light/dark mode via getUserConfigs + onThemeModeChanged), src/logseq/graph.ts (status.graph from getCurrentGraph + onCurrentGraphChanged), logseq.beforeunload → controller.dispose().
  • 2026-09-15 — Host facts recorded while wiring M2 (Ref §5.2 updated): a registerUIItem toolbar item is wrapped in div#injected-ui-item-<key>-<pid> (components/plugins.cljs ui-item-renderer, the slot for setupInjectedUI); once the user pins any toolbar item, unpinned items move into the “plugins” dropdown and are rendered with a pl- prefix on the key. provideStyle({key}) is write-once per load (register-plugin-resources). The toolbar CSS is therefore static and scoped by our own gdsync-tb* classes, with state carried by data-state on the re-registered template.
  • 2026-09-15 — M3 Google auth written (plan M3 steps 1–4), host-agnostic in src/google/ (no logseq import; fetch, sleep, storage and credentials are injected) and unit-tested against a scripted fake fetch:
    • Step 1 credentials.ts: resolveClientCredentials(builtIn, settings): a settings override wins but must be complete (ID and secret), otherwise the built-in .env.local pair; both missing → an error that points at Settings → Advanced. Read lazily on every use so a settings change applies without a reload.
    • Step 2 oauth.ts + ui/ConnectBox.tsx: POST /device/code (scope drive.file only), poll POST /token at interval, slow_down → +5 s (RFC 8628 §3.5), access_denied → “denied”, expired_token or the local deadline → “expired”, cancel via an AbortSignal-aware sleep. The panel shows the code, an “Open in browser” button (App.openExternalLink), Copy (clipboard, with a fallback toast), the expiry countdown and Cancel; a sticky toast (key: gdsync-connect) repeats the code while the overlay is closed. Polling lives in the auth service, so closing the panel does not interrupt it.
    • Step 3 tokenStore.ts + auth.ts: one JSON string at FileStorage key auth/google-session.json ({version:1, accessToken, refreshToken, expiresAt, scope, account, connectedAt}). Refresh when < 60 s remain and once on a 401 (single-flight); invalid_grant / unauthorized_client drop the session (signed-out with reason: 'session-expired', toast). Sign-out revokes the refresh token (best effort, warning toast with the myaccount.google.com link on failure) and always clears locally. restore() at startup is offline (no network call) so the connected state survives a restart.
    • Step 4 http.ts: one fetch-based client; backoff min(32 s, 1 s·2^n) + jitter(0–1 s), max 5 retries, on 429, 5xx and 403 with a Drive rate-limit reason (userRateLimitExceeded, rateLimitExceeded, sharingRateLimitExceeded); Retry-After honoured up to 60 s; network failures retried twice. OAuth 403s (slow_down, access_denied) and 428 are never retried. Bodies must be re-sendable (string/URLSearchParams/Blob/ArrayBuffer), a contract M4 uploads must respect.
    • Account e-mail: drive.file carries no identity and userinfo would need extra scopes, so it comes from Drive about.get?fields=user(emailAddress) (account.ts, works under drive.file, spike H3c). If that call fails right after sign-in (e.g. Drive API not yet enabled) the session is kept with account: null, the panel shows “unknown account” plus a warning toast, and restore() retries the lookup on the next start.
    • Wiring: src/logseq/googleHost.ts builds the service from window.fetch, logseq.FileStorage and import.meta.env; main.tsx awaits auth.restore() before the “sync on startup” check. The M2 mock controller now delegates connect / cancelConnect / signOut to the real auth and mirrors auth.state into status.account / status.deviceFlow (new SyncStatus field; deriveSyncState unchanged, connecting shows as signed-out). SyncController gained cancelConnect(). Demo “Show state” buttons keep the real account when one exists.
  • 2026-09-15 — Host facts found during M3 (Ref §6.6 updated): FileStorage.getItem of a missing key rejects (“file not existed”, logseq.api.read_plugin_storage_file → read_rootdir_file checks existence and throws), so the store calls hasItem first. Because removeItem/clear are fire-and-forget (spike §4.4), TokenStore.clear() overwrites the file with the JSON literal null instead of deleting it; load() treats that as signed-out. No plan deviation.
  • 2026-09-15 — M3 choice (no plan deviation): the auth service keeps its own Store<AuthState> (signed-out | connecting | signed-in) and the controller mirrors it into SyncStatus, so src/google/ never imports logseq and M7’s real engine reuses the same GoogleAuth object and mirror. Toasts stay in the controller.
  • 2026-09-15 — M4 Drive layer written (plan M4 steps 1–5), host-agnostic in src/google/ over the injected authorized fetch (GoogleAuth.fetch):
    • Step 1 folderCache.ts ((parentId, name) → id plus the reverse for path rebuilding; forget(id) drops descendants) and layout.ts bootstrapLayout() → <root>/graphs/<graph>, <root>/snapshots/<graph>, <root>/profile (plan §3.3), all through ensureFolder. ensureFolder is find-or-create and collapses a folder created concurrently by another device onto the oldest one (createdTime, then id): Drive has no atomic create-if-absent and two “pages” folders would silently split a mirror.
    • Step 2 drive.ts DriveClient: getFile, paged listChildren (all/folders/files), findChild (oldest of duplicates), findByAppProperty, listTree (BFS with path segments; seeds the cache), pathUnder (rebuilds a path from parents via the cache, then files.get), createFile / updateFile (multipart ≤ 5 MB, resumable above; 1 MiB chunks, multiples of 256 KiB; appProperties + modifiedTime in the metadata), updateMetadata (appProperties merge, null deletes a key), download/downloadText (alt=media), trash (PATCH trashed), deleteForever (DELETE, cascades; for our own files only). driveUpload.ts holds the multipart body builder and the resumable protocol: 308 + Range → next offset, a lost session (404/410) restarts once from byte 0, a network failure mid-chunk asks the session for its status (Content-Range: bytes */total) and continues, three non-advancing 308s abort. 5xx/429 per request are the transport’s job underneath.
    • Step 3 getStartPageToken() / listChanges(token) (all pages, includeRemoved, restrictToMyDrive); HTTP 400 → InvalidPageTokenError so the engine falls back to a full listing (plan §3.6 step 4). Change entries carry the file’s current state (a file deleted since reads as removed on every entry, an id can repeat); the engine must treat the feed as “these ids changed, here is their state now”.
    • Step 4 lock.ts: <graph>/.gdsync/lock.json {version, deviceId, deviceName, acquiredAt, expiresAt} with appProperties.deviceId. acquire({ttlMs, breakExpired}) = read → (own stale lock: re-use in place | live foreign lock: held | expired: held, expired: true unless breakExpired, then delete) → create → re-list: the oldest lock file wins, the loser deletes its own. renew(ttlMs), release() (delete for good, 404-tolerant), read() for the panel. Unreadable content parses as an expired lock of “unknown device”.
    • Step 5 tests/google/fakeDrive.ts: an in-memory Drive v3 HTTP emulator (fetch-level, not an interface fake): files.* incl. multipart parsing and resumable sessions, changes.*, about, the q grammar of driveQuery.ts, the fields mask (a forgotten field comes back missing, like Google), Google-shaped error bodies, and fault injection (failNext, optionally after the server did the work). The real client is tested end to end against it (53 new tests → 158); M6’s two-device scenario tests get a realistic shared remote with fault injection through the real backoff.
    • errors.ts: describeGoogleError gained 404 and storageQuotaExceeded sentences. src/fs/hash.ts (sha256Hex via SubtleCrypto, M5 step 3) pulled forward for the smoke test.
    • Live check tooling (deleted with src/mock/ in M7): src/mock/driveSmoke.ts + “Run Drive smoke test” in the demo controls. 13 scripted steps against the real Drive in a throwaway root folder "<rootFolderName> smoke-<HHMMSS>", deleted at the end; on failure it stays for inspection and the error toast says so. It only READS one asset from the graph and refuses unless the graph path contains /logseq-test-graphs/ (D11). src/logseq/googleHost.ts gained createHostDriveClient(auth) for M7.
  • 2026-09-15 — M4 choice (no plan deviation): FakeDrive emulates Drive’s HTTP surface instead of implementing DriveClient in memory, so there is exactly one client implementation and the fake double-checks request shapes (multipart parts, Content-Range, q, fields). Cost: ~500 lines of test infrastructure; benefit: M6 fault injection exercises the real transport and upload code.

Session Handoff

  • Last session (2026-09-15, M4):
    • M3 was committed as d832b4e before this session.
    • Files added: src/google/{drive,driveQuery,driveUpload,folderCache,layout,lock}.ts, src/fs/hash.ts, src/mock/driveSmoke.ts, tests/google/{fakeDrive.ts,fakeDrive,drive,driveQuery,folderCache,layout,lock}.test.ts, tests/fs/hash.test.ts. Modified: src/google/errors.ts, src/logseq/googleHost.ts (createHostDriveClient), src/mock/mockSyncController.ts (runDriveSmoke), src/ui/DemoControls.tsx. Nothing committed yet for M4.
    • Local result: npm run lint clean, npm test 158/158 (53 new), npm run build green (dist/assets/index-*.js 377 kB).
    • M4 DoD live check passed in Logseq (user-confirmed, 2026-09-15 03:34), 13/13 steps in 40.5 s with 80 requests (/tmp/logseq-console.log, [gdsync] smoke): about.get 112 ms; layout bootstrap 9.2 s for 6 folder creates (≈1.5 s per ensureFolder, so M7 must persist the ids in state/<graph>.json and bootstrap only once); multipart create honoured appProperties + modifiedTime; resumable upload of big-noise.png (3,147,871 bytes) in 2.5 s with progress 1 MiB → 2 MiB → 3 MiB → end, so the 308 Range header is readable in the iframe; downloads hash-identical; appProperties PATCH merged, null deleted a key, appProperties has {…} query found the file; multipart PATCH updated content in place; listTree gave assets, assets/big-noise.png, pages, pages/smoke.md and a fresh client’s pathUnder resolved pages/smoke.md; changes.list returned 5 changes and none after the new token; the lock step (15 s of round trips) acquired, saw contention, renewed, released, broke an expired lock and left no file; trash flagged and hid the file; the smoke root folder DELETE cascaded (404 confirmed). Nothing left in Drive.
    • The user’s Google account is now signed in to the plugin (session file holds the JSON session).
    • M4 is complete; nothing committed yet. Next: commit on the user’s go (“M4: Drive layer …”), clear context, start M5 (plan M5 steps 1–4; read docs/spike-results.md §4 item 1 first; src/fs/hash.ts already exists).
  • Previous session (2026-09-15, M3):
    • M2 was committed as 006da93 before this session.
    • Files added: src/google/{credentials,errors,http,oauth,tokenStore,account,auth}.ts, src/logseq/googleHost.ts, src/ui/ConnectBox.tsx, tests/google/{helpers.ts,credentials,http,oauth,tokenStore,auth}.test.ts. Modified: src/main.tsx, src/mock/mockSyncController.ts, src/sync/{status,controller}.ts, src/logseq/toasts.ts (showStickyToast/closeToast), src/ui/{StatusPanel.tsx,App.css}. Nothing committed yet for M3.
    • Local result: npm run lint clean, npm test 105/105 (61 new), npm run build green (dist/assets/index-*.js 352 kB; the built-in client ID is inlined from .env.local).
    • M3 DoD live check passed in Logseq (user-confirmed, 2026-09-15), all 7 steps: (1) Connect Google shows the code, countdown and buttons plus the sticky toast; (2) Open in browser → code → allow → “Connected as ”, success toast, toolbar idle; (3) ~/.logseq/storages/logseq-google-drive-graph-sync/auth/google-session.json holds the JSON session; (4) after a Logseq restart the panel opens already connected, no network call; (5) Cancel mid-flow → “Sign-in cancelled”, back to signed-out; (6) Sign out → “access was revoked”, the file contains null, the app is gone from myaccount.google.com/permissions; (7) bogus client ID + secret → the invalid_client message, clearing both works again without a reload.
    • The user’s Google account is now signed out of the plugin (step 6 ran last); M4’s Drive smoke test starts with a fresh Connect.
    • .env.local still holds the working TV/limited-input OAuth client; consent screen status to be double-checked as “In production” before M9 (a “Testing” app expires refresh tokens after 7 days, which would show up as the “sign-in expired” toast).
  • Previous session (2026-09-15, M2):
    • M1 was committed as 64ec89c before this session.
    • Files added: src/sync/{store,status,conflict,controller}.ts, src/logseq/{settings,commands,toasts,theme,graph}.ts, src/mock/mockSyncController.ts, src/ui/{StatusPanel,ConflictDialog,DemoControls}.tsx, src/ui/{useStore,useNow,format}.ts; rewritten: src/main.tsx, src/logseq/toolbar.ts, src/ui/App.tsx, src/ui/App.css. Tests: tests/sync/{store,status,conflict}.test.ts, tests/logseq/{settings,toolbar}.test.ts, tests/ui/format.test.ts → 43 tests. Nothing committed yet for M2.
    • Local result: npm run lint clean, npm test 43/43, npm run build green (dist/assets/index-*.js 335 kB).
    • M2 DoD check passed in Logseq (user-confirmed, 2026-09-15): (1) toolbar cloud shows a grey dot (signed-out) and opens the panel; (2) Connect Google → pill “Up to date”, remote check runs; (3) Demo “Show state” buttons flip the toolbar badge (spinner / amber / red / grey); (4) “with conflicts” run opens the conflict dialog automatically, Esc/skip/apply-to-all behave, summary toast reports resolved/skipped; (5) “failing” run shows the error banner + red badge; (6) palette lists the three “Google Drive Sync:” commands; (7) settings gear renders the 5 headings and 10 items; (8) the panel follows Logseq’s light/dark toggle.
    • User decision: keep the “Google Drive Sync:” prefix on the palette labels.
    • M3 inputs already in place: settings.googleClientId/Secret overrides (D5 resolution env → settings is M3 step 1), controller.connect()/signOut() are the seams the device-code flow replaces, status.account drives the signed-out state.
  • Previous session (2026-09-15, M1):
    • Files added: package.json (+ lockfile), tsconfig{,.app,.node}.json, vite.config.ts (Vitest config inside), .oxlintrc.json, index.html, icon.svg, LICENSE, .github/workflows/publish.yml, src/main.tsx, src/vite-env.d.ts (types VITE_GOOGLE_CLIENT_ID/SECRET), src/logseq/{toolbar,hostVersion}.ts, src/ui/{App.tsx,App.css,useMainUiVisible.ts}, tests/logseq/hostVersion.test.ts (6 tests). Nothing committed yet for M1.
    • Local result: npm run lint clean, npm test 6/6, npm run build → dist/index.html + dist/assets/index-*.{js,css} with relative ./assets/ URLs (same shape as the working sibling plugin’s dist/).
    • In-Logseq load check passed (2026-09-15 01:03): unpacked plugin loaded from the repo root; toolbar icon, panel (plugin id + 0.10.15), and Escape / click-outside / × close confirmed by the user; console line [gdsync] logseq-google-drive-graph-sync ready (host 0.10.15) from dist/assets/index-*.js. The stale spikes/bridge/ entry was removed from the plugin dashboard.
    • Logseq was running during this session as /opt/logseq-desktop/Logseq --enable-logging with output tee’d to /tmp/logseq-console.log (a per-launch convenience, not durable).
    • README.md rewritten to match D9 (manual sync, no “real-time”); the full README with GCP setup, plaintext-token warning, and install steps stays an M9 item.
    • LICENSE (MIT © 2026 Wanderduck) confirmed by the user.
    • SDK audit facts (verified 2026-09-15): @logseq/libs 0.0.17 ships a prebuilt dist/lsplugin.user.js with no require of dompurify or lodash-es; dompurify is only imported by LSPlugin.core.d.ts (the host-side module that Logseq itself ships); our built dist/ contains neither DOMPurify nor lodash template code. The advisories therefore do not reach the shipped plugin. Decision on silencing them via overrides is with the user (see the recommendation in the M1 session summary).
    • Test graphs unchanged since M0: ~/logseq-test-graphs/gdsync-dev (harmless spike leftovers) and empty gdsync-dev-2; pristine tarball + reset script in ~/logseq-test-graphs/.
    • .env.local still holds the working TV/limited-input OAuth client; consent screen status to be double-checked as “In production” before M9.
  • Next action: (1) commit M4 on the user’s go; (2) clear context; (3) M5 Local file layer (plan §5 M5; spike §4 item 1): GraphFs interface + HostBridgeFs over window.top.apis.doAction, scanner with plan §3.4 ignore rules and an mtime/size hash cache (src/fs/hash.ts exists), atomic write + move-to-logseq/bak/gdsync/<ts>/ by rename, FakeGraphFs + tests.
1 Like

CURRENT PROGRESS REPORT:

PROGRESS LOG

  • Live status and session handoff for docs/implementation-plan.md.
  • Update this at the end of every milestone and before any context clear.

Milestone Status

Milestone Status Notes
M0 Feasibility spike :white_check_mark: Done 2026-09-15 Gate: architecture B confirmed. Evidence in docs/spike-results.md
M1 Scaffold :white_check_mark: Done 2026-09-15 lint, test, build green; unpacked plugin loaded in Logseq 0.10.15 (toolbar icon, panel, close paths confirmed by the user; console [gdsync] … ready (host 0.10.15))
M2 UI shell :white_check_mark: Done 2026-09-15 lint, test (44), build green; all 8 in-Logseq demo checks passed (user-confirmed): 5 toolbar states, panel, conflict dialog, toasts, palette, settings, theme
M3 Google auth :white_check_mark: Done 2026-09-15 lint, test (105), build green; all 7 in-Logseq checks passed (user-confirmed): device-code sign-in, session file, connected state survived a restart, cancel, sign-out + revoke, invalid_client negative path
M4 Drive layer :white_check_mark: Done 2026-09-15 lint, test (158), build green; live smoke test against real Drive passed 13/13 (user-confirmed): layout bootstrap, multipart + resumable (3.1 MB, 1 MiB chunks) uploads, downloads hash-identical, appProperties merge/delete/query, content update, tree listing, changes.list, lock acquire/held/renew/release/break-expired, trash, cascading delete; throwaway folder removed
M5 Local file layer :white_check_mark: Done 2026-09-15 lint, test (200), build green; GraphFs + HostBridgeFs, scanner with §3.4 ignore rules + mtime/size hash cache, atomic writes, bak sessions, FakeHost emulator; in-Logseq file smoke test on the test graph passed 13/13 (user-confirmed): bridge + dot dir, listing, cold scan 14 files/60 ms, warm scan 4 ms, 3.1 MB read 41 ms, atomic create/overwrite with bak copy, 300 KB binary round trip, rescan, rename, move-to-bak, profile root read. M5-C was not needed
M6 Sync engine core :white_large_square: Not started
M7 Manual two-way sync E2E :white_large_square: Not started Must force-save the editor before scan/write (spike §4.2)
M8 Snapshots, profile, restore :white_large_square: Not started
M9 Personal release :white_large_square: Not started

Decision / Deviation Log

{BEGINS WITH NEW ADDITIONS AFTER LAST UPDATE TO SAVE CHARACTERS}

  • 2026-09-15 — Host facts verified from the shipped 0.10.15 source before M5 (the Electron bundle’s source map at /opt/logseq-desktop/resources/app/electron.js.map embeds electron/handler.cljs, electron/utils.js, logseq/common/graph.cljs, electron/fs_watcher.cljs; extracted to the scratchpad, recorded in Ref §3.2):
    • ['listdir', dir, true] = deepReadDir: a flat array of absolute non-directory paths, dot files/dirs included, null when dir does not exist, Windows backslashes untouched. readdir (recursive, skips symlinks and every dot-prefixed name, but not logseq/bak) is not used.
    • writeFile swallows failures: the handler catches, writes a backup under logseq/bak/, sends an error notification to the renderer and resolves nil. So “not a stat object” = failure. It does not create parent directories.
    • Sync handlers (stat, rename, mkdir-recur, unlink) resolve with a host-realm Error; async handlers (copyFile = fs-extra.copy, listdir) reject with Error invoking remote method 'main': Error: ENOENT: …. Both surfaces are normalised to FsError with the Node code parsed from the message.
    • unlink really deletes only under ~/.logseq (dotdir-file?) or assets/storages; every other path is moved to <repo>/logseq/.recycle/<rel with / → _>, errors swallowed. repo is the graph directory, also for writeFile (its failure backup joins repo with logseq/bak).
    • The watcher’s ignored-path? skips logseq/bak, logseq/.recycle, logseq/version-files, node_modules, .DS_Store, logseq/graphs-txid.edn, logseq/pages-metadata.edn and any dot-prefixed segment (/\.[^.]+).
  • 2026-09-15 — M5 local file layer written (plan M5 steps 1–4), pure TS in src/fs/ (no host import; the bridge call and the file:// fetch are injected):
    • Step 1 graphFs.ts: GraphFs (root, list(dir?) recursive files incl. dot files, stat → {size, mtimeMs} or null, readBytes/readText (BOM kept), writeFile (creates parents, not atomic), rename/copyFile (create the target’s parents), mkdirp, unlink with the host’s recycle semantics documented), FsError {op, path, code} + isFsNotFound, strict root-relative paths (assertRelPath), fileUrl (per-segment percent-encoding, so #, ?, % and spaces survive; encodeURI would not).
    • Step 2 hostBridgeFs.ts: the adapter over ['listdir'|'stat'|'writeFile'|'rename'|'mkdir-recur'|'copyFile'|'unlink', …] + fetch('file://…') for bytes (readFile is text-only). Cross-realm Error/Date via Object.prototype.toString; Uint8Array views are sent as exactly-sized ArrayBuffers; a null writeFile answer becomes FsError EWRITE. src/logseq/fsHost.ts is the only place that knows window.top.apis.doAction (resolveBridge, BridgeUnavailableError, getDotDirRoot, createHostGraphFs, createHostProfileFs).
    • Step 3 ignore.ts (plan §3.4 as data: dirs logseq/bak, logseq/.recycle, logseq/version-files; segment .git at any depth; names .DS_Store/Thumbs.db case-insensitive; suffixes .swp, ~, .gdsync-tmp) and scanner.ts (scanFiles: list → filter → stat → re-hash only when size or mtime differs from the caller’s cache; mapLimit concurrency 8 (limit.ts); files that vanish between list/stat/read are counted, not fatal; result sorted by path with hashed/reused/ignored/vanished counts). The recorded stat is the pre-read one, so a file modified during the read is re-hashed next time.
    • Step 4 atomicWrite.ts (writeFileAtomic: writeFile to <dir>/.<name>.gdsync-tmp then rename; returns the stat rename preserves so the engine can seed its cache without a second stat; listTempFiles for leftovers) and bak.ts (createBakSession(fs, at, label?) → logseq/bak/gdsync/<YYYYMMDD-HHmmss>/; backupCopy = copyFile, moveIn = rename, never unlink; ~N suffix on a collision; label gives plan §3.7’s restore-<ts>).
    • Tests: tests/fs/fakeHost.ts is an emulator of the host handlers (doAction + fetch(file://)) with the quirks above (resolve-vs-reject per action, swallowed writeFile, recycle vs delete, stat blind to directories, clock, failNext fault injection). createFakeGraphFs() = the real HostBridgeFs over it, which is what M6 uses as its in-memory graph (same single-implementation decision as FakeDrive). 42 new tests → 200; cross-realm cases use node:vm.
    • Live check tooling (deleted with src/mock/ in M7): src/mock/fsSmoke.ts + “Run file smoke test” in the demo controls: 13 steps on the open graph (bridge + dot dir, listing, cold/warm scan, reads, missing paths, atomic create, bak copy + atomic overwrite with a 3 s watcher pause, 300 KB binary round trip, rescan, rename, move-to-bak, read-only profile root). Refuses unless the graph is under ~/logseq-test-graphs/ (D11); needs no Google account; leaves only logseq/bak/gdsync/smoke-<ts>/.
  • 2026-09-15 — M5 choices (refinements, no plan deviation): (1) the atomic-write temp name is dot-prefixed (pages/.Alpha.md.gdsync-tmp rather than Alpha.md.gdsync-tmp) because the host watcher and readdir ignore dot-prefixed segments, so Logseq never sees a half-written file; the .gdsync-tmp suffix of plan §3.6 step 7 is kept. (2) .git is ignored at any depth, not only at the root. (3) list() is the raw listdir (everything); the ignore rules live in the scanner, per spike §4 item 1. (4) HostBridgeFs is the single GraphFs implementation; FakeGraphFs of plan M5 step 4 is createFakeGraphFs() (real adapter over the fake host).
  • 2026-09-15 — Approved plan amendment (§3.4 scope, user decision): logseq/graphs-txid.edn (per-device Logseq Sync state) and logseq/pages-metadata.edn (legacy cache) are ignored too; Logseq itself never indexes them. Applied in src/fs/ignore.ts (GRAPH_IGNORED_FILES) and recorded in plan §3.4.
  • 2026-09-15 — Deferred to M9 (user decision): retention for logseq/bak/gdsync/ folders (one accumulates per sync run). Until then they are left in place; logseq/bak is ignored by Logseq and by the scanner.

Session Handoff

  • Last session (2026-09-15, M5):
    • M4 was committed as 631db4c before this session.
    • Files added: src/fs/{graphFs,hostBridgeFs,ignore,scanner,limit,atomicWrite,bak}.ts, src/logseq/fsHost.ts, src/mock/fsSmoke.ts, tests/fs/{fakeHost.ts,fakeHost,graphFs,hostBridgeFs,ignore,limit,scanner,atomicWrite,bak}.test.ts, tests/logseq/fsHost.test.ts. Modified: src/mock/mockSyncController.ts (runFsSmoke), src/ui/DemoControls.tsx. Nothing committed yet for M5.
    • Local result: npm run lint clean, npm test 200/200 (42 new), npm run build green (dist/assets/index-*.js 394 kB).
    • M5 DoD live check passed in Logseq (user-confirmed, 2026-09-15 04:03), 13/13 steps in 3.2 s (/tmp/logseq-console.log, [gdsync] fs smoke): bridge reachable, ~/.logseq resolved; listdir 18 entries (the 4 .recycle leftovers included, filtered by the scanner); cold scan 14 files hashed in 60 ms (incl. the 3.1 MB png), warm scan 14 cache hits in 4 ms (so a bridge stat round trip is well under 1 ms); config.edn text and big-noise.png bytes (3,147,871 B in 41 ms) hash-identical to the scan; missing path → null / ENOENT; atomic create left no temp file and the returned stat equalled the re-stat; bak copy held the first content after the atomic overwrite; 307,207 random bytes round-tripped byte-identical in 21 ms; rescan hashed exactly the 2 new files; rename; move-to-bak left the graph at 14 files with nothing in .recycle; profile root read 102 settings files, preferences.json (1360 chars) parsed, config/config.edn 613 B. No host errors in the console. Leftover, by design: ~/logseq-test-graphs/gdsync-dev/logseq/bak/gdsync/smoke-20260915-040327/ (2 page versions + the 300 KB bin).
    • Not yet observed live (unit-tested only): fetch(file://) of an empty file; a writeFile failure (the swallowed-null path); Windows paths.
    • The user’s Google account is still signed in (M4 left it so).
    • User decisions after the smoke: §3.4 amendment applied (ignore logseq/graphs-txid.edn, logseq/pages-metadata.edn); bak retention deferred to M9. M5 complete; committed as “M5: Local file layer …” on the user’s go.
    • Next: clear context, start M6 (plan M6 steps 1–5). createFakeGraphFs in tests/fs/fakeHost.ts + createFakeDrive in tests/google/fakeDrive.ts are the two fakes the scenario tests share; the engine’s state.entries feed scanFiles({ cache }), writeFileAtomic returns the stat to store back, createBakSession per run, listTempFiles for leftovers of a crashed run.
  • Previous session (2026-09-15, M4):
    • M3 was committed as d832b4e before this session.
    • Files added: src/google/{drive,driveQuery,driveUpload,folderCache,layout,lock}.ts, src/fs/hash.ts, src/mock/driveSmoke.ts, tests/google/{fakeDrive.ts,fakeDrive,drive,driveQuery,folderCache,layout,lock}.test.ts, tests/fs/hash.test.ts. Modified: src/google/errors.ts, src/logseq/googleHost.ts (createHostDriveClient), src/mock/mockSyncController.ts (runDriveSmoke), src/ui/DemoControls.tsx. Nothing committed yet for M4.
    • Local result: npm run lint clean, npm test 158/158 (53 new), npm run build green (dist/assets/index-*.js 377 kB).
    • M4 DoD live check passed in Logseq (user-confirmed, 2026-09-15 03:34), 13/13 steps in 40.5 s with 80 requests (/tmp/logseq-console.log, [gdsync] smoke): about.get 112 ms; layout bootstrap 9.2 s for 6 folder creates (≈1.5 s per ensureFolder, so M7 must persist the ids in state/<graph>.json and bootstrap only once); multipart create honoured appProperties + modifiedTime; resumable upload of big-noise.png (3,147,871 bytes) in 2.5 s with progress 1 MiB → 2 MiB → 3 MiB → end, so the 308 Range header is readable in the iframe; downloads hash-identical; appProperties PATCH merged, null deleted a key, appProperties has {…} query found the file; multipart PATCH updated content in place; listTree gave assets, assets/big-noise.png, pages, pages/smoke.md and a fresh client’s pathUnder resolved pages/smoke.md; changes.list returned 5 changes and none after the new token; the lock step (15 s of round trips) acquired, saw contention, renewed, released, broke an expired lock and left no file; trash flagged and hid the file; the smoke root folder DELETE cascaded (404 confirmed). Nothing left in Drive.
    • The user’s Google account is now signed in to the plugin (session file holds the JSON session).
    • M4 is complete; nothing committed yet. Next: commit on the user’s go (“M4: Drive layer …”), clear context, start M5 (plan M5 steps 1–4; read docs/spike-results.md §4 item 1 first; src/fs/hash.ts already exists).
  • Previous session (2026-09-15, M3):
    • M2 was committed as 006da93 before this session.
    • Files added: src/google/{credentials,errors,http,oauth,tokenStore,account,auth}.ts, src/logseq/googleHost.ts, src/ui/ConnectBox.tsx, tests/google/{helpers.ts,credentials,http,oauth,tokenStore,auth}.test.ts. Modified: src/main.tsx, src/mock/mockSyncController.ts, src/sync/{status,controller}.ts, src/logseq/toasts.ts (showStickyToast/closeToast), src/ui/{StatusPanel.tsx,App.css}. Nothing committed yet for M3.
    • Local result: npm run lint clean, npm test 105/105 (61 new), npm run build green (dist/assets/index-*.js 352 kB; the built-in client ID is inlined from .env.local).
    • M3 DoD live check passed in Logseq (user-confirmed, 2026-09-15), all 7 steps: (1) Connect Google shows the code, countdown and buttons plus the sticky toast; (2) Open in browser → code → allow → “Connected as ”, success toast, toolbar idle; (3) ~/.logseq/storages/logseq-google-drive-graph-sync/auth/google-session.json holds the JSON session; (4) after a Logseq restart the panel opens already connected, no network call; (5) Cancel mid-flow → “Sign-in cancelled”, back to signed-out; (6) Sign out → “access was revoked”, the file contains null, the app is gone from myaccount.google.com/permissions; (7) bogus client ID + secret → the invalid_client message, clearing both works again without a reload.
    • The user’s Google account is now signed out of the plugin (step 6 ran last); M4’s Drive smoke test starts with a fresh Connect.
    • .env.local still holds the working TV/limited-input OAuth client; consent screen status to be double-checked as “In production” before M9 (a “Testing” app expires refresh tokens after 7 days, which would show up as the “sign-in expired” toast).
  • Previous session (2026-09-15, M2):
    • M1 was committed as 64ec89c before this session.
    • Files added: src/sync/{store,status,conflict,controller}.ts, src/logseq/{settings,commands,toasts,theme,graph}.ts, src/mock/mockSyncController.ts, src/ui/{StatusPanel,ConflictDialog,DemoControls}.tsx, src/ui/{useStore,useNow,format}.ts; rewritten: src/main.tsx, src/logseq/toolbar.ts, src/ui/App.tsx, src/ui/App.css. Tests: tests/sync/{store,status,conflict}.test.ts, tests/logseq/{settings,toolbar}.test.ts, tests/ui/format.test.ts → 43 tests. Nothing committed yet for M2.
    • Local result: npm run lint clean, npm test 43/43, npm run build green (dist/assets/index-*.js 335 kB).
    • M2 DoD check passed in Logseq (user-confirmed, 2026-09-15): (1) toolbar cloud shows a grey dot (signed-out) and opens the panel; (2) Connect Google → pill “Up to date”, remote check runs; (3) Demo “Show state” buttons flip the toolbar badge (spinner / amber / red / grey); (4) “with conflicts” run opens the conflict dialog automatically, Esc/skip/apply-to-all behave, summary toast reports resolved/skipped; (5) “failing” run shows the error banner + red badge; (6) palette lists the three “Google Drive Sync:” commands; (7) settings gear renders the 5 headings and 10 items; (8) the panel follows Logseq’s light/dark toggle.
    • User decision: keep the “Google Drive Sync:” prefix on the palette labels.
    • M3 inputs already in place: settings.googleClientId/Secret overrides (D5 resolution env → settings is M3 step 1), controller.connect()/signOut() are the seams the device-code flow replaces, status.account drives the signed-out state.
  • Previous session (2026-09-15, M1):
    • Files added: package.json (+ lockfile), tsconfig{,.app,.node}.json, vite.config.ts (Vitest config inside), .oxlintrc.json, index.html, icon.svg, LICENSE, .github/workflows/publish.yml, src/main.tsx, src/vite-env.d.ts (types VITE_GOOGLE_CLIENT_ID/SECRET), src/logseq/{toolbar,hostVersion}.ts, src/ui/{App.tsx,App.css,useMainUiVisible.ts}, tests/logseq/hostVersion.test.ts (6 tests). Nothing committed yet for M1.
    • Local result: npm run lint clean, npm test 6/6, npm run build → dist/index.html + dist/assets/index-*.{js,css} with relative ./assets/ URLs (same shape as the working sibling plugin’s dist/).
    • In-Logseq load check passed (2026-09-15 01:03): unpacked plugin loaded from the repo root; toolbar icon, panel (plugin id + 0.10.15), and Escape / click-outside / × close confirmed by the user; console line [gdsync] logseq-google-drive-graph-sync ready (host 0.10.15) from dist/assets/index-*.js. The stale spikes/bridge/ entry was removed from the plugin dashboard.
    • Logseq was running during this session as /opt/logseq-desktop/Logseq --enable-logging with output tee’d to /tmp/logseq-console.log (a per-launch convenience, not durable).
    • README.md rewritten to match D9 (manual sync, no “real-time”); the full README with GCP setup, plaintext-token warning, and install steps stays an M9 item.
    • LICENSE (MIT © 2026 Wanderduck) confirmed by the user.
    • SDK audit facts (verified 2026-09-15): @logseq/libs 0.0.17 ships a prebuilt dist/lsplugin.user.js with no require of dompurify or lodash-es; dompurify is only imported by LSPlugin.core.d.ts (the host-side module that Logseq itself ships); our built dist/ contains neither DOMPurify nor lodash template code. The advisories therefore do not reach the shipped plugin. Decision on silencing them via overrides is with the user (see the recommendation in the M1 session summary).
    • Test graphs unchanged since M0: ~/logseq-test-graphs/gdsync-dev (harmless spike leftovers) and empty gdsync-dev-2; pristine tarball + reset script in ~/logseq-test-graphs/.
    • .env.local still holds the working TV/limited-input OAuth client; consent screen status to be double-checked as “In production” before M9.
  • Next action: (1) clear context; (2) M6 Sync engine core (plan M6 steps 1–5, pure TS in src/sync/): state model + store (§3.5), three-way planner (§3.6 step 5), conflict surfacing with “apply to all”, executor with concurrency limit + journal + idempotent resume, randomized two-device scenario tests on createFakeGraphFs + createFakeDrive with fault injection.

CURRENT PROGRESS REPORT:

PROGRESS LOG

  • Live status and session handoff for docs/implementation-plan.md.
  • Update this at the end of every milestone and before any context clear.

Milestone Status

Milestone Status Notes
M0 Feasibility spike :white_check_mark: Done 2026-09-15 Gate: architecture B confirmed. Evidence in docs/spike-results.md
M1 Scaffold :white_check_mark: Done 2026-09-15 lint, test, build green; unpacked plugin loaded in Logseq 0.10.15 (toolbar icon, panel, close paths confirmed by the user; console [gdsync] … ready (host 0.10.15))
M2 UI shell :white_check_mark: Done 2026-09-15 lint, test (44), build green; all 8 in-Logseq demo checks passed (user-confirmed): 5 toolbar states, panel, conflict dialog, toasts, palette, settings, theme
M3 Google auth :white_check_mark: Done 2026-09-15 lint, test (105), build green; all 7 in-Logseq checks passed (user-confirmed): device-code sign-in, session file, connected state survived a restart, cancel, sign-out + revoke, invalid_client negative path
M4 Drive layer :white_check_mark: Done 2026-09-15 lint, test (158), build green; live smoke test against real Drive passed 13/13 (user-confirmed): layout bootstrap, multipart + resumable (3.1 MB, 1 MiB chunks) uploads, downloads hash-identical, appProperties merge/delete/query, content update, tree listing, changes.list, lock acquire/held/renew/release/break-expired, trash, cascading delete; throwaway folder removed
M5 Local file layer :white_check_mark: Done 2026-09-15 lint, test (200), build green; GraphFs + HostBridgeFs, scanner with §3.4 ignore rules + mtime/size hash cache, atomic writes, bak sessions, FakeHost emulator; in-Logseq file smoke test on the test graph passed 13/13 (user-confirmed): bridge + dot dir, listing, cold scan 14 files/60 ms, warm scan 4 ms, 3.1 MB read 41 ms, atomic create/overwrite with bak copy, 300 KB binary round trip, rescan, rename, move-to-bak, profile root read. M5-C was not needed
M6 Sync engine core :white_check_mark: Done 2026-09-15 lint, test (297, incl. a 40-seed fuzz; 200 seeds green on demand), build green; pure src/sync/ (state, remote, planner, executor, engine) + src/google/mirror.ts; randomized two-device scenarios with fault injection and simulated crashes: nothing vanishes, both devices converge with Drive. No in-Logseq step (pure TS). Two fuzz-found fixes became approved plan amendments (see log)
M7 Manual two-way sync E2E :white_large_square: Not started Must force-save the editor before scan/write (spike §4.2)
M8 Snapshots, profile, restore :white_large_square: Not started
M9 Personal release :white_large_square: Not started

Decision / Deviation Log

{BEGINS WITH NEW ADDITIONS AFTER LAST UPDATE TO SAVE CHARACTERS}

  • 2026-09-15 — M6 sync engine core written (plan M6 steps 1–5), pure TS in src/sync/ (no logseq/Google imports) plus one adapter in src/google/mirror.ts:
    • Step 1 state.ts (§3.5): device.json, state/<graphKey>.json, journal/<graphKey>.json as JSON strings over a JsonStorage (= logseq.FileStorage; hasItem before getItem, cleared file = null). Parsing is defensive: anything unreadable reads as a fresh state, which the three-way plan turns into an add/add merge, never into data loss. graphKey(name) = sanitised name + FNV-1a hash. ensureDevice() creates the device id once. Refinement of §3.5 (proposed amendment, see below): the state carries a remote map (the persisted remote view) next to entries; entries name the mtime mtimeMs.
    • remote.ts: the RemoteMirror interface (fetchDelta / download / upload / trash), RemoteFile (path, id, sha256 from appProperties, size, times, md5) and applyDeltaWithDuplicates(view, delta): view ⊕ a full listing or a changes feed, duplicate paths collapse onto the oldest file and the younger twins are reported.
    • Step 2 planner.ts: planSync({local, remote, base}) implements the §3.6 step 5 table incl. deletes, add/add-identical and both-deleted, plus two details: a base refresh when only stats or Drive identity drifted (update-base, no I/O), and remote-change detection by (id, modifiedTime, size) when a remote file carries no sha. conflictOps() maps D7 answers to ops (normalizeChoice for delete conflicts; unanswered = skipped and planned again).
    • Step 3: conflicts reach the UI as ConflictItem[] through the engine’s resolveConflicts hook; apply-to-all lives in the M2 dialog. ConflictSide.sha256 became string | null (a remote file without our appProperty), the dialog shows “unknown”.
    • Step 4 executor.ts: a pool (concurrency 4) that reports one Completion per finished op (new base entry + remote file per path), re-checks the scan-time stat before overwriting or moving a local file (a file the user edited during the run is skipped and becomes a conflict next time), verifies downloaded bytes against the remote sha, copies previous content to bak before an overwrite, moves deletes into bak, continues after a failed op, trips a breaker after 3 consecutive failures, honours an AbortSignal, and treats a throwing hook as a crash (no new op starts, in-flight ones finish, the error is rethrown). engine.ts runSync(deps, hooks): recover journal → move leftover *.gdsync-tmp to bak → scan (entries as hash cache) → delta → plan → conflicts (journaled answers replayed for the identical conflict) → journal the plan → execute, flushing completions every 25 ops / 3 s → persist state (+ token, remote view) → clear journal.
    • Step 5 tests/sync/world.ts builds the two-device world (one FakeDrive, per-device FakeHost graph + FileStorage + Drive client, the real mirror, the real backoff transport with instant sleeps, one ticking clock). engine.test.ts covers the three first-run modes, propagation, deletes, every conflict answer, journal recovery (flushed and unflushed), replayed answers, temp cleanup, abort, faults, the lost-response duplicate, restart + new folder, mid-sync edits, resumable binaries, progress. scenario.test.ts is the DoD: 40 seeds × 14 rounds (GDSYNC_FUZZ_SEEDS=200 for more) of random edits/deletes on either device, random conflict answers incl. skips, injected faults (5xx, network errors, persistent 503 on uploads, lost create response with the server having done the work, host writeFile swallowed, rename EPERM) and simulated crashes at random execute events; invariant per run: every content live before the run is still somewhere after it (either graph incl. bak, Drive live or trash) except a version the syncing device itself had synced and replaced; at the end both graphs equal Drive, one live file per Drive path, states exact, journals clear.
    • Result: 97 new tests → 297, 200-seed fuzz green, build green (394 kB, unchanged: nothing imports the engine until M7).
  • 2026-09-15 — M6 design choices (refinements, no plan deviation): (1) src/sync/ sees Drive only through RemoteMirror, so it stays free of Google imports; the tests run the real createDriveMirror over FakeDrive (M4 rule: no interface fakes). (2) The remote view is persisted so the changes token advances on every run; without it a skipped conflict or a failed download would either lose the remote change or freeze the token. (3) Journal recovery folds the recorded completions into the state and then RE-PLANS instead of replaying ops: an op that finished unrecorded leaves L = R ≠ B (base update), a half-written download leaves a temp file (moved to bak), an op that never ran is planned again, and an edit made between crash and retry is respected. Conflict answers are replayed only when path and both hashes match. (4) “Keep both” keeps the LOCAL version under conflictCopyName(path, thisDevice, runStart) and lets the remote version keep the original name; when the copy already exists (an interrupted run made it) the op degrades to the download half. (5) Mirror: a file’s path is rebuilt from parents (never trusted from appProperties.relPath), .gdsync/ is excluded, a folder in the feed that the cache cannot vouch for (new, renamed, moved, trashed) triggers a full listing (v1’s answer to folder renames; rare after the first sync), concurrent uploads into one new directory share the folder creation, an update that hits 404 falls back to create, trash is 404-tolerant.
  • 2026-09-15 — Fuzz findings, fixed in M6. (a) and (c) are approved plan amendments (user decision 2026-09-15), applied to docs/implementation-plan.md §3.6 step 7 and §3.5:
    • (a) Approved amendment to §3.6 step 7: a “keep local” answer on a both-modified conflict overwrote a Drive version that could exist nowhere else (the other device had edited on since uploading it). The executor now downloads that version into logseq/bak/gdsync/<ts>/<path> before uploading (BakSession.stash), the mirror image of the approved download-bak rule. Cost: one download per such answer.
    • (b) A create whose response is lost and retried by the transport leaves a younger, identical twin on Drive (Drive has no idempotent create). Every device addresses the oldest, so the twin never receives updates and would stay as stale clutter forever. applyDeltaWithDuplicates reports younger duplicates and the engine trashes them (trash-duplicate op, Drive trash keeps them). No plan text affected; recorded because it changes what the mirror does to files it did not plan.
    • (c) Approved amendment to §3.5: state/<graph-key>.json gains remote: { [relPath]: { path, id, sha256, size, modifiedTime, createdTime, md5 } }, and entries[*].mtime is spelled mtimeMs.
  • 2026-09-15 — M6 notes for later: bak retention (deferred to M9) now also covers stashed remote versions; a failed trash-duplicate is retried only when the twin reappears in a delta (a full listing), which is rare and harmless; the engine passes error text through describeError (M7 injects describeGoogleError).

Session Handoff

  • Last session (2026-09-15, M6):
    • M5 was committed as 8aafbad before this session.
    • Files added: src/sync/{remote,state,planner,executor,engine}.ts, src/google/mirror.ts, tests/sync/{world.ts,state,remote,planner,executor,engine,scenario}.test.ts, tests/google/mirror.test.ts. Modified: src/sync/conflict.ts (ConflictSide.sha256: string | null), src/ui/ConflictDialog.tsx (null guard), src/fs/bak.ts (stash). Nothing committed yet for M6.
    • Local result: npm run lint clean, npm test 297/297 (97 new, 40 of them fuzz seeds), GDSYNC_FUZZ_SEEDS=200 npx vitest run tests/sync/scenario.test.ts 200/200 in ~6 s, npm run build green (dist/assets/index-*.js 394 kB, unchanged: src/main.tsx does not import the engine yet).
    • No in-Logseq step this milestone (pure TS; the DoD is the fuzz). The user’s Google account is still signed in.
    • User decisions: amendments (a) keep-local stash (§3.6 step 7) and (c) state.remote (§3.5) approved and applied to the plan; M6 committed as “M6: Sync engine core …” on the user’s go.
    • M7 inputs: runSync(deps, hooks) in src/sync/engine.ts with deps = { fs: createHostGraphFs(...), remote: createDriveMirror({ client: createHostDriveClient(auth), graphFolderId }), store: createSyncStateStore(logseq.FileStorage), graphKey: graphKey(graph.name), deviceId (ensureDevice with crypto.randomUUID), deviceName: settings.deviceName, describeError: describeGoogleError, log } and hooks = { onProgress → status.running + progress toast, resolveConflicts → status.pendingConflicts + a promise the dialog resolves, signal }. SyncRunResult maps onto SyncSummary (uploaded/downloaded/deletedLocal/deletedRemote/conflictsResolved/conflictsSkipped; failures/skippedOps/stoppedEarly feed the error banner). Around it M7 adds plan §3.6 steps 1–2 and 9–10: preflight (auth, host version, Editor.exitEditingMode() + ≥ 1 s wait per spike §4.2), bootstrapLayout once with the ids persisted in state.driveRootId / state.graphFolderId (M4: ≈1.5 s per folder lookup), createDriveLock acquire / renew on a timer / release, then the snapshot hook (M8). The three first-run modes need no extra code (planner). Delete src/mock/ and src/ui/DemoControls.tsx, keep the auth mirroring from mockSyncController.ts.
  • Previous session (2026-09-15, M5):
    • M4 was committed as 631db4c before this session.
    • Files added: src/fs/{graphFs,hostBridgeFs,ignore,scanner,limit,atomicWrite,bak}.ts, src/logseq/fsHost.ts, src/mock/fsSmoke.ts, tests/fs/{fakeHost.ts,fakeHost,graphFs,hostBridgeFs,ignore,limit,scanner,atomicWrite,bak}.test.ts, tests/logseq/fsHost.test.ts. Modified: src/mock/mockSyncController.ts (runFsSmoke), src/ui/DemoControls.tsx. Nothing committed yet for M5.
    • Local result: npm run lint clean, npm test 200/200 (42 new), npm run build green (dist/assets/index-*.js 394 kB).
    • M5 DoD live check passed in Logseq (user-confirmed, 2026-09-15 04:03), 13/13 steps in 3.2 s (/tmp/logseq-console.log, [gdsync] fs smoke): bridge reachable, ~/.logseq resolved; listdir 18 entries (the 4 .recycle leftovers included, filtered by the scanner); cold scan 14 files hashed in 60 ms (incl. the 3.1 MB png), warm scan 14 cache hits in 4 ms (so a bridge stat round trip is well under 1 ms); config.edn text and big-noise.png bytes (3,147,871 B in 41 ms) hash-identical to the scan; missing path → null / ENOENT; atomic create left no temp file and the returned stat equalled the re-stat; bak copy held the first content after the atomic overwrite; 307,207 random bytes round-tripped byte-identical in 21 ms; rescan hashed exactly the 2 new files; rename; move-to-bak left the graph at 14 files with nothing in .recycle; profile root read 102 settings files, preferences.json (1360 chars) parsed, config/config.edn 613 B. No host errors in the console. Leftover, by design: ~/logseq-test-graphs/gdsync-dev/logseq/bak/gdsync/smoke-20260915-040327/ (2 page versions + the 300 KB bin).
    • Not yet observed live (unit-tested only): fetch(file://) of an empty file; a writeFile failure (the swallowed-null path); Windows paths.
    • The user’s Google account is still signed in (M4 left it so).
    • User decisions after the smoke: §3.4 amendment applied (ignore logseq/graphs-txid.edn, logseq/pages-metadata.edn); bak retention deferred to M9. M5 complete; committed as “M5: Local file layer …” on the user’s go.
    • Next: clear context, start M6 (plan M6 steps 1–5). createFakeGraphFs in tests/fs/fakeHost.ts + createFakeDrive in tests/google/fakeDrive.ts are the two fakes the scenario tests share; the engine’s state.entries feed scanFiles({ cache }), writeFileAtomic returns the stat to store back, createBakSession per run, listTempFiles for leftovers of a crashed run.
  • Previous session (2026-09-15, M4):
    • M3 was committed as d832b4e before this session.
    • Files added: src/google/{drive,driveQuery,driveUpload,folderCache,layout,lock}.ts, src/fs/hash.ts, src/mock/driveSmoke.ts, tests/google/{fakeDrive.ts,fakeDrive,drive,driveQuery,folderCache,layout,lock}.test.ts, tests/fs/hash.test.ts. Modified: src/google/errors.ts, src/logseq/googleHost.ts (createHostDriveClient), src/mock/mockSyncController.ts (runDriveSmoke), src/ui/DemoControls.tsx. Nothing committed yet for M4.
    • Local result: npm run lint clean, npm test 158/158 (53 new), npm run build green (dist/assets/index-*.js 377 kB).
    • M4 DoD live check passed in Logseq (user-confirmed, 2026-09-15 03:34), 13/13 steps in 40.5 s with 80 requests (/tmp/logseq-console.log, [gdsync] smoke): about.get 112 ms; layout bootstrap 9.2 s for 6 folder creates (≈1.5 s per ensureFolder, so M7 must persist the ids in state/<graph>.json and bootstrap only once); multipart create honoured appProperties + modifiedTime; resumable upload of big-noise.png (3,147,871 bytes) in 2.5 s with progress 1 MiB → 2 MiB → 3 MiB → end, so the 308 Range header is readable in the iframe; downloads hash-identical; appProperties PATCH merged, null deleted a key, appProperties has {…} query found the file; multipart PATCH updated content in place; listTree gave assets, assets/big-noise.png, pages, pages/smoke.md and a fresh client’s pathUnder resolved pages/smoke.md; changes.list returned 5 changes and none after the new token; the lock step (15 s of round trips) acquired, saw contention, renewed, released, broke an expired lock and left no file; trash flagged and hid the file; the smoke root folder DELETE cascaded (404 confirmed). Nothing left in Drive.
    • The user’s Google account is now signed in to the plugin (session file holds the JSON session).
    • M4 is complete; nothing committed yet. Next: commit on the user’s go (“M4: Drive layer …”), clear context, start M5 (plan M5 steps 1–4; read docs/spike-results.md §4 item 1 first; src/fs/hash.ts already exists).
  • Previous session (2026-09-15, M3):
    • M2 was committed as 006da93 before this session.
    • Files added: src/google/{credentials,errors,http,oauth,tokenStore,account,auth}.ts, src/logseq/googleHost.ts, src/ui/ConnectBox.tsx, tests/google/{helpers.ts,credentials,http,oauth,tokenStore,auth}.test.ts. Modified: src/main.tsx, src/mock/mockSyncController.ts, src/sync/{status,controller}.ts, src/logseq/toasts.ts (showStickyToast/closeToast), src/ui/{StatusPanel.tsx,App.css}. Nothing committed yet for M3.
    • Local result: npm run lint clean, npm test 105/105 (61 new), npm run build green (dist/assets/index-*.js 352 kB; the built-in client ID is inlined from .env.local).
    • M3 DoD live check passed in Logseq (user-confirmed, 2026-09-15), all 7 steps: (1) Connect Google shows the code, countdown and buttons plus the sticky toast; (2) Open in browser → code → allow → “Connected as ”, success toast, toolbar idle; (3) ~/.logseq/storages/logseq-google-drive-graph-sync/auth/google-session.json holds the JSON session; (4) after a Logseq restart the panel opens already connected, no network call; (5) Cancel mid-flow → “Sign-in cancelled”, back to signed-out; (6) Sign out → “access was revoked”, the file contains null, the app is gone from myaccount.google.com/permissions; (7) bogus client ID + secret → the invalid_client message, clearing both works again without a reload.
    • The user’s Google account is now signed out of the plugin (step 6 ran last); M4’s Drive smoke test starts with a fresh Connect.
    • .env.local still holds the working TV/limited-input OAuth client; consent screen status to be double-checked as “In production” before M9 (a “Testing” app expires refresh tokens after 7 days, which would show up as the “sign-in expired” toast).
  • Previous session (2026-09-15, M2):
    • M1 was committed as 64ec89c before this session.
    • Files added: src/sync/{store,status,conflict,controller}.ts, src/logseq/{settings,commands,toasts,theme,graph}.ts, src/mock/mockSyncController.ts, src/ui/{StatusPanel,ConflictDialog,DemoControls}.tsx, src/ui/{useStore,useNow,format}.ts; rewritten: src/main.tsx, src/logseq/toolbar.ts, src/ui/App.tsx, src/ui/App.css. Tests: tests/sync/{store,status,conflict}.test.ts, tests/logseq/{settings,toolbar}.test.ts, tests/ui/format.test.ts → 43 tests. Nothing committed yet for M2.
    • Local result: npm run lint clean, npm test 43/43, npm run build green (dist/assets/index-*.js 335 kB).
    • M2 DoD check passed in Logseq (user-confirmed, 2026-09-15): (1) toolbar cloud shows a grey dot (signed-out) and opens the panel; (2) Connect Google → pill “Up to date”, remote check runs; (3) Demo “Show state” buttons flip the toolbar badge (spinner / amber / red / grey); (4) “with conflicts” run opens the conflict dialog automatically, Esc/skip/apply-to-all behave, summary toast reports resolved/skipped; (5) “failing” run shows the error banner + red badge; (6) palette lists the three “Google Drive Sync:” commands; (7) settings gear renders the 5 headings and 10 items; (8) the panel follows Logseq’s light/dark toggle.
    • User decision: keep the “Google Drive Sync:” prefix on the palette labels.
    • M3 inputs already in place: settings.googleClientId/Secret overrides (D5 resolution env → settings is M3 step 1), controller.connect()/signOut() are the seams the device-code flow replaces, status.account drives the signed-out state.
  • Previous session (2026-09-15, M1):
    • Files added: package.json (+ lockfile), tsconfig{,.app,.node}.json, vite.config.ts (Vitest config inside), .oxlintrc.json, index.html, icon.svg, LICENSE, .github/workflows/publish.yml, src/main.tsx, src/vite-env.d.ts (types VITE_GOOGLE_CLIENT_ID/SECRET), src/logseq/{toolbar,hostVersion}.ts, src/ui/{App.tsx,App.css,useMainUiVisible.ts}, tests/logseq/hostVersion.test.ts (6 tests). Nothing committed yet for M1.
    • Local result: npm run lint clean, npm test 6/6, npm run build → dist/index.html + dist/assets/index-*.{js,css} with relative ./assets/ URLs (same shape as the working sibling plugin’s dist/).
    • In-Logseq load check passed (2026-09-15 01:03): unpacked plugin loaded from the repo root; toolbar icon, panel (plugin id + 0.10.15), and Escape / click-outside / × close confirmed by the user; console line [gdsync] logseq-google-drive-graph-sync ready (host 0.10.15) from dist/assets/index-*.js. The stale spikes/bridge/ entry was removed from the plugin dashboard.
    • Logseq was running during this session as /opt/logseq-desktop/Logseq --enable-logging with output tee’d to /tmp/logseq-console.log (a per-launch convenience, not durable).
    • README.md rewritten to match D9 (manual sync, no “real-time”); the full README with GCP setup, plaintext-token warning, and install steps stays an M9 item.
    • LICENSE (MIT © 2026 Wanderduck) confirmed by the user.
    • SDK audit facts (verified 2026-09-15): @logseq/libs 0.0.17 ships a prebuilt dist/lsplugin.user.js with no require of dompurify or lodash-es; dompurify is only imported by LSPlugin.core.d.ts (the host-side module that Logseq itself ships); our built dist/ contains neither DOMPurify nor lodash template code. The advisories therefore do not reach the shipped plugin. Decision on silencing them via overrides is with the user (see the recommendation in the M1 session summary).
    • Test graphs unchanged since M0: ~/logseq-test-graphs/gdsync-dev (harmless spike leftovers) and empty gdsync-dev-2; pristine tarball + reset script in ~/logseq-test-graphs/.
    • .env.local still holds the working TV/limited-input OAuth client; consent screen status to be double-checked as “In production” before M9.
  • Next action: (1) clear context; (2) M7 Manual two-way sync end-to-end (plan M7 steps 1–4): real SyncController around runSync (see the M7 inputs above), lock + preflight + first-run modes, graph switch, remote check on panel open, sync-on-startup, then the manual E2E on gdsync-dev / gdsync-dev-2.

CURRENT PROGRESS REPORT:

PROGRESS LOG

  • Live status and session handoff for docs/implementation-plan.md.
  • Update this at the end of every milestone and before any context clear.

Milestone Status

Milestone Status Notes
M0 Feasibility spike :white_check_mark: Done 2026-09-15 Gate: architecture B confirmed. Evidence in docs/spike-results.md
M1 Scaffold :white_check_mark: Done 2026-09-15 lint, test, build green; unpacked plugin loaded in Logseq 0.10.15 (toolbar icon, panel, close paths confirmed by the user; console [gdsync] … ready (host 0.10.15))
M2 UI shell :white_check_mark: Done 2026-09-15 lint, test (44), build green; all 8 in-Logseq demo checks passed (user-confirmed): 5 toolbar states, panel, conflict dialog, toasts, palette, settings, theme
M3 Google auth :white_check_mark: Done 2026-09-15 lint, test (105), build green; all 7 in-Logseq checks passed (user-confirmed): device-code sign-in, session file, connected state survived a restart, cancel, sign-out + revoke, invalid_client negative path
M4 Drive layer :white_check_mark: Done 2026-09-15 lint, test (158), build green; live smoke test against real Drive passed 13/13 (user-confirmed): layout bootstrap, multipart + resumable (3.1 MB, 1 MiB chunks) uploads, downloads hash-identical, appProperties merge/delete/query, content update, tree listing, changes.list, lock acquire/held/renew/release/break-expired, trash, cascading delete; throwaway folder removed
M5 Local file layer :white_check_mark: Done 2026-09-15 lint, test (200), build green; GraphFs + HostBridgeFs, scanner with §3.4 ignore rules + mtime/size hash cache, atomic writes, bak sessions, FakeHost emulator; in-Logseq file smoke test on the test graph passed 13/13 (user-confirmed): bridge + dot dir, listing, cold scan 14 files/60 ms, warm scan 4 ms, 3.1 MB read 41 ms, atomic create/overwrite with bak copy, 300 KB binary round trip, rescan, rename, move-to-bak, profile root read. M5-C was not needed
M6 Sync engine core :white_check_mark: Done 2026-09-15 lint, test (297, incl. a 40-seed fuzz; 200 seeds green on demand), build green; pure src/sync/ (state, remote, planner, executor, engine) + src/google/mirror.ts; randomized two-device scenarios with fault injection and simulated crashes: nothing vanishes, both devices converge with Drive. No in-Logseq step (pure TS). Two fuzz-found fixes became approved plan amendments (see log)
M7 Manual two-way sync E2E :white_check_mark: Done 2026-09-15 lint, test (320, fuzz 200 seeds green), build green; real SyncController (src/logseq/syncController.ts) around the pure src/sync/session.ts (layout ids persisted + verified, Drive lock acquire/renew/release, editor force-save before scan and before every local write, D9 remote check, graph-switch abort, sync-on-startup); src/mock/ + demo controls deleted. Two-device E2E against real Drive passed (user-confirmed, log-verified): local → Drive (14 up, 10.8 s incl. the 9 s layout bootstrap), both-non-empty merge on device 2 (3 add/add conflicts → keep both, 14 down), cross-edit conflict resolved, no-op run 228 ms warm; both trees hash-identical (17 files), journals clear, no failures
M8 Snapshots, profile, restore :white_large_square: Not started
M9 Personal release :white_large_square: Not started

Decision / Deviation Log

{BEGINS WITH NEW ADDITIONS AFTER LAST UPDATE TO SAVE CHARACTERS}

  • 2026-09-15 — M7 wiring written (plan M7 steps 1–3; step 4 is the live E2E below):
    • src/sync/session.ts (pure): runSyncSession(deps, hooks) = §3.6 steps 1–2 and 10 around runSync: layout from the persisted state.driveRootId/graphFolderId when they verify (else bootstrap, ids saved), a changed graph folder resets the state and clears the journal (the old base would read an empty new mirror as “everything deleted remotely” and delete the graph; a fresh state makes it the first-run merge instead), lock acquire({ttlMs: 5 min, breakExpired}) → held returns {kind:'locked', expired} without scanning, a renew loop every 90 s while the run lasts (a failed renewal aborts the run, lockLost, the lock is left to expire), flushEditor('before-scan') right before the scan, after the lock, beforeLocalWrite → flushEditor('before-write'), release in finally (also when the engine throws). checkRemoteStatus (D9) reads only: findLayout (find-only, never creates) → firstSync + the mirror’s file count, or countRemoteChanges judged against state.remote (the token predates a run’s own uploads, so the feed re-reports them; they must not count) + the foreign live lock. summarizeRun, describeRunProblems, describeSkippedOps, effectiveDeviceName (settings name sanitised, else device-<8 hex of the id>).
    • src/logseq/syncController.ts: the real SyncController. Auth mirroring kept from the mock; per-run deps (fresh Drive client per run, like a restart), hostBridge() failure → error banner; progress throttled to one store/toast update per 250 ms (src/sync/throttle.ts); conflicts → status.pendingConflicts + openPanel() + a promise the dialog resolves; a run’s outcome → lastSync/lastSyncAt/lastError (+ expiredLock → the panel’s “Break the lock and sync” button, syncNow({ breakExpiredLock: true })); graph switch (status.graph.path change) aborts a running sync with a reason, reloads lastSyncAt/lastSnapshotAt from that graph’s state; a session lost mid-run aborts too; dispose() aborts, resolves the dialog with “skip all”, awaits the run (lock released) and is awaited by beforeunload. flushEditor: Editor.checkEditing() → exitEditingMode() when editing, then wait 1.2 s (always before the scan, only when editing before a write). backupNow is an M8 placeholder toast.
    • Engine additions: SyncRunHooks.beforeLocalWrite(path) → ExecutorDeps.beforeLocalWrite, awaited ahead of the stat guard in download, delete-local and keep-both (twice there: before the copy and before the download half), never for uploads; graphKey(name, path = name) hashes the path (see the choice below). SyncStatus gained lastSyncAt (from the persisted state, survives restarts), SyncError.expiredLock, RemoteStatus.ok.firstSync; SyncController.syncNow(opts), dispose(): Promise<void>. src/google/layout.ts gained verifyLayout (3 files.get), resolveLayout (verify or bootstrap), findLayout (find-only). trackCurrentGraph returns { ready, dispose }; “sync on startup” awaits ready.
    • Deleted: src/mock/ (mock controller, Drive + file smoke scripts), src/ui/DemoControls.tsx, the demo CSS. The panel/App take the SyncController interface.
    • Tests: tests/sync/session.test.ts (18: first run, restart reuses ids with no name lookups, flush order, live/expired lock + break, release on throw, renew + lock lost, outer abort, folder change/trashed folder keep the graph, stale journal dropped, conflict answers threaded, remote check semantics incl. own uploads not counted and lock visibility, pure helpers), tests/sync/throttle.test.ts, layout verify/resolve/find cases, the executor hook, graphKey by path → 320 tests; lint, build (399 kB) green.
  • 2026-09-15 — M7 choices (refinements, no plan deviation): (1) state/<graph-key>.json is keyed by the graph’s path (graphKey(name, path)), not its name: the base describes the files at one directory, and the D11 device-2 simulation needs two local graphs that share the NAME (so both map to graphs/gdsync-dev on Drive) but not the state. Both simulated devices share device.json (one FileStorage), which only affects the lock’s “own stale lock” rule and appProperties.deviceId; acceptable for the simulation, the fuzz covers distinct ids. (2) D11 guard in code until M9: syncNow refuses any graph whose path lacks /logseq-test-graphs/ (DEV_GRAPH_MARKER in syncController.ts), so the real graph cannot be touched by accident; M9 removes it. (3) Lock TTL 5 min, renewed every 90 s; a crashed device blocks the others for ≤ 5 min, then the panel offers to break the lock. (4) A fresh Drive client per run so no folder cache outlives a run (the fuzz’s restart path). (5) The editor flush runs after the lock is held, right before the scan (the M0 note said “before the local scan”; doing it before the ≈9 s first-run layout bootstrap would leave a gap, and a run refused by the lock should not close the editor). (6) The “remote check” counts changes since the last sync, not “files to download” (that would need a local scan); the panel says so.
  • 2026-09-15 — Test-graph setup for the E2E: ~/logseq-test-graphs/gdsync-dev-2/ (empty, name gdsync-dev-2) would map to its own Drive folder graphs/gdsync-dev-2, so it cannot play device 2. Created ~/logseq-test-graphs/device-2/gdsync-dev/ (empty; graph name gdsync-dev, Logseq keys its cache by the full path so both can be added). gdsync-dev-2 is left as is (harmless).

Session Handoff

  • Last session (2026-09-15, M7):
    • M6 was committed as 0c5f092 before this session.
    • Files added: src/sync/{session,throttle}.ts, src/logseq/syncController.ts, tests/sync/{session,throttle}.test.ts. Modified: src/sync/{state,executor,engine,status,controller}.ts, src/google/layout.ts, src/logseq/{graph}.ts, src/main.tsx, src/ui/{App,StatusPanel}.tsx, src/ui/App.css, tests/google/layout.test.ts, tests/sync/{executor,state}.test.ts. Deleted: src/mock/*, src/ui/DemoControls.tsx. Nothing committed yet for M7.
    • Local result: npm run lint clean, npm test 320/320, GDSYNC_FUZZ_SEEDS=200 scenario green, npm run build green (dist/assets/index-*.js 399 kB; the engine is now bundled).
    • Logseq was running (/opt/logseq-desktop/Logseq --enable-logging since 02:57, tee’d to /tmp/logseq-console.log); the plugin’s Google account is signed in. The Drive has no “Logseq Graph Sync” root yet (the M4 smoke deleted its throwaway folder), so the first sync bootstraps the 6 folders (≈9 s).
    • M7 DoD E2E passed in Logseq (user-confirmed 2026-09-15 13:12–13:17; /tmp/logseq-console.log, [gdsync] sync: lines):
      1. gdsync-dev first sync: full delta, 14 local files hashed, 14 uploaded (incl. the 3.1 MB png), 0 conflicts, 10.8 s (≈9 s of it the one-time layout bootstrap; driveRootId/graphFolderId persisted).
      2. device-2/gdsync-dev (added as a new graph; Logseq seeds logseq/config.edn, logseq/custom.css, pages/contents.md into a new graph, so this was the both-non-empty first-run mode, not the empty one): full delta, 3 local files, 14 remote, 3 add/add conflicts → keep both, 14 downloaded, 3 conflict copies uploaded (*.conflict-device-f36ff7ea-20260915-1313.*), 65.8 s including the dialog time.
      3. Device 2 edit → changes delta, 1 hashed, 1 up, 2.5 s. Device 1 then: full delta (the feed carried the folder entries of run 1, see the M9 note), 1 hashed, 1 both-modified conflict resolved, 4 down (the edit + the 3 conflict copies), 10.3 s. Device 1 again: changes delta, 0 hashed, nothing to do, 228 ms.
      4. Both trees hash-identical (17 files each, sha256sum diff empty); both state files (state/gdsync-dev-4892d2ae.json, state/gdsync-dev-df7482b0.json, keyed by path) hold 17 entries + 17 remote and the same Drive ids; both journals cleared (null); one logseq/bak/gdsync/<ts>/ per run that wrote; no failures, skipped ops, lock breaks or renew failures in the log. Device name derived as device-f36ff7ea.
      5. The user reported the editor force-save and the graph-switch paths as working; the log holds no aborted or skipped run, so those two are covered by the session tests rather than by log evidence.
    • Drive now holds Logseq Graph Sync/{graphs/gdsync-dev, snapshots/gdsync-dev, profile} with the 17 files (root id 1Xi7xh6Eug-tw1MUf0FXImHCWB8ctpSbg). Leave it: M8 builds on it.
    • M9 note (performance): a run whose changes feed contains a folder entry the fresh per-run client cannot vouch for falls back to a full listing (one request per folder; 10 s here vs 2.5 s). With a fresh client every run, that is every run after ANY folder creation, including this device’s own uploads into a new folder (the token predates them). One-time per folder, not per run, but on a large graph it is worth seeding the folder cache from state.remote paths or resolving the folder with pathUnder before listing everything. DoD: both folders reconcile, conflicts prompt per D7, hashes match.
  • Previous session (2026-09-15, M6):
    • M5 was committed as 8aafbad before this session.
    • Files added: src/sync/{remote,state,planner,executor,engine}.ts, src/google/mirror.ts, tests/sync/{world.ts,state,remote,planner,executor,engine,scenario}.test.ts, tests/google/mirror.test.ts. Modified: src/sync/conflict.ts (ConflictSide.sha256: string | null), src/ui/ConflictDialog.tsx (null guard), src/fs/bak.ts (stash). Nothing committed yet for M6.
    • Local result: npm run lint clean, npm test 297/297 (97 new, 40 of them fuzz seeds), GDSYNC_FUZZ_SEEDS=200 npx vitest run tests/sync/scenario.test.ts 200/200 in ~6 s, npm run build green (dist/assets/index-*.js 394 kB, unchanged: src/main.tsx does not import the engine yet).
    • No in-Logseq step this milestone (pure TS; the DoD is the fuzz). The user’s Google account is still signed in.
    • User decisions: amendments (a) keep-local stash (§3.6 step 7) and (c) state.remote (§3.5) approved and applied to the plan; M6 committed as “M6: Sync engine core …” on the user’s go.
    • M7 inputs: runSync(deps, hooks) in src/sync/engine.ts with deps = { fs: createHostGraphFs(...), remote: createDriveMirror({ client: createHostDriveClient(auth), graphFolderId }), store: createSyncStateStore(logseq.FileStorage), graphKey: graphKey(graph.name), deviceId (ensureDevice with crypto.randomUUID), deviceName: settings.deviceName, describeError: describeGoogleError, log } and hooks = { onProgress → status.running + progress toast, resolveConflicts → status.pendingConflicts + a promise the dialog resolves, signal }. SyncRunResult maps onto SyncSummary (uploaded/downloaded/deletedLocal/deletedRemote/conflictsResolved/conflictsSkipped; failures/skippedOps/stoppedEarly feed the error banner). Around it M7 adds plan §3.6 steps 1–2 and 9–10: preflight (auth, host version, Editor.exitEditingMode() + ≥ 1 s wait per spike §4.2), bootstrapLayout once with the ids persisted in state.driveRootId / state.graphFolderId (M4: ≈1.5 s per folder lookup), createDriveLock acquire / renew on a timer / release, then the snapshot hook (M8). The three first-run modes need no extra code (planner). Delete src/mock/ and src/ui/DemoControls.tsx, keep the auth mirroring from mockSyncController.ts.
  • Previous session (2026-09-15, M5):
    • M4 was committed as 631db4c before this session.
    • Files added: src/fs/{graphFs,hostBridgeFs,ignore,scanner,limit,atomicWrite,bak}.ts, src/logseq/fsHost.ts, src/mock/fsSmoke.ts, tests/fs/{fakeHost.ts,fakeHost,graphFs,hostBridgeFs,ignore,limit,scanner,atomicWrite,bak}.test.ts, tests/logseq/fsHost.test.ts. Modified: src/mock/mockSyncController.ts (runFsSmoke), src/ui/DemoControls.tsx. Nothing committed yet for M5.
    • Local result: npm run lint clean, npm test 200/200 (42 new), npm run build green (dist/assets/index-*.js 394 kB).
    • M5 DoD live check passed in Logseq (user-confirmed, 2026-09-15 04:03), 13/13 steps in 3.2 s (/tmp/logseq-console.log, [gdsync] fs smoke): bridge reachable, ~/.logseq resolved; listdir 18 entries (the 4 .recycle leftovers included, filtered by the scanner); cold scan 14 files hashed in 60 ms (incl. the 3.1 MB png), warm scan 14 cache hits in 4 ms (so a bridge stat round trip is well under 1 ms); config.edn text and big-noise.png bytes (3,147,871 B in 41 ms) hash-identical to the scan; missing path → null / ENOENT; atomic create left no temp file and the returned stat equalled the re-stat; bak copy held the first content after the atomic overwrite; 307,207 random bytes round-tripped byte-identical in 21 ms; rescan hashed exactly the 2 new files; rename; move-to-bak left the graph at 14 files with nothing in .recycle; profile root read 102 settings files, preferences.json (1360 chars) parsed, config/config.edn 613 B. No host errors in the console. Leftover, by design: ~/logseq-test-graphs/gdsync-dev/logseq/bak/gdsync/smoke-20260915-040327/ (2 page versions + the 300 KB bin).
    • Not yet observed live (unit-tested only): fetch(file://) of an empty file; a writeFile failure (the swallowed-null path); Windows paths.
    • The user’s Google account is still signed in (M4 left it so).
    • User decisions after the smoke: §3.4 amendment applied (ignore logseq/graphs-txid.edn, logseq/pages-metadata.edn); bak retention deferred to M9. M5 complete; committed as “M5: Local file layer …” on the user’s go.
    • Next: clear context, start M6 (plan M6 steps 1–5). createFakeGraphFs in tests/fs/fakeHost.ts + createFakeDrive in tests/google/fakeDrive.ts are the two fakes the scenario tests share; the engine’s state.entries feed scanFiles({ cache }), writeFileAtomic returns the stat to store back, createBakSession per run, listTempFiles for leftovers of a crashed run.
  • Previous session (2026-09-15, M4):
    • M3 was committed as d832b4e before this session.
    • Files added: src/google/{drive,driveQuery,driveUpload,folderCache,layout,lock}.ts, src/fs/hash.ts, src/mock/driveSmoke.ts, tests/google/{fakeDrive.ts,fakeDrive,drive,driveQuery,folderCache,layout,lock}.test.ts, tests/fs/hash.test.ts. Modified: src/google/errors.ts, src/logseq/googleHost.ts (createHostDriveClient), src/mock/mockSyncController.ts (runDriveSmoke), src/ui/DemoControls.tsx. Nothing committed yet for M4.
    • Local result: npm run lint clean, npm test 158/158 (53 new), npm run build green (dist/assets/index-*.js 377 kB).
    • M4 DoD live check passed in Logseq (user-confirmed, 2026-09-15 03:34), 13/13 steps in 40.5 s with 80 requests (/tmp/logseq-console.log, [gdsync] smoke): about.get 112 ms; layout bootstrap 9.2 s for 6 folder creates (≈1.5 s per ensureFolder, so M7 must persist the ids in state/<graph>.json and bootstrap only once); multipart create honoured appProperties + modifiedTime; resumable upload of big-noise.png (3,147,871 bytes) in 2.5 s with progress 1 MiB → 2 MiB → 3 MiB → end, so the 308 Range header is readable in the iframe; downloads hash-identical; appProperties PATCH merged, null deleted a key, appProperties has {…} query found the file; multipart PATCH updated content in place; listTree gave assets, assets/big-noise.png, pages, pages/smoke.md and a fresh client’s pathUnder resolved pages/smoke.md; changes.list returned 5 changes and none after the new token; the lock step (15 s of round trips) acquired, saw contention, renewed, released, broke an expired lock and left no file; trash flagged and hid the file; the smoke root folder DELETE cascaded (404 confirmed). Nothing left in Drive.
    • The user’s Google account is now signed in to the plugin (session file holds the JSON session).
    • M4 is complete; nothing committed yet. Next: commit on the user’s go (“M4: Drive layer …”), clear context, start M5 (plan M5 steps 1–4; read docs/spike-results.md §4 item 1 first; src/fs/hash.ts already exists).
  • Previous session (2026-09-15, M3):
    • M2 was committed as 006da93 before this session.
    • Files added: src/google/{credentials,errors,http,oauth,tokenStore,account,auth}.ts, src/logseq/googleHost.ts, src/ui/ConnectBox.tsx, tests/google/{helpers.ts,credentials,http,oauth,tokenStore,auth}.test.ts. Modified: src/main.tsx, src/mock/mockSyncController.ts, src/sync/{status,controller}.ts, src/logseq/toasts.ts (showStickyToast/closeToast), src/ui/{StatusPanel.tsx,App.css}. Nothing committed yet for M3.
    • Local result: npm run lint clean, npm test 105/105 (61 new), npm run build green (dist/assets/index-*.js 352 kB; the built-in client ID is inlined from .env.local).
    • M3 DoD live check passed in Logseq (user-confirmed, 2026-09-15), all 7 steps: (1) Connect Google shows the code, countdown and buttons plus the sticky toast; (2) Open in browser → code → allow → “Connected as ”, success toast, toolbar idle; (3) ~/.logseq/storages/logseq-google-drive-graph-sync/auth/google-session.json holds the JSON session; (4) after a Logseq restart the panel opens already connected, no network call; (5) Cancel mid-flow → “Sign-in cancelled”, back to signed-out; (6) Sign out → “access was revoked”, the file contains null, the app is gone from myaccount.google.com/permissions; (7) bogus client ID + secret → the invalid_client message, clearing both works again without a reload.
    • The user’s Google account is now signed out of the plugin (step 6 ran last); M4’s Drive smoke test starts with a fresh Connect.
    • .env.local still holds the working TV/limited-input OAuth client; consent screen status to be double-checked as “In production” before M9 (a “Testing” app expires refresh tokens after 7 days, which would show up as the “sign-in expired” toast).
  • Previous session (2026-09-15, M2):
    • M1 was committed as 64ec89c before this session.
    • Files added: src/sync/{store,status,conflict,controller}.ts, src/logseq/{settings,commands,toasts,theme,graph}.ts, src/mock/mockSyncController.ts, src/ui/{StatusPanel,ConflictDialog,DemoControls}.tsx, src/ui/{useStore,useNow,format}.ts; rewritten: src/main.tsx, src/logseq/toolbar.ts, src/ui/App.tsx, src/ui/App.css. Tests: tests/sync/{store,status,conflict}.test.ts, tests/logseq/{settings,toolbar}.test.ts, tests/ui/format.test.ts → 43 tests. Nothing committed yet for M2.
    • Local result: npm run lint clean, npm test 43/43, npm run build green (dist/assets/index-*.js 335 kB).
    • M2 DoD check passed in Logseq (user-confirmed, 2026-09-15): (1) toolbar cloud shows a grey dot (signed-out) and opens the panel; (2) Connect Google → pill “Up to date”, remote check runs; (3) Demo “Show state” buttons flip the toolbar badge (spinner / amber / red / grey); (4) “with conflicts” run opens the conflict dialog automatically, Esc/skip/apply-to-all behave, summary toast reports resolved/skipped; (5) “failing” run shows the error banner + red badge; (6) palette lists the three “Google Drive Sync:” commands; (7) settings gear renders the 5 headings and 10 items; (8) the panel follows Logseq’s light/dark toggle.
    • User decision: keep the “Google Drive Sync:” prefix on the palette labels.
    • M3 inputs already in place: settings.googleClientId/Secret overrides (D5 resolution env → settings is M3 step 1), controller.connect()/signOut() are the seams the device-code flow replaces, status.account drives the signed-out state.
  • Previous session (2026-09-15, M1):
    • Files added: package.json (+ lockfile), tsconfig{,.app,.node}.json, vite.config.ts (Vitest config inside), .oxlintrc.json, index.html, icon.svg, LICENSE, .github/workflows/publish.yml, src/main.tsx, src/vite-env.d.ts (types VITE_GOOGLE_CLIENT_ID/SECRET), src/logseq/{toolbar,hostVersion}.ts, src/ui/{App.tsx,App.css,useMainUiVisible.ts}, tests/logseq/hostVersion.test.ts (6 tests). Nothing committed yet for M1.
    • Local result: npm run lint clean, npm test 6/6, npm run build → dist/index.html + dist/assets/index-*.{js,css} with relative ./assets/ URLs (same shape as the working sibling plugin’s dist/).
    • In-Logseq load check passed (2026-09-15 01:03): unpacked plugin loaded from the repo root; toolbar icon, panel (plugin id + 0.10.15), and Escape / click-outside / × close confirmed by the user; console line [gdsync] logseq-google-drive-graph-sync ready (host 0.10.15) from dist/assets/index-*.js. The stale spikes/bridge/ entry was removed from the plugin dashboard.
    • Logseq was running during this session as /opt/logseq-desktop/Logseq --enable-logging with output tee’d to /tmp/logseq-console.log (a per-launch convenience, not durable).
    • README.md rewritten to match D9 (manual sync, no “real-time”); the full README with GCP setup, plaintext-token warning, and install steps stays an M9 item.
    • LICENSE (MIT © 2026 Wanderduck) confirmed by the user.
    • SDK audit facts (verified 2026-09-15): @logseq/libs 0.0.17 ships a prebuilt dist/lsplugin.user.js with no require of dompurify or lodash-es; dompurify is only imported by LSPlugin.core.d.ts (the host-side module that Logseq itself ships); our built dist/ contains neither DOMPurify nor lodash template code. The advisories therefore do not reach the shipped plugin. Decision on silencing them via overrides is with the user (see the recommendation in the M1 session summary).
    • Test graphs unchanged since M0: ~/logseq-test-graphs/gdsync-dev (harmless spike leftovers) and empty gdsync-dev-2; pristine tarball + reset script in ~/logseq-test-graphs/.
    • .env.local still holds the working TV/limited-input OAuth client; consent screen status to be double-checked as “In production” before M9.
  • Next action: (1) commit M7 on the user’s go (“M7: Manual two-way sync end-to-end …”); (2) clear context; (3) M8 (plan M8 steps 1–4: snapshots, profile bundle, restore). M8 inputs: runSyncSession is where plan §3.6 step 9 goes (after runSync, before the lock release; summarizeRun(result, snapshotTaken) already takes the flag); state.lastSnapshotAt/lastProfileHash exist; bootstrapLayout returns snapshotsGraphId/profileId but resolveLayout persists only the root + graph ids, so the snapshot folders need an ensureFolder under the persisted root (2 lookups) or two more persisted ids (plan §3.5 lists only the two; a deviation needs sign-off); backupNow in syncController.ts is a placeholder toast; createHostProfileFs gives the read-only ~/.logseq root; status.lastSnapshotAt/lastProfileBackupAt are already shown by the panel. fflate is not installed yet (spike §4 item 7).

Great project! This is interesting to me. I wonder if tying this to Google Drive at the design phase drives any architectural decisions that make it hard later to replace it with a different sync host. If so, maybe add guidance in the spec to ensure that piece is easily swappable with additional hosts, unless that’s already there and I missed it.