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
8 changes: 4 additions & 4 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
},
"dependencies": {
"@mdbase-dev/connect-protocol": "file:vendor/mdbase-dev-connect-protocol-0.1.0-beta.145-e6e66bcd172f.tgz",
"@mdbase-dev/sdk": "file:vendor/sdk-1907e67f-production-beta187.tgz",
"@mdbase-dev/sdk": "file:vendor/sdk-e8ffb806-production-beta188.tgz",
"@mdbase-dev/ui": "file:vendor/ui-dev-fe158b59.tgz",
"react": "19.2.8",
"react-dom": "19.2.8"
Expand Down
6 changes: 3 additions & 3 deletions src/connection-notice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@ export const connectionNoticeLabels: Record<ConnectionNotice, string> = {
reconnecting: "Reconnecting",
saved_on_device: "Saved on this device; waiting for sync",
};
type Observed = Partial<Pick<MdbaseSnapshot, "phase" | "sync" | "writes" | "transport" | "live">>;
type Observed = Partial<Pick<MdbaseSnapshot, "phase" | "sync" | "transport" | "live">>;
export function connectionNotice(snapshot: Observed | null | undefined, online = true): ConnectionNotice | null {
if (!snapshot || (snapshot.phase && snapshot.phase.kind !== "ready")) return null;
if (snapshot.writes?.saveState === "saved_on_device") return "saved_on_device";
if (snapshot.sync?.writes?.saveState === "saved_on_device") return "saved_on_device";
const health = snapshot.sync?.health ?? snapshot.transport?.kind ?? null;
if (!online || health === "offline") return "offline";
if (health === "unreachable" || health === "server_busy" || health === "rate_limited" ||
snapshot.sync?.kind === "offline" || (snapshot.sync?.live ?? snapshot.live) === false) return "reconnecting";
snapshot.sync?.connection === "offline" || (snapshot.sync?.live ?? snapshot.live) === false) return "reconnecting";
return null;
}
24 changes: 12 additions & 12 deletions src/journal-data.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import {revisionOf, uuidv7, type MdbaseApplicationConnection, type RecordLease, type RecordSessionSnapshot, type wire, type FileRef} from "@mdbase-dev/sdk";
import {revisionOf, uuidv7, type DescribeResult, type MdbaseApplicationConnection, type RecordLease, type RecordSessionSnapshot, type RecordView, type FileRef} from "@mdbase-dev/sdk";
import {JournalDrafts, type JournalDraft} from "./drafts.js";
export const JOURNAL_CONTRACT = Object.freeze({id: "journal.entry", version: "0.1.0", digest: "sha256:7e95bdf03621919fb0e722e5a29b48a68d7a4eaa3a94aea033d96ea2f7ec34cc"});
export interface JournalEntryType {readonly name: string; readonly createdField: string}
export interface JournalEntry {readonly id: string; readonly created: string; readonly createdMs: number; readonly path: string}
/** Only current canonical catalog bindings, never the starter type name. */
export function journalEntryTypes(catalog: wire.DescribeResult): JournalEntryType[] {
export function journalEntryTypes(catalog: DescribeResult): JournalEntryType[] {
const contract = catalog.contracts.filter(c => c.id === JOURNAL_CONTRACT.id && c.version === JOURNAL_CONTRACT.version && c.digest === JOURNAL_CONTRACT.digest);
if (contract.length !== 1) throw new Error("This collection does not provide the required Journal contract.");
const types: JournalEntryType[] = [];
for (const type of catalog.types) {
const implementations = type.implements.filter(i => i.contract === JOURNAL_CONTRACT.id && i.version === JOURNAL_CONTRACT.version);
if (implementations.length > 1) throw new Error("This collection has an ambiguous Journal type binding.");
const field = implementations[0]?.fields.get("created");
const field = implementations[0]?.fields["created"];
if (field === undefined) continue;
// The native public create API accepts top-level fields. Do not implement a
// second field-reference parser or silently write a dotted literal key.
Expand All @@ -24,7 +24,7 @@ export function journalEntryTypes(catalog: wire.DescribeResult): JournalEntryTyp
}
/** A readable original record: confirmed, or kept on this device while the SDK
* write ledger settles it. Never a protected or inconsistent readback. */
function currentRecord(record: wire.RecordView, id: string): void {
function currentRecord(record: RecordView, id: string): void {
if (record.id !== id || (record.state.state !== "confirmed" && record.state.state !== "pending") || record.state.hold !== undefined || (record.state.unresolved ?? 0) !== 0 ||
typeof record.body !== "string" || typeof record.document !== "string" || revisionOf(record.document) !== record.revision) {
throw new Error("Keep this note on this device until its original record can be read safely.");
Expand Down Expand Up @@ -60,18 +60,18 @@ export class JournalData {
}
async types(): Promise<JournalEntryType[]> {
this.ensure();
const catalog = await this.connection.records.describe(this.caller.signal);
const catalog = await this.connection.records.describe({signal: this.caller.signal});
this.ensure();
return journalEntryTypes(catalog);
}
async entries(types: JournalEntryType[]): Promise<JournalEntry[]> {
this.ensure();
const entries: JournalEntry[] = [];
for await (const page of this.connection.records.pages({types: types.map(type => type.name)}, {}, this.caller.signal)) {
for await (const page of this.connection.records.pages({types: types.map(type => type.name)}, {signal: this.caller.signal})) {
this.ensure();
for (const record of page.records) {
const type = types.find(type => record.types.includes(type.name));
const created = type ? record.frontmatter.get(type.createdField) : null;
const created = type ? record.frontmatter[type.createdField] : null;
if (typeof created !== "string" || !Number.isFinite(Date.parse(created))) continue;
entries.push({id: record.id, created, createdMs: Date.parse(created), path: record.path});
}
Expand All @@ -83,7 +83,7 @@ export class JournalData {
this.ensure();
const local = this.drafts.list().find(draft => draft.targetCollectionId === this.connection.id && draft.recordId === entry.id);
if (local) return local;
const record = await this.connection.records.get(entry.id, {body: true, document: true}, this.caller.signal);
const record = await this.connection.records.get(entry.id, {include: {body: true, document: true}, signal: this.caller.signal});
this.ensure(); currentRecord(record, entry.id);
const draft = await this.drafts.mutate(() => this.drafts.put({...this.drafts.blank(this.connection.id, new Date(entry.createdMs)), id: entry.id,
created: entry.created, body: record.body!, savedBody: record.body!, path: record.path, recordId: record.id}));
Expand Down Expand Up @@ -123,19 +123,19 @@ export class JournalData {
typeName: type.name, createdField: type.createdField, path, created: original.created,
account: this.connection.scope.account, installation: this.connection.scope.installation}}));
const attempt = draft.createAttempt!;
const result = await this.connection.records.save(this.connection.records.create({id: attempt.recordId, path: attempt.path, type: attempt.typeName,
const result = await this.connection.records.settle(this.connection.records.create({id: attempt.recordId, path: attempt.path, type: attempt.typeName,
frontmatter: {[attempt.createdField]: attempt.created}, body: attempt.body}, {mutationId: attempt.mutationId, signal: this.caller.signal}), {signal: this.caller.signal});
this.ensure(draft);
// A refusal is definite: nothing was created, so the next save starts a new intent.
if (result.outcome === "rejected") {
await this.drafts.mutate(() => this.drafts.patch(id, {createAttempt: null}));
throw new Error(result.problem?.message ?? "mdbase refused this note. Your text is still on this device.");
throw new Error(result.problem.message);
}
}
// Confirmed or kept on this device: the SDK write ledger settles it. Read the
// ORIGINAL record by its pinned ID; creation is never resubmitted.
const attempt = draft.createAttempt!;
const record = await this.connection.records.get(attempt.recordId, {body: true, document: true, effective: true}, this.caller.signal)
const record = await this.connection.records.get(attempt.recordId, {include: {body: true, document: true, effective: true}, signal: this.caller.signal})
.catch(() => {throw new Error("Keep this page open while its original save is unresolved.");});
this.ensure(draft); currentRecord(record, attempt.recordId);
if (record.body !== attempt.body) throw new Error("The original saved note has changed. Your local text has been preserved.");
Expand Down Expand Up @@ -215,7 +215,7 @@ export class JournalData {
const original = this.drafts.get(id);
if (!original?.recordId) throw new Error("The original saved note is not available yet. Keep your text here.");
this.ensure(original, true);
const record = await this.connection.records.get(original.recordId, {body: true, document: true}, this.caller.signal);
const record = await this.connection.records.get(original.recordId, {include: {body: true, document: true}, signal: this.caller.signal});
this.ensure(original, true); currentRecord(record, original.recordId);
const owned = this.leases.get(id);
// A released conflict can still be retained by this SDK client. Reopen it
Expand Down
2 changes: 1 addition & 1 deletion src/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ export function mountJournal(document: Document, drafts: JournalDrafts, mdbase:
if (!message && opening?.state === "unresolved") message = "The original save is unresolved. Your text stays on this device.";
if (!message && record && ["conflict", "recovery", "deleted", "error"].includes(record.state)) message = "Your text is on this device. Resolve its original save before continuing.";
if (!message && actionProblem) message = actionProblem.title;
if (!message && phase?.kind === "ready" && (phase.problem || state?.problem)) message = state?.sync?.text ?? phase.problem?.title ?? describeError(state)?.title;
if (!message && phase?.kind === "ready" && (phase.problem || state?.problem)) message = phase.problem?.title ?? describeError(state)?.title;
// Never "Saving…" or "Saved" while offline, reconnecting or holding changes on this device.
const notice = connectionNotice(state, document.defaultView?.navigator.onLine ?? true);
if (!message && notice) message = connectionNoticeLabels[notice];
Expand Down
21 changes: 10 additions & 11 deletions tests/autosave-ownership.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import {mock, test} from "node:test";
import {readFileSync} from "node:fs";
import {JSDOM} from "jsdom";
import {connect, type Mdbase, type MdbaseClient} from "@mdbase-dev/sdk";
import type {Mdbase, MdbaseClient} from "@mdbase-dev/sdk";
import {JournalDrafts, type DraftLock} from "../src/drafts.js";
import {mountJournal} from "../src/view.js";
import {JournalData} from "../src/journal-data.js";
Expand All @@ -14,7 +14,7 @@ async function until(check: () => boolean): Promise<void> {
}
async function tabs(delayCatalog = false) {
const f = await applicationFixture();
const peer = await connect({connector: f.replica.connector(), app: {name: "peer-journal-tab", version: "test"}, reconnect: false});
const peer = await f.replica.client();
const locks = memoryLocks(), storage = memoryStorage();
let tail = Promise.resolve(), pause: Promise<void> | null = null;
const lock: DraftLock = operation => {const barrier = pause, next = tail.then(async () => {if (barrier) await barrier; return operation();}); tail = next.then(() => {}, () => {}); return next;};
Expand Down Expand Up @@ -79,7 +79,7 @@ test("idle status sweep never autosaves a peer prefix; owner saves the complete
try {assert.equal((await passive.session.flush()).ok, true);} finally {passive.release();}
assert.equal((await lease.session.flush()).ok, true);
await until(() => a.window.document.getElementById("status")!.textContent === "Saved");
assert.equal((await f.records.get(recordId, {body: true})).body, full);
assert.equal((await f.records.get(recordId, {include: {body: true}})).body, full);
assert.equal(a.page.value, full); assert.equal(a.drafts.get(id)?.body, full);
assert.equal(a.window.document.getElementById("draft-conflict")!.hidden, true);
assert.equal(b.view.currentDraft().id, blank); assert.equal(b.page.value, "");
Expand All @@ -106,12 +106,11 @@ test("closing the owner hands its latest unfinished draft to an idle tab", async
assert.equal((await lease.session.flush()).ok, true);
await until(() => b.drafts.get(id)?.savedBody === full);
assert.equal(updateB.mock.callCount(), 1);
assert.equal((await f.records.get(recordId, {body: true})).body, full);
assert.equal((await f.records.get(recordId, {include: {body: true}})).body, full);
assert.equal(b.drafts.get(id)?.body, full); assert.equal(b.drafts.get(id)?.savedBody, full);
assert.equal(createB.mock.callCount(), 0, "takeover retains the original create identity");
assert.equal(b.page.value, "", "background recovery leaves the idle page alone");
fresh = await connect({connector: f.replica.connector(), app: {name: "reloaded-journal-tab", version: "test"}, reconnect: false});
mock.method(fresh, "describe", async () => catalog());
fresh = await f.replica.client();
reloaded = await t.tab(t.application(fresh));
await until(() => reloaded!.window.document.getElementById("status")!.textContent === "Saved");
const resumed = await fresh.records.open(recordId, {autosave: {idleMs: 1200}});
Expand Down Expand Up @@ -162,7 +161,7 @@ test("the owner follows peer edits to its previous page without a second session
a.storage(id, previous);
await until(() => lease!.session.getSnapshot().body === full);
assert.equal((await lease.session.flush()).ok, true);
assert.equal((await f.records.get(recordId, {body: true})).body, full);
assert.equal((await f.records.get(recordId, {include: {body: true}})).body, full);
assert.equal(updateB.mock.callCount(), 0);
assert.equal(a.page.value, "");
} finally {lease?.release(); follower?.close(); await t.close();}
Expand Down Expand Up @@ -203,7 +202,7 @@ test("an independent editor still conflicts and local text is discarded only by
a.window.document.getElementById("use-saved-version")!.click();
await until(() => a.page.value === "Independent edit");
assert.equal(a.drafts.get(id)?.body, "Independent edit");
assert.equal((await f.records.get(recordId, {body: true})).body, "Independent edit");
assert.equal((await f.records.get(recordId, {include: {body: true}})).body, "Independent edit");
} finally {active?.release(); editor?.release(); await t.close();}
});
test("a delayed peer edit of a hidden page cannot borrow a later outside revision", async () => {
Expand All @@ -228,7 +227,7 @@ test("a delayed peer edit of a hidden page cannot borrow a later outside revisio
a.storage(id, previous);
await until(() => a.drafts.get(id)?.reviewRequired === true);
assert.equal((await active.session.flush()).ok, true);
assert.equal((await f.records.get(recordId, {body: true})).body, "Independent edit");
assert.equal((await f.records.get(recordId, {include: {body: true}})).body, "Independent edit");
assert.equal(a.drafts.get(id)?.body, full);
assert.equal(follower.page.value, full);
} finally {active?.release(); editor?.release(); follower?.close(); await t.close();}
Expand All @@ -250,7 +249,7 @@ test("queued input on the current page cannot skip the previous page's last peer
t.pause(null); gate.resolve();
await until(() => active!.session.getSnapshot().body === full);
assert.equal((await active.session.flush()).ok, true);
assert.equal((await f.records.get(recordId, {body: true})).body, full);
assert.equal((await f.records.get(recordId, {include: {body: true}})).body, full);
} finally {t.pause(null); gate.resolve(); active?.release(); follower?.close(); await t.close();}
});
for (const hideOwner of [true, false]) test(`a conflict is recoverable from another tab (owner ${hideOwner ? "hidden" : "displayed"})`, async () => {
Expand Down Expand Up @@ -282,7 +281,7 @@ for (const hideOwner of [true, false]) test(`a conflict is recoverable from anot
follower.write("Independent edit continued");
await until(() => resumed.session.getSnapshot().body === follower!.page.value);
assert.equal((await resumed.session.flush()).ok, true);
assert.equal((await f.records.get(recordId, {body: true})).body, follower.page.value);
assert.equal((await f.records.get(recordId, {include: {body: true}})).body, follower.page.value);
assert.equal(a.drafts.get(id)?.reviewRequired, false);
} finally {resumed.release();}
} finally {active?.release(); editor?.release(); follower?.close(); await t.close();}
Expand Down
4 changes: 2 additions & 2 deletions tests/connection-notice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ import assert from "node:assert/strict";
import {test} from "node:test";
import {connectionNotice} from "../src/connection-notice.js";
const ready = {phase: {kind: "ready"}} as const;
const sync = (health: string | null, live: boolean | null = true) => ({kind: "synced", health, live}) as never;
const sync = (health: string | null, live: boolean | null = true) => ({connection: "online", health, live}) as never;
void test("status never reads as saving or saved while offline, reconnecting or holding changes", () => {
assert.equal(connectionNotice({...ready, sync: sync(null)} as never), null);
assert.equal(connectionNotice({...ready, sync: sync(null)} as never, false), "offline");
assert.equal(connectionNotice({...ready, sync: sync("unreachable")} as never), "reconnecting");
assert.equal(connectionNotice({...ready, sync: sync(null, false)} as never), "reconnecting");
assert.equal(connectionNotice({...ready, sync: sync("offline"), writes: {saveState: "saved_on_device"}} as never), "saved_on_device");
assert.equal(connectionNotice({...ready, sync: {connection: "online", health: "offline", live: true, writes: {saveState: "saved_on_device"}}} as never), "saved_on_device");
assert.equal(connectionNotice({phase: {kind: "opening"}, live: false} as never), null);
});
Loading