Files
gitmost/apps/server/src/common/middlewares/domain.middleware.ts
T
agent_coder 28251b1e08 perf(server): low-hanging backend wins — indexes, auth dedup, embed coalescing, CTE short-circuit (#348)
One migration + targeted hot-path fixes. API behavior 1:1 (schema change = added
indexes + a byte-identical f_unaccent function-body swap, see below).

- Trigram + composite indexes (20260705T120000-perf-indexes.ts): GIN trigram on
  LOWER(f_unaccent(title/name)) for pages/users/groups (the /search/suggest
  leading-wildcard LIKE did a seq scan per keystroke — EXPLAIN now confirms
  Bitmap Index Scan on idx_pages_title_trgm), + page_history(page_id,id DESC),
  comments(page_id,id). DEVIATION (verified byte-identical): PG18 cannot inline
  the two-arg f_unaccent body during index creation, so up() swaps it to the
  schema-qualified single-arg `SELECT public.unaccent($1)` — same dictionary,
  identical output for all inputs, so the tsvector trigger + main @@ search stay
  consistent with NO reindex; down() restores the exact two-arg body.
- Auth path: jwt.strategy reuses req.raw.workspace when workspaceId matches (the
  middleware already validated it) instead of re-querying; domain.middleware
  caches the workspace lookup (withCache 15s, invalidated in all 8 WorkspaceRepo
  mutators, with a Date reviver for the JSON-serialized cache). USER + SESSION
  caching DEFERRED — the invalidation surface (role change doesn't revoke
  sessions; revocation includes background jobs) can't be safely covered, and a
  missed hook on a security path is worse than the win.
- AI re-embed coalescing: aiQueue.add gets {jobId: embed-<id>, delay: 30s} so
  active editing collapses to one job (worker reads current page state).
- filterAccessiblePageIds: hasRestrictedPagesInWorkspace short-circuit skips the
  recursive-ancestor CTE when a workspace has zero restricted pages (wired from
  search/favorites/notifications/recent/created-by). EXISTS on the same pageAccess
  table the CTE anti-joins → no false-positive / no access leak. Busts the cache
  on insertPageAccess so a 0->1 restricted transition takes effect immediately
  (review F1).
- Small: syncTransclusion guarded by a family-node probe (both old+new content, so
  the removal path is preserved); mention notifications enqueue only when the set
  gained a member; redis maintainLock clears a prior interval (leak fix).

Skipped as risky (flagged): global ValidationPipe transform change; a pool-wide
statement_timeout (would kill long CREATE INDEX migrations on the same pool).
NOTE: kept the trash query's `content` select — the trash UI reads page.content
for its preview modal (review F3, would have regressed).

Gate: server tsc 0; jest page-permission/auth/search/persistence 15 suites pass;
migration up+down+idempotency verified on real PG18 with EXPLAIN confirming index
use. No new deps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 04:32:38 +03:00

91 lines
3.2 KiB
TypeScript

import { Inject, Injectable, NestMiddleware } from '@nestjs/common';
import { FastifyRequest, FastifyReply } from 'fastify';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { EnvironmentService } from '../../integrations/environment/environment.service';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { Workspace } from '@docmost/db/types/entity.types';
import { withCache } from '../helpers/with-cache';
import { CacheKey, WORKSPACE_CACHE_TTL_MS } from '../helpers/cache-keys';
// #348 — timestamptz columns on the workspace row. The cache store (Keyv/Redis)
// JSON-serializes values, so a cached workspace comes back with these fields as
// ISO strings. Reviving them to Date keeps the cached path byte-identical to the
// direct DB path (postgres.js returns Date), so nothing downstream can observe a
// cache hit vs miss. Idempotent: `new Date(date)` on an already-Date value is a
// no-op-equivalent. Keep in sync with the workspace timestamptz columns.
const WORKSPACE_DATE_FIELDS: Array<keyof Workspace> = [
'createdAt',
'updatedAt',
'deletedAt',
'trialEndAt',
];
function reviveWorkspaceDates(workspace: Workspace): Workspace {
for (const field of WORKSPACE_DATE_FIELDS) {
const value = workspace[field];
if (value != null) {
(workspace as any)[field] = new Date(value as any);
}
}
return workspace;
}
@Injectable()
export class DomainMiddleware implements NestMiddleware {
constructor(
private workspaceRepo: WorkspaceRepo,
private environmentService: EnvironmentService,
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
) {}
async use(
req: FastifyRequest['raw'],
res: FastifyReply['raw'],
next: () => void,
) {
if (this.environmentService.isSelfHosted()) {
// #348 — cache the single-workspace lookup that runs on every request.
// Invalidated by every WorkspaceRepo mutator (see bustWorkspaceCache).
const workspace = await withCache(
this.cacheManager,
CacheKey.WORKSPACE_SELF_HOSTED,
WORKSPACE_CACHE_TTL_MS,
() => this.workspaceRepo.findFirst(),
);
if (!workspace) {
//throw new NotFoundException('Workspace not found');
(req as any).workspaceId = null;
return next();
}
reviveWorkspaceDates(workspace);
// TODO: unify
(req as any).workspaceId = workspace.id;
(req as any).workspace = workspace;
} else if (this.environmentService.isCloud()) {
const header = req.headers.host;
const subdomain = header.split('.')[0];
// #348 — cache per-subdomain workspace resolution. Keyed by subdomain (the
// hostname column); busted per hostname by every WorkspaceRepo mutator.
const workspace = await withCache(
this.cacheManager,
CacheKey.WORKSPACE_BY_HOST(subdomain),
WORKSPACE_CACHE_TTL_MS,
() => this.workspaceRepo.findByHostname(subdomain),
);
if (!workspace) {
(req as any).workspaceId = null;
return next();
}
reviveWorkspaceDates(workspace);
(req as any).workspaceId = workspace.id;
(req as any).workspace = workspace;
}
next();
}
}