dorfteich/packages/shared/src/pages.ts
Claude Fable 5 3f7190ebcc #153: Stabile Task-IDs + Toggle-Rückschreibpfad über den Collab-Server
task_item bekommt ein optionales id-Attr (default null — Bestandsdocs
bleiben gültig), durchgereicht in toDOM/parseDOM und dem Lese-HTML;
der Editor vergibt/entdoppelt IDs lazy per appendTransaction
(TaskItemIds-Extension, auch gegen Copy/Paste). Neuer Kanal
TASK_TOGGLE_CHANNEL; POST /pages/:id/tasks/:taskId {checked} prüft
Schreibrecht, registriert den Toggler als pending contributor und
feuert pg_notify; neuer collab task-toggle-listener (Struktur =
restore-listener) öffnet eine DirectConnection und flippt das
checked-Attribut in einer Transaktion — offene Editoren konvergieren,
unbekannte taskId = geloggter No-op. DB-Test (NOTIFY-Payload,
Attribution, 403/404/400).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC
2026-07-20 01:03:16 +02:00

156 lines
5.6 KiB
TypeScript

import { z } from 'zod';
/**
* Page schemas and views shared between api and web (issue #23). A page
* carries a Yjs document from day one (ADR 0003); in M2 its state is
* saved wholesale over REST as a base64 string.
*/
export const pageTitleSchema = z
.string()
.trim()
.min(1, 'validation.required')
.max(200, 'validation.tooLong');
export const pageSlugSchema = z
.string()
.trim()
.min(1, 'validation.required')
.max(60, 'validation.tooLong');
/**
* Maximum nesting depth of the page tree (issue #106), counted as levels like
* {@link MAX_LABEL_DEPTH}: a root page is level 1. Creating or moving a page
* whose deepest descendant would exceed this is rejected.
*/
export const MAX_PAGE_DEPTH = 6;
export const createPageInputSchema = z.object({
title: pageTitleSchema,
/** Parent page id for a nested page (issue #106); omitted/null creates at
* the root level. Must be a live page of the same pond. */
parentId: z.string().min(1).nullish(),
});
export type CreatePageInput = z.infer<typeof createPageInputSchema>;
export const updatePageInputSchema = z
.object({
title: pageTitleSchema,
slug: pageSlugSchema,
})
.partial();
export type UpdatePageInput = z.infer<typeof updatePageInputSchema>;
/**
* Move a page in the manual sidebar order (issue #45): place it between the
* `afterId` page (its new predecessor) and the `beforeId` page (its new
* successor); either is `null` at an end of the list. The server recomputes
* only the moved page's `sort_key`.
*/
export const repositionPageInputSchema = z.object({
afterId: z.string().min(1).nullable(),
beforeId: z.string().min(1).nullable(),
/**
* New parent for the page (issue #106): a page id nests it, `null` moves it
* to the root level, and an absent field keeps the current parent — so the
* one endpoint covers plain reordering, drag-onto-a-page, and the "Move
* to…" dialog atomically. Cycles and depth violations are rejected.
*/
parentId: z.string().min(1).nullable().optional(),
});
export type RepositionPageInput = z.infer<typeof repositionPageInputSchema>;
/**
* What happens to a page's live children when it is trashed (issue #107):
* `promote` re-attaches them to the deleted page's parent (the default —
* nothing disappears but the page itself); `subtree` trashes every live
* descendant along with it, which requires write permission on all of them.
*/
/** Body of `POST /pages/:id/tasks/:taskId` (issue #153). */
export const toggleTaskInputSchema = z.object({ checked: z.boolean() });
export type ToggleTaskInput = z.infer<typeof toggleTaskInputSchema>;
export const PAGE_DELETE_MODES = ['promote', 'subtree'] as const;
export type PageDeleteMode = (typeof PAGE_DELETE_MODES)[number];
export const pageDeleteQuerySchema = z.object({
mode: z.enum(PAGE_DELETE_MODES).default('promote'),
});
export type PageDeleteQuery = z.infer<typeof pageDeleteQuerySchema>;
export const savePageStateInputSchema = z.object({
/** Base64-encoded Yjs state (`Y.encodeStateAsUpdate`). */
state: z.string().min(1, 'validation.required'),
});
export type SavePageStateInput = z.infer<typeof savePageStateInputSchema>;
/** Max Yjs document size (operations.md §Limits) — a fixed operational
* ceiling, not a per-pond/user quota. */
export const MAX_PAGE_DOCUMENT_BYTES = 5 * 1024 * 1024;
export interface PageView {
id: string;
pondId: string;
/**
* Parent page in the tree (issue #106), or `null` at the root. In list
* responses the server nulls this when the caller may not read the parent,
* so a permission-sliced view never leaks a hidden page's id — the child
* then simply renders at the root level.
*/
parentId: string | null;
title: string;
slug: string;
sortKey: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
/** What `GET /pages/:id` returns: page meta plus its base64 Yjs state. */
export interface PageStateView extends PageView {
state: string;
}
/**
* A page in the sidebar list (`GET /ponds/:id/pages`) with the ids of the
* labels assigned to it (issue #44). The sidebar maps these to colours/names
* via the pond's label tree for chips and filters by them (descendant-inclusive
* via the shared tree helpers) — so no per-label detail is repeated here.
*/
export interface PageListItemView extends PageView {
labelIds: string[];
}
/** Why a version snapshot exists (ADR 0013): automatic (session end / active
* interval), a named manual snapshot, or the automatic pre-restore snapshot. */
export type PageVersionTrigger = 'auto' | 'manual' | 'pre_restore';
export const createVersionInputSchema = z.object({
// Optional since #125: Ctrl/Cmd+S snapshots without asking for a name.
label: z.string().trim().min(1, 'validation.required').max(100, 'validation.tooLong').optional(),
});
export type CreateVersionInput = z.infer<typeof createVersionInputSchema>;
/** A version in the history list (issue #41; no snapshot bytes). */
export interface PageVersionView {
id: string;
pageId: string;
trigger: PageVersionTrigger;
label: string | null;
/** Editor for manual/pre-restore versions; null for automatic snapshots. */
createdBy: string | null;
/** Users who edited since the previous version. */
contributorIds: string[];
/** Display names for contributorIds; deleted accounts are omitted. */
contributors: { id: string; name: string }[];
createdAt: string;
}
/** A single version with its rendered content, for viewing/diffing (issue #42). */
export interface PageVersionContentView extends PageVersionView {
/** Read-only HTML render of the snapshot. */
html: string;
/** Markdown of the snapshot, for the diff against the current page. */
markdown: string;
}