Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Fix additional issues
  • Loading branch information
revett committed Aug 7, 2026
commit c8f68bd50a99dc6b312c461c77208e6a0f2fa154
18 changes: 14 additions & 4 deletions docs/technical_sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@ correctness argument rests on.
| `BACKOFF_BASE_MS` | 2m | The first retry is the one most likely to fail the same way |
| `BACKOFF_MAX_MS` | 30m | Long enough to stop hammering, short enough to catch up |

Five minutes is the conservative opening value for polling, and it is meant to come down. Every
poll is a full pass today; once a pass can rule itself out with a single HEAD on the manifest, the
interval can shorten without costing anything.

The order the rules are checked in is the priority order. A failed pass is its own reason to run
again, ahead of everything and regardless of focus: starting a pass clears the pending local work it
covers, so a push that fails on an unfocused window would otherwise have nothing left to fire it and
Expand Down Expand Up @@ -125,7 +129,8 @@ One pass, in order:
4. Resolve vault identity, refusing if this bucket is not the one this device synced before
5. Plan the reconciliation from three snapshots
6. Execute every action, collecting per file failures rather than stopping at the first
7. Upload a manifest describing what the bucket now holds, conditional on that ETag
7. Upload a manifest describing what the bucket now holds, conditional on that ETag still being
current, or on the manifest still being absent for a first sync
8. Return the new snapshot for the caller to persist as `state.json`

The caller owns persistence. The previous snapshot is passed in and the new one handed back rather
Expand Down Expand Up @@ -241,6 +246,11 @@ names, and the blob is left exactly where it is, reachable for as long as any re
still names its address. Nothing touches the bucket, so nothing can fail, and there is no live
object at a shared key for another device to clobber.

An earlier version copied a deleted file's blob to a bucket side trash location to buy a recovery
window. That was removed deliberately: content addressing already makes a delete non destructive,
so the trash copy was a second mechanism guaranteeing something the first one already guaranteed.
Anyone proposing a server side recycle bin should know it was built once and taken out again.

### Conflicts

A conflict is a path that changed on both sides to different content. Neither edit is ever silently
Expand Down Expand Up @@ -276,9 +286,9 @@ differing content.
Two objects tell a device whether a bucket is the one it thinks it is.

- **The manifest** proves a sync has completed. Its absence usually means a fresh bucket.
- **The sentinel**, written once on the pass that completes a bucket's first sync and never
rewritten, proves a bucket has been synced before independently of whether the manifest currently
exists.
- **The sentinel** proves a bucket has been synced before, independently of whether the manifest
currently exists. It is written on the pass that completes a bucket's first sync, and on any
later pass that finds it missing, which is how a bucket that lost one heals itself.

The sentinel exists because a manifest can go missing for a bad reason: a lifecycle rule, a manual
deletion, a typo in a configured prefix. Once a sentinel exists, whether the manifest happens to be
Expand Down
7 changes: 7 additions & 0 deletions scripts/check-comments.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ function violations(path) {
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();

// Block comments are banned outright rather than measured, since the rules call for "//" and a
// block would otherwise smuggle unlimited prose and issue references past every check below.
if (line.startsWith("/*")) {
found.push({ line: i + 1, message: "block comment, use // instead" });
continue;
}

if (!line.startsWith("//")) {
if (blockLength > MAX_BLOCK_LINES) {
found.push({
Expand Down
4 changes: 2 additions & 2 deletions src/device/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ export const DEVICE_ID_KEY = "geode-device-id";
const DEVICE_SUFFIX_ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";

// deviceIdFrom returns the identifier naming this device: a recognisable platform label and a
// random suffix. Both halves are generated, never typed, so neither can be an unsafe path
// segment.
// random suffix, both generated rather than typed so neither can be an unsafe path segment. See
// docs/technical_device.md.
export function deviceIdFrom(label: string, suffix: string): string {
if (label === "") {
return suffix;
Expand Down
2 changes: 1 addition & 1 deletion src/log/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ function renderRow(list: HTMLElement, entry: LogEntry): void {
}

// GeodeLogView renders the persisted log, most recent first, always as a straight redraw of what
// the sink holds rather than a DOM mutation.
// the sink holds rather than a DOM mutation; see docs/technical_logging.md.
export class GeodeLogView extends ItemView {
private sink: LogSink;
private bus: LogBus;
Expand Down
3 changes: 2 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,8 @@ function tooltipFor(status: SyncStatus, detail: string): string {
return "Geode: click to sync";
}

// GeodePlugin is the Obsidian plugin entry point that owns settings load and save.
// GeodePlugin is the Obsidian plugin entry point that owns settings load and save; see
// docs/technical_plugin.md for the layering rule every adapter here follows.
export default class GeodePlugin extends Plugin {
settings: GeodeSettings = DEFAULT_SETTINGS;
// deviceId names this machine in conflict copies and logs, held in vault scoped localStorage
Expand Down
2 changes: 1 addition & 1 deletion src/schedule/schedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ export function armed(readiness: Readiness): boolean {
}

// due reports whether a pass should start at now and which trigger asked for it; the checks below
// run in priority order.
// run in priority order, which docs/technical_sync.md explains along with every constant here.
export function due(state: State, now: number): Due {
if (state.syncing || state.stopped) {
return { due: false };
Expand Down
3 changes: 2 additions & 1 deletion src/settings/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ export const DEFAULT_SETTINGS: GeodeSettings = {
// ConnectionStatus is the current in-memory state of a Test Connection check.
export type ConnectionStatus = "unknown" | "checking" | "ok" | "error";

// GeodeSettings is the persisted shape of a Geode plugin's user configuration.
// GeodeSettings is the persisted shape of a Geode plugin's user configuration; see
// docs/technical_settings.md for why each field is normalized where it is used rather than saved.
export type GeodeSettings = {
version: number;
provider: "r2" | "custom";
Expand Down
3 changes: 2 additions & 1 deletion src/settings/tab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ import {
// DEBUG_LABEL_WIDTH is the column width debug info labels are padded to, so values line up.
const DEBUG_LABEL_WIDTH = 12;

// renderSettingsTab draws every section into containerEl from the tab's current draft state.
// renderSettingsTab draws every section into containerEl from the tab's current draft state; see
// docs/technical_settings.md for the draft and dirty model.
export function renderSettingsTab(tab: GeodeSettingTab, containerEl: HTMLElement): void {
renderHeader(containerEl);
renderStorageSection(tab, containerEl);
Expand Down
2 changes: 1 addition & 1 deletion src/storage/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ export type ResultStatus =

// StorageClient reads, writes, deletes, and lists objects, taking and returning plain data so a
// future WebDAV or Dropbox client can satisfy the same shape. Every key is relative to the client's
// own root.
// own root; see docs/technical_storage.md.
export type StorageClient = {
putObject: (key: string, body: Uint8Array, condition?: PutCondition) => Promise<PutResult>;
getObject: (key: string, expectedBytes?: number) => Promise<GetResult>;
Expand Down
5 changes: 3 additions & 2 deletions src/sync/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,8 @@ type ActionResult = {
type LocalCheck = { ok: true; seen: FileStat } | { ok: false; failure: SyncFailure };

// executeSyncPlan carries out every action against the local vault and the remote bucket, and
// reports what completed and what failed rather than stopping at the first failure; now is passed
// in so a conflict's copy name stays deterministic under test.
// reports what completed and what failed rather than stopping at the first failure. The ordering
// every destructive write depends on is set out in docs/technical_sync.md.
export async function executeSyncPlan(
actions: SyncAction[],
local: Snapshot,
Expand Down Expand Up @@ -283,6 +283,7 @@ async function executeAction(

if (action.kind === "pushDelete") {
// A deletion is purely a manifest change, so it never touches the bucket and can never fail.
// The blob is left where it is, reachable while any retained manifest still names its address.
return successfulAction();
}

Expand Down
4 changes: 2 additions & 2 deletions src/sync/plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,8 +154,8 @@ test("manifestAfterSync: a pushDelete removes the entry", () => {
});

test("manifestAfterSync: a failed pushDelete leaves the entry standing", () => {
// A pushDelete that never completed (the trash copy or the delete itself failed) must not be
// taken as evidence the object is gone: it may still be sitting there untouched.
// A pushDelete that never completed must not be taken as evidence the object is gone: it may
// still be sitting there untouched.
const remote = snapshot(file("a.md", "h1"));

const result = manifestAfterSync(remote, [], []);
Expand Down
2 changes: 1 addition & 1 deletion src/sync/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ export function manifestAfterSync(
}

// planSync compares what changed locally and remotely since previous, the common ancestor, and
// decides what to push, pull, or flag as a genuine conflict.
// decides what to push, pull, or flag as a genuine conflict; see docs/technical_sync.md.
export function planSync(previous: Snapshot, local: Snapshot, remote: Snapshot): SyncAction[] {
const localChanges = diffSnapshots(previous, local);
const remoteChanges = diffSnapshots(previous, remote);
Expand Down
1 change: 1 addition & 0 deletions src/vault/obsidian.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {

// createObsidianLocalWriter returns a LocalWriter that applies pulled remote changes through the
// low level data adapter rather than the Vault API, since a newly pulled path has no TFile yet.
// docs/technical_plugin.md covers staging, atomic installs, and the rename aside fallback.
export function createObsidianLocalWriter(adapter: DataAdapter): LocalWriter {
return {
stageFile: async (path, data, mode) => {
Expand Down