CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 39s
CI & Build / integration (push) Successful in 34s
CI & Build / Python tests (push) Successful in 1m10s
CI & Build / Build & push image (push) Successful in 34s
Rule 156, across the whole client. `apiGet`, `apiPost`, `apiPut`, `apiPatch` and `apiDelete` each called bare `fetch`, whose default is to wait as long as the browser will — not a long timeout but the absence of one. The only AbortController in the frontend belonged to the SSE stream and was for cancellation. So every request in the app could hang forever, and there is no state a surface can render for "pending forever" that is not a lie: the spinner that never resolves looks exactly like work still in progress. Found while building the version readout (#3329), which had to tell "the fetch failed" apart from "still loading" and could not. ONE REQUEST PATH. The five verbs were near-identical bodies; they now delegate to a single `request()` that owns the deadline, so a sixth verb cannot be added without one. 30s by default — long enough to clear a cold embedding call and a list view under pool contention (#2384), so tripping it means something is wrong rather than merely busy. Overridable per call via `timeoutMs`. EXPIRY IS AN ApiError, which is the half of rule 156 that is easy to skip. A raw `DOMException: TimeoutError` reaches `apiErrorMessage(e, fallback)` as an object with no `body`, so all ~330 existing catch sites would have printed their generic fallback and the timeout would have been invisible in exactly the situation it exists to expose. Rethrown as `ApiError` with a 408 — a status no Scribe route returns, so it unambiguously means the client gave up — every one of those call sites now reports it correctly, untouched. Only TimeoutError is converted. A deliberate cancellation aborts with AbortError and passes through: a caller that cancelled its own request does not want that surfaced as a server failure. Pinned by a test, because collapsing the two is the obvious "simplification". STREAMS RELOCATE THE DEADLINE RATHER THAN ESCAPING IT. A wall-clock timeout would kill a long-lived SSE connection mid-flight, but two different waits are involved and only one of them is the stream: the CONNECT can fail to answer and now carries a 15s deadline, cleared the moment headers arrive; the BODY stays unbounded on purpose, since its failure mode is going quiet, which a timeout cannot distinguish from being idle — that is what reconnection and Last-Event-ID are for. Reading the connect as exempt because "the stream is long-lived" leaves an unreachable server looking like a quiet one. BULK TRANSFERS get their own value, not the default. Backup, notes export and admin restore walk the whole store and 30s would cut them off mid-work; they carry 10 minutes. Bounded, not unbounded — rule 156 asks for a deadline, not a short one, and no ceiling at all is what leaves a restore that died server-side spinning forever. Four source-inspection guards in the unit lane (no frontend test runner): no bare fetch anywhere; the default is actually applied — pinning the specific regression, since #3329's opt-in shape would pass every other check while leaving 330 callers unbounded; expiry converts to ApiError; and cancellation does not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TcCs1CcQ1ormdnzSshKqvN
538 lines
19 KiB
TypeScript
538 lines
19 KiB
TypeScript
export class ApiError extends Error {
|
|
status: number;
|
|
body: Record<string, unknown>;
|
|
|
|
constructor(status: number, body: Record<string, unknown>) {
|
|
const msg = (body.error as string) || `API error: ${status}`;
|
|
super(msg);
|
|
this.name = "ApiError";
|
|
this.status = status;
|
|
this.body = body;
|
|
}
|
|
}
|
|
|
|
async function handleResponse<T>(res: Response, path: string): Promise<T> {
|
|
if (!res.ok) {
|
|
let body: Record<string, unknown> = {};
|
|
try {
|
|
body = await res.json();
|
|
} catch {
|
|
body = { error: `API error: ${res.status}` };
|
|
}
|
|
|
|
// Redirect to login on 401 (except for auth endpoints)
|
|
if (res.status === 401 && !path.startsWith("/api/auth/")) {
|
|
const { default: router } = await import("@/router/index");
|
|
const currentPath = window.location.pathname;
|
|
if (currentPath !== "/login" && currentPath !== "/register") {
|
|
router.push({ name: "login", query: { redirect: currentPath } });
|
|
}
|
|
}
|
|
|
|
throw new ApiError(res.status, body);
|
|
}
|
|
// Handle 204 No Content
|
|
if (res.status === 204) {
|
|
return undefined as T;
|
|
}
|
|
return res.json() as Promise<T>;
|
|
}
|
|
|
|
/**
|
|
* The server's `{"error": "..."}` message from a failed call, or `fallback`
|
|
* when the failure carried none (network error, non-JSON body). The one place
|
|
* the error envelope is unpacked on the client — views used to restate this
|
|
* as a six-line `"body" in e` branch at every catch site.
|
|
*/
|
|
export function apiErrorMessage(e: unknown, fallback: string): string {
|
|
if (e && typeof e === "object" && "body" in e) {
|
|
const body = (e as { body?: { error?: unknown } }).body;
|
|
if (body && typeof body.error === "string" && body.error) return body.error;
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
/**
|
|
* How long an ordinary JSON call may wait before it is declared failed.
|
|
*
|
|
* Rule 156: a wait with no deadline is a bug. `fetch`'s own default is to wait
|
|
* as long as the browser will, which is not a deadline — it is the absence of
|
|
* one, and it renders as a spinner that never resolves. There is no state a
|
|
* surface can show for "pending forever" that is not a lie.
|
|
*
|
|
* 30s is chosen to be longer than anything healthy: it has to clear a cold
|
|
* embedding call and a list view under connection-pool contention (#2384 had
|
|
* /api/projects fanning 25 concurrent sessions at a 15-connection pool), so
|
|
* tripping it means something is genuinely wrong rather than merely busy. Slow
|
|
* BY DESIGN is a different case and passes its own value — see the callers in
|
|
* SettingsView that do.
|
|
*/
|
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
|
|
/** HTTP 408. Not a status any Scribe route returns, so it unambiguously means
|
|
* "the client gave up" rather than anything the server said. */
|
|
const CLIENT_TIMEOUT_STATUS = 408;
|
|
|
|
/**
|
|
* How long a STREAM may take to answer with its headers.
|
|
*
|
|
* Streams are the one case a wall-clock deadline would break: a long-lived SSE
|
|
* connection is *supposed* to stay open, and `AbortSignal.timeout` would kill
|
|
* it mid-flight along with the body. But that does not exempt them from rule
|
|
* 156 — it relocates the deadline. Two different waits are involved:
|
|
*
|
|
* connect — the server answering with headers. CAN fail to answer, so it
|
|
* carries this deadline, cleared the moment headers arrive.
|
|
* stream — the body, open indefinitely on purpose. Its failure mode is
|
|
* going quiet, which a timeout cannot tell from being idle; that
|
|
* is what reconnection and Last-Event-ID are for, not this.
|
|
*
|
|
* Reading the connect as exempt because "the stream is long-lived" is the easy
|
|
* mistake here, and it leaves an unreachable server looking like a quiet one.
|
|
*/
|
|
const STREAM_CONNECT_TIMEOUT_MS = 15_000;
|
|
|
|
/**
|
|
* A signal that aborts if headers do not arrive in time, plus the `settle` to
|
|
* call once they do. After `settle()` the returned signal never fires, so the
|
|
* stream body runs unbounded — which is the intent.
|
|
*/
|
|
function connectDeadline(base: AbortSignal): { signal: AbortSignal; settle: () => void } {
|
|
const gate = new AbortController();
|
|
const timer = setTimeout(
|
|
() => gate.abort(new DOMException("stream did not connect in time", "TimeoutError")),
|
|
STREAM_CONNECT_TIMEOUT_MS,
|
|
);
|
|
return {
|
|
signal: AbortSignal.any([base, gate.signal]),
|
|
settle: () => clearTimeout(timer),
|
|
};
|
|
}
|
|
|
|
export interface RequestOpts {
|
|
/** Override the deadline. Pass one when the call is slow BY DESIGN. */
|
|
timeoutMs?: number;
|
|
}
|
|
|
|
/**
|
|
* The one place a request is actually made — every verb below goes through
|
|
* here, so the deadline cannot be forgotten by adding a sixth.
|
|
*
|
|
* EXPIRY SURFACES AS AN `ApiError`, which is rule 156's second half: the
|
|
* failure has to arrive in the shape the caller already handles. A bare
|
|
* `DOMException: TimeoutError` would reach `apiErrorMessage(e, fallback)` as
|
|
* an object with no `body`, so every catch site in the app would report its
|
|
* generic fallback and the timeout would be invisible in the very situation it
|
|
* exists to expose. Rethrowing as `ApiError` means ~330 existing call sites
|
|
* report it correctly without being touched.
|
|
*
|
|
* Only a TIMEOUT is converted. A deliberate cancellation aborts with
|
|
* `AbortError` and is left alone — a caller that cancelled its own request
|
|
* does not want it reported as a server failure.
|
|
*/
|
|
async function request<T>(path: string, init: RequestInit, opts?: RequestOpts): Promise<T> {
|
|
const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
let res: Response;
|
|
try {
|
|
res = await fetch(path, { ...init, signal: AbortSignal.timeout(timeoutMs) });
|
|
} catch (e) {
|
|
if (e instanceof DOMException && e.name === "TimeoutError") {
|
|
throw new ApiError(CLIENT_TIMEOUT_STATUS, {
|
|
error: `The server did not answer within ${Math.round(timeoutMs / 1000)}s.`,
|
|
});
|
|
}
|
|
throw e;
|
|
}
|
|
return handleResponse<T>(res, path);
|
|
}
|
|
|
|
/** JSON body headers — the three write verbs sent an identical literal each. */
|
|
const JSON_HEADERS = { "Content-Type": "application/json" };
|
|
|
|
export function apiGet<T>(path: string, opts?: RequestOpts): Promise<T> {
|
|
return request<T>(path, {}, opts);
|
|
}
|
|
|
|
export function apiPost<T>(path: string, body: unknown, opts?: RequestOpts): Promise<T> {
|
|
return request<T>(path, { method: "POST", headers: JSON_HEADERS, body: JSON.stringify(body) }, opts);
|
|
}
|
|
|
|
export function apiPut<T>(path: string, body: unknown, opts?: RequestOpts): Promise<T> {
|
|
return request<T>(path, { method: "PUT", headers: JSON_HEADERS, body: JSON.stringify(body) }, opts);
|
|
}
|
|
|
|
export function apiPatch<T>(path: string, body: unknown, opts?: RequestOpts): Promise<T> {
|
|
return request<T>(path, { method: "PATCH", headers: JSON_HEADERS, body: JSON.stringify(body) }, opts);
|
|
}
|
|
|
|
export function apiDelete(path: string, opts?: RequestOpts): Promise<void> {
|
|
return request<void>(path, { method: "DELETE" }, opts);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sharing, Groups, Notifications, User search
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface ShareEntry {
|
|
id: number
|
|
permission: string
|
|
shared_with_user_id: number | null
|
|
shared_with_group_id: number | null
|
|
username?: string
|
|
group_name?: string
|
|
invited_by: number
|
|
created_at: string
|
|
}
|
|
|
|
export interface GroupEntry {
|
|
id: number
|
|
name: string
|
|
description: string | null
|
|
created_by: number | null
|
|
member_count: number
|
|
is_member: boolean
|
|
created_at: string
|
|
updated_at: string
|
|
}
|
|
|
|
export interface GroupMember {
|
|
id: number
|
|
group_id: number
|
|
user_id: number
|
|
role: string
|
|
username: string
|
|
email: string | null
|
|
created_at: string
|
|
}
|
|
|
|
export interface NotificationEntry {
|
|
id: number
|
|
user_id: number
|
|
type: string
|
|
payload: Record<string, unknown>
|
|
read_at: string | null
|
|
created_at: string
|
|
}
|
|
|
|
export interface UserSearchResult {
|
|
id: number
|
|
username: string
|
|
}
|
|
|
|
// --- User search ---
|
|
export const searchUsers = (q: string) =>
|
|
apiGet<{ users: UserSearchResult[] }>(`/api/users/search?q=${encodeURIComponent(q)}`).then(r => r.users)
|
|
|
|
// --- Groups ---
|
|
export const listGroups = () => apiGet<{ groups: GroupEntry[] }>('/api/groups').then(r => r.groups)
|
|
export const createGroup = (name: string, description?: string) =>
|
|
apiPost<GroupEntry>('/api/groups', { name, description })
|
|
export const updateGroup = (id: number, data: { name?: string; description?: string }) =>
|
|
apiPatch<GroupEntry>(`/api/groups/${id}`, data)
|
|
export const deleteGroup = (id: number) => apiDelete(`/api/groups/${id}`)
|
|
export const getGroupDetail = (id: number) =>
|
|
apiGet<GroupEntry & { members: GroupMember[] }>(`/api/groups/${id}`)
|
|
export const listGroupMembers = (id: number) =>
|
|
apiGet<{ members: GroupMember[] }>(`/api/groups/${id}/members`).then(r => r.members)
|
|
export const addGroupMember = (groupId: number, userId: number, role: string) =>
|
|
apiPost<GroupMember>(`/api/groups/${groupId}/members`, { user_id: userId, role })
|
|
export const updateGroupMember = (groupId: number, userId: number, role: string) =>
|
|
apiPatch<GroupMember>(`/api/groups/${groupId}/members/${userId}`, { role })
|
|
export const removeGroupMember = (groupId: number, userId: number) =>
|
|
apiDelete(`/api/groups/${groupId}/members/${userId}`)
|
|
|
|
// --- Project shares ---
|
|
export const listProjectShares = (projectId: number) =>
|
|
apiGet<{ shares: ShareEntry[] }>(`/api/projects/${projectId}/shares`).then(r => r.shares)
|
|
export const createProjectShare = (projectId: number, body: { user_id?: number; group_id?: number; permission: string }) =>
|
|
apiPost<ShareEntry>(`/api/projects/${projectId}/shares`, body)
|
|
export const updateProjectShare = (projectId: number, shareId: number, permission: string) =>
|
|
apiPatch<ShareEntry>(`/api/projects/${projectId}/shares/${shareId}`, { permission })
|
|
export const deleteProjectShare = (projectId: number, shareId: number) =>
|
|
apiDelete(`/api/projects/${projectId}/shares/${shareId}`)
|
|
|
|
// --- Note / task shares ---
|
|
export const listNoteShares = (noteId: number) =>
|
|
apiGet<{ shares: ShareEntry[] }>(`/api/notes/${noteId}/shares`).then(r => r.shares)
|
|
export const createNoteShare = (noteId: number, body: { user_id?: number; group_id?: number; permission: string }) =>
|
|
apiPost<ShareEntry>(`/api/notes/${noteId}/shares`, body)
|
|
export const updateNoteShare = (noteId: number, shareId: number, permission: string) =>
|
|
apiPatch<ShareEntry>(`/api/notes/${noteId}/shares/${shareId}`, { permission })
|
|
export const deleteNoteShare = (noteId: number, shareId: number) =>
|
|
apiDelete(`/api/notes/${noteId}/shares/${shareId}`)
|
|
|
|
// --- Shared-with-me ---
|
|
export const getSharedWithMe = () =>
|
|
apiGet<{ projects: Record<string, unknown>[]; notes: Record<string, unknown>[] }>('/api/shared-with-me')
|
|
|
|
// --- In-app notifications ---
|
|
export const getNotifications = (all = false) =>
|
|
apiGet<{ notifications: NotificationEntry[] }>(`/api/notifications${all ? '?all=true' : ''}`).then(r => r.notifications)
|
|
export const getNotificationCount = () =>
|
|
apiGet<{ count: number }>('/api/notifications/count').then(r => r.count)
|
|
export const markNotificationRead = (id: number) => apiPost<void>(`/api/notifications/${id}/read`, {})
|
|
export const markAllNotificationsRead = () => apiPost<{ marked: number }>('/api/notifications/read-all', {})
|
|
|
|
export interface SSEStreamHandle {
|
|
close(): void;
|
|
/** Resolves when the stream closes (normally or via error/abort). */
|
|
done: Promise<void>;
|
|
}
|
|
|
|
export interface SSENamedEvent {
|
|
id: number;
|
|
event: string;
|
|
data: Record<string, unknown>;
|
|
}
|
|
|
|
export function apiSSEStream(
|
|
path: string,
|
|
onEvent: (event: SSENamedEvent) => void,
|
|
options?: { lastEventId?: number; signal?: AbortSignal },
|
|
): SSEStreamHandle {
|
|
const controller = new AbortController();
|
|
const combinedSignal = options?.signal
|
|
? AbortSignal.any([controller.signal, options.signal])
|
|
: controller.signal;
|
|
|
|
const headers: Record<string, string> = {};
|
|
if (options?.lastEventId !== undefined) {
|
|
headers["Last-Event-ID"] = String(options.lastEventId);
|
|
}
|
|
|
|
const done = (async () => {
|
|
// Bounded connect, unbounded stream — see STREAM_CONNECT_TIMEOUT_MS.
|
|
const connect = connectDeadline(combinedSignal);
|
|
let res: Response;
|
|
try {
|
|
res = await fetch(path, { headers, signal: connect.signal });
|
|
} finally {
|
|
connect.settle();
|
|
}
|
|
if (!res.ok) {
|
|
let body: Record<string, unknown> = {};
|
|
try {
|
|
body = await res.json();
|
|
} catch {
|
|
body = { error: `API error: ${res.status}` };
|
|
}
|
|
if (res.status === 401 && !path.startsWith("/api/auth/")) {
|
|
const { default: router } = await import("@/router/index");
|
|
router.push({ name: "login" });
|
|
}
|
|
throw new ApiError(res.status, body);
|
|
}
|
|
const reader = res.body?.getReader();
|
|
if (!reader) throw new Error("No response body");
|
|
|
|
const decoder = new TextDecoder();
|
|
let buffer = "";
|
|
|
|
// SSE field parsing state
|
|
let currentId = -1;
|
|
let currentEvent = "message";
|
|
let currentData = "";
|
|
|
|
function dispatch() {
|
|
if (!currentData) return;
|
|
let parsed: Record<string, unknown>;
|
|
try {
|
|
parsed = JSON.parse(currentData);
|
|
} catch {
|
|
return;
|
|
}
|
|
onEvent({ id: currentId, event: currentEvent, data: parsed });
|
|
// Reset for next event
|
|
currentEvent = "message";
|
|
currentData = "";
|
|
}
|
|
|
|
function processLine(line: string) {
|
|
if (line === "") {
|
|
// Empty line = end of event
|
|
dispatch();
|
|
return;
|
|
}
|
|
if (line.startsWith(":")) return; // comment / keepalive
|
|
const colonIdx = line.indexOf(":");
|
|
let field: string;
|
|
let value: string;
|
|
if (colonIdx === -1) {
|
|
field = line;
|
|
value = "";
|
|
} else {
|
|
field = line.slice(0, colonIdx);
|
|
value = line.slice(colonIdx + 1);
|
|
if (value.startsWith(" ")) value = value.slice(1);
|
|
}
|
|
switch (field) {
|
|
case "id":
|
|
currentId = parseInt(value, 10);
|
|
break;
|
|
case "event":
|
|
currentEvent = value;
|
|
break;
|
|
case "data":
|
|
currentData += value;
|
|
break;
|
|
}
|
|
}
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
buffer += decoder.decode(value, { stream: true });
|
|
const lines = buffer.split("\n");
|
|
buffer = lines.pop() || "";
|
|
for (const line of lines) {
|
|
processLine(line);
|
|
}
|
|
}
|
|
// Process remaining buffer
|
|
if (buffer) {
|
|
processLine(buffer);
|
|
}
|
|
dispatch();
|
|
})().catch(() => {
|
|
// Stream closed or aborted — handled by caller via reconnection
|
|
});
|
|
|
|
return { close: () => controller.abort(), done };
|
|
}
|
|
|
|
export async function apiStreamPost(
|
|
path: string,
|
|
body: unknown,
|
|
onChunk: (data: Record<string, unknown>) => void
|
|
): Promise<void> {
|
|
// Bounded connect, unbounded stream — see STREAM_CONNECT_TIMEOUT_MS.
|
|
const connect = connectDeadline(new AbortController().signal);
|
|
let res: Response;
|
|
try {
|
|
res = await fetch(path, {
|
|
method: "POST",
|
|
headers: JSON_HEADERS,
|
|
body: JSON.stringify(body),
|
|
signal: connect.signal,
|
|
});
|
|
} finally {
|
|
connect.settle();
|
|
}
|
|
if (!res.ok) {
|
|
let errBody: Record<string, unknown> = {};
|
|
try {
|
|
errBody = await res.json();
|
|
} catch {
|
|
errBody = { error: `API error: ${res.status}` };
|
|
}
|
|
|
|
if (res.status === 401 && !path.startsWith("/api/auth/")) {
|
|
const { default: router } = await import("@/router/index");
|
|
router.push({ name: "login" });
|
|
}
|
|
|
|
throw new ApiError(res.status, errBody);
|
|
}
|
|
const reader = res.body?.getReader();
|
|
if (!reader) throw new Error("No response body");
|
|
|
|
const decoder = new TextDecoder();
|
|
let buffer = "";
|
|
|
|
function processLine(line: string) {
|
|
const trimmed = line.trim();
|
|
if (trimmed.startsWith("data: ")) {
|
|
let data;
|
|
try {
|
|
data = JSON.parse(trimmed.slice(6));
|
|
} catch {
|
|
return; // Skip malformed JSON lines
|
|
}
|
|
onChunk(data);
|
|
}
|
|
}
|
|
|
|
function processBuffer() {
|
|
const lines = buffer.split("\n");
|
|
// Keep the last (possibly incomplete) line in the buffer
|
|
buffer = lines.pop() || "";
|
|
for (const line of lines) {
|
|
processLine(line);
|
|
}
|
|
}
|
|
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
buffer += decoder.decode(value, { stream: true });
|
|
processBuffer();
|
|
}
|
|
} catch {
|
|
// Stream may close with a network error after all data was sent.
|
|
}
|
|
|
|
// Flush any remaining complete lines in the buffer
|
|
if (buffer.trim()) {
|
|
// The buffer may contain one or more unprocessed lines
|
|
const remaining = buffer;
|
|
buffer = "";
|
|
for (const line of remaining.split("\n")) {
|
|
processLine(line);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── API Keys ─────────────────────────────────────────────────────────────────
|
|
|
|
export interface ApiKeyEntry {
|
|
id: number
|
|
name: string
|
|
scope: string
|
|
key_prefix: string
|
|
last_used_at: string | null
|
|
}
|
|
|
|
export const listApiKeys = () =>
|
|
apiGet<{ api_keys: ApiKeyEntry[] }>('/api/api-keys').then(r => r.api_keys)
|
|
|
|
export const createApiKey = (name: string, scope: 'read' | 'write') =>
|
|
apiPost<{ key: string; api_key: ApiKeyEntry }>('/api/api-keys', { name, scope })
|
|
|
|
export const revokeApiKey = (id: number) => apiDelete(`/api/api-keys/${id}`)
|
|
|
|
// ── User Profile ─────────────────────────────────────────────────────────────
|
|
|
|
export interface UserProfile {
|
|
display_name: string
|
|
job_title: string
|
|
industry: string
|
|
expertise_level: 'novice' | 'intermediate' | 'expert'
|
|
response_style: 'concise' | 'balanced' | 'detailed'
|
|
tone: 'casual' | 'professional' | 'technical'
|
|
interests: string[]
|
|
work_schedule: { days?: string[]; start?: string; end?: string }
|
|
}
|
|
|
|
export const getProfile = () => apiGet<UserProfile>('/api/profile')
|
|
export const updateProfile = (data: Partial<UserProfile>) =>
|
|
apiPut<UserProfile>('/api/profile', data)
|
|
|
|
|
|
// ── Note Versions (pinning) ──────────────────────────────────────────────────
|
|
|
|
import type { NoteVersion } from '../types/task'
|
|
|
|
/** Mark a note version as manually pinned, optionally with a commit-note
|
|
* label. Re-calling with a different label updates the label. */
|
|
export const pinNoteVersion = (noteId: number, versionId: number, label?: string | null) =>
|
|
apiPost<NoteVersion>(
|
|
`/api/notes/${noteId}/versions/${versionId}/pin`,
|
|
{ label: label ?? null },
|
|
)
|
|
|
|
/** Downgrade a manually-pinned version back to rolling. Does NOT delete
|
|
* the row — older rows may be FIFO-pruned by the next autosave. */
|
|
export const unpinNoteVersion = (noteId: number, versionId: number) =>
|
|
apiDelete(`/api/notes/${noteId}/versions/${versionId}/pin`)
|