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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "mdbase-obsidian",
"name": "mdbase",
"version": "0.4.19",
"version": "0.4.20",
"minAppVersion": "1.11.4",
"description": "Edit mdbase types and mirror hosted collections through mdbase Connect.",
"author": "calluma",
Expand Down
12 changes: 6 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "mdbase-obsidian",
"version": "0.4.19",
"version": "0.4.20",
"description": "mdbase types and sync for Obsidian",
"main": "main.js",
"type": "module",
Expand Down Expand Up @@ -58,7 +58,7 @@
},
"dependencies": {
"@callumalpass/mdbase-interop": "0.1.0-rc.2",
"@mdbase-dev/sdk": "file:vendor/sdk-1907e67f-production-beta187.tgz",
"@mdbase-dev/sdk": "file:vendor/sdk-e8ffb806-production-beta188.tgz",
"@noble/hashes": "2.4.0",
"ajv": "^8.20.0",
"ajv-formats": "^3.0.1",
Expand Down
9 changes: 4 additions & 5 deletions src/sync/controller.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ConflictEntry, ConflictValue, Hold, MdbaseClient, SyncStatus } from "@mdbase-dev/sdk/thin-client";
import type { ConflictEntry, ConflictValue, Hold, MdbaseClient, MdbaseSync, SyncStatus } from "@mdbase-dev/sdk/thin-client";
import { hexToBytes } from "@noble/hashes/utils.js";
import {
collectionIdFrom,
Expand Down Expand Up @@ -59,7 +59,7 @@ export interface SyncSnapshot {
direct?: {
text: string;
phase: {
kind: string; sync?: { kind: string; pending: number; health?: string | null; live?: boolean | null; holds?: number; conflicts?: number }; problem?: unknown;
kind: string; sync?: Pick<MdbaseSync, "state" | "connection" | "pending" | "health" | "live" | "holds" | "conflicts"> & { installing?: boolean }; problem?: unknown;
/** A Private collection waiting for its key: how the account unlocks it. */
unlock?: { mode: "password" | "strict" | "none"; unlocking: boolean; name: string | null } | null;
/** Still starting after the plugin stopped waiting: offer Restart sync. */
Expand Down Expand Up @@ -452,7 +452,6 @@ export class SyncController {
private readonly decided = new Set<string>();

private async showMerged(client: MdbaseClient, entries: ConflictEntry[]): Promise<void> {
const sdk = await import("@mdbase-dev/sdk/thin-client");
// Choosing a held version also records the conflict it settled; the person
// already decided, so don't ask again.
const already = entries.filter((entry) => this.decided.has(entry.conflict.id));
Expand All @@ -466,14 +465,14 @@ export class SyncController {
const text = (value: ConflictValue | undefined): string => {
if (!value) return "";
if (value.form === "text") return value.text;
if (value.form === "value") return JSON.stringify(sdk.toPlain(value.value));
if (value.form === "value") return JSON.stringify(value.value);
return value.form === "deleted" ? "(deleted)" : "(a file)";
};
const lost = entry.conflict.lost;
return {
entry, path: this.paths.get(id) ?? "", kind: entry.conflict.kind, field: entry.conflict.field ?? null,
kept: text(entry.conflict.kept), lost: text(lost),
lostValue: lost.form === "value" ? sdk.toPlain(lost.value) : undefined,
lostValue: lost.form === "value" ? lost.value : undefined,
};
}));
if (this.client === client) this.set({ merged });
Expand Down
35 changes: 17 additions & 18 deletions src/sync/native/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,11 @@ const confirmed = async (w: any) => {
// revision a read-back returns (checked against production; the view's own
// `state` is the stale optimistic one), which saves that read. The caller keeps
// only identity and revision.
const written = async (ws: any[], id: string) => {
for (const w of ws) {
const receipt: any = await w.confirmed;
const r = receipt?.state === "confirmed" && receipt.status === "applied" ? receipt.records?.find((x: any) => x.id === id) : undefined;
if (r && typeof r.revision === "string" && typeof r.path === "string") return { id: r.id, path: r.path, revision: r.revision, via: "receipt" };
}
return plain(await records().get(id, { document: true }));
const written = async (w: any, id: string) => {
const receipt: any = await w.confirmed;
const r = receipt?.state === "confirmed" && receipt.status === "applied" ? receipt.records?.find((x: any) => x.id === id) : undefined;
if (r && typeof r.revision === "string" && typeof r.path === "string") return { id: r.id, path: r.path, revision: r.revision, via: "receipt" };
return plain(await records().get(id, { include: { document: true } }));
};

function phaseSummary() {
Expand All @@ -61,7 +59,9 @@ function phaseSummary() {
return {
kind: p.kind,
collectionId: p.collectionId ?? null,
sync: s.sync ? { kind: s.sync.kind, text: s.sync.text, pending: s.sync.pending, health: s.sync.health, live: s.sync.live ?? null, holds: s.sync.holds ?? 0, conflicts: s.sync.conflicts ?? 0 } : null,
sync: s.sync ? { state: s.sync.state, connection: s.sync.connection, pending: s.sync.pending, health: s.sync.health, live: s.sync.live, holds: s.sync.holds, conflicts: s.sync.conflicts,
// The SDK reads a first download as catching up; the replica says which it is.
installing: p.connection?.records?.status?.installing !== undefined } : null,
// `action` is the SDK's suggested next step ("approve" to request access again).
problem: p.problem ? { code: p.problem.code, reason: p.problem.reason, key: p.problem.key ?? null, title: p.problem.title, message: p.problem.message, action: p.problem.action ?? null } : null,
// A Private collection without its key on this device: how the account
Expand Down Expand Up @@ -112,21 +112,21 @@ const methods: Record<string, (...args: any[]) => Promise<any>> = {
for (let limit = 64; ; limit = Math.max(1, Math.floor(limit / 2))) {
const out: any[] = [];
try {
for await (const page of records().queryPages({ limit }, { document: true }, undefined, { onReset: () => { out.length = 0; } }))
for await (const page of records().queryPages({ limit }, { include: { document: true }, onReset: () => { out.length = 0; } }))
for (const r of page.records) out.push(plain(r));
return out;
} catch (e: any) {
if (limit === 1 || e?.code !== "too_large" || e?.reason !== "query_budget_exceeded") throw e;
}
}
},
get: async (id: string) => plain(await records().get(id, { document: true })),
get: async (id: string) => plain(await records().get(id, { include: { document: true } })),
// Bounded ordinary reads for an event's affected paths. Only not_found means
// absent: permission, budget and transient errors must never look like deletes.
at: async (paths: string[]) => {
const out: any[] = []; let next = 0;
const read = async (path: string) => {
try { return { ...plain(await records().get({ path }, { document: true })), kind: "record" }; }
try { return { ...plain(await records().get({ path }, { include: { document: true } })), kind: "record" }; }
catch (e: any) { if (e?.code !== "not_found") throw e; }
try { return { ...fplain(await files().get({ path })), kind: "file" }; }
catch (e: any) { if (e?.code !== "not_found") throw e; }
Expand All @@ -139,21 +139,20 @@ const methods: Record<string, (...args: any[]) => Promise<any>> = {
},
watch: async (cursor?: string) => {
watch?.close?.(); watch?.stop?.();
watch = records().watchChanges(cursor, (batch: any) =>
post({ type: "changes", batch: { cursor: batch.cursor, reset: batch.reset, changes: batch.changes.map((c: any) => ({ id: c.id, path: c.path, kind: c.kind, entity: c.entity ?? null })) } }));
watch = records().watchChanges((batch: any) =>
post({ type: "changes", batch: { cursor: batch.cursor, reset: batch.reset, changes: batch.changes.map((c: any) => ({ id: c.id, path: c.path, kind: c.kind, entity: c.entity ?? null })) } }), { cursor });
// The ACK is delivered only after the initial reset/replay callback. Keep
// startup's barrier closed until that callback has reached the parent.
await watch!.ready;
return true;
},
create: async (path: string, document: string) => {
const w: any = await confirmed(await records().create({ path, document }, SYNC_WRITE));
return written([w], w.recordId);
return written(w, w.recordId);
},
replace: async (id: string, base: { path: string; doc: string }, next: { path: string; doc: string }, revision: string) => {
const ws: any[] = await records().submit([{ kind: "document", id, base, new: next, ifRevision: revision }], SYNC_WRITE);
await Promise.all(ws.map(confirmed));
return written(ws, id);
const w = await confirmed(await records().submit([{ kind: "document", id, base, new: next, ifRevision: revision }], SYNC_WRITE));
return written(w, id);
},
// The collection's file policy (which attachments sync, for every device).
inclusion: async () => {
Expand Down Expand Up @@ -245,7 +244,7 @@ const methods: Record<string, (...args: any[]) => Promise<any>> = {
const until = Date.now() + 120_000;
while (true) {
const status = await records().getStatus();
const writes: any = mdbase.getSnapshot().writes;
const writes: any = mdbase.getSnapshot().sync?.writes;
if (writes?.rejected?.length || status.holds > 0 || status.unresolved > 0) {
log(`drain blocked: rejected=${writes?.rejected?.length ?? 0} holds=${status.holds} unresolved=${status.unresolved} pending=${status.pending}`);
throw new Error("Unresolved writes/holds; ownership retained");
Expand Down
12 changes: 6 additions & 6 deletions src/sync/native/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const { spawnSync } = nativeModule<typeof import("node:child_process")>("child_p
const { brotliDecompressSync } = nativeModule<typeof import("node:zlib")>("zlib");
import packedAssets from "embedded:assets";
import { validVaultPort } from "../ownership";
import { AWAITING_APPROVAL, HANDED_OVER, NEEDS_APPROVAL, PRIVATE_COLLECTIONS_HINT, SIGN_IN_STOPPED, STARTING_STALLED, failedText, stoppedText } from "../words";
import { AWAITING_APPROVAL, HANDED_OVER, NEEDS_APPROVAL, PRIVATE_COLLECTIONS_HINT, SIGN_IN_STOPPED, STARTING_STALLED, failedText, readyText, stoppedText, syncOffline } from "../words";
import { stageDownload } from "./download";
import { retryUpload, type UploadIdentity } from "./upload";
import { conflictPath, reconcileDecision, remoteMoveDecision, localMovePairs, Synced } from "./reconcile";
Expand Down Expand Up @@ -544,17 +544,17 @@ export default class NativeSync extends Component {
const sameStop = phase.kind !== "ready" && phase.kind !== "needs_unlock" && phase.kind === was.kind && phase.problem && JSON.stringify(phase.problem) === JSON.stringify(was.problem);
if (this.admitting && sameStop) this.setStatus(this.statusText);
else if (this.admitting && !(phase.kind === "ready" && (this.syncingFiles || this.startingMirror || this.reconcileRetryTimer !== null)))
this.setStatus(phase.kind === "ready" ? phase.sync?.text ?? "Checking your files" : phaseText[phase.kind] ?? "Starting sync");
this.setStatus(phase.kind === "ready" ? phase.sync ? readyText(phase.sync) : "Checking your files" : phaseText[phase.kind] ?? "Starting sync");
// A pass in progress keeps its own progress text, but going offline or
// coming back must still reach the UI; "Uploading 83 of 500" would
// otherwise stay up for as long as the connection is down (QA2-1).
else if (this.admitting && phase.sync?.kind !== was.sync?.kind) this.setStatus(this.statusText);
else if (this.admitting && phase.sync?.state !== was.sync?.state) this.setStatus(this.statusText);
// Names/documents never belong in this log.
this.log(`phase ${phase.kind}${phase.problem ? ` ${phase.problem.code ?? ""} ${phase.problem.reason ?? ""}` : ""}${phase.sync ? ` sync=${phase.sync.kind} pending=${phase.sync.pending}` : ""}`);
this.log(`phase ${phase.kind}${phase.problem ? ` ${phase.problem.code ?? ""} ${phase.problem.reason ?? ""}` : ""}${phase.sync ? ` sync=${phase.sync.state} pending=${phase.sync.pending}` : ""}`);
// A wrong password is shown beside the form; it doesn't stop sync.
if (this.admitting && phase.kind !== "needs_unlock" && phase.problem && JSON.stringify(phase.problem) !== JSON.stringify(was.problem))
this.fail("sync", phase.problem, this.needsRestart && this.canStartOver ? SIGN_IN_STOPPED : stoppedText(phase.problem), true);
if (phase.sync?.kind === "offline" && was.sync?.kind !== "offline")
if (syncOffline(phase.sync) && !syncOffline(was.sync))
new Notice("mdbase: sync service unreachable. Changes are saved on this device and will sync when the connection returns.", 10000);
if (this.admitting && (phase.kind === "opening" || phase.kind === "needs_unlock") && phase.collectionId) {
try { this.claim(phase.collectionId); } catch (e) { this.pauseForOwnership(e); return; }
Expand Down Expand Up @@ -1273,7 +1273,7 @@ export default class NativeSync extends Component {
this.lastSynced = Date.now();
if (this.activityTimer === null) this.activityTimer = window.setTimeout(() => { this.activityTimer = null; this.writeActivity(); }, 2000);
}
this.passStatus(this.phase.sync?.text ?? "Synced");
this.passStatus(this.phase.sync ? readyText(this.phase.sync) : "Synced");
// Notes refused under the old definitions get another chance now.
if (definitionsChanged && failed.length) window.setTimeout(() => this.requestReconcile(), 0);
return failed.length === 0;
Expand Down
31 changes: 19 additions & 12 deletions src/sync/words.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { MdbaseSync } from "@mdbase-dev/sdk/thin-client";
import type { SyncSnapshot } from "./controller";

export type Tone = "ok" | "busy" | "quiet" | "attention" | "problem";
Expand Down Expand Up @@ -119,17 +120,23 @@ const STATE_REASONS: Record<string, string> = {
sync_not_eligible: "This computer's mdbase sign-in stopped working, so sync is stopped here. Your notes here are untouched.",
};

/** The ready state in words (the SDK's own text is "Synced through <log position>"). */
export function readyText(sync: { kind: string; pending: number; health?: string | null } | undefined): string {
type ReadySync = Pick<MdbaseSync, "state" | "connection" | "pending" | "health"> & { installing?: boolean };

/**
* Sync can't reach mdbase from here: no network, or the service did not answer.
* A busy or rate-limited service, or a replica still connecting, is not offline.
*/
export function syncOffline(sync: ReadySync | null | undefined): boolean {
return sync?.connection === "offline" || sync?.health === "unreachable";
}

/** The ready state in words. */
export function readyText(sync: ReadySync | null | undefined): string {
const waiting = sync?.pending ? `${sync.pending === 1 ? "1 change" : `${sync.pending.toLocaleString()} changes`} waiting to send.` : "";
switch (sync?.kind) {
case "offline": return `Offline. Changes are saved here and sync when the connection returns.${waiting ? ` ${waiting}` : ""}`;
case "catching_up": return "Getting the latest changes from your other devices.";
case "installing": return "Downloading the collection.";
default:
if (sync?.health === "server_busy" || sync?.health === "rate_limited") return "mdbase is busy. Sync carries on shortly.";
return waiting || "Everything is up to date.";
}
if (syncOffline(sync)) return `Offline. Changes are saved here and sync when the connection returns.${waiting ? ` ${waiting}` : ""}`;
if (sync?.state === "catching_up") return sync.installing ? "Downloading the collection." : "Getting the latest changes from your other devices.";
if (sync?.health === "server_busy" || sync?.health === "rate_limited") return "mdbase is busy. Sync carries on shortly.";
return waiting || "Everything is up to date.";
}

export function summarize(s: SyncSnapshot): SyncSummary {
Expand Down Expand Up @@ -165,10 +172,10 @@ export function summarize(s: SyncSnapshot): SyncSummary {
}
// Offline outranks progress text: an upload that can't reach mdbase isn't
// progressing, and the offline notice alone disappears (QA-3).
const offline = ready && !problem && phase.sync?.kind === "offline";
const offline = ready && !problem && syncOffline(phase.sync);
if (offline) return { label: "Offline", detail: readyText(phase.sync), tone: "attention", syncing: true };
const checking = /checking your files|uploading|downloading/i.test(text);
const label = problem ? "Sync needs attention" : checking ? "Syncing" : ready ? (phase.sync?.kind === "offline" ? "Offline" : phase.sync?.pending ? "Syncing" : "Synced") : phase.kind === "signed_out" ? "Sync off" : phase.kind === "choosing" ? "Choose collection" : phase.kind === "authorizing" ? "Finish sign-in" : "Starting sync";
const label = problem ? "Sync needs attention" : checking ? "Syncing" : ready ? (syncOffline(phase.sync) ? "Offline" : phase.sync?.pending ? "Syncing" : "Synced") : phase.kind === "signed_out" ? "Sync off" : phase.kind === "choosing" ? "Choose collection" : phase.kind === "authorizing" ? "Finish sign-in" : "Starting sync";
const detail = ready && !problem && !checking ? readyText(phase.sync) : text;
return { label, detail, tone: problem ? "problem" : checking ? "busy" : ready ? "ok" : "quiet", syncing: ready || problem };
}
Expand Down
11 changes: 7 additions & 4 deletions test/native/device.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,13 @@ test("activity keeps the most recent entries first, up to the limit", () => {
});

test("the ready state is described in words, not log positions", () => {
assert.equal(readyText({ kind: "synced", pending: 0 }), "Everything is up to date.");
assert.equal(readyText({ kind: "synced", pending: 3 }), "3 changes waiting to send.");
assert.match(readyText({ kind: "offline", pending: 1 }), /^Offline\. .* 1 change waiting to send\.$/);
assert.equal(readyText({ kind: "catching_up", pending: 0 }), "Getting the latest changes from your other devices.");
assert.equal(readyText({ state: "saved", connection: "online", health: null, pending: 0 }), "Everything is up to date.");
assert.equal(readyText({ state: "saving", connection: "online", health: null, pending: 3 }), "3 changes waiting to send.");
assert.match(readyText({ state: "saved_on_device", connection: "reconnecting", health: "unreachable", pending: 1 }), /^Offline\. .* 1 change waiting to send\.$/);
assert.equal(readyText({ state: "catching_up", connection: "online", health: null, pending: 0 }), "Getting the latest changes from your other devices.");
assert.equal(readyText({ state: "catching_up", connection: "online", health: null, pending: 0, installing: true }), "Downloading the collection.");
assert.equal(readyText({ state: "reconnecting", connection: "reconnecting", health: null, pending: 0 }), "Everything is up to date.", "a replica still connecting is not offline");
assert.equal(readyText({ state: "reconnecting", connection: "reconnecting", health: "server_busy", pending: 0 }), "mdbase is busy. Sync carries on shortly.", "a busy service is not offline");
});

test("the collection's file policy leaves out attachments only, as Connect does", () => {
Expand Down
Loading
Loading