Add page CRUD and Yjs state persistence (#23)
All checks were successful
CD / Build and push images (push) Successful in 1m52s
CI / Lint, typecheck, test (push) Successful in 1m34s
CI / Auth e2e pack (push) Successful in 1m44s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m7s
CD / Promote to Int (push) Successful in 10s

Prisma models `pages`/`page_updates`/`page_content_cache` per
data-model.md. Endpoints: POST /ponds/:id/pages (title -> empty Yjs doc
state, seeded via y-prosemirror), GET /pages/:id (meta + base64 state),
PUT /pages/:id/state (client-encoded Yjs state, rejected above the 5 MiB
operations.md limit or if it doesn't decode into a valid document for
the schema), PATCH /pages/:id (title/slug — explicit slug changes
validate uniqueness per pond, title-only renames keep the slug),
DELETE (soft). Access follows InterimAccessService via the page's pond,
same 404-not-403 interim rule as ponds.

State saves decode the Yjs update with yjs + y-prosemirror and run it
through the #24 shared derivation functions (docToPlainText/
docToMarkdown/docToHtml/extractOutline) to refresh page_content_cache.
The Yjs XmlFragment name ("default") and the derivation call are
factored so the collab server's persistence hooks (#35) can reuse both.

Raised the API's JSON body limit to 8 MiB (main.ts and the e2e test
app) to fit base64-encoded page state.

Closes #23
This commit is contained in:
Claude Sonnet 5 2026-07-05 22:25:41 +02:00
parent b0d9a00c18
commit 98e159ab50
16 changed files with 891 additions and 1 deletions

View File

@ -21,14 +21,21 @@
"@prisma/client": "^6.3.0",
"argon2": "^0.44.0",
"cookie-parser": "^1.4.7",
"fractional-indexing": "^4.0.0",
"i18next": "^26.3.4",
"nestjs-pino": "^4.3.0",
"nodemailer": "^9.0.3",
"pino": "^9.6.0",
"pino-http": "^10.4.0",
"prisma": "^6.3.0",
"prosemirror-model": "^1.25.9",
"prosemirror-state": "^1.4.4",
"prosemirror-view": "^1.42.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.0",
"y-prosemirror": "^1.3.7",
"y-protocols": "^1.0.7",
"yjs": "^13.6.31",
"zod": "^3.25.76"
},
"devDependencies": {

View File

@ -0,0 +1,59 @@
-- CreateTable
CREATE TABLE "pages" (
"id" TEXT NOT NULL,
"pond_id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"ydoc_state" BYTEA NOT NULL,
"sort_key" TEXT NOT NULL,
"created_by" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"deleted_at" TIMESTAMP(3),
"deleted_by" TEXT,
CONSTRAINT "pages_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "page_updates" (
"id" TEXT NOT NULL,
"page_id" TEXT NOT NULL,
"seq" INTEGER NOT NULL,
"update" BYTEA NOT NULL,
CONSTRAINT "page_updates_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "page_content_cache" (
"page_id" TEXT NOT NULL,
"plain_text" TEXT NOT NULL,
"markdown" TEXT NOT NULL,
"html" TEXT NOT NULL,
"outline" JSONB NOT NULL,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "page_content_cache_pkey" PRIMARY KEY ("page_id")
);
-- CreateIndex
CREATE INDEX "pages_pond_id_idx" ON "pages"("pond_id");
-- CreateIndex
CREATE UNIQUE INDEX "pages_pond_id_slug_key" ON "pages"("pond_id", "slug");
-- CreateIndex
CREATE UNIQUE INDEX "page_updates_page_id_seq_key" ON "page_updates"("page_id", "seq");
-- AddForeignKey
ALTER TABLE "pages" ADD CONSTRAINT "pages_pond_id_fkey" FOREIGN KEY ("pond_id") REFERENCES "ponds"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "pages" ADD CONSTRAINT "pages_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "page_updates" ADD CONSTRAINT "page_updates_page_id_fkey" FOREIGN KEY ("page_id") REFERENCES "pages"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "page_content_cache" ADD CONSTRAINT "page_content_cache_page_id_fkey" FOREIGN KEY ("page_id") REFERENCES "pages"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -46,6 +46,7 @@ model User {
sessions Session[]
authTokens AuthToken[]
ponds Pond[]
pages Page[]
@@map("users")
}
@ -74,11 +75,71 @@ model Pond {
owner User @relation(fields: [ownerId], references: [id])
usage PondUsage?
pages Page[]
@@index([ownerId])
@@map("ponds")
}
/// A wiki page (data-model.md §pages). Carries a Yjs document from day one
/// (ADR 0003) even though M2 saves it wholesale over REST; `ydocState` is
/// the merged state Y.Doc, decoded by the API to derive `PageContentCache`
/// on every save (issue #23). `sortKey` uses fractional indexing so pages
/// can be reordered without rewriting siblings (sidebar reorder is #26).
model Page {
id String @id @default(uuid())
pondId String @map("pond_id")
title String
slug String
ydocState Bytes @map("ydoc_state")
sortKey String @map("sort_key")
createdBy String @map("created_by")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
deletedBy String? @map("deleted_by")
pond Pond @relation(fields: [pondId], references: [id])
creator User @relation(fields: [createdBy], references: [id])
updates PageUpdate[]
contentCache PageContentCache?
@@unique([pondId, slug])
@@index([pondId])
@@map("pages")
}
/// Append log for incremental Yjs updates (data-model.md), compacted
/// periodically. Unused by M2's whole-state REST saves; the collab
/// server's persistence hooks (#35) are the first real writer.
model PageUpdate {
id String @id @default(uuid())
pageId String @map("page_id")
seq Int
update Bytes
page Page @relation(fields: [pageId], references: [id], onDelete: Cascade)
@@unique([pageId, seq])
@@map("page_updates")
}
/// Derived plain representation refreshed on every state save (issue #23),
/// built from the Yjs state via the shared editor schema. `outline` is the
/// heading tree (`OutlineEntry[]` from @dorfteich/shared) as jsonb.
model PageContentCache {
pageId String @id @map("page_id")
plainText String @map("plain_text")
markdown String
html String
outline Json
updatedAt DateTime @updatedAt @map("updated_at")
page Page @relation(fields: [pageId], references: [id], onDelete: Cascade)
@@map("page_content_cache")
}
enum QuotaSubjectType {
USER
POND

View File

@ -9,6 +9,7 @@ import { AppConfig } from './config/app-config.service';
import { ConfigModule } from './config/config.module';
import { HealthModule } from './health/health.module';
import { MailModule } from './mail/mail.module';
import { PagesModule } from './pages/pages.module';
import { PondsModule } from './ponds/ponds.module';
import { PrismaModule } from './prisma/prisma.module';
import { RateLimitModule } from './rate-limit/rate-limit.module';
@ -24,6 +25,7 @@ import { UsersModule } from './users/users.module';
SettingsModule,
UsersModule,
PondsModule,
PagesModule,
AuthModule,
AdminModule,
LoggerModule.forRootAsync({

View File

@ -35,6 +35,9 @@ async function bootstrap(): Promise<void> {
// real client for rate limiting and audit logs.
app.set('trust proxy', 1);
app.use(cookieParser());
// Base64-encoded Yjs page state (max 5 MiB, operations.md) inflates by
// ~4/3; 8 MiB leaves headroom for the JSON envelope around it.
app.useBodyParser('json', { limit: '8mb' });
app.setGlobalPrefix('api/v1');
app.enableShutdownHooks();

View File

@ -0,0 +1,70 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
Patch,
Post,
Put,
Req,
} from '@nestjs/common';
import {
CreatePageInput,
PageStateView,
PageView,
SavePageStateInput,
UpdatePageInput,
createPageInputSchema,
savePageStateInputSchema,
updatePageInputSchema,
} from '@dorfteich/shared';
import { AuthedRequest } from '../auth/auth.guard';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { PagesService } from './pages.service';
/** Page CRUD and Yjs state persistence (issue #23). */
@Controller()
export class PagesController {
constructor(private readonly pages: PagesService) {}
@Post('ponds/:pondId/pages')
async create(
@Param('pondId') pondId: string,
@Body(new ZodValidationPipe(createPageInputSchema)) input: CreatePageInput,
@Req() request: AuthedRequest,
): Promise<PageView> {
return this.pages.create(request.user!, pondId, input);
}
@Get('pages/:id')
async getState(@Param('id') id: string, @Req() request: AuthedRequest): Promise<PageStateView> {
return this.pages.getState(request.user!, id);
}
@Put('pages/:id/state')
async saveState(
@Param('id') id: string,
@Body(new ZodValidationPipe(savePageStateInputSchema)) input: SavePageStateInput,
@Req() request: AuthedRequest,
): Promise<PageStateView> {
return this.pages.saveState(request.user!, id, input);
}
@Patch('pages/:id')
async update(
@Param('id') id: string,
@Body(new ZodValidationPipe(updatePageInputSchema)) input: UpdatePageInput,
@Req() request: AuthedRequest,
): Promise<PageView> {
return this.pages.update(request.user!, id, input);
}
@Delete('pages/:id')
@HttpCode(204)
async remove(@Param('id') id: string, @Req() request: AuthedRequest): Promise<void> {
await this.pages.softDelete(request.user!, id);
}
}

View File

@ -0,0 +1,248 @@
import { INestApplication } from '@nestjs/common';
import { editorSchema } from '@dorfteich/shared';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { prosemirrorJSONToYXmlFragment } from 'y-prosemirror';
import * as Y from 'yjs';
import { AuthTokensService } from '../auth/auth-tokens.service';
import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
/** Encodes a one-paragraph doc with the given text as a base64 Yjs state. */
function stateWithText(text: string): string {
const ydoc = new Y.Doc();
const fragment = ydoc.getXmlFragment('default');
const doc = editorSchema.node('doc', null, [
editorSchema.node('paragraph', null, [editorSchema.text(text)]),
]);
prosemirrorJSONToYXmlFragment(editorSchema, doc.toJSON(), fragment);
const state = Buffer.from(Y.encodeStateAsUpdate(ydoc)).toString('base64');
ydoc.destroy();
return state;
}
describe.skipIf(!hasTestDb)('pages (e2e, issue #23)', () => {
let app: INestApplication;
let prisma: PrismaClient;
const suffix = uniqueSuffix();
const password = 'seiten voller notizen 1';
const owner = { username: `pia-pages-${suffix}`, displayName: `Pia Pages ${suffix}` };
const outsider = { username: `otto-pages-${suffix}`, displayName: `Otto Outside ${suffix}` };
let ownerCookie: string;
let outsiderCookie: string;
let pondId: string;
const api = () => request(app.getHttpServer());
async function loginOf(username: string): Promise<string> {
const res = await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: username, password })
.expect(200);
return sessionCookieOf(res);
}
beforeAll(async () => {
prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({});
app = await createTestApp();
const users = app.get(UsersService);
const tokens = app.get(AuthTokensService);
const ownerUser = await users.createUser({
username: owner.username,
email: `${owner.username}@example.org`,
displayName: owner.displayName,
password,
locale: 'en',
});
const verifyToken = await tokens.issue(ownerUser.id, 'EMAIL_VERIFICATION', 600);
await api().post('/api/v1/auth/verify-email').send({ token: verifyToken }).expect(204);
ownerCookie = await loginOf(owner.username);
const outsiderUser = await users.createUser({
username: outsider.username,
email: `${outsider.username}@example.org`,
displayName: outsider.displayName,
password,
locale: 'en',
});
await users.markEmailVerified(outsiderUser.id);
outsiderCookie = await loginOf(outsider.username);
const ponds = await api().get('/api/v1/ponds').set('Cookie', ownerCookie).expect(200);
pondId = ponds.body.find((p: { type: string }) => p.type === 'personal').id;
});
afterAll(async () => {
const users = await prisma.user.findMany({
where: { username: { contains: suffix } },
select: { id: true },
});
await prisma.page.deleteMany({
where: { pond: { owner: { username: { contains: suffix } } } },
});
await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: users.map((u) => u.id) } } });
await prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } });
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
await prisma.$disconnect();
await app.close();
});
it('creates a page with an empty Yjs state and a title-derived slug', async () => {
const res = await api()
.post(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', ownerCookie)
.send({ title: `Welcome ${suffix}` })
.expect(201);
expect(res.body.slug).toBe(`welcome-${suffix}`);
expect(res.body.pondId).toBe(pondId);
const fetched = await api()
.get(`/api/v1/pages/${res.body.id}`)
.set('Cookie', ownerCookie)
.expect(200);
expect(typeof fetched.body.state).toBe('string');
expect(Buffer.from(fetched.body.state, 'base64').length).toBeGreaterThan(0);
});
it('gives duplicate titles deterministic slug suffixes within the same pond', async () => {
const title = `Duplicate ${suffix}`;
const first = await api()
.post(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', ownerCookie)
.send({ title })
.expect(201);
const second = await api()
.post(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', ownerCookie)
.send({ title })
.expect(201);
expect(first.body.slug).toBe(`duplicate-${suffix}`);
expect(second.body.slug).toBe(`duplicate-${suffix}-2`);
});
it('derives plain text and markdown into page_content_cache on state save', async () => {
const created = await api()
.post(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', ownerCookie)
.send({ title: `Derivation ${suffix}` })
.expect(201);
await api()
.put(`/api/v1/pages/${created.body.id}/state`)
.set('Cookie', ownerCookie)
.send({ state: stateWithText(`hello from ${suffix}`) })
.expect(200);
const cache = await prisma.pageContentCache.findUniqueOrThrow({
where: { pageId: created.body.id },
});
expect(cache.plainText).toBe(`hello from ${suffix}`);
expect(cache.markdown).toBe(`hello from ${suffix}`);
expect(cache.html).toBe(`<p>hello from ${suffix}</p>`);
});
it('rejects state saves beyond the document size limit', async () => {
const created = await api()
.post(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', ownerCookie)
.send({ title: `Oversized ${suffix}` })
.expect(201);
// Decoded size exceeds the 5 MiB domain limit but its base64 form
// still fits the (much larger) raw HTTP body-size ceiling.
const oversized = Buffer.alloc(5.5 * 1024 * 1024, 1).toString('base64');
const res = await api()
.put(`/api/v1/pages/${created.body.id}/state`)
.set('Cookie', ownerCookie)
.send({ state: oversized })
.expect(413);
expect(res.body.code).toBe('page_document_too_large');
expect(res.body.details.limitBytes).toBe(5 * 1024 * 1024);
});
it('rejects state bytes that are not a valid Yjs update', async () => {
const created = await api()
.post(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', ownerCookie)
.send({ title: `Garbage ${suffix}` })
.expect(201);
const res = await api()
.put(`/api/v1/pages/${created.body.id}/state`)
.set('Cookie', ownerCookie)
.send({ state: Buffer.from('not a yjs update').toString('base64') })
.expect(400);
expect(res.body.code).toBe('invalid_page_state');
});
it('keeps the slug stable on a title-only rename; validates explicit slug changes', async () => {
const created = await api()
.post(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', ownerCookie)
.send({ title: `Original ${suffix}` })
.expect(201);
const renamed = await api()
.patch(`/api/v1/pages/${created.body.id}`)
.set('Cookie', ownerCookie)
.send({ title: `Renamed ${suffix}` })
.expect(200);
expect(renamed.body.title).toBe(`Renamed ${suffix}`);
expect(renamed.body.slug).toBe(created.body.slug);
const other = await api()
.post(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', ownerCookie)
.send({ title: `Taken ${suffix}` })
.expect(201);
const conflict = await api()
.patch(`/api/v1/pages/${created.body.id}`)
.set('Cookie', ownerCookie)
.send({ slug: other.body.slug })
.expect(409);
expect(conflict.body.code).toBe('slug_taken');
const changed = await api()
.patch(`/api/v1/pages/${created.body.id}`)
.set('Cookie', ownerCookie)
.send({ slug: `custom-slug-${suffix}` })
.expect(200);
expect(changed.body.slug).toBe(`custom-slug-${suffix}`);
});
it('soft-deletes a page', async () => {
const created = await api()
.post(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', ownerCookie)
.send({ title: `Trashed ${suffix}` })
.expect(201);
await api().delete(`/api/v1/pages/${created.body.id}`).set('Cookie', ownerCookie).expect(204);
await api().get(`/api/v1/pages/${created.body.id}`).set('Cookie', ownerCookie).expect(404);
});
it('hides pages in foreign ponds (404, not 403)', async () => {
const created = await api()
.post(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', ownerCookie)
.send({ title: `Private ${suffix}` })
.expect(201);
await api().get(`/api/v1/pages/${created.body.id}`).set('Cookie', outsiderCookie).expect(404);
await api()
.patch(`/api/v1/pages/${created.body.id}`)
.set('Cookie', outsiderCookie)
.send({ title: 'hijacked' })
.expect(404);
await api()
.post(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', outsiderCookie)
.send({ title: 'sneaky' })
.expect(404);
});
});

View File

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { PondsModule } from '../ponds/ponds.module';
import { PagesController } from './pages.controller';
import { PagesService } from './pages.service';
@Module({
imports: [PondsModule],
controllers: [PagesController],
providers: [PagesService],
exports: [PagesService],
})
export class PagesModule {}

View File

@ -0,0 +1,208 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
PayloadTooLargeException,
} from '@nestjs/common';
import {
CreatePageInput,
MAX_PAGE_DOCUMENT_BYTES,
PageStateView,
PageView,
SavePageStateInput,
UpdatePageInput,
slugify,
} from '@dorfteich/shared';
import { Page, Prisma, User } from '@prisma/client';
import { generateKeyBetween } from 'fractional-indexing';
import { PinoLogger } from 'nestjs-pino';
import { InterimAccessService } from '../ponds/interim-access.service';
import { PrismaService } from '../prisma/prisma.service';
import {
deriveContent,
DerivedPageContent,
emptyPageState,
InvalidPageStateError,
} from './yjs-content';
/** `outline` is a plain JSON-serializable array; Prisma's Json input just needs the cast. */
function contentCacheData(
content: DerivedPageContent,
): Prisma.PageContentCacheCreateWithoutPageInput {
return {
plainText: content.plainText,
markdown: content.markdown,
html: content.html,
outline: content.outline as unknown as Prisma.InputJsonValue,
};
}
@Injectable()
export class PagesService {
constructor(
private readonly prisma: PrismaService,
private readonly access: InterimAccessService,
private readonly logger: PinoLogger,
) {
this.logger.setContext(PagesService.name);
}
viewOf(page: Page): PageView {
return {
id: page.id,
pondId: page.pondId,
title: page.title,
slug: page.slug,
sortKey: page.sortKey,
createdAt: page.createdAt.toISOString(),
updatedAt: page.updatedAt.toISOString(),
deletedAt: page.deletedAt?.toISOString() ?? null,
};
}
stateViewOf(page: Page): PageStateView {
return { ...this.viewOf(page), state: Buffer.from(page.ydocState).toString('base64') };
}
/** Deterministic unique slug within one pond (mirrors PondsService). */
private async generateUniqueSlugInPond(pondId: string, base: string): Promise<string> {
const slug = slugify(base) || 'page';
const taken = new Set(
(
await this.prisma.page.findMany({
where: { pondId, OR: [{ slug }, { slug: { startsWith: `${slug}-` } }] },
select: { slug: true },
})
).map((row) => row.slug),
);
if (!taken.has(slug)) return slug;
for (let n = 2; ; n += 1) {
const candidate = `${slug}-${n}`;
if (!taken.has(candidate)) return candidate;
}
}
private async findVisiblePage(user: User, id: string): Promise<Page> {
const page = await this.prisma.page.findFirst({
where: { id, deletedAt: null },
include: { pond: true },
});
if (!page) throw new NotFoundException();
this.access.assertCanSee(user, page.pond);
return page;
}
private async findModifiablePage(user: User, id: string): Promise<Page> {
const page = await this.prisma.page.findFirst({
where: { id, deletedAt: null },
include: { pond: true },
});
if (!page) throw new NotFoundException();
this.access.assertCanModify(user, page.pond);
return page;
}
async create(user: User, pondId: string, input: CreatePageInput): Promise<PageView> {
const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } });
this.access.assertCanModify(user, pond);
const slug = await this.generateUniqueSlugInPond(pond.id, input.title);
const last = await this.prisma.page.findFirst({
where: { pondId: pond.id },
orderBy: { sortKey: 'desc' },
select: { sortKey: true },
});
const sortKey = generateKeyBetween(last?.sortKey ?? null, null);
const state = emptyPageState();
const content = deriveContent(state);
const page = await this.prisma.page.create({
data: {
pondId: pond.id,
title: input.title,
slug,
sortKey,
ydocState: state,
createdBy: user.id,
contentCache: { create: contentCacheData(content) },
},
});
this.logger.info({ pageId: page.id, pondId: pond.id, userId: user.id }, 'audit: page created');
return this.viewOf(page);
}
async getState(user: User, id: string): Promise<PageStateView> {
const page = await this.findVisiblePage(user, id);
return this.stateViewOf(page);
}
async saveState(user: User, id: string, input: SavePageStateInput): Promise<PageStateView> {
const page = await this.findModifiablePage(user, id);
const state = new Uint8Array(Buffer.from(input.state, 'base64'));
if (state.length > MAX_PAGE_DOCUMENT_BYTES) {
throw new PayloadTooLargeException({
code: 'page_document_too_large',
details: { limitBytes: MAX_PAGE_DOCUMENT_BYTES },
});
}
let content: DerivedPageContent;
try {
content = deriveContent(state);
} catch (error) {
if (error instanceof InvalidPageStateError) {
throw new BadRequestException({ code: 'invalid_page_state' });
}
throw error;
}
const updated = await this.prisma.page.update({
where: { id: page.id },
data: {
ydocState: state,
contentCache: {
upsert: {
create: contentCacheData(content),
update: contentCacheData(content),
},
},
},
});
this.logger.info({ pageId: id, userId: user.id }, 'audit: page state saved');
return this.stateViewOf(updated);
}
async update(user: User, id: string, input: UpdatePageInput): Promise<PageView> {
const page = await this.findModifiablePage(user, id);
let slug = page.slug;
if (input.slug !== undefined) {
const normalized = slugify(input.slug) || page.slug;
if (normalized !== page.slug) {
const clash = await this.prisma.page.findFirst({
where: { pondId: page.pondId, slug: normalized, id: { not: page.id } },
select: { id: true },
});
if (clash) throw new ConflictException({ code: 'slug_taken' });
}
slug = normalized;
}
const updated = await this.prisma.page.update({
where: { id: page.id },
data: { title: input.title, slug },
});
return this.viewOf(updated);
}
async softDelete(user: User, id: string): Promise<void> {
const page = await this.findModifiablePage(user, id);
await this.prisma.page.update({
where: { id: page.id },
data: { deletedAt: new Date(), deletedBy: user.id },
});
this.logger.info({ pageId: id, userId: user.id }, 'audit: page trashed');
}
}

View File

@ -0,0 +1,71 @@
import {
docToHtml,
docToMarkdown,
docToPlainText,
editorSchema,
extractOutline,
OutlineEntry,
} from '@dorfteich/shared';
import { Node } from 'prosemirror-model';
import { prosemirrorJSONToYXmlFragment, yXmlFragmentToProseMirrorRootNode } from 'y-prosemirror';
import * as Y from 'yjs';
/**
* The Yjs XmlFragment name the editor binds to (TipTap's collaboration
* extension defaults to "default", #25) api, web, and collab (#35) must
* all agree on this or Yjs states become unreadable across them.
*/
const FRAGMENT_NAME = 'default';
/** Thrown for state bytes that are not a well-formed Yjs update for this schema. */
export class InvalidPageStateError extends Error {}
function docFromState(state: Uint8Array): Node {
const ydoc = new Y.Doc();
try {
Y.applyUpdate(ydoc, state);
return yXmlFragmentToProseMirrorRootNode(ydoc.getXmlFragment(FRAGMENT_NAME), editorSchema);
} catch (error) {
throw new InvalidPageStateError(error instanceof Error ? error.message : 'invalid Yjs state');
} finally {
ydoc.destroy();
}
}
/** A fresh Yjs state containing a single empty paragraph. */
export function emptyPageState(): Uint8Array<ArrayBuffer> {
const ydoc = new Y.Doc();
try {
const fragment = ydoc.getXmlFragment(FRAGMENT_NAME);
const emptyDoc = editorSchema.node('doc', null, [editorSchema.node('paragraph')]);
prosemirrorJSONToYXmlFragment(editorSchema, emptyDoc.toJSON(), fragment);
// Copy into a plain ArrayBuffer-backed view — yjs's own return type is
// the wider `Uint8Array<ArrayBufferLike>`, which Prisma's Bytes input
// (`Uint8Array<ArrayBuffer>`) does not accept directly.
return new Uint8Array(Y.encodeStateAsUpdate(ydoc));
} finally {
ydoc.destroy();
}
}
export interface DerivedPageContent {
plainText: string;
markdown: string;
html: string;
outline: OutlineEntry[];
}
/**
* Decodes a page's Yjs state into the derived representations stored in
* `page_content_cache` (issue #23). The collab server (#35) will decode
* the same way and call the same shared derivation functions (#24).
*/
export function deriveContent(state: Uint8Array): DerivedPageContent {
const doc = docFromState(state);
return {
plainText: docToPlainText(doc),
markdown: docToMarkdown(doc),
html: docToHtml(doc),
outline: extractOutline(doc),
};
}

View File

@ -1,5 +1,6 @@
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import type { NestExpressApplication } from '@nestjs/platform-express';
import cookieParser from 'cookie-parser';
import { AppModule } from '../app.module';
@ -17,8 +18,10 @@ export async function createTestApp(): Promise<INestApplication> {
process.env.DATABASE_URL ??= 'postgresql://nobody:nothing@127.0.0.1:59999/absent';
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
const app = moduleRef.createNestApplication();
const app = moduleRef.createNestApplication<NestExpressApplication>();
app.use(cookieParser());
// Mirrors main.ts: base64 Yjs page state needs more than Express's 100kb default.
app.useBodyParser('json', { limit: '8mb' });
app.setGlobalPrefix('api/v1');
await app.init();
return app;

View File

@ -19,6 +19,9 @@
"cannot_revoke_current_session": "Beende deine aktuelle Sitzung über die Abmeldung.",
"personal_pond_undeletable": "Der persönliche Teich kann nicht gelöscht werden.",
"quota_exceeded": "Das Kontingent ist erreicht (Limit: {{limit}}).",
"slug_taken": "Dieser Adressname ist in diesem Teich bereits vergeben.",
"page_document_too_large": "Die Seite ist zu groß (Limit: {{limitBytes}} Bytes).",
"invalid_page_state": "Der übermittelte Seiteninhalt ist ungültig.",
"network": "Der Server war nicht erreichbar.",
"validation": {
"required": "Dieses Feld ist erforderlich.",

View File

@ -19,6 +19,9 @@
"cannot_revoke_current_session": "Use sign-out to end your current session.",
"personal_pond_undeletable": "The personal pond cannot be deleted.",
"quota_exceeded": "The quota has been reached (limit: {{limit}}).",
"slug_taken": "This slug is already taken in this pond.",
"page_document_too_large": "The page is too large (limit: {{limitBytes}} bytes).",
"invalid_page_state": "The submitted page content is invalid.",
"network": "The server could not be reached.",
"validation": {
"required": "This field is required.",

View File

@ -4,5 +4,6 @@ export * from './editor-schema';
export * from './env';
export * from './health';
export * from './i18n-tools';
export * from './pages';
export * from './ponds';
export * from './quotas';

View File

@ -0,0 +1,58 @@
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');
export const createPageInputSchema = z.object({
title: pageTitleSchema,
});
export type CreatePageInput = z.infer<typeof createPageInputSchema>;
export const updatePageInputSchema = z
.object({
title: pageTitleSchema,
slug: pageSlugSchema,
})
.partial();
export type UpdatePageInput = z.infer<typeof updatePageInputSchema>;
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;
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;
}

79
pnpm-lock.yaml generated
View File

@ -50,6 +50,9 @@ importers:
cookie-parser:
specifier: ^1.4.7
version: 1.4.7
fractional-indexing:
specifier: ^4.0.0
version: 4.0.0
i18next:
specifier: ^26.3.4
version: 26.3.4(typescript@5.9.3)
@ -68,12 +71,30 @@ importers:
prisma:
specifier: ^6.3.0
version: 6.19.3(typescript@5.9.3)
prosemirror-model:
specifier: ^1.25.9
version: 1.25.9
prosemirror-state:
specifier: ^1.4.4
version: 1.4.4
prosemirror-view:
specifier: ^1.42.0
version: 1.42.0
reflect-metadata:
specifier: ^0.2.2
version: 0.2.2
rxjs:
specifier: ^7.8.0
version: 7.8.2
y-prosemirror:
specifier: ^1.3.7
version: 1.3.7(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.42.0)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31)
y-protocols:
specifier: ^1.0.7
version: 1.0.7(yjs@13.6.31)
yjs:
specifier: ^13.6.31
version: 13.6.31
zod:
specifier: ^3.25.76
version: 3.25.76
@ -2305,6 +2326,10 @@ packages:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'}
fractional-indexing@4.0.0:
resolution: {integrity: sha512-Nr2P1Yyaj2sy1Qdt/wI3GcByxrUdbSKg5+cGvw8f18hT2SkQt6V72iOjBpk7IO3UkzBsdlGRs2a4z6dLrJprgw==}
engines: {node: ^14.13.1 || >=16.0.0}
fresh@2.0.0:
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
engines: {node: '>= 0.8'}
@ -2467,6 +2492,9 @@ packages:
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
isomorphic.js@0.2.5:
resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==}
iterare@1.2.1:
resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==}
engines: {node: '>=6'}
@ -2531,6 +2559,11 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
lib0@0.2.117:
resolution: {integrity: sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==}
engines: {node: '>=16'}
hasBin: true
lilconfig@3.1.3:
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
engines: {node: '>=14'}
@ -3638,6 +3671,22 @@ packages:
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
y-prosemirror@1.3.7:
resolution: {integrity: sha512-NpM99WSdD4Fx4if5xOMDpPtU3oAmTSjlzh5U4353ABbRHl1HtAFUx6HlebLZfyFxXN9jzKMDkVbcRjqOZVkYQg==}
engines: {node: '>=16.0.0', npm: '>=8.0.0'}
peerDependencies:
prosemirror-model: ^1.7.1
prosemirror-state: ^1.2.3
prosemirror-view: ^1.9.10
y-protocols: ^1.0.1
yjs: ^13.5.38
y-protocols@1.0.7:
resolution: {integrity: sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==}
engines: {node: '>=16.0.0', npm: '>=8.0.0'}
peerDependencies:
yjs: ^13.0.0
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@ -3645,6 +3694,10 @@ packages:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
yjs@13.6.31:
resolution: {integrity: sha512-Eq+5BRfbeGyqGVrTJL3bEcr8gKkxPuyuoHmAwpk52fDb8kOVMrfVSTRPd6yiGgX5Fskb96qCRjzjbRjrL4YEnw==}
engines: {node: '>=16.0.0', npm: '>=8.0.0'}
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
@ -5681,6 +5734,8 @@ snapshots:
forwarded@0.2.0: {}
fractional-indexing@4.0.0: {}
fresh@2.0.0: {}
fs-extra@10.1.0:
@ -5821,6 +5876,8 @@ snapshots:
isexe@2.0.0: {}
isomorphic.js@0.2.5: {}
iterare@1.2.1: {}
jest-worker@27.5.1:
@ -5872,6 +5929,10 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
lib0@0.2.117:
dependencies:
isomorphic.js: 0.2.5
lilconfig@3.1.3: {}
lines-and-columns@1.2.4: {}
@ -6962,10 +7023,28 @@ snapshots:
wrappy@1.0.2: {}
y-prosemirror@1.3.7(prosemirror-model@1.25.9)(prosemirror-state@1.4.4)(prosemirror-view@1.42.0)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31):
dependencies:
lib0: 0.2.117
prosemirror-model: 1.25.9
prosemirror-state: 1.4.4
prosemirror-view: 1.42.0
y-protocols: 1.0.7(yjs@13.6.31)
yjs: 13.6.31
y-protocols@1.0.7(yjs@13.6.31):
dependencies:
lib0: 0.2.117
yjs: 13.6.31
yallist@3.1.1: {}
yargs-parser@21.1.1: {}
yjs@13.6.31:
dependencies:
lib0: 0.2.117
yocto-queue@0.1.0: {}
yoctocolors-cjs@2.1.3: {}