Backend Architecture (apps/api)
NestJS modular monolith. Sibling docs: system · database · realtime · search · ai · security.
Invariants (from
ARCHITECTURE.md§3):
- Layering:
Controller → ApplicationService → Repository → PostgreSQL.- Modules communicate via injected application services or the event bus — never by importing another module's repository or Prisma client.
- Authorization is server-side, always, via the
PermissionGuard.- Side effects (search, webhooks, automations, notifications) are events, not calls.
- Services depend on repository interfaces; Prisma implementations live in
apps/api/src/common/repositories.
1. Module Table
Sixteen modules, each a NestJS module with controller / service / dto / repositories
subfolders. Key endpoint families (all under /v1):
| # | Module | Responsibility | Key endpoints |
|---|---|---|---|
| 1 | AuthModule |
register, login, logout, refresh-token rotation, password reset, email verification, SSO entry points (SAML/OIDC), session listing/revocation | POST /auth/register, POST /auth/login, POST /auth/refresh, POST /auth/logout, POST /auth/password-reset/request, POST /auth/password-reset/confirm, POST /auth/verify-email, GET /auth/sessions, DELETE /auth/sessions/:id, GET /auth/sso/:orgSlug/redirect |
| 2 | UsersModule |
profile, display name, avatar upload (→ FileModule), security settings (password change), locale/timezone/theme | GET /users/me, PATCH /users/me, PATCH /users/me/password, POST /users/me/avatar |
| 3 | OrganizationModule |
org CRUD, org members + roles, org policies (policy JSON), SSO provider config, SCIM server, retention policies |
POST /organizations, PATCH /organizations/:id, GET /organizations/:id/members, PATCH /organizations/:id/policy, GET /organizations/:id/scim/v2/* (SCIM) |
| 4 | WorkspaceModule |
workspace CRUD, members + role changes, invitations (issue/accept/revoke), teams (teamspaces) + team members, workspace settings | POST /workspaces, GET /workspaces?memberId=, PATCH /workspaces/:id, POST /workspaces/:id/members/:userId/role, POST /workspaces/:id/invitations, POST /invitations/:token/accept, POST /workspaces/:id/teams, PATCH /workspaces/:id/teams/:tid |
| 5 | PageModule |
page CRUD, nested tree with fractional positions, trash/restore/purge, favorites, page versions, sharing (delegates to ShareModule), icon/cover metadata | GET /workspaces/:wid/pages/tree, GET /pages/:id, POST /workspaces/:wid/pages, PATCH /pages/:id, POST /pages/:id/move (fractional), POST /pages/:id/trash, POST /pages/:id/restore, POST /pages/:id/favorite, GET /pages/:id/versions, POST /pages/:id/versions/:v/restore |
| 6 | BlockModule |
block CRUD, batch ops (bulk create/delete/move in one transaction), fractional positioning, synced blocks, block versions | GET /pages/:id/blocks, POST /pages/:id/blocks (single or {blocks: []} batch), PATCH /blocks/:id, POST /blocks/batch (moves/creates/deletes array), POST /blocks/:id/duplicate |
| 7 | DatabaseModule |
database CRUD, properties (22 types), views (9 types), records (record = page), the record query engine (filter/sort/group/paginate via packages/database-engine), batch record ops |
POST /workspaces/:wid/databases, GET /databases/:id, PATCH /databases/:id/properties/:pid, POST /databases/:id/views, POST /databases/:id/records, PATCH /databases/:id/records/:rid, POST /databases/:id/records/batch, POST /databases/:id/query (the workhorse: {viewId?, filter?, sort?, group?, page?, pageSize?}) |
| 8 | RelationModule |
relations (cardinality, bidirectional), relation links (m2m join), rollups, formulas (safe AST parse/evaluate — never eval), database buttons, recompute scheduling (event-driven → worker) |
POST /databases/:id/relations, PUT /databases/:id/relations/:rid/links (set a record's links), POST /databases/:id/formulas (validates + parses AST, infers returnType, records dependencies), POST /databases/:id/buttons/:bid/execute |
| 9 | FileModule |
presigned S3 upload (multipart for ≥ 100 MB), pipeline status (scan → metadata → thumbnail), download via short-lived signed URL, file trash/versions | POST /files (create File row + presigned PUT), GET /files/:id (status), GET /files/:id/download (signed URL), POST /files/:id/versions, DELETE /files/:id (soft) |
| 10 | CommentModule |
comment threads on page/block/selection/record, replies, resolve/reopen, reactions, mention extraction → MentionNotification + Notification | GET /pages/:id/comments, POST /pages/:id/comments, PATCH /comments/:id, POST /comments/:id/resolve, POST /comments/:id/reopen, POST /comments/:id/reactions, POST /comments/:id/replies |
| 11 | SearchModule |
query API over OpenSearch (full-text / prefix / fuzzy / hybrid), suggestions, recent searches per user | POST /search ({query, mode, types[], filters, page}), GET /search/suggestions?q=, POST /search/recent, DELETE /search/recent/:id |
| 12 | NotificationModule |
in-app notification list + unread count, mark read/read-all, channel preferences (in-app/email/webhook) | GET /notifications?unread=, POST /notifications/read-all, PATCH /notifications/:id/read, PATCH /notifications/preferences |
| 13 | AutomationModule |
automation CRUD (triggers/conditions/actions), activate/pause, manual trigger, run history + logs | POST /workspaces/:wid/automations, PATCH /automations/:id, POST /automations/:id/trigger, GET /automations/:id/runs, GET /automations/:id/runs/:runId/logs |
| 14 | AIModule |
chat (Ask AI), writing tools (summarize/rewrite/improve/translate), generation via structured tool calls (page/database/formula/template/tasks), RAG workspace search, usage + per-workspace budget enforcement | POST /ai/chat, POST /ai/capabilities/:capability (summarize |
| 15 | IntegrationModule (with ApiKeyModule, WebhookModule, ShareModule, TemplateModule) |
public REST API keys (scoped, hashed), OAuth2 authorization-code flow, webhook subscriptions + delivery log + manual replay, public share links (scope, password, expiry), workspace/database/page/team templates | POST /api-keys, DELETE /api-keys/:id, POST /oauth/authorize (code flow), POST /workspaces/:wid/webhooks, POST /webhooks/:id/replay, POST /shares (→ /share/:token), GET /share/:token, POST /workspaces/:wid/templates, POST /templates/:id/instantiate |
| 16 | AuditModule |
audit log query (filter by actor/action/resource/time), CSV export (streamed), retention enforcement hook (enterprise) | GET /audit-logs?workspaceId&actorId&action&from&to, GET /audit-logs/export (admin) |
Plus cross-cutting infrastructure (not domain modules):
| Area | Location | Contents |
|---|---|---|
| Guards | src/common/guards |
JwtAuthGuard, ApiKeyAuthGuard, ShareTokenGuard (public share), TenantGuard, PermissionGuard, RateLimitGuard, CsrfGuard |
| Interceptors | src/common/interceptors |
SerializeInterceptor (DTO mapping), AuditInterceptor, LoggingInterceptor, ResponseTimeInterceptor |
| Filters | src/common/filters |
AllExceptionsFilter (maps ApiError → JSON envelope) |
| Pipes | src/common/pipes |
ZodValidationPipe |
| Repositories | src/common/repositories |
all Prisma*Repository implementations (interface per domain) |
| Events | src/events |
EventBus (Redis streams producer), stream consumer bootstrap for api-side consumers |
| Config | src/config |
Zod env validation, feature flags |
2. Layering
┌──────────────────────────────────────────────────────────────────────┐
│ Controller (HTTP only: routing, @Body/@Param, guards declarations, │
│ response status codes, DTO in/out) │
│ e.g. PagesController.create(@Body() dto: CreatePageDto) │
├──────────────────────────────────────────────────────────────────────┤
│ ApplicationService (domain rules, orchestration, transactions, │
│ permission *decisions* consumed, event publishing, │
│ audit logging) │
│ e.g. PageService.create(ctx, dto): │
│ • validate invariants (parent exists, title length) │
│ • tx: pageRepo.insert + favorite? + auditLog.insert │
│ • events.publish('page.created', …) │
├──────────────────────────────────────────────────────────────────────┤
│ Repository (interface per domain; Prisma implementation; │
│ tenant scoping applied HERE — every query carries │
│ workspaceId; read-replica selection for @ReadReplica) │
│ e.g. interface PageRepository { listChildren(wid, parentId) } │
│ class PrismaPageRepository implements PageRepository { … } │
├──────────────────────────────────────────────────────────────────────┤
│ PostgreSQL (Prisma) · Redis (cache/events/queues) · S3 · OpenSearch │
└──────────────────────────────────────────────────────────────────────┘
Rules enforced in code review + lint:
- Controllers contain no Prisma calls, no business rules — route + DTO + guards.
- Services never import
PrismaClient— only repository interfaces, other services (allowed cross-module calls: Page→Block, Database→Relation, AI→any-read),EventBus,AuditService. - Repositories never make HTTP calls or publish events.
packages/*(shared, database-engine, permissions, search, ai, storage) are pure — they receive data in, return data out; no NestJS decorators.
2.1 Request Context
Every request gets a RequestContext (AsyncLocalStorage, built by middleware):
interface RequestContext {
requestId: string; // X-Request-Id (generated if absent)
traceId: string; // OpenTelemetry trace id (propagated into event envelopes)
principal: {
kind: 'user' | 'api_key' | 'system';
id: string; // userId or apiKeyId
email?: string;
};
workspaceId: string | null; // resolved by TenantGuard
organizationId: string | null;
userAgent: string;
ipAddress: string;
scopes: ApiKeyScope[]; // for api_key principals
}
Services receive ctx as the first argument (explicit, never this.req) — this is what
AuditService.log(ctx, …) and the permission guard read.
3. Request Lifecycle
HTTP request
│
▼
[1] NestJS router match
│
▼
[2] RequestContextMiddleware ── requestId/traceId, parse principal (cookie JWT / Bearer /
│ API key header), build RequestContext
│
▼
[3] Guard chain (Nest runs in registration order)
│ a. ApiKeyAuthGuard | JwtAuthGuard | ShareTokenGuard
│ → 401 UNAUTHENTICATED on failure
│ b. CsrfGuard (cookie-authenticated state-changing methods only;
│ token compared to cookie `factory_csrf`; Bearer/API-key requests exempt)
│ c. TenantGuard
│ → resolve workspaceId: from path :wid (membership checked) or
│ from JWT claim (API keys: key.workspaceId) → 403 if not member
│ d. RateLimitGuard
│ → Redis sliding window per (kind: user|api_key|ip, id, route-class)
│ → 429 RATE_LIMITED with Retry-After
│ e. PermissionGuard
│ → @RequirePermission('page', 'EDIT', {id: ':id'})
│ → packages/permissions.evaluate(ctx, resource) → 403 PERMISSION_DENIED
│ (+ audit row permission.denied for repeated denials, throttled)
│
▼
[4] ZodValidationPipe
│ DTO schemas from @factory/shared; 400 VALIDATION_ERROR with field-level detail
│
▼
[5] Controller method
│ → ApplicationService method (domain logic, tx, events, audit)
│
▼
[6] SerializeInterceptor ── entity → DTO (whitelisting, never leak internal fields)
│
▼
[7] Response 2xx { data, error: null, meta? } (or error envelope via [8])
[8] any thrown ApiError → AllExceptionsFilter →
{ error: { code, message, details? }, requestId } (+ stable HTTP status)
4. DTO / Zod Validation Pipeline
- One schema, two worlds. Every DTO is a Zod schema in
packages/shared/src/dtos/*(e.g.CreatePageSchema,RecordQuerySchema,WebhookCreateSchema). The frontendimports the same schemas for form validation (React Hook Formresolver); the API validates withZodValidationPipe. TypeScript types are derived (z.infer), so wire-format drift is a compile error. - Where validation runs:
ZodValidationPipeon every route: body, query, and params against the declared schema (NestJSValidationPipe+ our Zod adapter). Failures produceVALIDATION_ERRORwith{ field, code, message }[](Zod issue path → field name).- Domain validation in services — things schemas can't express: "parent page
must exist and not be trashed", "form question must map to a real property",
"formula parses to a valid AST". Thrown as
VALIDATION_ERRORor more specific codes. - JSONB deep validation —
content,properties,config,filterJSONB fields have dedicated per-type schemas inpackages/shared(e.g.BlockContentSchema.discriminatedUnion('type', …),FilterTreeSchemarecursive AND/OR/NOT). The database query engine validates view settings the same way.
- Normalization: pipe applies Zod
.transform(trim strings, coerce ids to cuid shape check, lowercase emails). Prisma receives already-typed values. - Prisma layer: parameterized queries only; no string SQL anywhere in application
code (raw SQL only in
prisma/migrations).
5. Permission Guard Flow
Guard: PermissionGuard + engine packages/permissions (pure, unit-tested, also used
by the search indexer to stamp grants into search documents — one evaluation
implementation everywhere).
5.1 Decorator
@RequirePermission('page', 'EDIT', { idParam: 'id' })
@RequirePermission('workspace', 'ADMIN', { scopeParam: 'wid' })
Resource kinds: workspace | teamspace | page | database | record | file | organization. Levels: NONE < VIEW < COMMENT < EDIT < FULL_ACCESS (ordinal
comparison; EDIT implies COMMENT/VIEW; FULL_ACCESS implies everything and adds
permission-management).
5.2 Evaluation algorithm
evaluate(ctx, resource: {type, id, workspaceId}):
1. Load PermissionDecisionCache (Redis, key perm:{wid}:{granteeIds}:{resType}:{resId},
TTL 60 s, invalidated on permission.changed event) — hit → return
2. Resolve grantee set: { user:ctx.userId } ∪ teams(user) ∪ { role: workspaceRole }
3. Load grants:
SELECT * FROM permissions
WHERE workspace_id = wid
AND (grantee_type, grantee_id) IN (grantee set)
AND (resource_type, resource_id) = (res.type, res.id)
PLUS, for inheritance: walk up the resource chain collecting grants:
page → parent pages (until workspace root) → workspace
database/record → the containing database → its containing page → …
(chain walked via pageId/parentId; max depth 50, cycle-guarded)
4. Base level from role floor:
OWNER → FULL_ACCESS, ADMIN → EDIT, MEMBER → EDIT (workspace-owned content),
GUEST → VIEW (the role only sets a FLOOR for workspace-level resources;
resource grants can grant more, and an explicit NONE
grant at any level VETOES inheritance — "explicit deny")
5. Final level = max(grants along chain) with explicit NONE veto;
for `record`: if the record page is trashed → level capped at VIEW (read-only)
6. Owner bypass: workspace owner always FULL_ACCESS; page.createdBy gets
inherited EDIT unless an explicit grant says otherwise
7. Write decision to cache; return { level, reason } (reason logged in decision log)
5.3 Guard integration & decision log
- The guard calls
evaluatewith the declared level; insufficient →PERMISSION_DENIED(403) with areason(no_grant,explicit_none,guest_capped, …). - Decision log: every evaluation for state-changing requests is appended to
audit_logsonly when the outcome is DENIED (denials are the interesting signal); allowed operations are audited through the normal action audit (page.created, …). Denial audit is throttled (1 row per 5 s per actor+resource) to avoid DoS via permission probes. - The guard also scopes reads: list endpoints pass the evaluated level into the
query (e.g.
pages/treefilters to pages with level ≥ VIEW;databases/:id/queryhides columns the caller can't see if property-level visibility is later introduced — MVP: visibility is at the resource level).
6. DTO Conventions & Response Envelope
// success
{ "data": { … }, "error": null, "meta": { "cursor": "eyJwYWdlIjo1fQ" } }
// error (HTTP status always matches the code class)
{
"error": {
"code": "VALIDATION_ERROR", // stable machine code
"message": "Title must be at most 200 characters.", // human-safe, no internals
"details": [ { "field": "title", "code": "too_big", "message": "max 200" } ]
},
"requestId": "req_01J…"
}
- Pagination is cursor-based everywhere (
meta.cursoropaque token = base64 of{id, position}) — no offset pagination for tenant data. - Versioning: URL prefix
/v1; breaking changes get/v2(contract tested in CI).
7. Domain Event Bus
7.1 Transport
- Producer:
EventBus(apps/api/src/events/event-bus.ts), also linked fromapps/realtime(persist events) andapps/worker(some actions re-emit).XADD factory:events * stream-type <event-type> envelope <JSON>withMAXLEN ~ 500000(approximate trim; consumers ack their own groups). - Consumers: consumer groups, one per consumer (
XREADGROUP GROUP <group> <consumerId>withCOUNT 32,XACKafter idempotent processing,XAUTOCLAIMevery 60 s for stale messages > 30 s). All consumer loops run inapps/worker(separate process, scales independently). - Guarantee: at-least-once. Consumers MUST be idempotent (upsert keyed by
envelope.eventIdor resource state). Ordering: no cross-consumer ordering; within a consumer, per-stream ordering — resource-level ordering where it matters is achieved by making the consumer op itself idempotent and final-state-based. - Dead-letter: 3 consecutive processing failures on one message →
XADD factory:events:deadwith the envelope + error; alert on group depth (see system, §6.3).
7.2 Event envelope
// packages/shared/src/events/envelope.ts — single schema for ALL domain events
interface DomainEventEnvelope {
eventId: string; // cuid, unique, used for idempotency
type: string; // e.g. "page.created"
version: 1; // schema version of the payload
workspaceId: string;
actor: { kind: 'user' | 'api_key' | 'system'; id: string };
resource: { type: string; id: string }; // e.g. {type: 'page', id: '…'}
payload: Record<string, unknown>; // per-type schema (below)
occurredAt: string; // ISO-8601, server clock
traceId: string; // OTel trace id from the originating request
}
Event catalog (payloads abbreviated):
type |
Emitted by | Key payload fields |
|---|---|---|
user.registered / user.login / user.logout |
AuthModule | userId, ip (login) |
workspace.created / workspace.member.added / workspace.member.removed |
WorkspaceModule | workspaceId, userId, role |
page.created / page.updated / page.deleted / page.shared / page.trashed / page.restored |
PageModule | pageId, parentId, title, delta? (block change summary) |
block.updated |
BlockModule / Realtime persist | pageId, blockIds[], op (created/moved/edited/deleted) |
database.created / database.updated |
DatabaseModule | databaseId, changedProperties? |
record.created / record.updated / record.archived |
DatabaseModule / forms / automations | databaseId, recordId, pageId, changedProperties[] |
relation.linked / relation.unlinked |
RelationModule | databaseId, recordId, targetDatabaseId, targetRecordId |
comment.created / comment.resolved |
CommentModule | commentId, pageId?, mentionIds[] |
file.uploaded / file.deleted |
FileModule (worker pipeline) | fileId, pageId?, mimeType |
form.submitted |
Forms (DatabaseModule) | formId, recordId, answersHash |
automation.executed |
AutomationModule (worker) | automationId, runId, status |
permission.changed |
PermissionGuard-adjacent services | resource{type,id}, grantee{type,id}, level, actorId — invalidates permission + search caches |
api_key.created / api_key.revoked |
ApiKeyModule | apiKeyId, prefix |
webhook.created / webhook.delivered / webhook.failed |
WebhookModule | webhookId, deliveryId, statusCode |
share.created / share.revoked |
ShareModule | shareToken, scope |
7.3 Consumer groups & responsibilities
| Group | Worker | What it does |
|---|---|---|
search-index |
search-indexer | upsert/delete search_documents mirror + OpenSearch doc (incl. permission stamp); page/block events also feed the AI chunker/embedder |
webhooks |
webhook-dispatcher | match webhooks.events subscriptions → sign payload (HMAC-SHA256, per-webhook secret) → BullMQ webhook-deliveries (ordering per webhook, backoff 1 m → 2 h, 10 attempts → REJECTED + alert) |
automation |
automation-engine | trigger matcher (event type + Automation.databaseId scope) → condition evaluator → enqueue AutomationRun actions into BullMQ automation |
notifications |
notification-fan-out | comment.created/mentions/assignments → Notification rows (channel IN_APP) + BullMQ email for EMAIL channel; PAGE_SHARED on share events |
ai-index |
ai-indexer | page.created/updated, database.created → chunk → embed (AI gateway) → OpenSearch knn vector field + ai_embeddings bookkeeping (see ai, §5) |
audit |
audit-flusher | best-effort secondary sink for audit events that couldn't be written in-transaction (e.g. from realtime persist) — keeps audit complete |
8. Error Model
Typed ApiError hierarchy (packages/shared/src/errors.ts), thrown by services,
mapped by AllExceptionsFilter:
class ApiError extends Error {
constructor(readonly code: ErrorCode, readonly httpStatus: number,
message: string, readonly details?: unknown, readonly cause?: unknown) {}
}
// subclasses: ValidationError, NotFoundError, PermissionDeniedError,
// ConflictError, RateLimitedError, UnauthenticatedError, ExternalServiceError
Full code table (stable contract for clients and the public API):
| Code | HTTP | Meaning | Typical cause |
|---|---|---|---|
VALIDATION_ERROR |
400 | input failed schema/domain validation | Zod issue, bad JSONB shape, unknown enum value |
UNAUTHENTICATED |
401 | missing/invalid/expired credentials | bad JWT, rotated-out refresh token, expired API key |
PERMISSION_DENIED |
403 | authenticated but insufficient level | guard denial, guest cap, explicit NONE grant |
NOT_FOUND |
404 | resource missing or not visible to caller (do not leak existence) | bad id, trashed+purged, other tenant, share expired |
CONFLICT |
409 | state conflict | optimistic version mismatch, duplicate unique (slug, email, (recordId, propertyId)), refresh reuse detection (session revoked) |
RATE_LIMITED |
429 | sliding-window limit exceeded | per user/API key/IP; Retry-After header set |
CSRF_VIOLATION |
403 | missing/invalid CSRF token on cookie-auth mutation | cross-site form post |
FILE_TOO_LARGE |
413 | exceeds plan/workspace limit | upload over usageLimits.maxFileSizeMb |
FILE_VIRUS_DETECTED |
422 | ClamAV hit | file pipeline |
SUSPENDED |
403 | user/account suspended | User.status = SUSPENDED |
EMAIL_NOT_VERIFIED |
403 | action requires verified email (e.g. invite send) | login ok, action blocked |
INVITATION_EXPIRED / INVITATION_INVALID |
409/404 | invitation token state | token expired, revoked, already accepted |
PASSWORD_POLICY_VIOLATION |
400 | password fails org policy | length/complexity rules |
FORM_CLOSED |
409 | form not accepting submissions | Form.status = CLOSED |
BUDGET_EXCEEDED |
402 | AI token budget exhausted for workspace | per-workspace budget (see ai, §7) |
PROVIDER_ERROR |
502 | upstream AI provider failed/timeout | provider 5xx, rate limit, malformed tool call |
SEARCH_UNAVAILABLE |
503 | OpenSearch down | fail-open: return 503 with code so UI degrades to "search unavailable" |
REALTIME_UNAVAILABLE |
503 | realtime service down | UI falls back to REST polling (read-only mode) |
INTERNAL_ERROR |
500 | unhandled | logged with stack + requestId; message is generic ("Something went wrong") |
NOT_IMPLEMENTED |
501 | endpoint not available on this plan/phase | plan-gated features |
Rules: (1) codes are frozen contract — adding is fine, changing meaning is a major
version; (2) 5xx messages never leak internals (stack goes to logs + requestId);
(3) 404 vs 403: for resources that exist but aren't visible, always NOT_FOUND
(existence leakage is a vuln); (4) every CONFLICT from version mismatch includes
the current version in details so the client can rebase.
9. Audit Service
- API:
auditService.log(ctx, { action, resourceType, resourceId, pageId?, metadata? })— writes anAuditLogrow. - Same-transaction rule: when the audited mutation runs in a Prisma transaction,
the audit insert joins that transaction (audit row commits or rolls back with the
change — no audit without the change, no change without audit). Events emitted outside
transactions (e.g. realtime persist) are emitted as
audit.*domain events consumed by theauditgroup (final write, idempotent byeventId). - Mandatory events (enforced by a lint/test checklist in CI — see
security, §9):
user.login,user.logout,user.password_reset,user.registered,page.created,page.updated,page.deleted,page.shared,permission.changed,workspace.member.added/removed,database.created,record.created,record.updated,file.uploaded,automation.executed,api_key.created,api_key.revoked,sso.login,scim.user.created/updated/deactivated,share.created/revoked. - Row shape:
actorType(USER/API_KEY/SYSTEM/INTEGRATION),actorId,workspaceId,pageId,resourceType,resourceId,action,ipAddress,userAgent,metadata(JSON: before/after diff for permission changes, old/new level, share token hash, etc.),createdAt. - Retention & access: enterprise retention policies purge or hold per
retention_policies; the AuditModule viewer requiresworkspace ADMIN(or org policyaudit.viewer); export is streamed CSV (no full-table loads).
10. Rate Limiting
Redis sliding window (not fixed buckets — no burst at boundaries), implemented in
RateLimitGuard + packages/shared/src/rate-limit.ts (counter via Lua script for
atomicity: INCR + PEXPIRE on the window key).
| Scope | Key pattern | Default limit | Notes |
|---|---|---|---|
| Auth endpoints (login/register/reset) | rl:auth:{ip}:{userId?} |
10 / 15 min per IP, 5 / 15 min per account | brute-force protection; 429 + generic message (no "wrong email or password" distinction beyond the normal UNAUTHENTICATED) |
| Per-user API | rl:user:{userId}:{routeClass} |
300 / min | route class = coarse family (read, write, upload) |
| Per-API-key | rl:key:{apiKeyId}:{routeClass} |
key.rateLimit ?? 600 / min | rateLimit column on api_keys overrides |
| Per-IP (unauthenticated) | rl:ip:{ip} |
60 / min | share pages, forms, login |
| AI endpoints | rl:ai:{userId} + budget |
30 / min + token budget | see ai, §7 |
| Public form submissions | rl:form:{formId}:{ip} |
5 / 10 min | spam control |
| WebSocket connect | per-IP in realtime app | 30 / min new connections | separate enforcement in apps/realtime |
Behavior: on exceed → 429 RATE_LIMITED with Retry-After seconds; limits are
configurable per workspace via workspace.settings.rateLimits (enterprise override);
all 429s increment rate_limited_total metric for alerting.
11. Caching Strategy
Redis-only (no local process caches for shared state — replicas must agree).
| Cache | Key | TTL | Invalidation |
|---|---|---|---|
| Page tree (sidebar) | tree:{wid}:{userId} (visibility-filtered subtree) |
120 s | page.created/updated/deleted/trashed/shared + permission.changed for the workspace (event-driven DEL of the workspace prefix) |
| Permission decisions | perm:{wid}:{resType}:{resId}:{granteeSetHash} |
60 s | permission.changed event → DEL all perm:{wid}:* (prefix scan bounded; workspaces have < 10k grants) |
| Search suggestions | sugg:{wid}:{q-prefix} |
300 s | search-index consumer group DEL on doc change (bounded by doc count per workspace) |
| Workspace + member profile | ws:{wid}, user:{uid} |
300 s | profile/workspace update events |
| Database schema (properties+views) | dbmeta:{dbId} |
300 s | database.updated |
| File signed-URL map | not cached (URLs short-lived, 5 min) | — | — |
| AI: RAG context (top chunks) | rag:{wid}:{q-hash} |
600 s | index change events (DEL prefix rag:{wid}:* throttled) |
Rules: (1) cache-aside everywhere (read-through, write-invalidate — never
write-through to cache); (2) all cache writes set TTL (no immortal keys); (3) a cache
miss falls through to Postgres — cache outage degrades to slower, never to wrong
answers; (4) tenant isolation by key prefix (wid in every key) — no cross-tenant
eviction or read; (5) metrics: cache_hit_ratio{cache=…} per cache, alert below 60 %
on the hot ones (tree, permissions).
12. Transactions & Concurrency
- Prisma interactive transactions for multi-row invariants: block batch ops (create/move/delete array in one tx), record create (record + page + property rows), record query that also writes (none — queries are read-only), relation link set (delete+insert links), button action (read record → mutate → write run log).
- Optimistic concurrency:
pages.version,blocks.version,database_records.version— updates carryexpectedVersion; mismatch →CONFLICTwith the current version. The Yjs path bypassesversionfor text (CRDT merges) but the persist step uses a CAS on(pageId, lastYjsClock)to serialize realtime-vs-REST concurrent writes (realtime persist wins; REST mutation sees the fresh state). - Long-running work never holds a DB transaction: file pipeline, automations,
webhooks, rollup recompute are BullMQ jobs that open short transactions per step and
coordinate via state columns (
file.status,automation_runs.status). - Deadlock policy: Prisma
P2034→ retry once with backoff (100 ms), thenINTERNAL_ERROR; deadlocks are logged with the query context (should be rare given tx ordering rules: always lock parent before child, record before properties).
13. Observability (api)
- Tracing: OpenTelemetry SDK;
traceIdinRequestContext→ into event envelopes → into BullMQ job metadata → into OpenSearch index metadata, so one user action is a single trace across api → worker → realtime. - Metrics (Prometheus
/metrics):http_request_duration_seconds{route_class,code},db_pool_active,events_published_total{type},cache_hit_ratio{cache},rate_limited_total{scope},ai_tokens_total{model,kind},ws_connections(realtime app). - Logging: structured JSON (pino):
requestId,traceId,userId,workspaceId,route,durationMs,code; sensitive fields redacted by a redactor (passwords, tokens, emails in non-audit logs).
14. AI Page-Generation Executor (TASK-0126)
The AI "Generate page" feature (POST /workspaces/:id/ai/generate) is the one
write path where an LLM produces a program. It is built so the model never
touches the database and never runs with more permission than the requester:
- Plan, then execute. The model emits a ref-based plan — an ordered list
of
createPage/insertBlocks/createDatabase/createRelationtool calls — which is zod-validated (packages/aiparseGeneratePlan;createDatabasereuses the database-AI schema) before anything runs. The executor walks the plan sequentially and executes each step through the existing domain services (page.create,block.create,database.create+createProperty+record.createRecord,relation.create). There is no separate "AI write path": every step is the same call a user action makes, so each one re-checks permissions server-side. The endpoint'sparentIdseeds the firstcreatePage; later pages nest under the root andcreateDatabase's ref resolves to it. - Rollback set, not a transaction. Writes span multiple services and the
rollback must only undo this run's resources (pre-existing user content is
never touched). The executor accumulates a
createdRefsset in creation order; on a failing step, timeout, or client cancel it rolls back in reverse — databases viadatabase.remove(hard delete, cascades its page/records; falls back topage.trash), pages viapage.trash(cascades descendants). Best-effort: one rollback failure is logged, not thrown. - One terminal SSE frame per stream. The stream carries per-step
toolprogress then exactly one ofdone/cancelled/error. Total is capped at 120 s (AI_GENERATE_TIMEOUT_MS, per-call) →ai_timeout+ rollback; the client's SSE abort → executor stops after the current step, rolls back, and emitscancelled. - Idempotency (service-level). The generate route is
@SkipIdempotency()— the global body-replay interceptor cannot serve an SSE stream. Instead the service keeps a 24 h Redis memo of a successful run's{pageId, created, tokensIn, tokensOut}keyed by the client'sIdempotency-Key, and re-emits the terminaldoneon a retry (SSE-safe; a cancelled/failed run is not memoized). - Cost + audit. One hidden per-call conversation (
metadata.feature:"generate",generate:title, excluded from the panel list) carries the token ledger; auditai.page_generated/ai.generation_failed {failedStep, reason}.