Connect SDK

Editing records

Keep one record open while people type: autosave, conflicts, lost responses, renames and deletions handled by one shared session.

An application that lets people type into a record should not call update() on every change. Open the record as a session instead. The session saves after a pause in typing, never runs two writes for the same record, and tells your interface what state the record is in.

Open a record

editor.ts
const opened = await connection.records.open("Notes/today.md", {
  autosave: { idleMs: 1_000 },
  timeoutMs: 8_000
});
if (!opened.ok) return renderProblem(opened.problem);
const { session, release } = opened.value;

const unsubscribe = session.subscribe(() => render(session.getSnapshot()));
editor.onChange((text) => session.setBody(text));
properties.onChange((patch) => session.patchFrontmatter(patch));

open() reads the record and returns a lease: the session and a release() function for this view. Every view that opens the same path shares one session, so an application never has two competing writers for a record. Each save sends only the body and the frontmatter keys that changed, checked against the revision they were based on.

Stay current while it is open

watch.ts
const watch = await connection.watch({}, { timeoutMs: 20_000 });
if (watch.ok) {
  const stopFollowing = connection.records.follow(watch.value);
  // On sign-out: stopFollowing(); watch.value.close();
}

follow() routes collection changes to open sessions. A record changed elsewhere is re-read, a renamed record keeps its session at the new path, and a deleted record keeps its local text. After a change gap every open record is re-read. The session recognizes its own saves, including when the saved text comes back normalized before the save has finished, so they are never mistaken for someone else's edit.

States

  • saved: the text is the saved record.
  • unsaved: local changes wait for autosave or flush().
  • saving: a write is in flight. Typing continues; newer text is saved next.
  • conflict: the record changed elsewhere in a part edited locally.
  • recovery: a save's response was lost. The same save is recovered exactly; nothing new is written until it is. This includes a save interrupted by a reload: open() resumes it.
  • error: the last save failed. Local text is kept, and nothing retries in the background.
  • deleted: the record no longer exists. Local text is kept.

snapshot.problem holds the typed problem behind error and recovery.

Resolve conflicts

conflict.ts
session.resolve({ keep: "mine" });   // write local changes over the newer record
session.resolve({ keep: "theirs" }); // take the newer record
session.resolve({ body: merged });   // save text the user merged

A change made elsewhere is a conflict only when it touched something edited locally: the body, or a frontmatter key the user changed. Otherwise the session builds on the newer record without asking. If the other change produced exactly the local text, the record is simply saved.

Leave safely

leave.ts
const flushed = await session.flush({ timeoutMs: 8_000 });
if (!flushed.ok) {
  if (flushed.problem.code === "concurrent_modification") {
    showConflict(session.getSnapshot()); // local body and snapshot.remote
  } else {
    renderProblem(flushed.problem);
  }
}
unsubscribe();
release();

flush() saves until nothing is left, or returns why it cannot. Releasing the last view never cancels a save in progress; the session is dropped once it has nothing left to save.

Use it from React

use-record.tsx
const store = externalStore(session);

function useRecord() {
  return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
}

Records behind your own storage

custom-session.ts
import { MdbaseRecordSession } from "@mdbase-dev/connect";
import type { MdbaseRecordSessionAdapter } from "@mdbase-dev/connect/advanced";

const adapter: MdbaseRecordSessionAdapter<Note> = {
  revision: (note) => note.revision,
  body: (note) => note.body,
  frontmatter: (note) => note.frontmatter,
  write: (base, change, options) => notes.write(base, change, options), // ConnectOutcome<Note>
  read: (base, options) => notes.read(base.id, options),
  recover: (requestId, options) => notes.recover(requestId, options),
  isPending: (requestId) => notes.isPending(requestId)
};
const session = new MdbaseRecordSession(note, adapter, { autosave: { idleMs: 650 } });

Applications that reach records through their own repository or a test double construct the session with an adapter from @mdbase-dev/connect/advanced. Adapters report failures as ConnectOutcome values. A file_not_found problem from read marks the record deleted, and recover must continue the original write through its pending mutation, never send a new one.