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
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:
parent
b0d9a00c18
commit
98e159ab50
@ -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": {
|
||||
|
||||
@ -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;
|
||||
@ -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
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
70
apps/api/src/pages/pages.controller.ts
Normal file
70
apps/api/src/pages/pages.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
248
apps/api/src/pages/pages.e2e.db.test.ts
Normal file
248
apps/api/src/pages/pages.e2e.db.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
14
apps/api/src/pages/pages.module.ts
Normal file
14
apps/api/src/pages/pages.module.ts
Normal 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 {}
|
||||
208
apps/api/src/pages/pages.service.ts
Normal file
208
apps/api/src/pages/pages.service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
71
apps/api/src/pages/yjs-content.ts
Normal file
71
apps/api/src/pages/yjs-content.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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';
|
||||
|
||||
58
packages/shared/src/pages.ts
Normal file
58
packages/shared/src/pages.ts
Normal 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
79
pnpm-lock.yaml
generated
@ -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: {}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user