Add version snapshots: automatic, named, thinning (#41)
All checks were successful
CD / Build and push images (push) Successful in 2m53s
CI / Lint, typecheck, test (push) Successful in 2m1s
CI / Auth e2e pack (push) Successful in 2m24s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 11s
All checks were successful
CD / Build and push images (push) Successful in 2m53s
CI / Lint, typecheck, test (push) Successful in 2m1s
CI / Auth e2e pack (push) Successful in 2m24s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 11s
Version history is a core kickoff decision (ADR 0013). Snapshots are full, self-contained encoded Yjs states, so restore never depends on the update log and compaction (#40) cannot lose history. (The page_versions / page_pending_contributors tables and base schema landed a commit early, bundled into 3583a04; this commit completes #41.) - schema: page_versions gains created_by (editor of manual/pre-restore versions; null for automatic snapshots). shared: PageVersionView, CreateVersionInput, PageVersionTrigger. - collab: PostgresVersionStore tracks contributors per open doc (onChange), flushes them to the shared page_pending_contributors accumulator on store, creates an automatic snapshot on last-participant disconnect (only if something changed — no duplicate on a quick reconnect) and every 30 active-editing minutes. Contributors and snapshot are consumed atomically. - api: POST /pages/:id/versions creates a named version (write permission, label + creator, snapshot reconstructed from persisted state, consumes the same contributor accumulator). Daily version-thinning scheduler job keeps all versions for 90 days, then the newest auto snapshot per day; manual and pre-restore versions are never thinned. pre_restore trigger reserved for #42. Tests: collab (one auto version on session end with the full two-author contributor set, none when unchanged, no duplicate on reconnect, interval snapshot); api (named version stores label+creator, contributor set consumed, non-owner refused, thinning time-travel keeps newest-per-day beyond window). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
This commit is contained in:
parent
3583a046a2
commit
6fb6f6fce7
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "page_versions" ADD COLUMN "created_by" TEXT;
|
||||||
@ -148,6 +148,9 @@ model PageVersion {
|
|||||||
ydocSnapshot Bytes @map("ydoc_snapshot")
|
ydocSnapshot Bytes @map("ydoc_snapshot")
|
||||||
trigger PageVersionTrigger
|
trigger PageVersionTrigger
|
||||||
label String?
|
label String?
|
||||||
|
/// Who created this version: the editor for `MANUAL`/`PRE_RESTORE`, null for
|
||||||
|
/// automatic snapshots (which have a contributor set instead of one author).
|
||||||
|
createdBy String? @map("created_by")
|
||||||
contributorIds String[] @map("contributor_ids")
|
contributorIds String[] @map("contributor_ids")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import { RateLimitModule } from './rate-limit/rate-limit.module';
|
|||||||
import { SettingsModule } from './settings/settings.module';
|
import { SettingsModule } from './settings/settings.module';
|
||||||
import { TrashModule } from './trash/trash.module';
|
import { TrashModule } from './trash/trash.module';
|
||||||
import { UsersModule } from './users/users.module';
|
import { UsersModule } from './users/users.module';
|
||||||
|
import { VersionsModule } from './versions/versions.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -32,6 +33,7 @@ import { UsersModule } from './users/users.module';
|
|||||||
FilesModule,
|
FilesModule,
|
||||||
TrashModule,
|
TrashModule,
|
||||||
CompactionModule,
|
CompactionModule,
|
||||||
|
VersionsModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
LoggerModule.forRootAsync({
|
LoggerModule.forRootAsync({
|
||||||
|
|||||||
22
apps/api/src/versions/versions.controller.ts
Normal file
22
apps/api/src/versions/versions.controller.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { Body, Controller, Param, Post, Req } from '@nestjs/common';
|
||||||
|
import { CreateVersionInput, PageVersionView, createVersionInputSchema } from '@dorfteich/shared';
|
||||||
|
|
||||||
|
import { AuthedRequest } from '../auth/auth.guard';
|
||||||
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
|
import { VersionsService } from './versions.service';
|
||||||
|
|
||||||
|
/** Page version history (issue #41, ADR 0013). List/get/restore arrive with #42. */
|
||||||
|
@Controller()
|
||||||
|
export class VersionsController {
|
||||||
|
constructor(private readonly versions: VersionsService) {}
|
||||||
|
|
||||||
|
/** Create a named version of the page. Requires write access. */
|
||||||
|
@Post('pages/:id/versions')
|
||||||
|
async createNamed(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body(new ZodValidationPipe(createVersionInputSchema)) input: CreateVersionInput,
|
||||||
|
@Req() request: AuthedRequest,
|
||||||
|
): Promise<PageVersionView> {
|
||||||
|
return this.versions.createNamed(request.user!, id, input);
|
||||||
|
}
|
||||||
|
}
|
||||||
32
apps/api/src/versions/versions.module.ts
Normal file
32
apps/api/src/versions/versions.module.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { Module, OnModuleInit } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { PondsModule } from '../ponds/ponds.module';
|
||||||
|
import { SchedulerModule } from '../scheduler/scheduler.module';
|
||||||
|
import { SchedulerService } from '../scheduler/scheduler.service';
|
||||||
|
|
||||||
|
import { VersionsController } from './versions.controller';
|
||||||
|
import { VersionsService } from './versions.service';
|
||||||
|
|
||||||
|
/** Daily, per operations.md's maintenance-jobs table (ADR 0013 thinning). */
|
||||||
|
const VERSION_THINNING_CADENCE_SECONDS = 24 * 60 * 60;
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PondsModule, SchedulerModule],
|
||||||
|
controllers: [VersionsController],
|
||||||
|
providers: [VersionsService],
|
||||||
|
exports: [VersionsService],
|
||||||
|
})
|
||||||
|
export class VersionsModule implements OnModuleInit {
|
||||||
|
constructor(
|
||||||
|
private readonly scheduler: SchedulerService,
|
||||||
|
private readonly versions: VersionsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
onModuleInit(): void {
|
||||||
|
this.scheduler.register({
|
||||||
|
name: 'version-thinning',
|
||||||
|
cadenceSeconds: VERSION_THINNING_CADENCE_SECONDS,
|
||||||
|
run: () => this.versions.thinDueVersions().then(() => undefined),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
170
apps/api/src/versions/versions.service.db.test.ts
Normal file
170
apps/api/src/versions/versions.service.db.test.ts
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
import { INestApplication, NotFoundException } from '@nestjs/common';
|
||||||
|
import { PrismaClient, User } from '@prisma/client';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import * as Y from 'yjs';
|
||||||
|
|
||||||
|
import { createTestApp } from '../testing/test-app';
|
||||||
|
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||||
|
import { VERSION_RETENTION_DAYS, VersionsService } from './versions.service';
|
||||||
|
|
||||||
|
describe.skipIf(!hasTestDb)('VersionsService (db, issue #41)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let prisma: PrismaClient;
|
||||||
|
let versions: VersionsService;
|
||||||
|
const suffix = uniqueSuffix();
|
||||||
|
let owner: User;
|
||||||
|
let outsider: User;
|
||||||
|
let pondId: string;
|
||||||
|
const pageIds: string[] = [];
|
||||||
|
|
||||||
|
async function createPage(): Promise<string> {
|
||||||
|
const id = randomUUID();
|
||||||
|
await prisma.page.create({
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
pondId,
|
||||||
|
title: 'Versioned',
|
||||||
|
slug: `p-${id.slice(0, 8)}`,
|
||||||
|
ydocState: new Uint8Array(Y.encodeStateAsUpdate(new Y.Doc())),
|
||||||
|
sortKey: 'a0',
|
||||||
|
createdBy: owner.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
pageIds.push(id);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
prisma = createTestPrisma();
|
||||||
|
app = await createTestApp();
|
||||||
|
versions = app.get(VersionsService);
|
||||||
|
|
||||||
|
owner = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
username: `ver-owner-${suffix}`,
|
||||||
|
email: `ver-owner-${suffix}@example.test`,
|
||||||
|
displayName: 'Version Owner',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
outsider = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
username: `ver-out-${suffix}`,
|
||||||
|
email: `ver-out-${suffix}@example.test`,
|
||||||
|
displayName: 'Version Outsider',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const pond = await prisma.pond.create({
|
||||||
|
data: {
|
||||||
|
slug: `ver-pond-${suffix}`,
|
||||||
|
name: 'Version Pond',
|
||||||
|
type: 'PERSONAL',
|
||||||
|
ownerId: owner.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
pondId = pond.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (pageIds.length > 0) await prisma.page.deleteMany({ where: { id: { in: pageIds } } });
|
||||||
|
await prisma.pond.deleteMany({ where: { id: pondId } });
|
||||||
|
await prisma.user.deleteMany({ where: { id: { in: [owner.id, outsider.id] } } });
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a named version storing label and creator', async () => {
|
||||||
|
const pageId = await createPage();
|
||||||
|
const view = await versions.createNamed(owner, pageId, { label: 'before restructuring' });
|
||||||
|
|
||||||
|
expect(view).toMatchObject({
|
||||||
|
trigger: 'manual',
|
||||||
|
label: 'before restructuring',
|
||||||
|
createdBy: owner.id,
|
||||||
|
});
|
||||||
|
const row = await prisma.pageVersion.findUniqueOrThrow({ where: { id: view.id } });
|
||||||
|
expect(row.label).toBe('before restructuring');
|
||||||
|
expect(row.createdBy).toBe(owner.id);
|
||||||
|
expect(row.trigger).toBe('MANUAL');
|
||||||
|
expect(row.ydocSnapshot.byteLength).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('captures and clears the pending contributor set', async () => {
|
||||||
|
const pageId = await createPage();
|
||||||
|
const authorA = randomUUID();
|
||||||
|
const authorB = randomUUID();
|
||||||
|
await prisma.pagePendingContributor.createMany({
|
||||||
|
data: [
|
||||||
|
{ pageId, userId: authorA },
|
||||||
|
{ pageId, userId: authorB },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const view = await versions.createNamed(owner, pageId, { label: 'snapshot' });
|
||||||
|
expect([...view.contributorIds].sort()).toEqual([authorA, authorB].sort());
|
||||||
|
// The accumulator is consumed so the next version does not re-attribute them.
|
||||||
|
expect(await prisma.pagePendingContributor.count({ where: { pageId } })).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a named version for a user without write access', async () => {
|
||||||
|
const pageId = await createPage();
|
||||||
|
await expect(versions.createNamed(outsider, pageId, { label: 'nope' })).rejects.toBeInstanceOf(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
expect(await prisma.pageVersion.count({ where: { pageId } })).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('thins auto versions beyond the window to the newest per day, keeping the rest', async () => {
|
||||||
|
const pageId = await createPage();
|
||||||
|
const daysAgo = (days: number, hour: number): Date => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() - days);
|
||||||
|
d.setHours(hour, 0, 0, 0);
|
||||||
|
return d;
|
||||||
|
};
|
||||||
|
const snapshot = new Uint8Array(Y.encodeStateAsUpdate(new Y.Doc()));
|
||||||
|
const auto = (createdAt: Date) => ({
|
||||||
|
pageId,
|
||||||
|
ydocSnapshot: snapshot,
|
||||||
|
trigger: 'AUTO' as const,
|
||||||
|
contributorIds: [],
|
||||||
|
createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Two auto versions on one day beyond the window (older + newer), one auto
|
||||||
|
// on another day beyond the window, one manual beyond the window, and one
|
||||||
|
// auto inside the window.
|
||||||
|
const oldDayOlder = daysAgo(120, 8);
|
||||||
|
const oldDayNewer = daysAgo(120, 20);
|
||||||
|
const otherOldDay = daysAgo(200, 12);
|
||||||
|
await prisma.pageVersion.createMany({
|
||||||
|
data: [
|
||||||
|
auto(oldDayOlder),
|
||||||
|
auto(oldDayNewer),
|
||||||
|
auto(otherOldDay),
|
||||||
|
{ ...auto(daysAgo(150, 10)), trigger: 'MANUAL', label: 'keep me', createdBy: owner.id },
|
||||||
|
auto(daysAgo(3, 10)), // within the window
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const removed = await versions.thinDueVersions();
|
||||||
|
expect(removed).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
const remaining = await prisma.pageVersion.findMany({
|
||||||
|
where: { pageId },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
select: { trigger: true, createdAt: true, label: true },
|
||||||
|
});
|
||||||
|
const times = remaining.map((v) => v.createdAt.getTime());
|
||||||
|
// The older of the two same-day beyond-window auto versions is gone…
|
||||||
|
expect(times).not.toContain(oldDayOlder.getTime());
|
||||||
|
// …its newer same-day sibling survives, as does the other-day one.
|
||||||
|
expect(times).toContain(oldDayNewer.getTime());
|
||||||
|
expect(times).toContain(otherOldDay.getTime());
|
||||||
|
// Manual and within-window auto are always kept.
|
||||||
|
expect(remaining.some((v) => v.label === 'keep me')).toBe(true);
|
||||||
|
expect(remaining.length).toBe(4);
|
||||||
|
expect(VERSION_RETENTION_DAYS).toBe(90);
|
||||||
|
});
|
||||||
|
});
|
||||||
142
apps/api/src/versions/versions.service.ts
Normal file
142
apps/api/src/versions/versions.service.ts
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { CreateVersionInput, PageVersionTrigger, PageVersionView } from '@dorfteich/shared';
|
||||||
|
import { PageVersion, PageVersionTrigger as PrismaTrigger, User } from '@prisma/client';
|
||||||
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
|
import * as Y from 'yjs';
|
||||||
|
|
||||||
|
import { InterimAccessService } from '../ponds/interim-access.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-versions older than this are thinned to one snapshot per day; everything
|
||||||
|
* within the window is kept in full (ADR 0013, default 90 days). Manual and
|
||||||
|
* pre-restore versions are intentional and never thinned.
|
||||||
|
*/
|
||||||
|
export const VERSION_RETENTION_DAYS = 90;
|
||||||
|
|
||||||
|
const TRIGGER_TO_VIEW: Record<PrismaTrigger, PageVersionTrigger> = {
|
||||||
|
AUTO: 'auto',
|
||||||
|
MANUAL: 'manual',
|
||||||
|
PRE_RESTORE: 'pre_restore',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page version history (issue #41, ADR 0013). Named versions are created here,
|
||||||
|
* permission-gated; automatic versions are created by the collab server. Both
|
||||||
|
* consume the shared `page_pending_contributors` accumulator so a version's
|
||||||
|
* contributor set reflects who edited since the previous version. The daily
|
||||||
|
* thinning job keeps history bounded.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class VersionsService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly access: InterimAccessService,
|
||||||
|
private readonly logger: PinoLogger,
|
||||||
|
) {
|
||||||
|
this.logger.setContext(VersionsService.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
viewOf(version: Omit<PageVersion, 'ydocSnapshot'>): PageVersionView {
|
||||||
|
return {
|
||||||
|
id: version.id,
|
||||||
|
pageId: version.pageId,
|
||||||
|
trigger: TRIGGER_TO_VIEW[version.trigger],
|
||||||
|
label: version.label,
|
||||||
|
createdBy: version.createdBy,
|
||||||
|
contributorIds: version.contributorIds,
|
||||||
|
createdAt: version.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a named version (requires write access, ADR 0013 / permissions.md).
|
||||||
|
* The snapshot is the page's current persisted state (base state plus the
|
||||||
|
* update log); it can lag the very latest live keystrokes by the collab
|
||||||
|
* store debounce, a deliberate simplification for a manual "save this state".
|
||||||
|
*/
|
||||||
|
async createNamed(
|
||||||
|
user: User,
|
||||||
|
pageId: string,
|
||||||
|
input: CreateVersionInput,
|
||||||
|
): Promise<PageVersionView> {
|
||||||
|
const page = await this.prisma.page.findFirst({
|
||||||
|
where: { id: pageId, deletedAt: null },
|
||||||
|
include: { pond: true },
|
||||||
|
});
|
||||||
|
if (!page) throw new NotFoundException();
|
||||||
|
this.access.assertCanModify(user, page.pond);
|
||||||
|
|
||||||
|
const snapshot = await this.reconstructSnapshot(pageId);
|
||||||
|
|
||||||
|
const created = await this.prisma.$transaction(async (tx) => {
|
||||||
|
// Consume the contributors accumulated since the previous version.
|
||||||
|
const pending = await tx.pagePendingContributor.findMany({ where: { pageId } });
|
||||||
|
const contributorIds = pending.map((row) => row.userId);
|
||||||
|
await tx.pagePendingContributor.deleteMany({ where: { pageId } });
|
||||||
|
return tx.pageVersion.create({
|
||||||
|
data: {
|
||||||
|
pageId,
|
||||||
|
ydocSnapshot: snapshot,
|
||||||
|
trigger: 'MANUAL',
|
||||||
|
label: input.label,
|
||||||
|
createdBy: user.id,
|
||||||
|
contributorIds,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.info(
|
||||||
|
{ event: 'audit: version created', pageId, versionId: created.id, userId: user.id },
|
||||||
|
'named version created',
|
||||||
|
);
|
||||||
|
return this.viewOf(created);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reconstruct the page's full current Yjs state (base + update log). */
|
||||||
|
private async reconstructSnapshot(pageId: string): Promise<Uint8Array<ArrayBuffer>> {
|
||||||
|
const page = await this.prisma.page.findUniqueOrThrow({
|
||||||
|
where: { id: pageId },
|
||||||
|
select: { ydocState: true },
|
||||||
|
});
|
||||||
|
const updates = await this.prisma.pageUpdate.findMany({
|
||||||
|
where: { pageId },
|
||||||
|
orderBy: { seq: 'asc' },
|
||||||
|
select: { update: true },
|
||||||
|
});
|
||||||
|
const doc = new Y.Doc();
|
||||||
|
try {
|
||||||
|
Y.applyUpdate(doc, new Uint8Array(page.ydocState));
|
||||||
|
for (const row of updates) Y.applyUpdate(doc, new Uint8Array(row.update));
|
||||||
|
// A fresh copy: Prisma's Bytes input type is Uint8Array<ArrayBuffer>,
|
||||||
|
// which the ArrayBufferLike-typed encode result does not satisfy.
|
||||||
|
return new Uint8Array(Y.encodeStateAsUpdate(doc));
|
||||||
|
} finally {
|
||||||
|
doc.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin automatic versions older than the retention window down to the newest
|
||||||
|
* one per day, keeping every manual/pre-restore version and everything within
|
||||||
|
* the window (ADR 0013). Returns the number of versions removed.
|
||||||
|
*/
|
||||||
|
async thinDueVersions(): Promise<number> {
|
||||||
|
const result = await this.prisma.$executeRaw`
|
||||||
|
DELETE FROM page_versions v
|
||||||
|
WHERE v.trigger = 'AUTO'
|
||||||
|
AND v.created_at < now() - make_interval(days => ${VERSION_RETENTION_DAYS}::int)
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM page_versions newer
|
||||||
|
WHERE newer.page_id = v.page_id
|
||||||
|
AND newer.trigger = 'AUTO'
|
||||||
|
AND newer.created_at < now() - make_interval(days => ${VERSION_RETENTION_DAYS}::int)
|
||||||
|
AND date_trunc('day', newer.created_at) = date_trunc('day', v.created_at)
|
||||||
|
AND newer.created_at > v.created_at
|
||||||
|
)`;
|
||||||
|
if (result > 0) {
|
||||||
|
this.logger.info({ event: 'version.thinning.run', removed: result }, 'thinned old versions');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ import { createLogger } from './logger.js';
|
|||||||
import { PostgresPagePersistence } from './persistence.js';
|
import { PostgresPagePersistence } from './persistence.js';
|
||||||
import { createCollabServer } from './server.js';
|
import { createCollabServer } from './server.js';
|
||||||
import { PostgresSessionRegistry } from './session-registry.js';
|
import { PostgresSessionRegistry } from './session-registry.js';
|
||||||
|
import { PostgresVersionStore } from './version-store.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Entry point of the collaboration server (ADR 0003). Validates the
|
* Entry point of the collaboration server (ADR 0003). Validates the
|
||||||
@ -23,6 +24,7 @@ async function bootstrap(): Promise<void> {
|
|||||||
// its heartbeat gets the server's open-document accessor at start() below,
|
// its heartbeat gets the server's open-document accessor at start() below,
|
||||||
// which sidesteps the mutual reference between the two.
|
// which sidesteps the mutual reference between the two.
|
||||||
const sessionRegistry = new PostgresSessionRegistry({ pool, logger });
|
const sessionRegistry = new PostgresSessionRegistry({ pool, logger });
|
||||||
|
const versionStore = new PostgresVersionStore({ pool, logger });
|
||||||
|
|
||||||
const server = createCollabServer({
|
const server = createCollabServer({
|
||||||
version: env.APP_VERSION,
|
version: env.APP_VERSION,
|
||||||
@ -31,6 +33,7 @@ async function bootstrap(): Promise<void> {
|
|||||||
pingDatabase: () => pingDatabase(pool),
|
pingDatabase: () => pingDatabase(pool),
|
||||||
persistence: new PostgresPagePersistence(pool),
|
persistence: new PostgresPagePersistence(pool),
|
||||||
sessionRegistry,
|
sessionRegistry,
|
||||||
|
versionStore,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Terminate live sessions when access to a pond is revoked (issue #39). The
|
// Terminate live sessions when access to a pond is revoked (issue #39). The
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import type { Logger } from 'pino';
|
|||||||
import { buildHealthReport, isHealthRequest, type DatabaseProbe } from './health.js';
|
import { buildHealthReport, isHealthRequest, type DatabaseProbe } from './health.js';
|
||||||
import type { PagePersistence } from './persistence.js';
|
import type { PagePersistence } from './persistence.js';
|
||||||
import type { SessionRegistry } from './session-registry.js';
|
import type { SessionRegistry } from './session-registry.js';
|
||||||
|
import type { VersionStore } from './version-store.js';
|
||||||
|
|
||||||
/** Per-connection context returned by onAuthenticate and used by later hooks. */
|
/** Per-connection context returned by onAuthenticate and used by later hooks. */
|
||||||
export interface CollabContext {
|
export interface CollabContext {
|
||||||
@ -29,6 +30,11 @@ export interface CollabServerDeps {
|
|||||||
* which keeps the unit/integration server tests free of a DB dependency.
|
* which keeps the unit/integration server tests free of a DB dependency.
|
||||||
*/
|
*/
|
||||||
sessionRegistry?: SessionRegistry;
|
sessionRegistry?: SessionRegistry;
|
||||||
|
/**
|
||||||
|
* Creates automatic version snapshots and tracks contributors (#41).
|
||||||
|
* Optional for the same reason as `sessionRegistry`.
|
||||||
|
*/
|
||||||
|
versionStore?: VersionStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -49,7 +55,8 @@ export interface CollabErrorMessage {
|
|||||||
* cache (#35).
|
* cache (#35).
|
||||||
*/
|
*/
|
||||||
export function createCollabServer(deps: CollabServerDeps): Server {
|
export function createCollabServer(deps: CollabServerDeps): Server {
|
||||||
const { version, logger, tokenSecret, pingDatabase, persistence, sessionRegistry } = deps;
|
const { version, logger, tokenSecret, pingDatabase, persistence, sessionRegistry, versionStore } =
|
||||||
|
deps;
|
||||||
|
|
||||||
return new Server({
|
return new Server({
|
||||||
name: 'dorfteich-collab',
|
name: 'dorfteich-collab',
|
||||||
@ -104,11 +111,20 @@ export function createCollabServer(deps: CollabServerDeps): Server {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
async onDisconnect({ documentName, socketId, clientsCount }) {
|
/** Attribute each change to the editing user for version contributor sets (#41). */
|
||||||
|
async onChange({ documentName, context }) {
|
||||||
|
if (context?.userId) versionStore?.recordContributor(documentName, context.userId);
|
||||||
|
},
|
||||||
|
|
||||||
|
async onDisconnect({ documentName, socketId, clientsCount, document }) {
|
||||||
logger.info(
|
logger.info(
|
||||||
{ event: 'connection.close', documentName, socketId, clientsCount },
|
{ event: 'connection.close', documentName, socketId, clientsCount },
|
||||||
'collaboration connection closed',
|
'collaboration connection closed',
|
||||||
);
|
);
|
||||||
|
// The editing session ended: snapshot a version if anything changed (#41).
|
||||||
|
if (clientsCount === 0) {
|
||||||
|
await versionStore?.onSessionEnd(documentName, document);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -120,6 +136,8 @@ export function createCollabServer(deps: CollabServerDeps): Server {
|
|||||||
const loaded = await persistence.loadInto(documentName, document);
|
const loaded = await persistence.loadInto(documentName, document);
|
||||||
// Advertise the open session so the compaction job skips this page (#40).
|
// Advertise the open session so the compaction job skips this page (#40).
|
||||||
sessionRegistry?.markOpen(documentName);
|
sessionRegistry?.markOpen(documentName);
|
||||||
|
// Start the active-editing interval clock for automatic versions (#41).
|
||||||
|
versionStore?.noteOpened(documentName);
|
||||||
logger.debug(
|
logger.debug(
|
||||||
{ event: 'document.load', documentName, loaded },
|
{ event: 'document.load', documentName, loaded },
|
||||||
loaded ? 'document loaded' : 'document not found, starting empty',
|
loaded ? 'document loaded' : 'document not found, starting empty',
|
||||||
@ -165,6 +183,8 @@ export function createCollabServer(deps: CollabServerDeps): Server {
|
|||||||
},
|
},
|
||||||
'document persisted',
|
'document persisted',
|
||||||
);
|
);
|
||||||
|
// Flush contributors and take an interval snapshot if one is due (#41).
|
||||||
|
await versionStore?.onStore(documentName, document);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Drop the per-document store bookkeeping once Hocuspocus unloads it. */
|
/** Drop the per-document store bookkeeping once Hocuspocus unloads it. */
|
||||||
@ -172,6 +192,7 @@ export function createCollabServer(deps: CollabServerDeps): Server {
|
|||||||
persistence.forget(documentName);
|
persistence.forget(documentName);
|
||||||
// The session ended; let the compaction job consider this page again (#40).
|
// The session ended; let the compaction job consider this page again (#40).
|
||||||
sessionRegistry?.markClosed(documentName);
|
sessionRegistry?.markClosed(documentName);
|
||||||
|
versionStore?.forget(documentName);
|
||||||
},
|
},
|
||||||
|
|
||||||
async onRequest({ request, response }) {
|
async onRequest({ request, response }) {
|
||||||
|
|||||||
126
apps/collab/src/version-store.db.test.ts
Normal file
126
apps/collab/src/version-store.db.test.ts
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import { pino } from 'pino';
|
||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import * as Y from 'yjs';
|
||||||
|
|
||||||
|
import { PostgresVersionStore } from './version-store.js';
|
||||||
|
import { collabTestDatabaseUrlOrUndefined } from './testing/test-db.js';
|
||||||
|
|
||||||
|
const url = collabTestDatabaseUrlOrUndefined;
|
||||||
|
const logger = pino({ enabled: false });
|
||||||
|
|
||||||
|
/** A small non-empty Yjs doc to snapshot. */
|
||||||
|
function makeDoc(text: string): Y.Doc {
|
||||||
|
const doc = new Y.Doc();
|
||||||
|
doc.getText('t').insert(0, text);
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.skipIf(!url)('PostgresVersionStore (DB-backed, issue #41)', () => {
|
||||||
|
let pool: Pool;
|
||||||
|
const userId = randomUUID();
|
||||||
|
const authorA = randomUUID();
|
||||||
|
const authorB = randomUUID();
|
||||||
|
const pondId = randomUUID();
|
||||||
|
let pageId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
pool = new Pool({ connectionString: url });
|
||||||
|
await pool.query(
|
||||||
|
'INSERT INTO users (id, username, email, display_name) VALUES ($1, $2, $3, $4)',
|
||||||
|
[userId, `vs-${userId.slice(0, 8)}`, `${userId}@example.test`, 'Version Tester'],
|
||||||
|
);
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO ponds (id, slug, name, type, owner_id, updated_at)
|
||||||
|
VALUES ($1, $2, 'Version Pond', 'PERSONAL', $3, now())`,
|
||||||
|
[pondId, `vs-pond-${pondId.slice(0, 8)}`, userId],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Fresh page per test so version/contributor state never leaks between them.
|
||||||
|
pageId = randomUUID();
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO pages (id, pond_id, title, slug, ydoc_state, sort_key, created_by, updated_at)
|
||||||
|
VALUES ($1, $2, 'Test', $3, $4, 'a0', $5, now())`,
|
||||||
|
[
|
||||||
|
pageId,
|
||||||
|
pondId,
|
||||||
|
`p-${pageId.slice(0, 8)}`,
|
||||||
|
Buffer.from(Y.encodeStateAsUpdate(new Y.Doc())),
|
||||||
|
userId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// Pages (one per test) cascade to versions + pending contributors.
|
||||||
|
await pool.query('DELETE FROM pages WHERE pond_id = $1', [pondId]);
|
||||||
|
await pool.query('DELETE FROM ponds WHERE id = $1', [pondId]);
|
||||||
|
await pool.query('DELETE FROM users WHERE id = $1', [userId]);
|
||||||
|
await pool.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function versionsOf(
|
||||||
|
id: string,
|
||||||
|
): Promise<{ trigger: string; contributor_ids: string[]; label: string | null }[]> {
|
||||||
|
const res = await pool.query<{
|
||||||
|
trigger: string;
|
||||||
|
contributor_ids: string[];
|
||||||
|
label: string | null;
|
||||||
|
}>(
|
||||||
|
'SELECT trigger, contributor_ids, label FROM page_versions WHERE page_id = $1 ORDER BY created_at',
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
return res.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('creates exactly one auto version on session end with the full contributor set', async () => {
|
||||||
|
const store = new PostgresVersionStore({ pool, logger });
|
||||||
|
store.recordContributor(pageId, authorA);
|
||||||
|
store.recordContributor(pageId, authorB);
|
||||||
|
|
||||||
|
const doc = makeDoc('two authors edited');
|
||||||
|
await store.onSessionEnd(pageId, doc);
|
||||||
|
|
||||||
|
const versions = await versionsOf(pageId);
|
||||||
|
expect(versions).toHaveLength(1);
|
||||||
|
expect(versions[0]!.trigger).toBe('AUTO');
|
||||||
|
expect([...versions[0]!.contributor_ids].sort()).toEqual([authorA, authorB].sort());
|
||||||
|
doc.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates no version when nothing changed (empty contributor set)', async () => {
|
||||||
|
const store = new PostgresVersionStore({ pool, logger });
|
||||||
|
await store.onSessionEnd(pageId, makeDoc('untouched'));
|
||||||
|
expect(await versionsOf(pageId)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not create a duplicate version on a quick reconnect with no edits', async () => {
|
||||||
|
const store = new PostgresVersionStore({ pool, logger });
|
||||||
|
store.recordContributor(pageId, authorA);
|
||||||
|
await store.onSessionEnd(pageId, makeDoc('edited once'));
|
||||||
|
expect(await versionsOf(pageId)).toHaveLength(1);
|
||||||
|
|
||||||
|
// Reconnect + disconnect without any edit: pending is empty, so no version.
|
||||||
|
store.noteOpened(pageId);
|
||||||
|
await store.onSessionEnd(pageId, makeDoc('edited once'));
|
||||||
|
expect(await versionsOf(pageId)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates an interval snapshot from onStore when the interval has elapsed', async () => {
|
||||||
|
// intervalMs 0 => any store after an edit is due for an interval snapshot.
|
||||||
|
const store = new PostgresVersionStore({ pool, logger, intervalMs: 0 });
|
||||||
|
store.noteOpened(pageId);
|
||||||
|
store.recordContributor(pageId, authorA);
|
||||||
|
|
||||||
|
await store.onStore(pageId, makeDoc('interval edit'));
|
||||||
|
|
||||||
|
const versions = await versionsOf(pageId);
|
||||||
|
expect(versions).toHaveLength(1);
|
||||||
|
expect(versions[0]!.trigger).toBe('AUTO');
|
||||||
|
expect(versions[0]!.contributor_ids).toEqual([authorA]);
|
||||||
|
});
|
||||||
|
});
|
||||||
159
apps/collab/src/version-store.ts
Normal file
159
apps/collab/src/version-store.ts
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
import type { Pool } from 'pg';
|
||||||
|
import type { Logger } from 'pino';
|
||||||
|
import * as Y from 'yjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automatic version snapshots created by the collab server (issue #41, ADR
|
||||||
|
* 0013). Contributors are tracked per open document: {@link recordContributor}
|
||||||
|
* collects the users who edit, {@link onStore} flushes them to the shared
|
||||||
|
* `page_pending_contributors` accumulator (so the api's named-version endpoint
|
||||||
|
* sees them too) and creates the periodic interval snapshot, and
|
||||||
|
* {@link onSessionEnd} creates the end-of-session snapshot.
|
||||||
|
*
|
||||||
|
* A snapshot is only written when there were edits since the previous version
|
||||||
|
* (the accumulator is non-empty), which both implements "skip no-op periods"
|
||||||
|
* and prevents duplicate versions on a quick reconnect with no changes.
|
||||||
|
*/
|
||||||
|
export interface VersionStore {
|
||||||
|
/** Attribute a change to a user (called from `onChange`). */
|
||||||
|
recordContributor(pageId: string, userId: string): void;
|
||||||
|
/** Initialise interval bookkeeping when a document opens. */
|
||||||
|
noteOpened(pageId: string): void;
|
||||||
|
/** Flush contributors and create an interval snapshot if one is due. */
|
||||||
|
onStore(pageId: string, doc: Y.Doc): Promise<void>;
|
||||||
|
/** Create the end-of-session snapshot when the last participant leaves. */
|
||||||
|
onSessionEnd(pageId: string, doc: Y.Doc): Promise<void>;
|
||||||
|
/** Drop per-document bookkeeping when the document unloads. */
|
||||||
|
forget(pageId: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VersionStoreDeps {
|
||||||
|
pool: Pool;
|
||||||
|
logger: Logger;
|
||||||
|
/** Active-editing interval between automatic snapshots (default 30 min). */
|
||||||
|
intervalMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_INTERVAL_MS = 30 * 60 * 1000;
|
||||||
|
|
||||||
|
export class PostgresVersionStore implements VersionStore {
|
||||||
|
private readonly intervalMs: number;
|
||||||
|
/** Users who have edited since the last flush, per open document. */
|
||||||
|
private readonly dirty = new Map<string, Set<string>>();
|
||||||
|
/** Timestamp (ms) of the last automatic snapshot, per open document. */
|
||||||
|
private readonly lastVersionAt = new Map<string, number>();
|
||||||
|
|
||||||
|
constructor(private readonly deps: VersionStoreDeps) {
|
||||||
|
this.intervalMs = deps.intervalMs ?? DEFAULT_INTERVAL_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
recordContributor(pageId: string, userId: string): void {
|
||||||
|
let set = this.dirty.get(pageId);
|
||||||
|
if (!set) {
|
||||||
|
set = new Set();
|
||||||
|
this.dirty.set(pageId, set);
|
||||||
|
}
|
||||||
|
set.add(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
noteOpened(pageId: string): void {
|
||||||
|
this.lastVersionAt.set(pageId, Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
async onStore(pageId: string, doc: Y.Doc): Promise<void> {
|
||||||
|
await this.flushContributors(pageId);
|
||||||
|
const last = this.lastVersionAt.get(pageId) ?? Date.now();
|
||||||
|
if (Date.now() - last >= this.intervalMs) {
|
||||||
|
const created = await this.createVersion(pageId, doc);
|
||||||
|
// Reset the interval clock only when a snapshot was actually written, so
|
||||||
|
// an idle-but-open page doesn't churn empty checks into versions.
|
||||||
|
if (created) this.lastVersionAt.set(pageId, Date.now());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onSessionEnd(pageId: string, doc: Y.Doc): Promise<void> {
|
||||||
|
await this.flushContributors(pageId);
|
||||||
|
await this.createVersion(pageId, doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
forget(pageId: string): void {
|
||||||
|
this.dirty.delete(pageId);
|
||||||
|
this.lastVersionAt.delete(pageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Move the in-memory contributor set into the shared DB accumulator. */
|
||||||
|
private async flushContributors(pageId: string): Promise<void> {
|
||||||
|
const set = this.dirty.get(pageId);
|
||||||
|
if (!set || set.size === 0) return;
|
||||||
|
// Swap in a fresh set first so contributors arriving during the await are
|
||||||
|
// not lost with the ones being flushed.
|
||||||
|
this.dirty.set(pageId, new Set());
|
||||||
|
const users = [...set];
|
||||||
|
try {
|
||||||
|
await this.deps.pool.query(
|
||||||
|
`INSERT INTO page_pending_contributors (page_id, user_id)
|
||||||
|
SELECT $1, u FROM unnest($2::text[]) AS u
|
||||||
|
ON CONFLICT (page_id, user_id) DO NOTHING`,
|
||||||
|
[pageId, users],
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Put the users back so the next flush retries them; never throw out of a
|
||||||
|
// hook (a page purged mid-session is the expected benign failure here).
|
||||||
|
const current = this.dirty.get(pageId) ?? new Set<string>();
|
||||||
|
for (const u of users) current.add(u);
|
||||||
|
this.dirty.set(pageId, current);
|
||||||
|
this.deps.logger.warn(
|
||||||
|
{ event: 'version.contributors.flush_failed', pageId, err: (error as Error).message },
|
||||||
|
'could not flush contributors',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write an automatic snapshot if there are pending contributors, consuming
|
||||||
|
* them atomically. Returns whether a version row was created.
|
||||||
|
*/
|
||||||
|
private async createVersion(pageId: string, doc: Y.Doc): Promise<boolean> {
|
||||||
|
const snapshot = Buffer.from(Y.encodeStateAsUpdate(doc));
|
||||||
|
const client = await this.deps.pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
const pending = await client.query<{ user_id: string }>(
|
||||||
|
'SELECT user_id FROM page_pending_contributors WHERE page_id = $1 FOR UPDATE',
|
||||||
|
[pageId],
|
||||||
|
);
|
||||||
|
if (pending.rows.length === 0) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return false; // nothing changed since the last version
|
||||||
|
}
|
||||||
|
const contributors = pending.rows.map((row) => row.user_id);
|
||||||
|
// Guard the FK: skip (but still clear the accumulator) if the page was
|
||||||
|
// trashed/purged mid-session.
|
||||||
|
const inserted = await client.query(
|
||||||
|
`INSERT INTO page_versions (id, page_id, ydoc_snapshot, trigger, label, contributor_ids, created_at)
|
||||||
|
SELECT gen_random_uuid(), $1, $2, 'AUTO', NULL, $3::text[], now()
|
||||||
|
WHERE EXISTS (SELECT 1 FROM pages WHERE id = $1 AND deleted_at IS NULL)`,
|
||||||
|
[pageId, snapshot, contributors],
|
||||||
|
);
|
||||||
|
await client.query('DELETE FROM page_pending_contributors WHERE page_id = $1', [pageId]);
|
||||||
|
await client.query('COMMIT');
|
||||||
|
const created = (inserted.rowCount ?? 0) > 0;
|
||||||
|
if (created) {
|
||||||
|
this.deps.logger.debug(
|
||||||
|
{ event: 'version.auto.created', pageId, contributors: contributors.length },
|
||||||
|
'automatic version snapshot created',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return created;
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK').catch(() => undefined);
|
||||||
|
this.deps.logger.warn(
|
||||||
|
{ event: 'version.auto.failed', pageId, err: (error as Error).message },
|
||||||
|
'could not create automatic version snapshot',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -56,3 +56,25 @@ export interface PageView {
|
|||||||
export interface PageStateView extends PageView {
|
export interface PageStateView extends PageView {
|
||||||
state: string;
|
state: 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({
|
||||||
|
label: z.string().trim().min(1, 'validation.required').max(100, 'validation.tooLong'),
|
||||||
|
});
|
||||||
|
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[];
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user