desktop M10.3: frontend data-source adapter seam (repo interface + rest.ts)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 9s
CI & Build / Build & push image (push) Successful in 29s

Extract a typed repository interface (adapters/repo.ts) from the scattered
store/view -> api.* calls, backed by adapters/rest.ts (verbatim HTTP mapping)
and selected through adapters/index.ts. Every store and the notes-facing views
now depend on `repo`, never the HTTP client directly -- the seam the offline
local source (M10.5, over Tauri invoke) plugs into next.

Behavior-preserving for web: rest.ts maps each semantic method to the exact
endpoint the code called before; query-string and multipart building moved out
of the stores/views into rest.ts (the one place that knows the URL shape).
Client-side logic (reconcile/sort/optimistic reorder/toasts) stays in the
stores. GraphView + admin SettingsView keep direct api calls -- out of the
offline-core scope (M10.5 is board/editor/capture/search/filter/labels/
checklists/reminders).

Task 1992.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
2026-07-24 22:31:00 -04:00
co-authored by Claude Opus 4.8
parent b08cdb92b5
commit 20cf15c99c
14 changed files with 346 additions and 90 deletions
+19
View File
@@ -0,0 +1,19 @@
// The data source the whole app uses. Import `repo` from here — never reach for
// `api/client` in stores/views.
//
// Today it always resolves to the REST source. M10.5 adds the offline desktop
// source (`local.ts`, over Tauri `invoke`) and flips this to a runtime choice:
//
// import { isDesktop } from "../desktop/bridge";
// import { local } from "./local";
// export const repo: Repo = isDesktop() && !hasConfiguredServer() ? local : rest;
//
// so a fresh desktop launch runs fully offline, and a server-connected one keeps
// using REST. Web is unaffected.
import { rest } from "./rest";
import type { Repo } from "./repo";
export const repo: Repo = rest;
export type { Repo } from "./repo";
+140
View File
@@ -0,0 +1,140 @@
// The data-source seam. Stores/views talk to this typed repository interface
// instead of reaching for the HTTP client directly, so the SAME UI can run
// against the REST backend (web + a server-connected desktop) or a fully-local
// on-device source (offline desktop). `rest.ts` implements it over `api/client`;
// `local.ts` (M10.5) implements it over Tauri `invoke`; `index.ts` picks one.
//
// Keep this interface a thin, semantic mirror of the current calls: every method
// maps 1:1 to a backend operation and returns the same shape the stores already
// consume. Client-side logic (list reconciliation, optimistic updates, toasts)
// stays in the stores — the repo is data access only.
import type { NoteColor } from "../notes/colors";
import type { Note, NoteFacets, NoteView, NoteKind, NoteRevision } from "../stores/notes";
import type { Label } from "../stores/labels";
import type { SavedFilter } from "../stores/savedFilters";
import type { Device } from "../stores/devices";
import type { TitleEntry } from "../stores/titles";
import type { User } from "../stores/session";
import type { PublicConfig } from "../stores/config";
// ---- notes payload shapes ----------------------------------------------------
// The full board query the GET /api/notes endpoint accepts. `rest.ts` renders it
// to a query string (the one place that knows the URL shape); a local source
// reads the same fields structurally.
export interface NoteListQuery {
view: NoteView;
labelId?: string | null;
facets?: NoteFacets;
// The timeline view sorts by creation instead of the board's pinned/position order.
sort?: "created";
}
export interface NoteCreateInput {
title: string;
body: string;
color: NoteColor;
kind?: NoteKind;
items?: string[];
}
// The mutable subset of a note (PATCH /api/notes/:id).
export type NoteChanges = Partial<
Pick<Note, "title" | "body" | "color" | "kind" | "pinned" | "archived" | "remind_at" | "recurrence">
>;
export interface ChecklistItemChanges {
text?: string;
checked?: boolean;
}
export interface Backlink {
id: string;
title: string;
}
export interface ImportResult {
source: string;
imported: number;
skipped: number;
}
export interface DeviceToken {
token: string;
device: Device;
}
// ---- per-domain repositories -------------------------------------------------
export interface ConfigRepo {
get(): Promise<PublicConfig>;
}
export interface AuthRepo {
me(): Promise<User>;
login(email: string, password: string): Promise<User>;
register(email: string, password: string, displayName: string): Promise<User>;
logout(): Promise<void>;
}
export interface DevicesRepo {
list(): Promise<Device[]>;
create(name: string): Promise<DeviceToken>;
remove(id: string): Promise<void>;
}
export interface LabelsRepo {
list(): Promise<Label[]>;
create(name: string): Promise<Label>;
rename(id: string, name: string): Promise<Label>;
setColor(id: string, color: string): Promise<Label>;
remove(id: string): Promise<void>;
merge(sourceId: string, into: string): Promise<Label>;
}
export interface NotesRepo {
list(query: NoteListQuery): Promise<Note[]>;
get(id: string): Promise<Note>;
create(input: NoteCreateInput): Promise<Note>;
createTitled(title: string): Promise<Note>;
update(id: string, changes: NoteChanges): Promise<Note>;
completeReminder(id: string): Promise<Note>;
snoozeReminder(id: string, minutes: number): Promise<Note>;
setLabels(id: string, labelIds: string[]): Promise<Note>;
addItem(id: string, text: string): Promise<Note>;
updateItem(id: string, itemId: string, changes: ChecklistItemChanges): Promise<Note>;
deleteItem(id: string, itemId: string): Promise<Note>;
uploadAttachment(id: string, file: File): Promise<Note>;
deleteAttachment(id: string, attId: string): Promise<Note>;
unfurl(id: string, url: string): Promise<Note>;
deletePreview(id: string, previewId: string): Promise<Note>;
import(file: File): Promise<ImportResult>;
reorder(orderedIds: string[]): Promise<void>;
trash(id: string): Promise<Note>;
restore(id: string): Promise<Note>;
deleteForever(id: string): Promise<void>;
revisions(id: string): Promise<NoteRevision[]>;
restoreRevision(id: string, revId: string): Promise<Note>;
reminders(): Promise<Note[]>;
titles(): Promise<TitleEntry[]>;
search(q: string): Promise<Note[]>;
backlinks(id: string): Promise<Backlink[]>;
linkSearch(q: string): Promise<TitleEntry[]>;
}
export interface SavedFiltersRepo {
list(): Promise<SavedFilter[]>;
create(name: string, params: NoteFacets): Promise<SavedFilter>;
remove(id: string): Promise<void>;
rename(id: string, name: string): Promise<SavedFilter>;
}
export interface Repo {
config: ConfigRepo;
auth: AuthRepo;
devices: DevicesRepo;
labels: LabelsRepo;
notes: NotesRepo;
savedFilters: SavedFiltersRepo;
}
+117
View File
@@ -0,0 +1,117 @@
// REST implementation of the repository seam: maps each semantic operation to the
// existing HTTP endpoint via `api/client`. This is the ONLY place that knows about
// URL paths, query strings, and multipart bodies. Behaviour here must stay a
// verbatim mirror of the calls the stores/views made before the seam existed — the
// web app is unchanged; the offline `local.ts` source (M10.5) is the alternative.
import { api } from "../api/client";
import type { Note, NoteRevision } from "../stores/notes";
import type { Label } from "../stores/labels";
import type { SavedFilter } from "../stores/savedFilters";
import type { Device } from "../stores/devices";
import type { TitleEntry } from "../stores/titles";
import type { User } from "../stores/session";
import type { PublicConfig } from "../stores/config";
import type {
Backlink,
DeviceToken,
ImportResult,
NoteChanges,
NoteCreateInput,
NoteListQuery,
ChecklistItemChanges,
Repo,
} from "./repo";
// Render a board query to the GET /api/notes query string. Mirrors the param
// building that used to live in notes.store.load()/TimelineView (order-preserving;
// query-param order is irrelevant to the server, but kept close for review).
function notesQuery(q: NoteListQuery): string {
const params = new URLSearchParams();
params.set("filter", q.view);
if (q.labelId) params.append("label", q.labelId);
for (const id of q.facets?.label ?? []) if (id) params.append("label", id);
if (q.facets?.q) params.set("q", q.facets.q);
if (q.facets?.color) params.set("color", q.facets.color);
if (q.facets?.kind) params.set("kind", q.facets.kind);
if (q.facets?.has_reminder) params.set("has_reminder", "true");
if (q.facets?.has_attachment) params.set("has_attachment", "true");
if (q.facets?.created_after) params.set("created_after", q.facets.created_after);
if (q.facets?.created_before) params.set("created_before", q.facets.created_before);
if (q.sort) params.set("sort", q.sort);
return params.toString();
}
function fileForm(file: File): FormData {
const form = new FormData();
form.append("file", file);
return form;
}
export const rest: Repo = {
config: {
get: () => api.get<PublicConfig>("/api/config"),
},
auth: {
me: () => api.get<User>("/api/auth/me"),
login: (email, password) => api.post<User>("/api/auth/login", { email, password }),
register: (email, password, displayName) =>
api.post<User>("/api/auth/register", { email, password, display_name: displayName }),
logout: () => api.post<void>("/api/auth/logout"),
},
devices: {
list: async () => (await api.get<{ devices: Device[] }>("/api/auth/devices")).devices,
create: (name) => api.post<DeviceToken>("/api/auth/devices", { name }),
remove: (id) => api.del<void>(`/api/auth/devices/${id}`),
},
labels: {
list: async () => (await api.get<{ labels: Label[] }>("/api/labels")).labels,
create: (name) => api.post<Label>("/api/labels", { name }),
rename: (id, name) => api.patch<Label>(`/api/labels/${id}`, { name }),
setColor: (id, color) => api.patch<Label>(`/api/labels/${id}`, { color }),
remove: (id) => api.del<void>(`/api/labels/${id}`),
merge: (sourceId, into) => api.post<Label>(`/api/labels/${sourceId}/merge`, { into }),
},
notes: {
list: async (query) => (await api.get<{ notes: Note[] }>(`/api/notes?${notesQuery(query)}`)).notes,
get: (id) => api.get<Note>(`/api/notes/${id}`),
create: (input: NoteCreateInput) => api.post<Note>("/api/notes", input),
createTitled: (title) => api.post<Note>("/api/notes", { title, body: "" }),
update: (id, changes: NoteChanges) => api.patch<Note>(`/api/notes/${id}`, changes),
completeReminder: (id) => api.post<Note>(`/api/notes/${id}/reminder/complete`),
snoozeReminder: (id, minutes) => api.post<Note>(`/api/notes/${id}/reminder/snooze`, { minutes }),
setLabels: (id, labelIds) => api.put<Note>(`/api/notes/${id}/labels`, { label_ids: labelIds }),
addItem: (id, text) => api.post<Note>(`/api/notes/${id}/items`, { text }),
updateItem: (id, itemId, changes: ChecklistItemChanges) =>
api.patch<Note>(`/api/notes/${id}/items/${itemId}`, changes),
deleteItem: (id, itemId) => api.del<Note>(`/api/notes/${id}/items/${itemId}`),
uploadAttachment: (id, file) => api.postForm<Note>(`/api/notes/${id}/attachments`, fileForm(file)),
deleteAttachment: (id, attId) => api.del<Note>(`/api/notes/${id}/attachments/${attId}`),
unfurl: (id, url) => api.post<Note>(`/api/notes/${id}/unfurl`, { url }),
deletePreview: (id, previewId) => api.del<Note>(`/api/notes/${id}/previews/${previewId}`),
import: (file) => api.postForm<ImportResult>("/api/notes/import", fileForm(file)),
reorder: (orderedIds) => api.post<void>("/api/notes/reorder", { ids: orderedIds }),
trash: (id) => api.post<Note>(`/api/notes/${id}/trash`),
restore: (id) => api.post<Note>(`/api/notes/${id}/restore`),
deleteForever: (id) => api.del<void>(`/api/notes/${id}`),
revisions: async (id) => (await api.get<{ revisions: NoteRevision[] }>(`/api/notes/${id}/revisions`)).revisions,
restoreRevision: (id, revId) => api.post<Note>(`/api/notes/${id}/revisions/${revId}/restore`),
reminders: async () => (await api.get<{ notes: Note[] }>("/api/notes/reminders")).notes,
titles: async () => (await api.get<{ titles: TitleEntry[] }>("/api/notes/titles")).titles,
search: async (q) => (await api.get<{ notes: Note[] }>(`/api/notes/search?q=${encodeURIComponent(q)}`)).notes,
backlinks: async (id) => (await api.get<{ backlinks: Backlink[] }>(`/api/notes/${id}/backlinks`)).backlinks,
linkSearch: async (q) =>
(await api.get<{ results: TitleEntry[] }>(`/api/notes/link-search?q=${encodeURIComponent(q)}`)).results,
},
savedFilters: {
list: async () => (await api.get<{ filters: SavedFilter[] }>("/api/saved-filters")).filters,
create: (name, params) => api.post<SavedFilter>("/api/saved-filters", { name, params }),
remove: (id) => api.del<void>(`/api/saved-filters/${id}`),
rename: (id, name) => api.patch<SavedFilter>(`/api/saved-filters/${id}`, { name }),
},
};