Files
MindSift/frontend/src/lib/docSync/transport.ts
T
sandrodachandClaude Opus 5.5 aea4324c20
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m14s
refactor(notes): remove legacy whole-document saves; all block changes go through sync
Phase 5 of the collaboration plan:
- Remove PATCH /documents/{id}/blocks, PatchDocumentBlocks, the
  DOC_BLOCKS_PATCHED event and its types, FlushDocumentState and
  LoadDocumentState. PUT /documents/{id} rejects blocks (400) and only
  changes title, icon, folder and pin; editors may now change title/icon.
- The to-do overview ticks via a single operation on /docs/{id}/sync
  (new skipLog flag). Task links set the block tick with a targeted
  operation and notify open editors; creating a linked task needs edit
  rights.
- Keep the three share tables: DocRole already decides centrally, and
  rewriting the visibility queries would only add risk (documented in plan).
- Tests adapted to the new paths; share e2e test now also ticks a to-do in
  the overview.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 14:35:59 +01:00

123 lines
3.6 KiB
TypeScript

// HTTP transport of the note sync protocol (see Doku/api/rest.md). Every data exchange is a plain request,
// so it works behind proxies that block WebSockets.
import { getToken } from '../api';
import type { Block, Effect, Op, Result } from './model';
export interface LeaseInfo {
blockId: string;
userId: string;
name: string;
}
export interface Presence {
userId: string;
name: string;
}
export interface SnapshotResponse {
docId: string;
title: string;
titleVersion: number;
headSeq: number;
blocks: Block[];
role: string;
shared: boolean;
}
export interface LogEntry {
seq: number;
opId?: string;
userId: string;
clientId: string;
effect: Effect;
}
export interface SyncRequest {
clientId: string;
lastSeq: number;
ops: Op[];
leases?: { hold: string[]; release: string[] };
skipLog?: boolean; // one-off changes do not need the log back
}
export interface SyncResponse {
results: Result[];
ops: LogEntry[];
headSeq: number;
hasMore: boolean;
resync?: boolean;
role: string;
shared: boolean;
sessions: number;
presence: Presence[];
leases: { granted: string[]; denied: string[]; others: LeaseInfo[] };
}
/** status 0 = network error or timeout; otherwise the HTTP status. */
export class TransportError extends Error {
constructor(message: string, public status: number) {
super(message);
}
}
export interface ChangesResponse {
headSeq: number;
changed: boolean;
}
export interface Transport {
snapshot(docId: string): Promise<SnapshotResponse>;
/** Long poll: resolves when the note changes, at the latest after waitSec seconds. */
changes(docId: string, since: number, waitSec: number, signal?: AbortSignal): Promise<ChangesResponse>;
sync(docId: string, body: SyncRequest, opts?: { keepalive?: boolean }): Promise<SyncResponse>;
leave(docId: string, clientId: string): Promise<void>;
}
const TIMEOUT_MS = 15000;
async function call<T>(path: string, init: RequestInit & { keepalive?: boolean } = {}, timeoutMs = TIMEOUT_MS): Promise<T> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
const token = getToken();
if (token) headers.Authorization = `Bearer ${token}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
init.signal?.addEventListener('abort', () => controller.abort());
let res: Response;
try {
res = await fetch(path, { ...init, headers, cache: 'no-store', signal: init.keepalive ? undefined : controller.signal });
} catch (err: any) {
throw new TransportError(err?.message || 'Netzwerkfehler', 0);
} finally {
clearTimeout(timer);
}
if (res.status === 401) {
window.dispatchEvent(new CustomEvent('mindsift:unauthorized'));
}
if (!res.ok) {
let msg = `HTTP ${res.status}`;
try {
const data = await res.json();
if (data?.error) msg = data.error;
} catch {
// ignore
}
throw new TransportError(msg, res.status);
}
return res.json();
}
export const httpTransport: Transport = {
snapshot: (docId) => call(`/api/v1/docs/${encodeURIComponent(docId)}/snapshot`),
changes: (docId, since, waitSec, signal) =>
call(`/api/v1/docs/${encodeURIComponent(docId)}/changes?since=${since}&wait=${waitSec}`, { signal }, (waitSec + 10) * 1000),
sync: (docId, body, opts) =>
call(`/api/v1/docs/${encodeURIComponent(docId)}/sync`, { method: 'POST', body: JSON.stringify(body), keepalive: opts?.keepalive }),
leave: async (docId, clientId) => {
await call(`/api/v1/docs/${encodeURIComponent(docId)}/leave`, {
method: 'POST',
body: JSON.stringify({ clientId }),
keepalive: true,
});
},
};