Address PR #215 review: temporary notes hardening

Must-fix:
- CHANGELOG: add [Unreleased]/Added entry for temporary notes (#201).
- temporary-note-cleanup: re-check temporary_expires_at at deletion time so a
  concurrent "Make permanent" (sets it NULL) between the batch SELECT and the
  per-row removePage wins the race and the note is not trashed. Add unit tests
  for the make-permanent and already-trashed race windows.

Non-blocking review items:
- temporary-note-cleanup: cap the sweep batch (LIMIT 500) so a large backlog is
  not loaded into memory; remainder drains on the next hourly run.
- client: extract duplicated post-toggle cache sync into
  syncTemporaryExpiresInCache() shared by the header menu and the banner.
- Remove the tautological migration spec that mocked the whole Kysely builder.
- Tests: cover create() frozen temporaryExpiresAt (workspace override + NULL
  default fallback + non-temporary skips lookup) and restorePage disarming the
  timer (temporaryExpiresAt: null).

Deferred (forward-looking, non-blocking): extract
PageService.computeTemporaryExpiresAt() to dedupe the deadline formula and drop
the @InjectKysely from PageTemplateController; replace migration unit test with
a real Postgres up/down integration test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-26 17:23:03 +03:00
parent eb5b696431
commit 983f2fa654
9 changed files with 266 additions and 120 deletions

View File

@@ -2,6 +2,7 @@ import { BadRequestException } from '@nestjs/common';
import { PageService } from './page.service';
import { MovePageDto } from '../dto/move-page.dto';
import { Page } from '@docmost/db/types/entity.types';
import { DEFAULT_TEMPORARY_NOTE_HOURS } from '../constants/temporary-note.constants';
// Direct instantiation with stub deps. The Test.createTestingModule form failed
// to resolve the @InjectKysely()/@InjectQueue() tokens at compile(), and this
@@ -420,4 +421,79 @@ describe('PageService', () => {
});
});
});
describe('create() temporary deadline (#201)', () => {
// db stub for the workspaces.temporaryNoteHours lookup:
// selectFrom('workspaces').select(['temporaryNoteHours']).where(...).executeTakeFirst()
const makeDb = (workspaceRow: any) => {
const builder: any = {
selectFrom: jest.fn(() => builder),
select: jest.fn(() => builder),
where: jest.fn(() => builder),
executeTakeFirst: jest.fn().mockResolvedValue(workspaceRow),
};
return builder;
};
const makeGeneralQueue = () =>
({ add: jest.fn().mockReturnValue({ catch: jest.fn() }) }) as any;
const run = async (dto: any, workspaceRow: any) => {
const pageRepo = {
insertPage: jest.fn().mockResolvedValue({ id: 'p1' }),
};
const db = makeDb(workspaceRow);
const svc = new PageService(
pageRepo as any, // pageRepo
{} as any, // pagePermissionRepo
{} as any, // attachmentRepo
db as any, // db
{} as any, // storageService
{} as any, // attachmentQueue
{} as any, // aiQueue
makeGeneralQueue(), // generalQueue
{} as any, // eventEmitter
{} as any, // collaborationGateway
{} as any, // watcherService
{} as any, // transclusionService
);
// nextPagePosition runs a real db query; stub it out.
jest.spyOn(svc, 'nextPagePosition').mockResolvedValue('a0' as any);
await svc.create('u1', 'w1', dto, undefined);
return { payload: pageRepo.insertPage.mock.calls[0][0], db };
};
afterEach(() => jest.useRealTimers());
it('freezes temporaryExpiresAt at now + workspace hours when temporary', async () => {
jest.useFakeTimers().setSystemTime(new Date('2026-06-26T00:00:00.000Z'));
const { payload } = await run(
{ title: 't', spaceId: 's1', temporary: true },
{ temporaryNoteHours: 5 },
);
expect(payload.temporaryExpiresAt).toEqual(
new Date(Date.now() + 5 * 60 * 60 * 1000),
);
});
it('falls back to DEFAULT_TEMPORARY_NOTE_HOURS when the workspace hours are null', async () => {
jest.useFakeTimers().setSystemTime(new Date('2026-06-26T00:00:00.000Z'));
const { payload } = await run(
{ title: 't', spaceId: 's1', temporary: true },
{ temporaryNoteHours: null },
);
expect(payload.temporaryExpiresAt).toEqual(
new Date(Date.now() + DEFAULT_TEMPORARY_NOTE_HOURS * 60 * 60 * 1000),
);
});
it('leaves temporaryExpiresAt undefined and skips the workspace lookup for a non-temporary page', async () => {
const { payload, db } = await run(
{ title: 't', spaceId: 's1' },
{ temporaryNoteHours: 5 },
);
expect(payload.temporaryExpiresAt).toBeUndefined();
expect(db.selectFrom).not.toHaveBeenCalled();
});
});
});

View File

@@ -3,10 +3,17 @@ import { TemporaryNoteCleanupService } from '../temporary-note-cleanup.service';
/**
* Chainable Kysely stub that records every `.where(...)` call so the test can
* assert the sweep only selects armed, expired, not-yet-trashed notes. The
* terminal `.execute()` resolves the configured expired rows.
* terminal `.execute()` resolves the configured expired rows (the batch SELECT);
* `.executeTakeFirst()` resolves the per-row deadline re-read done just before
* each `removePage`. By default the re-read reports the note as still armed and
* still expired (epoch deadline < now), so the sweep proceeds to delete it;
* tests override `reReadFirst` to simulate a concurrent "Make permanent".
*/
function makeDbStub(expiredRows: any[]) {
const whereCalls: any[][] = [];
const reReadFirst = jest
.fn()
.mockResolvedValue({ temporaryExpiresAt: new Date(0), deletedAt: null });
const builder: any = {
selectFrom: jest.fn(() => builder),
select: jest.fn(() => builder),
@@ -14,9 +21,11 @@ function makeDbStub(expiredRows: any[]) {
whereCalls.push(args);
return builder;
}),
limit: jest.fn(() => builder),
execute: jest.fn().mockResolvedValue(expiredRows),
executeTakeFirst: reReadFirst,
};
return { builder, whereCalls };
return { builder, whereCalls, reReadFirst };
}
describe('TemporaryNoteCleanupService.sweepExpiredTemporaryNotes', () => {
@@ -38,6 +47,9 @@ describe('TemporaryNoteCleanupService.sweepExpiredTemporaryNotes', () => {
expect(ops).toEqual(['is not', '<', 'is']);
// last operand is the trash filter -> null
expect(whereCalls[2][2]).toBeNull();
// The batch SELECT is capped so a large backlog is not pulled at once.
expect(builder.limit).toHaveBeenCalledTimes(1);
expect(builder.limit.mock.calls[0][0]).toBeGreaterThan(0);
});
it('soft-deletes each expired note via removePage, attributed to its creator', async () => {
@@ -77,6 +89,40 @@ describe('TemporaryNoteCleanupService.sweepExpiredTemporaryNotes', () => {
expect(pageRepo.removePage).toHaveBeenNthCalledWith(2, 'good', 'u2', 'w1');
});
it('does NOT trash a note made permanent in the race window', async () => {
// The batch SELECT saw the note as expired, but before its turn in the loop
// the user clicked "Make permanent" (temporary_expires_at -> null). The
// deadline re-read must catch this and skip the delete so the keep wins.
const expired = [{ id: 'p1', creatorId: 'u1', workspaceId: 'w1' }];
const { builder, reReadFirst } = makeDbStub(expired);
reReadFirst.mockResolvedValueOnce({
temporaryExpiresAt: null,
deletedAt: null,
});
const pageRepo = { removePage: jest.fn() } as any;
const service = new TemporaryNoteCleanupService(builder, pageRepo);
await service.sweepExpiredTemporaryNotes();
expect(reReadFirst).toHaveBeenCalledTimes(1);
expect(pageRepo.removePage).not.toHaveBeenCalled();
});
it('skips a note already trashed since the batch SELECT', async () => {
const expired = [{ id: 'p1', creatorId: 'u1', workspaceId: 'w1' }];
const { builder, reReadFirst } = makeDbStub(expired);
reReadFirst.mockResolvedValueOnce({
temporaryExpiresAt: new Date(0),
deletedAt: new Date(),
});
const pageRepo = { removePage: jest.fn() } as any;
const service = new TemporaryNoteCleanupService(builder, pageRepo);
await service.sweepExpiredTemporaryNotes();
expect(pageRepo.removePage).not.toHaveBeenCalled();
});
it('does nothing when no notes are expired', async () => {
const { builder } = makeDbStub([]);
const pageRepo = { removePage: jest.fn() } as any;

View File

@@ -14,6 +14,11 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo';
export class TemporaryNoteCleanupService {
private readonly logger = new Logger(TemporaryNoteCleanupService.name);
// Cap a single sweep so a large backlog (e.g. many notes created during
// downtime under a short lifetime) is not loaded into memory at once. The
// remainder is drained on the next hourly run; sub-hour overshoot is fine.
private static readonly SWEEP_BATCH_LIMIT = 500;
constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly pageRepo: PageRepo,
@@ -32,11 +37,37 @@ export class TemporaryNoteCleanupService {
.where('temporaryExpiresAt', 'is not', null)
.where('temporaryExpiresAt', '<', now)
.where('deletedAt', 'is', null) // not already in trash
.limit(TemporaryNoteCleanupService.SWEEP_BATCH_LIMIT)
.execute();
let trashed = 0;
for (const page of expired) {
try {
// Re-check the deadline at deletion time. The SELECT above is not
// transactional, so a user may click "Make permanent"
// (toggleTemporary sets temporary_expires_at = null) in the window
// between the SELECT and this per-row removePage. removePage deletes
// by id with only a `deletedAt IS NULL` filter and never re-reads the
// deadline, so without this guard a concurrently-kept note would
// still be trashed. Re-read the row and skip it unless it is still
// armed AND still expired, so a concurrent make-permanent wins.
const current = await this.db
.selectFrom('pages')
.select(['temporaryExpiresAt', 'deletedAt'])
.where('id', '=', page.id)
.executeTakeFirst();
if (
!current ||
current.deletedAt !== null ||
current.temporaryExpiresAt === null ||
new Date(current.temporaryExpiresAt) >= now
) {
// Made permanent, already trashed, or no longer expired since the
// SELECT — leave it alone.
continue;
}
// Reuse the exact soft-delete path: recursive over children, removes
// shares in a transaction, and emits PAGE_SOFT_DELETED (tree
// invalidation + watcher notifications). Attribute the automatic

View File

@@ -1,88 +0,0 @@
// Mock the `sql` tagged template so the migration's partial-index statement is
// recorded without a real database. Keep `Kysely` (type-only) intact.
const sqlCalls: string[] = [];
jest.mock('kysely', () => ({
sql: (strings: TemplateStringsArray) => {
sqlCalls.push(strings.join('{}'));
return { execute: jest.fn().mockResolvedValue(undefined) };
},
}));
import {
up,
down,
} from '../20260626T130000-page-temporary-notes';
/**
* Chainable Kysely schema stub: each builder method returns `this` and records
* (method, firstArg) so the test can assert the columns/index the migration
* touches. `addColumn` runs its column-builder callback to exercise it.
*/
function makeSchemaStub() {
const calls: Array<[string, any]> = [];
const colBuilder: any = new Proxy(
{},
{ get: () => () => colBuilder },
);
const builder: any = {
schema: {} as any,
alterTable(name: string) {
calls.push(['alterTable', name]);
return builder;
},
addColumn(name: string, _type: any, cb?: (c: any) => any) {
calls.push(['addColumn', name]);
if (cb) cb(colBuilder);
return builder;
},
dropColumn(name: string) {
calls.push(['dropColumn', name]);
return builder;
},
dropIndex(name: string) {
calls.push(['dropIndex', name]);
return builder;
},
execute: jest.fn().mockResolvedValue(undefined),
};
builder.schema = builder;
return { db: builder, calls };
}
describe('migration 20260626T130000-page-temporary-notes', () => {
beforeEach(() => {
sqlCalls.length = 0;
});
it('up adds both columns and creates the partial cleanup index', async () => {
const { db, calls } = makeSchemaStub();
await up(db);
const added = calls.filter((c) => c[0] === 'addColumn').map((c) => c[1]);
expect(added).toContain('temporary_expires_at');
expect(added).toContain('temporary_note_hours');
const altered = calls.filter((c) => c[0] === 'alterTable').map((c) => c[1]);
expect(altered).toContain('pages');
expect(altered).toContain('workspaces');
// The partial index is created via the raw sql tag.
expect(sqlCalls.join(' ')).toContain('pages_temporary_expires_at_idx');
expect(sqlCalls.join(' ')).toContain('temporary_expires_at IS NOT NULL');
expect(sqlCalls.join(' ')).toContain('deleted_at IS NULL');
});
it('down reverses both columns and drops the index', async () => {
const { db, calls } = makeSchemaStub();
await down(db);
const dropped = calls.filter((c) => c[0] === 'dropColumn').map((c) => c[1]);
expect(dropped).toContain('temporary_expires_at');
expect(dropped).toContain('temporary_note_hours');
const droppedIdx = calls
.filter((c) => c[0] === 'dropIndex')
.map((c) => c[1]);
expect(droppedIdx).toContain('pages_temporary_expires_at_idx');
});
});

View File

@@ -0,0 +1,64 @@
import { PageRepo } from './page.repo';
/**
* Regression guard for #201: restorePage must disarm the temporary-note death
* timer by setting `temporaryExpiresAt = null` alongside the un-delete fields.
* Otherwise a restored note whose frozen deadline already passed would be
* re-trashed by the very next cleanup sweep. There is no real DB here — a
* chainable Kysely proxy records every `.set(...)` payload so we can assert the
* single restore UPDATE clears the deadline.
*/
function makeRestoreDbStub(opts: {
pageToRestore: any;
descendants: any[];
}) {
const setCalls: any[] = [];
const proxy: any = new Proxy(function () {}, {
get(_t, prop) {
if (prop === 'then') return undefined;
if (prop === 'set')
return (payload: any) => {
setCalls.push(payload);
return proxy;
};
if (prop === 'executeTakeFirst')
return () => Promise.resolve(opts.pageToRestore);
if (prop === 'execute') return () => Promise.resolve(opts.descendants);
if (prop === 'withRecursive')
return (_name: string, cb: any) => {
// Exercise the recursive CTE builder against the proxy without a DB.
try {
cb(proxy);
} catch {
// builder shape only; ignore
}
return proxy;
};
return () => proxy;
},
});
return { proxy, setCalls };
}
describe('PageRepo.restorePage temporary-timer disarm (#201)', () => {
it('clears temporaryExpiresAt together with the un-delete fields', async () => {
const { proxy, setCalls } = makeRestoreDbStub({
// No parent => the deleted-parent lookup and detach branch are skipped, so
// the only UPDATE is the bulk restore we assert on.
pageToRestore: { id: 'p1', parentPageId: null, spaceId: 's1' },
descendants: [{ id: 'p1' }],
});
const eventEmitter = { emit: jest.fn() } as any;
const repo = new PageRepo(proxy, {} as any, eventEmitter);
await repo.restorePage('p1', 'w1');
expect(setCalls).toHaveLength(1);
expect(setCalls[0]).toEqual({
deletedById: null,
deletedAt: null,
temporaryExpiresAt: null,
});
});
});