import { z } from "zod"; import { BLOCK_TYPES } from "../../constants.js"; /** * The REST block type, case-tolerant on input. * * The DB/REST wire enum is UPPERCASE (`BLOCK_TYPES`), but the web editor's * codec is lowercase (`file`, `heading_1`, …). The realtime gateway already * reconciles the two at its boundary (`prisma-store.ts` `codecToDbType` / * `dbToCodecType`); the REST path now does the same so a block created via * `POST /pages/:id/blocks` with the codec case stores and returns the DB * case. UPPERCASE input is unchanged (backward compatible — existing * integration tests send and expect `PARAGRAPH`/`FILE`/…). */ const blockTypeSchema = z .string() .min(1) .transform((t) => t.toUpperCase()) .pipe(z.enum(BLOCK_TYPES)); /** * Block content: the canonical shape is an object (e.g. `{ text }`), but the * web InlineEditor round-trips content as an HTML STRING (its MVP storage * model — see the API's own `blockText` in modules/ai/prompts.ts, which * documents both shapes must stay readable). Accepting a string here is what * lets the editor's `PATCH /blocks/:id` and batch `op:"update"` saves persist * web-typed text instead of 400ing. */ const blockContentSchema = z.union([z.string(), z.record(z.unknown())]); export const CreateBlockDto = z.object({ type: blockTypeSchema, // The web editor stores text as an HTML STRING (the InlineEditor storage // model) — accept string OR record here, exactly like UpdateBlockDto. A // record-only schema 400'd every web paste/import (content: "

…

"). content: blockContentSchema.default({}), properties: z.record(z.unknown()).default({}), parentId: z.string().min(1).nullable().optional(), afterBlockId: z.string().min(1).optional(), beforeBlockId: z.string().min(1).optional(), position: z.string().min(1).optional(), /** * Optional client-chosen block id (the web paste path generates * `blk_`-prefixed ids so nested pastes can reference their parent by id * in ONE batch). Uniqueness is enforced by the DB primary key — a * collision aborts the whole batch (no partial application). Omitted → * the server assigns a cuid (today's behavior). */ id: z.string().min(1).max(128).optional() }); export type CreateBlockInput = z.infer; export const DuplicateBlockDto = z.object({ parentId: z.string().min(1).nullable().optional(), afterBlockId: z.string().min(1).optional() }); export type DuplicateBlockInput = z.infer; export const UpdateBlockDto = z.object({ type: blockTypeSchema.optional(), content: blockContentSchema.optional(), properties: z.record(z.unknown()).optional(), archived: z.boolean().optional(), /** * TASK-0123: optional optimistic-concurrency base. When present the write * only applies if the block is still at exactly this version; otherwise a * 409 CONFLICT `{ reason: "stale_version", actualVersion }` is returned. * Omitted → the historical unconditional increment (unchanged behavior). */ baseVersion: z.number().int().positive().optional() }); export type UpdateBlockInput = z.infer; export const MoveBlockDto = z.object({ parentId: z.string().min(1).nullable().optional(), afterBlockId: z.string().min(1).optional(), beforeBlockId: z.string().min(1).optional(), position: z.string().min(1).optional() }); export type MoveBlockInput = z.infer; const BatchUpsertOp = z.object({ op: z.literal("upsert"), block: CreateBlockDto }); const BatchDeleteOp = z.object({ op: z.literal("delete"), blockId: z.string().min(1) }); const BatchMoveOp = z.object({ op: z.literal("move"), blockId: z.string().min(1), move: MoveBlockDto }); /** * The web editor's debounced save (components/editor/block-save-queue.ts) * flushes `op:"update"` ops — one per typed block — to this endpoint. The * union used to omit it, so EVERY batched text save 400'd ("Invalid * discriminator value") and the queue silently re-queued forever. The patch * is the web's `BlockPatch` (content may be an HTML string — the InlineEditor * storage model; type conversions clear properties, mirroring PATCH). * `passthrough()` (rather than stripping unknown keys) so the client's * field set is never silently dropped. */ const BatchUpdateOp = z.object({ op: z.literal("update"), blockId: z.string().min(1), patch: z .object({ type: blockTypeSchema.optional(), content: blockContentSchema.optional(), properties: z.record(z.unknown()).optional(), parentId: z.string().min(1).nullable().optional(), position: z.string().min(1).optional() }) .passthrough() }); export const BatchBlocksDto = z.object({ pageId: z.string().min(1), // The `pages.version` the client last read (the Phase-4 409-rebase path, // now also the TASK-0092 flush serialization): a stale baseVersion → 409 // CONFLICT carrying the NEW version ("flush wins; REST sees fresh state // on retry"). Omitted (legacy clients / import / AI / automation) → the // write proceeds, still advancing the version so a concurrent realtime // flush's CAS fails and rebases over the REST write (never clobbers it). baseVersion: z.number().int().min(1).optional(), ops: z.array( z.discriminatedUnion("op", [BatchUpsertOp, BatchUpdateOp, BatchDeleteOp, BatchMoveOp]) ).min(1).max(200) }); export type BatchBlocksInput = z.infer; export const ListBlocksQuery = z.object({ parentId: z.string().nullable().optional(), depth: z.union([z.enum(["1", "all"]), z.coerce.number().int().min(1)]).optional() }); export type ListBlocksInput = z.infer;