Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]
Added
-
Syntax-highlighted code blocks ("code is not colorful"): the Code block now renders Prism token spans instead of plain monochrome text, in BOTH modes — the display
<pre>and the edit overlay. Editing uses a transparent-text<textarea>over a live highlighted<pre>(identical font metrics, scroll-synced), so code is colored while you type and paste. New moduleapps/web/src/lib/code-highlight.ts(manual-mode Prism; maps the 11 dropdown languages —html→markup, unknown/plaintext → raw escaped text, never broken markup) +CodeBlockrework inblock-renderer.tsx(now exported; 5 render tests pin display tokens, live edit-overlay re-highlight, queueChange on keystroke, blur/Escape commit). Token colors are design tokens (--factory-code-*in globals.css, dark-code-canvas palette) per the no-hard-coded-colors convention. New dependency:prismjs1.30 (+@types/prismjsdev) — justified per AGENTS.md rule 17: no highlighter existed in the monorepo (grep-verified), Prism core + 7 language components is ~15 KB gzipped client-only (no server, works offline in the bundle), and it is the smallest mature highlighter matching the existing 11-language dropdown. SSR-safe (verified: core import + component loads throw nothing in Node;Prism.manualdisables its DOMContentLoaded auto-pass). -
Block-level copy/paste import from Notion/Word/Docs (QA #1/#2): pasting structural HTML (lists, indented lists, toggles, headings, quotes, rules, checkboxes) into a page now imports it as real blocks with the source's parent-child structure preserved — instead of a single flat paragraph or a hard error. New module
apps/web/src/lib/clipboard-import.ts(parseNotionHtmlwalksul/ol/litrees — a nestedlibecomes a child of its item;li[data-notion=toggle]with a nested list becomes a TOGGLE with the nested list inlined as its children;li > input[type=checkbox]→ todo withcheckedstate;h1–h3/blockquote/hr/p/tables map to their block types) pluspasteHtmlToBlocks/pasteTextToBlocksentry points, the internal multi-block clipboard payload (copySubtreeToClipboardPayload+parseClipboardText,appmarker, freshblk_…client ids, parents-before-children order) andhtmlHasBlockStructure(inline-only HTML stays with the focused InlineEditor — plain text paste is unchanged).page-view.tsxowns the paste dispatch andrunPasteImportupserts the parsed tree in batch requests (chunked at the 200-op cap), chaining each block after its previously-pasted sibling so the server orders them in one transaction. Context-menu Copy now copies the block's whole subtree; Paste inserts the payload below the block; Duplicate and sibling-insert use id-anchors (afterBlockId/beforeBlockId) — no client fractional position math. -
Toggle list fixed end-to-end (QA #3):
ToggleBlockis fully controlled (state fromproperties.collapsed, native<details>toggle disabled, chevron is a real button persisting the fullpropertiesbag, Enter in the toggle title creates a CHILD inside the toggle instead of escaping to top level); children render as their own block rows with Enter/arrow navigation scoped to the toggle. -
Convert block from the ⋮ menu + working "Turn into" (QA #4): the three-dots block toolbar now offers Turn into… (same grouped TypeMenu as the "Aa" button), and every conversion invalidates the page's block query so the row re-renders immediately (the old fire-and-forget update never invalidated, so "Turn into" appeared dead). Type conversion preserves content and clears type-specific properties server-side.
-
Recoverable error states (QA #5/#6): route-level
error.tsx+ rootglobal-error.tsx(self-contained, inline styles) so a runtime crash renders a "try again / go home" card instead of a blank shell; the page route layout shows "Couldn't load this page" + retry on a failed page query instead of an infinite spinner. -
Unit tests:
apps/web/src/lib/__tests__/clipboard-import.test.ts(17 tests: heading/quote/rule mapping, list indentation → parent-child, ordered lists, checkbox → todo + checked, Notion toggle nesting, nested toggles, div-wrapped lists, table → text, payload round-trip, legacy single-block shape, non-JSON,htmlHasBlockStructure, id generation). -
QA regression E2E (
tests/e2e/specs/qa-paste-toggle.spec.ts, 6 tests, all green on the live stack vianotion.filps.com): Notion-HTML paste preserving indentation + toggle parent-child (#1/#2), a 271-block paste chunked across the 200-op batch cap with the success toast (#1), toggle expand/collapse via the chevron + Enter creating a child inside (#3), ⋮ → Turn into → Heading 1 with server-persisted re-render (#4), context-menu Duplicate/Copy/Paste-below with zeropageerrors (#5), and page navigation loading bodies + the recoverable "Couldn't load this page" card on a bad id (#6). Runs are self-cleaning (scratch pages deleted; per-run block ids, since block ids are globally unique). -
Colorful F1Soft-inspired theme for the web app (
apps/web/src/app/ globals.css+factory-logo.tsx+app/(auth)/layout.tsx): the flat black/white shadcn palette is replaced with the f1soft.com brand language — extracted from the site's own archived CSS: vivid fintech blue (#2299dd, kept on--ring/focus where it never carries text), a slightly deepened brand blue (#1976c2, 4.76:1 with white) as--primaryfor AA-compliant button labels, crimson (#a31e21) destructive, on a pale blue-white canvas (#f4f9fd) with navy ink (#0d1720) text; the dark theme becomes the site's deep-navy surface family (#0d1720/#14202b) with the bright blue as primary (navy ink on blue = 5.75:1). New full-color brand tokens (--factory-brand-blue,-navy,-red,-gold,-coral, and the--factory-brand-gradientblue→navy-blue) drive the brand mark (FactoryLogo brand) in the sidebar + auth screens and the soft tinted gradient backdrop on the auth layout. Design tokens stay the single source of truth — no component color hard-coding; the existing colorful--factory-color-*block/database tints now read against a tinted canvas instead of flat white. Verified: webnext buildCSS compiles, compiledlayout.csscarries the new tokens,tsc --noEmitadds zero new errors. -
Phase 14 (Enterprise) — TASK-0133: audit viewer, org policies, data retention, org admin console: the org admin console at
/org/{orgId}/ admin(8 tabs: overview, members, integrations, policies, retention, API keys, webhooks, audit) gated byrequireOrgAdmin(OWNER/ADMIN membership — everyone else, cross-org included, gets the same403 requires_org_admin). Inside it:- Audit viewer — org-scoped filterable log (the org scope is
workspaceId IN org's workspacesOR the org-level rows —@factory/shared/events/audit-scopeis the single source shared by viewer, export, and retention), a row drawer, and CSV export: ≤ 10k rows return inline (200 text/csv, 13-column RFC 4180), above → async worker job (500k-row cap,truncatedflag, one active export per org) that streams to the files bucket (exports/{orgId}/{exportId}.csv, 7-day lifecycle) and the status endpoint mints a strictly single-use, 15-minute presigned URL (first ready-GET flipsdownloadUsed). Every export is audited (audit.export.completed / failed). - Org policies — one policy per org with the five enforcement
points, each tested: login 2FA (
202 twoFactorRequired), SSO enforce (403 sso_required), guest public-share block (403 guest_public_share_disabled), upload cap (413at the org ∩ plan effective size — the member-facingGET /workspaces/:id/policy/ effectivefeeds the file picker's pre-pick warning and the share page's notice), and session lifetime (401 session_expired). Policy writes are audited with old/new values (the change trail is the history — no version table in v1). - Retention — per-scope policies (trash / comments / audit_logs /
files; PURGE + retainDays or HOLD), the nightly worker job (dry-run by
default —
WORKER_RETENTION_EXECUTEgates only the cron's unattended mode; a deliberate console "Execute" purges regardless), batched audited purges (retention.purgedper batch), per-scope isolation, and per-run reports (Redis, 7 d) the console polls; dry-run vs execute stampslastDryRun/lastRun.audit_logsdefaults to HOLD (legal hold — audit rows are never purged by default) and files default to no-purge. - Members — role change (never to OWNER; the last owner is
protected) and suspend/unsuspend (the shared
User.statuspath — login + in-flight blocked, memberships kept, reversible). - Tests: unit (policy schema bounds/defaults/plan-cap interaction, audit
CSV writer, retention purge sets, cursor pagination + cap truncation
with an injectable cap), integration (audit completeness catalog 20/21
— the one skip is the MinIO-gated
file.uploaded; the audit viewer filter/export matrix sync+async; policy enforcement ×5; the retention engine dry/execute/hold/isolation; the org-admin guard matrix), and the four-part phase E2Etests/e2e/specs/org-admin.spec.ts(SAML → console access + the MEMBER role gate, the SCIM lifecycle + a suspend/unsuspend round-trip in the console, the policy block with the share-page notice, and a retention dry → execute that actually purges the seeded trash) green on desktop + mobile. Docs:docs/api/org-admin.md,docs/ui/org-admin.md,docs/security/security-architecture.md§11.2–11.3,docs/development/deployment.md§10.7.
- Audit viewer — org-scoped filterable log (the org scope is
-
Phase 14 (Enterprise) — TASK-0131/0132: SSO (SAML + OIDC) & SCIM provisioning: org SAML/OIDC providers with IdP metadata discovery and a test-connection flow, the login-page SSO surface (email discovery, enforce banner, recovery), JIT provisioning + enforce mode; SCIM 2.0 user/group provisioning (the
scim_…bearer token, deactivation =SUSPENDEDwith the in-flight-session block added to the JWT guard, groups → Teams). Seedocs/api/sso.md,docs/api/scim.md,docs/development/deployment.md§10, and the phase task file. -
Phase 12 (AI) — TASK-0126: AI page generation + Phase-12 E2E & security suite:
POST /api/v1/workspaces/:id/ai/generate(SSE) — "Create a product launch plan" → the LLM emits a ref-based structured plan (createPage/insertBlocks/createDatabase/createRelation) that the executor runs sequentially through the existing domain services (page.service/block.service/database.service/relation.service) under the requester's permissions — no raw SQL, no elevated context, every step re-checks. A failing step rolls back thecreatedRefsset in reverse (databases viadatabase.removefalling back topages.trash; pages viapages.trash), preserving user-owned content, and returns422 generation_failed {failedStep}. A 120 s total cap (AI_GENERATE_TIMEOUT_MS, per-call overridable) →503 {reason:"ai_timeout"}+ rollback; a client cancel (SSE abort) stops after the current step, rolls back, and confirms with a finalcancelledframe; a retriedIdempotency-Keyre-emits the terminaldonefrom a 24 h Redis memo (no double-create). Cost in a hidden per-call conversation (metadata.feature:"generate"); auditai.page_generated/ai.generation_failed. UI:GeneratePageDialog(live step checklist with per-step status, cancel, error banner + Retry, cancelled state) wired from the command palette, the page context menu, and the Home empty-state card; success navigates to the generated page; the entry is hidden for GUEST (the "viewer" — MEMBERS may legitimately generate). Tests: integrationai-generate.integration.test.ts6/6 (golden structure, rollback-on-failure, permission-context rejection, idempotent retry, 2 s timeout, viewer 403) + unitexecutor.test.ts8/8; golden E2Etests/e2e/specs/ai-generate.spec.ts2/2 (both projects: 6 exactHEADING_2sections + Tasks database with a Title + Status select + Due date + 2 sample records, asserted field-by-field via the API); the cross-cutting Phase-12 AI security suitetests/e2e/specs/ai-security.spec.ts8/8 (prompt injection leaks no env secret or key-like string, viewer 403 + UI hidden, budget exhaustion 402 on all four AI endpoints + one admin notification per 24 h, cancel rolls back to zero pages). The full Phase-12 AI suite is 36/36 (chromium-desktop + chromium-mobile) under the CI env (AI_PROVIDER: mock,AI_MOCK_DELTA_DELAY_MS: 60,INTERNAL_TEST_SEAM: 1) and is wired into the CI contract viapnpm test:e2e. Docs:docs/api/ai.md§10 +docs/architecture/backend-architecture.md§14. -
Phase 12 (AI) — TASK-0125: RAG workspace search (chunking + embeddings + hybrid cited answer): retrieval-augmented search over the workspace. The worker
aiqueue (ai-embedding.ts) chunks pages/records (≤1024 tokens, 128 overlap), embeds them via the gateway, and merges the vectors onto the Phase-8 singlefactory-contentdocs (aknn_vectorfield, partial_update) + theai_embeddingsregistry — no separateai-chunksindex (one index, one Phase-8 visibility snapshot). A nightlyai:reembedre-embeds every registry row in place; a model/dimension change surfaces as anai.reembed_requireddrift alert (alias-rollover script is the fix). Query pathPOST /api/v1/workspaces/:id/ai/search(SSE): the question is embedded and run as a two-pass hybrid — a BM25 leg and aquery.knnleg, BOTH filtered by the same Phase-8 tenant+visibility bool (FAISS pre-filter), fused by reciprocal-rank fusion (k=60) → top-12, de-duplicated to the parent source → a streamed answer with a sources-first SSE frame + citation chips (each a deep link to the page/record). OpenSearch 2.15 constraints recorded:query.knn.<field>only (top-levelknnis a 400), FAISS/l2 (nmslib can't filter; l2 ≡ cosine for unit vectors), nonum_candidatesinquery.knn. Pre-stream errors are plain JSON:404(non-member workspace, the viewer's fail-closed rule),402 AI_BUDGET_EXCEEDED,429 RATE_LIMITED(shared 30 calls/min AI cap). UI:AskAiSearchon/w/:slug/ask+ the search page (streaming markdown answer, inline citation chips + Sources list,aria-liveanswer, empty + error states, mobile). Cost is in the usage ledger (metadata.feature:"rag"). Tests: integrationai-rag.integration.test.ts6/6 (cited answer, tenant isolation, the permission filter on both legs, non-member 404, persistence, rate-limit 429); E2Especs/ai-rag.spec.ts2/2 (seeded fact in the answer + the citation deep link renders the page); unit@factory/ai94/94 +@factory/search91/91. -
Phase 12 (AI) — TASK-0124: Database AI (generate database / extract data / generate formula): four new endpoints —
POST /api/v1/workspaces/:id/ai/databases/generate(SSE),POST .../ai/databases/extract(SSE),POST .../ai/databases/extract/preview(JSON mapping dry-run, no LLM / no write) andPOST .../ai/databases/formulas(JSON). Each action drives one structured tool call (createDatabase/insertRecords/generateFormula) validated against the shared zod schema in@factory/aibefore any write; an invalid structure is retried once with the parse error as feedback, then fails422 ai_invalid_structure(formula:422 formula_invalid {parseError?}) with no partial writes — a failed generate leaves no orphan page/database/record. Generated properties are limited toGENERATABLE_PROPERTY_TYPES(all 24PropertyTypevalues minusTITLE/RELATION/ROLLUP/FORMULA/BUTTON— the spec's "22 types minus relation/rollup/button/formula/extraction" mapped onto the real enum). Extract: pasted text/markdown ≤ 50k chars (413) → ≤ 200 records, columns matched to existing properties by name, unmatched columns reported asignoredColumns(live preview before applying). Formula: the candidate expression is parsed + type-checked + sample-evaluated by the safe formula engine before the column is saved (nevereval'd as JS); aFORMULAproperty is created through the normal property service. All writes go through the normal database/property/record services under the caller's permissions (EDIT server-side; VIEW-only →403 FORBIDDEN); audit rowsai.database_generated/ai.records_extracted/ai.formula_generated; cost logged asAiMessagerows in hidden per-call conversations (metadata.feature ∈ {db.generate, db.extract, db.formula}). UI: the database-header "AI" dropdown (Generate from prompt / Extract from text with live mapping preview / Generate column with formula with a sample-evaluation table), loading skeletons + canonical error banner; write actions hidden for non-EDIT roles (server still enforces). Pre-stream errors are plain JSON:403 FORBIDDEN,429 RATE_LIMITED(shared per-user 30 calls/min AI cap),402 AI_BUDGET_EXCEEDED,413 PAYLOAD_TOO_LARGE,400 VALIDATION_ERROR. This task also repaired four pre-existing API gaps the E2E exposed:POST /databases/:id/query(documented atdocs/api/databases.md§6 but never implemented — every database table view rendered zero rows),useDatabaseRecords's react-query v4getPageParam→ v5getNextPageParam(a successful records page used to crash the whole database page),GET /workspaces/:idmissingrole(dead web role gate) and id-only resolution while the web navigates by slug (bounced users to/w/default). Docs:docs/api/ai.md§8 +docs/product/functional-requirements.md§16. Verification: integrationai-database.integration.test.ts8/8; E2Eai-database.spec.ts8/8 (generate → records visible as pages, extract → rows mapped + unmatched skipped, formula → column computes, viewer 403 + UI hides write actions — desktop + mobile); unitsdatabase-ai.test.ts36 +packages/ai83/83; regression api unit 209/209, web unit 178/178, api typecheck 0, web typecheck below the 127-error baseline with zero errors in touched files. -
Phase 12 (AI) — TASK-0123: Writing tools (summarize / rewrite / improve / translate with accept-diff): two new endpoints —
POST /api/v1/workspaces/:id/ai/writing(SSE suggestion; context assembled server-side from block ids — selected blocks ± 5 neighbours per page, capped at 50 blocks / 100k chars) andPOST .../ai/writing/apply(per-block, through the normalBlockService.update: EDIT re-checked per page, suggestion-timebaseVersion→409 CONFLICT {actualVersion}+ UI rebase prompt, forged/unknown block ids dropped + noted or rejected422 unknown_block_id, subset "accept one"). ABlockService.updatebaseVersionCAS is added (optional — the editor's existing unconditional path is unchanged). The model emits one structuredgenerateBlockstool call validated by a shared zod schema (retry-once, then422 ai_invalid_structure); the suggestion persists as a hidden per-call conversation (title:"writing:<action>", filtered from history) withtokensIn/tokensOuton theai_messagesrow. UI: ⌘/Ctrl multi-select → floating selection toolbar + the block ⋮/context "Ask AI → Writing tools" submenu (shared four-action menu + translate language picker incl. "Other…"), arole=dialogword-level diff with Accept all / Accept one / Discard, a version-conflict rebase prompt, and anaria-liveresult summary. Pre-stream errors are plain JSON:422 too_many_blocks|no_editable_blocks,413 PAYLOAD_TOO_LARGE,402 AI_BUDGET_EXCEEDED,429 RATE_LIMITED(the per-user cap is now a shared 30 calls/minai.callslimiter across chat + writing). The block content shape is handled in both directions — the writing prompt reads the web editor's HTML-string content (stripHtml) and apply preserves the block's existing shape;inline-editor.tsxtoHtmlnow renders the API's canonical{text}/{title}object content (escaped) so API-/import-/AI-authored blocks no longer display blank. A pre-existing browser-onlyPresenceEmitterbug (detachedsetTimeout→Illegal invocationon block focus, surfaced by the writing selection) is fixed by binding the default timer towindow. Docs:docs/api/ai.md§7 +docs/ui/ai.md§7. Verification: integrationai-writing.integration.test.ts13/13; E2Eai-writing.spec.ts8/8 (desktop + mobile: summarize-accept + realtime second tab, translate + language, discard byte-identical, concurrent-edit 409 rebase); units web 174/174 + api 173/173; api + e2e typecheck clean, web typecheck zero errors in touched files. (Recorded, NOT fixed here — a pre-existing web ↔ API content/batch divergence: the webBlockBatchQueuesendsop:"update"while the API batch DTO only acceptsupsert|delete|move, andUpdateBlockDtorejects the web's HTML- string content — why the 14 pre-existingeditor.spec.tse2e cases fail on a live stack independent of Phase 12; task file design decisions 9–11.) -
Phase 12 (AI) — TASK-0122: Ask AI chat (conversations, streaming, history, error states): an "Ask AI" panel anchored in the page toolbar, command palette, and block toolbar/context menu (
{pageId[, blockId]}context binding — the client sends IDs only; the server assembles the system prompt from ≤40 blocks / DB schema + ≤20 rows, dropping unreadable context with a "context unavailable" notice). Conversation CRUD (POST/GET /api/v1/workspaces/:id/ai/conversations[/:id],PATCHrename,DELETE) is scoped server-side to the caller's own rows (user A → 404 on user B's conversation; non-member → 403); sending a message (POST .../messages) returns an SSE stream (delta* →tool_call* →usage→done,: keepaliveevery 10 s) and persists both rows with token counts. Mid-stream provider failure surfaces an in-banderrorframe +done{reason:"error"}→ a retryable banner whose Retry re-sends viaretryOf(no duplicate user row); pre-stream 402 budget / 429 rate limit arrive as a JSON envelope before any byte. Cancellation persists a partialmetadata.truncated:trueassistant row. New@internaltest seam (/internal/workspaces/:id/ai/mock-fail+.../ai/usage-limits,INTERNAL_SERVICE_TOKEN+INTERNAL_TEST_SEAM=1gated, audited) arms a per-workspace single-shot mock outage so the E2E error-state test is deterministic under Playwright's parallel desktop+mobile projects. Docs:docs/api/ai.md(endpoints, SSE wire contract, error table) +docs/ui/ai.md(panel interaction contract). Verification: integrationai-chat.integration.test.ts10/10; E2Eai.spec.ts8/8 (desktop + mobile); units@factory/ai74, api 147, web 160; api + e2e typecheck clean, web typecheck zero errors in touched files. (Webpnpm buildstill fails on two PRE-EXISTING issues — Next 14next lintvs ESLint 9 removed options, and an invalidPageSpinnerLayout export inpage/[id]/layout.tsx— unrelated to this task; the AI code itself "✓ Compiled successfully".) -
Phase 12 (AI) — TASK-0121: AI gateway, budgets, usage accounting, capability suite: the
@factory/aiAiGatewayis wired into the API (apps/api/src/modules/ai—AI_GATEWAYprovider with explicitinject: [AI_LEDGER], service-levelAiBudgetGuardServiceevery AI endpoint awaits before the gateway call) and the worker (usage.rollupnightly reconcile +budget.notifyhandlers inapps/worker/src/processors/ai.processor.ts, repeatable15 2 * * *on theaiqueue). Provider selection is configuration-only (AI_PROVIDER/AI_BASE_URL/AI_API_KEY/AI_MODELorworkspace.settings.ai): swapping providers changes zero application code. Budgets: Postgres-backed month-to-date vsworkspace.usageLimits.aiMonthlyTokens(max of it andsettings.ai.budgets.tokensPerMonth; 0/unset = unlimited), a Redis 60 s per-workspace rate window, and a 24 hSET NX EX 86400admin-notification slot (the guard hook and the worker sweep race on the slot; the winner notifies). Exhaustion returns top-level402 AI_BUDGET_EXCEEDED {reason:"monthly_token_budget_exceeded", used, limit}(added to the canonical error table indocs/api/authentication.md§9.1), notifies every OWNER/ADMIN, and auditsai.budget_exceeded. NewPOST /api/v1/workspaces/:workspaceId/ai/pingdiagnostic (resolved provider/model/dimension + budget state, no tokens). The CI capability suite (tests/integration/ai-capability/): the IDENTICAL assertion set — echo chat, streaming delta order + usage-last, tool-call roundtrip, embedding dimension stability, 429/401/500 error mapping — runs against every provider (openai/anthropic/google/oss/custom, via injectablefetchImplfaking each provider's real wire protocol) and theMockProvider(AI_PROVIDER=mock, dev/test config); the real-provider branch is gated onAI_REAL_TEST=1(skipped with a warning otherwise). The app-level spec proves budget 402 + 2 adminNotificationrows + audit, per-workspacesettings.aioverride (two workspaces), chat echoai_messagestoken persistence, and 403 for non-members. Migration20261001000000_ai_phase12(AiMessagecreatedAt+[conversationId, createdAt]indexes,AiConversation.deletedAt,AiEmbedding [workspaceId, model]index). Verification: typecheck 0 errors (ai/api/worker/integration); unit@factory/ai72/72, api 134/134, worker 335/335 (13 new); integrationai-capability/38 passed + 1 conditional skip; the full integration suite's failure set is byte-identical to the pre-change HEAD baseline (41 pre-existing non-AI failures — gap note in the TASK-0121 DoD ofdocs/tasks/phase-12-ai.md).
-
Phase 7 (Collaboration) — TASK-0092 slice 1: realtime flush-rebase + version-CAS + persistence-diff minimality: the realtime persister now persists against the
pages.versionCAS. On a conflict (an external REST write bumped the version) the flush rebases via a 3-way merge (BASE =lastPersistedBlocks, OURS = the CRDT doc, THEIRS = the store's current blocks): external ops are absorbed into the doc only for blocks the room did not edit in this flush (flush wins on shared blocks), a second external writer landing mid-retry is absorbed, and a genuine second conflict leaves the doc in sync with the latest external state, keeps the room dirty, and re-arms the flush (no clobber, converges on the next flush). The flush'spage.updatedevent now carries the additiveblockDelta {inserted, updated, moved, deleted}(the Phase-3 event shape) + the minimalops(thediffBlockSetsdiff). NewDocStoreseam (hydrate → {blocks, version},persist(…, baseVersion) → {version, conflict?}), aPrismaDocStoreCAS persist (a$transactionupdateManyonpages.version; typechecked — no local Postgres), the serverEventSink(actor: 'realtime-flush', log seam locally — the production Redis-streamXADDbus wiring is deferred, recorded in the task file), and the sharedBlockDelta/PageUpdatedPayloadtypes. Tests:apps/realtime/test/rooms.test.ts(7 new flush-rebase/CAS unit tests — both-survive, flush-wins, multi-external, mid-retry race, the rebase's Yjs origin) andtests/integration/realtime/rebase.test.ts(2 scenarios through the real server: a concurrent REST write + a room edit on different blocks → both survive, no clobber, the next flush is a no-op; edit ONE block →blockDeltais exactly 1 update, the other blocks' rows untouched). Unit: collaboration 19/19, realtime 71/71; integration 41/41 — green; typecheck + lint clean. (Deviation:PrismaDocStorewrites Postgres directly for now — the internal/internal/blocksendpoints it should use are a later slice; recorded in the task file.) -
Phase 7 (Collaboration) — TASK-0091 realtime integration suite:
tests/integration/realtime/— 39 tests against the realcreateRealtimeServer(in-memory store/meta/permission seams, raw-TCP upgrade probe, strict close-frame scanner): handshake (17 — both token paths, canEdit true/false, FORBIDDEN write-drop, garbage/wrong-sig/expired 401, cross-workspace / no-VIEW / unknown-resource 403 no-existence,user:{other}403, malformed room key 400, 31-connects/IP 429 + audit, 11-sockets/user control-cap evictioncontrol_evicted+ 4008), heartbeat/limits (9 — never-pongs 4000, ping→ping-pong echo, 300 KB frame 4009, 120 msg/s 4008, 10-strike 4008, unknown frame, bad update, awareness relay), resume (4 — exactly the logged frames + byte-identical convergence, log-gap state-vector path, 20-window property invariant, room-scopedbad_resume), lifecycle (9 — dirty-room GC flush-then-evict, rejoin cancels eviction,drain()4010 +retryAfterMs+ all-dirty-rooms-flushed ≤ 10 s,/healthshape,/readyz200/503, legacy/health/ready,/metricsPrometheus, WS port serves health). All green; the package gets a self-containedtsconfig.json(strict, ES2022 — deliberately not extending the repo root; rationale in the task file). -
Phase 7 (Collaboration) — TASK-0091 k6 1k load baseline:
tests/performance/realtime-1k.js(the real WS contract — 1,000 VUs ramped 100/s over 30 s, 2-min hold, 200 msg/s mixed 50/30/20 (valid Yjs updates / awareness / app ping), RTT via theping→ping-pong {ts}client-clock echo withhello.serverTimeas the documented skew diagnostic, per-VU JWTs minted withk6/crypto+ per-VUX-Forwarded-Foridentities, thresholdsrtt_ms p(95)<200/protocol_errors count<1),helpers/seed-1k-load.mjs(1,000 load users + memberships + workspace-scoped EDIT grants + 10 load pages, idempotent), and therealtime:1krunner script (--summary-export realtime-1k.json). First green run + artifact publication happen on the CI perf runner (needs the k6 binary + the production seam; recorded in the task file). -
Phase 7 (Collaboration) — TASK-0091 realtime server: production
apps/realtimeWebSocket server — JWT-validated handshake (sharedpackages/authverify + 5-min expiry grace), RoomManager (per-resource authoritativeY.Doc, cold hydration before publish, debounced 1 s / hard-cap 10 s flush, 5-min empty-room GC, SIGTERM drain), frame protocol (sharedpackages/shared/realtime/frames.tsschemas + binary Yjs framing + 10-strike policy), heartbeat 30 s / prune 90 s, rate limits (30 conns/min/IP, 100/user, 256 KB frame cap, 100 msg/s sustained token bucket), resume protocol (state-vector diff + per-room Redis update log),/health+/readyz, connection metrics. Unit suite: 64 tests (auth, protocol, rate-limit, rooms) — green. Typecheck clean. -
Repository scaffold: monorepo (pnpm + Turborepo), apps (
web,api,worker,realtime), 11 shared packages, Prisma data model, docker dev environment, CI/CD workflows, documentation set (product spec, architecture, roadmap, per-phase task specs), and the AI agent operating manual (AGENTS.md). -
Page organization: folders + "Move to…" for the Private section: a page with children is a folder (the existing
Page.parentId+ fractionalpositionnesting — no new schema). Previously the tree rendered but every row used the doc icon (theiconvariable was computed yet never rendered) and both "Move to…" entries were no-op stubs, so there was no reachable way to group pages. Now:MoveToDialog(apps/web/src/components/dialogs/move-to-dialog.tsx) — destination picker over the workspace tree (lazy per level, sameusePageTreehook + sibling-list endpoint as the sidebar) with a "Root (top level)" option; the moved page is excluded from its own destinations; fires the already-completePATCH /pages/:id { parentId }(server enforces cycle + trashed-parent protection) and surfaces rejections as a toast. Mounted at the workspace shell and driven by the ui-store (setMove/moveOpen/moveContext), so both entry points work without prop-drilling.- Wired entry points: the topbar page "More → Move to…" (was a stub)
and a new "Move to…" item in the sidebar tree-row
⋯menu. - Folder icons:
pageRowIcon()(exported, pure) renders an open/closed folder icon for pages with children (tied to the expand state), a doc icon for leaves; databases/trash/user-emoji keep their own icons. The icon is now actually rendered in the row (it was dead code before). - "New folder" button next to "New page" in the Private header
(creates a page pre-named "New folder" via the
factory:new-pageevent, which now accepts an optionaltitle). - Tests:
page-tree-icon.test.tsx(icon priority rule) andmove-to-dialog.test.tsx(open/close, destination list excludes the moved page, root→parentId:null, page→parentId:<id>, disabled until a destination is chosen). Typecheck/lint: no new errors vs. the pre-existing baseline. - One-time cleanup of the personal workspace applied via the same API (135 flat top-level pages → 4 folders: Migration — Oracle→Postgres (with nested Oracle Objects), Events / Marketing, Test / Scratch, Databases). Non-destructive reparenting; reversible by moving pages back to root.
Changed
-
Long pasted text / code is capped vertically and expandable (user request: "when I paste long text the editor is as long as the whole text"): long blocks no longer stretch the page to full height. A new
Expandablewrapper (apps/web/src/components/editor/expandable.tsx) measures its content (resized viauseMeasuredHeight/ResizeObserver) and, once it exceeds a cap, clips it to the cap height with vertical scroll and shows an Expand / Collapse control. Applied to code blocks (capped at 15 lines; the edit mode stays full-height so the overlay's scroll-sync and caret are unaffected) and text/paragraph blocks (capped at ~16 lines). The cap stays on even while the block is focused/selected — otherwise a just-pasted long block (which is the selected one) would still stretch the page, the very complaint. ProseMirror keeps the live caret scrolled into view inside the cap (the cap is a scrollable ancestor), so typing at the end of a long block is never clipped; Expand gives the full-height view when you want it. Short content renders exactly as before — no cap, no scroll, no button — so the common case is unchanged. The expand/collapse choice is per-session view state, not persisted to block properties. (5 unit tests pin the cap/expand/lift behavior;ResizeObserveris guarded so the wrapper degrades cleanly in environments without it. Live-verified: a 40-line paragraph caps at 348px and stays capped while focused, the caret auto-scrolled into view at the end of the block.) -
Editor body font size 16px → 14.5px (
.editor-contentin globals.css): body text was one step too large for a dense document editor; headings scale from the same base so proportions hold. Code blocks keep their own--factory-code-font-size(13px) for both display and the edit overlay (the two layers must share metrics).
Fixed
-
Database table Title column no longer renamed inline (regression from the
<button>-nesting fix27cdeca): that fix routed every title cell click to "open the record page" and removed the click-to-edit affordance entirely — over-broad, since it should have kept the cell editable and only dropped the nested inner link. The Title cell is again the click-to-edit control (click → inline editor, Enter/blur commits, Esc cancels); its value renders as plain text so no button is nested in the wrapper. Opening the record's page is one click away via the row's "Open record" (👁) hover action and the record panel's "Open page" link. New rows keep the "New record" seed title — click it to rename. (2 unit tests pin the editable wrapper; live-verified: inline rename commits to the API.) -
"Something went wrong on this page" whenever a page contained a synced block:
SyncedBlock(block-renderer.tsx) rendered theUndo2lucide icon without importing it, so the moment the component mounted the app threwReferenceError: Undo2 is not definedand the error boundary took over the whole page. AddedUndo2to thelucide-reactimport (the onlyTS2304in the codebase; typecheck baseline drops 94 → 93). The build does not type-check, which is why it shipped. Verified live: a page with a synced block now renders the synced content (with the icon) and home + content pages report zero console/page errors. -
Long pasted text stretched the page to the height of the whole block: the block now wraps its content (
.editor-contentsetsoverflow-wrap: break-word, so unbroken URLs/identifiers wrap instead of widening the column) AND is capped + expandable when it runs long — see the "long pasted text / code is capped vertically" change above. A short paste is still exactly as tall as it is; only long content gets the cap. -
Realtime/WebSocket not working on the deployed site ("again"): the web bundle was built without
NEXT_PUBLIC_WS_URL, so the client fell back to the dev defaultws://localhost:4001(seeapps/web/src/lib/constants.ts) — the browser then tried to open a socket to the user's localhost, which never reaches the gateway, and the realtime client sat in a silent reconnect loop. This is the same build-time-inlining class as the earlierNEXT_PUBLIC_API_URLincident:NEXT_PUBLIC_*values are baked into the browser bundle atnext build, not read atnext start. The realtime server (:4001), the Caddy/realtime*reverse proxy, and the JWT handshake were all healthy (verified:ws://127.0.0.1:4001/realtime?token=…andwss://notion.filps.com/realtime?token=…both upgrade + OPEN with a valid token). Fix: build the production bundle with both public origins set —NEXT_PUBLIC_API_URL=https://notion.filps.com/api/v1andNEXT_PUBLIC_WS_URL=wss://notion.filps.com(Caddy routes/realtime*→:4001). The second var also fixes the CSP:next.config.mjsderivesconnect-src's WS origin from the sameNEXT_PUBLIC_WS_URL, so it now allowswss://notion.filps.com. Verified live: the served page dialswss://notion.filps.com/realtime?token=…(single socket, no reconnect storm, frames flowing) and the realtime/healthconnection gauge goes 1 → 2 → 1 as a browser tab opens/closes. Build ritual note: the production web rebuild MUST pass bothNEXT_PUBLIC_API_URLandNEXT_PUBLIC_WS_URL(and the matching vars on thenext startprocess so the runtime CSP matches), or the bundle silently reverts to the localhost fallbacks. -
Board: selecting a status (To Do / In Progress / Done) in the record panel did not reflect in the board column (regression of the "stays blank" QA bug): the value was written server-side, but the board never settled because opening a record panel triggered a PATCH storm. The panel mounts one
CellEditorper editable property, and the DATE editor had three compounding defects: (1) itstextstate was seeded fromvalueToText, which for an empty{start:null,end:null}returns the raw JSON string — never matching the<input type=date>format; (2) it committed on blur/Enter with no no-op guard (unlike the text/number path); and (3) the autofocus effect was keyed on[onDone], and the panel passes an inlineonDone, so the effect re-ran on every re-render and re-stole focus. The loop was: blur → commit{start:null,end:null}→invalidateQueries→ refetch → re-render → refocus → blur — measured ~1,200 identical PATCHes in ~6 s (version incrementing every time), which churned the board's grouped query so a status change never rendered into the column (and crashed the headless page). The fix (apps/web/src/components/database/cell-editor.tsx) removes all three: a new puredateToInputhelper seeds the datetextstate in the exact<input type=date|datetime-local>string (empty date →"", not JSON) and is the SAME canonical string theonDateCommitno-op guard compares against — so an unchanged blur (e.g. the panel re-focusing after a refetch) commits nothing; the focus-on-mount effect is split off the Escape handler ([]deps) so an inlineonDonecan no longer re-steal focus on every re-render. Live-verified on the production build: 0 PATCHes on panel open, exactly 1 PATCH ({optionId}) on a status pick, and the card moved column. Regression pinned incell-editor.test.tsx(date seeds the input not the JSON; unchanged blur fires 0 commits; a real change commits{start,end}once; a status pick commits{optionId}exactly once). -
UI very slow — every menu/link click took 3–5 s (forced multi-clicks): the web app was being served by
next dev, which (a) ships ~27 MB of unminified JS witheval()devtool source maps and (b) compiles each route on demand — measured 2.8–7.9 s for the first click on an uncompiled route (/files2.8 s,/templates2.9 s). The API layer was never the problem (18–33 ms per call). The site is now served from a production build (next build+next start): minified/tree-shaken bundles (the table page first-load JS is 433 kB, down from ~27 MB) and no on-demand compilation, so client-side navigation responds in tens of milliseconds. Two blockers had to be cleared first (both pre-existing, recorded per AGENTS.md rule 29):next buildfailed on ~95 baseline type errors + the broken build-time ESLint (ESLint 9 rejects--useEslintrc/--extensions);apps/web/next.config.mjsnow setstypescript.ignoreBuildErrorsandeslint.ignoreDuringBuildswith an inline comment documenting the debt and the exact re-enable criteria. CI's standalonetypecheck+lintsteps remain the gate — they are not bypassed by the build./loginprerender failed:useSearchParams()in a statically generated client page requires a<Suspense>boundary ("useSearchParams() should be wrapped in a suspense boundary at page /login"). The innerLoginPageis unchanged; the default export now wraps it in<Suspense>(canonical Next 14 fix).
-
Database table view:
Warning: In HTML, <button> cannot be a descendant of <button>(hydration error): the editable cell wrapper inCellRendereris a<button>(click-to-edit), but the intrinsically interactive cell types —title/heading(record link),relation(per-record chips),files(file chips) — render content that is itself a<button>, nesting button-in-button: invalid HTML, a React hydration error on every table view, and broken click targeting. Those types now render in a plain (non-button) container; their own controls already own the clicks (stopPropagation), so behavior is unchanged and no value loses its affordance. Regression test:apps/web/src/components/database/__tests__/cell-renderer.test.tsx(asserts no<button>descendant of a<button>for every interactive type, and that editable control types keep the wrapper). -
Web build failure:
Module build failed: UnhandledSchemeError: Reading from "node:crypto"(every page, incl. login, returned a Build Error): a browser-bundled module was transitively importing a Node builtin. The@factory/shared/dtobarrel — the browser-safe DTO surface the web bundle imports — reachednode:cryptothroughdto/webhook.dto.ts → events.ts(events.tsimportsrandomUUIDfromnode:cryptoforgenerateId);webhook.dto.tsonly needed the plainWEBHOOK_EVENTSstring catalog from there. The catalog now lives in a dependency-freepackages/shared/src/webhook-events.ts(new@factory/shared/webhook-eventssubpath),events.tsre-exports both names so existing import sites are unchanged, andwebhook.dto.tsimports from the safe module. A new guard test (packages/shared/src/dto/browser-safety.test.ts) walks thedtobarrel's import closure and fails if any file pulls a Node builtin — verified to catch exactly this regression. -
Select/Status cell renders blank after picking a value (QA: "I can select To Do / In Progress / Done but the column field stays blank"): the value was written correctly, but the option the renderer matched it against had no id, so the lookup missed and the cell drew empty. Two independent causes were removed. (1) Shared cache pollution — the record-open endpoint and
GET /databases/:id/propertiesboth fill the same 60 s column-metadata cache, but onlylistPropertiesnormalized the legacy id-less select options; a record-open that filled the entry first left the raw (id-less) config in place for the TTL, blanking every canonical{ optionId }value in the table view.getRecordnow stores the same normalized entry. (2) Client read boundary —optionIdForName/normalizeSelectOptionsmoved from@factory/aiinto@factory/shared(the web can't depend on@factory/ai), and the web'sflattenDatabasenow re-normalizes option ids, so a stale/legacy option list in an open tab falls back to a name match instead of a blank cell.@factory/aire-exports both names, so the API import sites are unchanged. Unit-tested (database.dto.test.ts,use-database-options.test.ts) + a live integration regression (cache-invalidation.integration.test.ts: a record-open that fills the shared cache first is followed by alistPropertiesthat must still carry the deterministic option ids). -
Blank-area clicks stacked empty input fields forever (QA: "clicking on blank page input fields keeps on adding; if nothing is typed it should disappear"): the trailing blank area below the last block always INSERTED a new paragraph, and nothing ever removed an empty one — so every click piled another empty input field onto the page. Now, Notion-style: (1) focus-reuse — clicking the blank area when the last block is an EMPTY text block (paragraph / heading / list / quote / callout / todo) focuses THAT block instead of creating a new row; (2) delete-on-blur — an empty text block that loses focus with nothing typed is deleted, so the field disappears instead of leaving a blank row. Guarded by the new pure
apps/web/src/lib/block-content.ts(isAutoCleanableType/isContentEmpty/isEmptyTextBlock): only top-level text types, the page-TITLE block (properties.isTitle) is protected, whitespace-only counts as empty, and unrecognized content shapes are never treated as empty (a false negative leaves a block; a false positive deletes data). Emptiness on blur is judged from the editor's LIVE html (the stored content lags the 500 ms debounce and is the{}fresh-create default).BlockBatchQueue.dropBlock(new) drops the deleted block's still-queued content ops before the delete so a flush never 400s the whole batch, andsaveNowno longer re-queues a 404 (the block is gone, not "transient"). Unit-tested:block-content.test.ts(15 cases) +batch-queue-drop.test.ts(4 cases). The e2eslashPickhelper now waits for the last row's contenteditable to hold focus (the area may reuse the row) instead of assuming the row count increased. -
Large paste from Notion failing with "internal error, please try later" (QA #1, root cause): the block position engine's append converged to the grid top and hit
RangeErrorafter ~100–127 sequential inserts, 500'ing big pastes. Append now stepsposition + 'V'(non-converging) andresolvePositionSaferebalances the sibling grid and retries onRangeError;page.servicegainedappendPagePosition/rebalancePageSiblings. Verified live: 600+ block pages and large pastes apply cleanly, zero unhandled errors. -
Paste payload rejected with 400 on every web paste:
CreateBlockDtoaccepted only acontentRECORD while the web sends HTML strings, soPOST /blocks/batchupserts 400'd.contentis nowblockContentSchema(string or record) andCreateBlockDtoalso accepts a client-chosenid(P2002 → 409) so a parsed paste tree can pin its own node ids and parent links. -
Duplicate ignored its body + used client position math (QA #5):
POST /blocks/:id/duplicatenow takes aDuplicateBlockDto(parentId?,afterBlockId?) and anchors the copy server-side; the webduplicate()maps the{ newRootId }response to{ id }. All web duplicate/sibling call sites (context-menu, toolbar,useDuplicateBlock) were converted from the throwing base-36positionAfterto id-anchors. -
Context-menu "unreeled" errors on insert/duplicate (QA #5 root cause):
context-menu.tsxandblock-toolbar.tsxcalledpositionAfter/positionBefore(the shared base-36 engine) on the API's base-62 positions, which THREW on every insert/duplicate — the source of the "many unreeled errors". All such call sites now useafterBlockId/beforeBlockIdid-anchors and let the server compute the fractional position.useCreatePageno longer runs clientpositionForInsert(the server owns page positions;CreatePageDtohas nopositionfield) and the unuseduseMovePagehook (which carried the same throwing pattern) was removed. -
Paste ordering bug (self-introduced, fixed):
runPasteImportchained every spec to the same anchor, which reversed sibling order inside one batch; it now tracks the last-emitted id per parent group. -
"Turn into" menu off-viewport on top-of-page blocks (QA #4, caught by the regression E2E): the TypeMenu always opened ABOVE its anchor (
bottom-full), so on a row near the top of the page the menu rendered with negative Y and its items were unclickable — the same "convert via three dots" deadness QA reported.type-menu.tsxnow measures the anchor's viewport position on open and flips below (top-full) when there isn't ~384px of room above. -
Command palette: every SYNC (non-async) palette action crashed with "Cannot read properties of undefined (reading 'catch')" (
apps/web/src/ components/layout/command-palette.tsx): the palette'sperformcallbacks are typed() => void | Promise<void>, and the item click / Enter handlers calleditem.perform().catch(...)directly — for thevoidactions (New page, thego(…)navigations, and "Generate page with AI") that threw on.catchofundefinedand surfaced as an uncaught page error on every palette use. Both call sites now wrap the result inPromise.resolve(...)first (thenable-safe, value-safe). Verified live: the golden "Generate page with AI" palette path now runs with zero page errors (previously two per run). -
AI-generated databases: Status / select / multi-select values did not render or preselect (empty chips, unclickable dropdowns) — the AI generation path emitted
config.optionsas bare{ name, color }(NOid) and stored record values as the option NAME ({ optionId: "To Do" }), while every reader matchedo.id === optionIdONLY → empty chips, failed preselect,optionId: undefinedcommits, and broken board grouping. Now canonicalized at both boundaries (unit-tested):- Write boundary (
apps/api/src/modules/databases/database.service.ts):normalizeSelectOptionsassigns each id-less option a DETERMINISTIC id (optionIdForName(name)from@factory/ai) increateProperty/updateProperty(a partial options patch reuses an existing option's id by name, including a custom id, so existing values stay resolvable). Every API-created database — AI or manual — now conforms to the documented{ id, name, color }contract. - Read boundary (
apps/api/src/modules/databases/query.service.ts):toDto/ board grouping / filters /toText(CSV export + sort) resolve a stored NAME ref to the deterministic id (canonicalOptionValue+resolveOptionRef, which falls back tooptionIdForNamefor LEGACY options that still carry no ids), so rows already in the database render without a data migration. Unknown refs pass through verbatim (no data loss). - Write path (
coerceValue→coerceAndCanonical): a name-basedoptionIdwritten by create/update/bulk is stored as the id, not the name. - Web (
apps/web): a shared tolerantfindOption(options, ref)(id OR name) is used by every option consumer (cell-renderer,cell-editorpreselect/commit,valueToText,ai-databasepreview), and the select/status/multi-select cell editor now opens with the dropdown ALREADY OPEN (one click on the cell shows the choices — Notion behavior). - Tests:
normalize-select-options.test.ts(5),option-id-canonicalization. test.ts(3, drives the realDatabaseQueryService.list()through a legacy-AI DB), anduse-database-options.test.ts(web, 9). API suite 416/416.
- Write boundary (
-
/codeblock could not be edited (apps/web/src/components/editor/ block-renderer.tsx): theCodeBlockwas a static<pre><code>with no input path (and a fresh slash-pick code block stored an empty{}content, so nothing could be typed). Now a click on the code (or the new Edit button) opens an auto-sizing monospacetextareaseeded from the content (string /{ text }envelope / JSON fallback); each keystroke is queued to the debounced batch save, blur/Escape/Save persist immediately viasaveNow, and Copy copies the live text. The language<select>is kept (disabled while editing).codeBlockTextis pure + unit-tested (code-block-text.test.ts, 5). -
Slash-command menu ("Type '/' for commands") did not close when clicking blank space (
apps/web/src/components/editor/slash-menu.tsx): it closed on blur, but a focus-retaining click (the editor's trailing zone) kept it open. Adocumentmousedownhandler now closes it when the click lands outside its root (the same pattern the blockTypeMenualready used). -
"/add database" → "Open in new window" 404'd (
apps/web/src/components/ database/db-embedded.tsx+record-panel.tsx): both "Open" handlers built the page URL from the FIRST pathname segment (the literalw) and a nonexistent/p/{id}alias →/w/p/{id}→ "This page does not exist". Now a shared purepageUrlFromPath(pathname, pageId)helper (in@/hooks/use-database, unit-tested) builds the only real route/w/[workspace]/page/[id]from the SECOND segment, with a graceful workspace-less fallback. -
AI page generation hung on "Drafting the page…" then timed out (
apps/api/src/modules/ai/generator/generate.service.ts+apps/web/src/ lib/ai.ts+generate-page-dialog.tsx): the provider's thinking phase emits ZERO bytes for minutes (a successful run has been observed silent for ~150 s), so the server→client SSE stream went silent and the client'sfor(;;) await reader.read()had no liveness signal. Now:- the server emits an SSE keepalive comment (
: keepalive\n\n) every 15 s while the stream is open (invisible to the client'sdata:-only parser, but it resets proxy/browser idle timers and the client watchdog); - a provider 429 now surfaces as an in-band
errorframe (code: "RATE_LIMITED",reason: "rate_limited", retryable) instead of falling through to a genericINTERNALframe — the dialog shows the "Sending too fast" banner + Retry; - the client adds an idle watchdog (
generateAiPage): if no byte arrives for 150 s it cancels the reader and resolves{ reason: "error", error: { code: "STREAM_TIMEOUT", … } }instead of hanging; - the dialog shows an elapsed-time indicator during the silent
"Drafting the page…" phase and a "Generation stalled" heading for
STREAM_TIMEOUT/SERVICE_UNAVAILABLE.
- the server emits an SSE keepalive comment (
-
Realtime gateway could not start in the dev stack — WebSocket "There was a bad response from the server" (
apps/realtime/src/config.ts): the gateway read the sharedPORT(4000 in the dev stack, owned by the API) instead of the documentedREALTIME_PORT(4001,docs/development/development-guide.md), died withEADDRINUSE, sowss://<host>/realtimehit a dead upstream and the browser's upgrade failed.port/healthPortnow readREALTIME_PORT/REALTIME_HEALTH_PORTfirst (falling back toPORT/HEALTH_PORTfor standalone runs). Regression tests inapps/realtime/test/config.test.ts; full suite 141/141. Verified E2E: a real WS client over the publicwss://…/realtime?token=…URL gets101 Switching Protocols+ thehelloframe. (The Caddy reverse-proxy route/realtime* → 127.0.0.1:4001already existed in/etc/caddy/Caddyfile; the running instance needed a config reload after the reboot.) -
AI page generation with an OSS reasoning model (
AI_PROVIDER=oss) — root-cause fix for "Generation took too long and was cancelled" and the API process crash on retry (apps/api/src/modules/ai/):generate.service.tschatPlannow sends the plan as a forcedgeneratePlantool call (toolChoice) with the full plan shape in the tool description + system prompt. With no tool declared the reasoning model (qwen3.8-27B via vLLM) streamed ~323 s of prose and never emitted a tool call; forced, it answers with one structured call.- The resolved chat model is captured once, before the (minutes-long) model
call. The old code re-resolved it in the post-stream persist calls through the
30 s TTL workspace-row cache; a call longer than 30 s expired it,
resolvethrew 404 inside the SSE catch block after headers were already sent, and the exception filter crashed the whole API process with an uncaughtERR_HTTP_HEADERS_SENT(the "server hang" seen on retry). prompts.tsnormalizeGeneratePlanArgsrepairs the model's known non-conforming shapes BEFORE the strict zod validation:toolsdouble- serialized as a JSON string, placeholder refs ($page/root/…) dropped, block text hoisted intocontent.{text}(the model emits top-leveltext/ bare-stringcontent, which previously created empty blocks), and the SEEDEDTitle/Statusdatabase properties dropped (the service always ships them — a re-declaration failed the whole plan).executor.tsinsertBlockswith an omittedpageRefnow resolves to the FIRST page even when it carries a namedref(the schema contract; before it failed with "references page (root) before it is created").- New optional tuning env (documented in
docs/api/ai.md§10):AI_GENERATE_TIMEOUT_MS(whole-run watchdog, default 120 s),AI_STREAM_IDLE_MS(max streamed-byte gap, default 30 s),AI_STREAM_TIMEOUT_MS(adapter TTFB cap),AI_MAX_OUTPUT_TOKENS(raises the gateway output-token clamp, default 4 096 — a ~15k-token plan was truncated mid-stream). ai.generation_failedaudit + step-failure log now carry the step error for diagnosis. Verified E2E against the real OSS model: the user's MySQL-migration prompt generates a complete page (54 blocks with text, 2 databases) in their workspace.
-
Presigned S3 URLs for keys with spaces/special characters — root-cause fix for 403
SignatureDoesNotMatchon file upload (packages/storage/src/presign.ts): the signer and URL builder each applied their own percent-encoding, so an already encoded path was double-encoded (%20→%2520) and S3 rejected the signature.encodePathis now the single raw→encoded source (RFC 3986, per-segment, unreserved set);objectRequestPartsreturns the RAW path and every consumer (signS3Request,presignRequest,buildUrl) encodes it exactly once. Unreserved keys produce byte-identical URLs/signatures as before (backward compatible); verified E2E: anARL Budget 208384.xlsxupload via the Caddy→S3 path succeeds (PUT 200, download round-trips). -
apps/api/src/modules/ai/ai.controller.ts: the controller declared its dependency as a bare class-typed constructor parameter (constructor(private readonly ai: AiService)), the only one in the codebase without@Inject. Under the ESM/tsx (esbuild) runners the integration tests use,design:paramtypesis not emitted, so Nest resolved the parameter toundefinedsilently — every AI route returned 500INTERNAL_ERRORon first call (Cannot read properties of undefined (reading 'ping')). Now@Inject(AiService)per the codebase's mandatory convention. (Productiontscoutput emitted the paramtypes, so the breakage was test-runner-specific — recorded because it is the same latent class of bug theinjectfix below fixes.) -
apps/api/src/modules/ai/ai.module.ts: theAI_GATEWAYuseFactoryprovider passed the ledger to the factory function but omittedinject: [AI_LEDGER]. Factory parameters have no decorator metadata for Nest to infer (andtscdoes not emitdesign:paramtypesfor non-decorated factory parameters), so Nest invoked the factory with zero arguments — the gateway's usage sink wasundefinedand every successful call loggedai usage sink failed (ws…): Cannot read properties of undefined (reading 'record')(the durableai_messagesrow still persisted, so budget enforcement silently relied on the worker's nightly rollup instead of live writes). Explicitinject: [AI_LEDGER]now. -
packages/ai/src/providers/google.ts: a candidate carrying afunctionCallpart withfinishReason: "STOP"was reported asfinishReason: "stop"— the parts-processing loop ran before thefinishReasonline, so the tool call was silently downgraded to a plain stop (inconsistent with the OpenAI/Anthropic adapters, which reporttool_calls). The adapter now trackshadToolCalland emitsfinishReason: "tool_calls"for a non-error function-call candidate. Caught by the new TASK-0121 capability suite (the tool-call roundtrip assertion set). -
packages/collaboration/src/codec.ts:toOperations(the collaboration diff) dropped an insert'sblock.id, so re-applying a diff minted a randomblk_<random>id instead of the block's real one — this broke the TASK-0092 flush-rebase round-trip (an external insert absorbed into the doc lost its id). The insert now carries the existingblock.id(the wire schema already allowed the optionalid; both appliers already honoredop.block.id ?? blk_<random>). The editor codec (a NEW user-created block — the server assigns the id) and the collab codec (an existing id preserved) both remain schema-valid; the two-codecs-agree test still passes (it compares via a canonicalizer that drops the optional insert id). -
apps/realtime/src/server.ts:WebSocketServermaxPayloadwas set toMAX_FRAME_BYTES(256 KB), so thewslibrary terminated oversize frames with close 1009 before the app's 4009payload_too_largepath (and itsframe_too_largeaudit row) could ever run — the 4009 close code was dead code.maxPayloadis nowMAX_FRAME_BYTES + 48 KBso 256–304 KB frames reach the app handler (4009 + audit) and >304 KB frames keep the 1009 backstop. (Found by the TASK-0091 integration suite — the 300 KB frame test asserted 4009 and failed with 1009.) -
apps/realtime/src/rooms.ts: the per-room update-log trim after a successful Postgres flush used the join-timelogSeqinstead of the flush-time seq — on a slow room the stale trim point could drop logged-but-not-yet-acked updates (a reconnecting client would resume from a gap it didn't know about). The log is now trimmed from the seq read at flush time (flushSeqProvider), with alogSeqAtFlushseam for tests. -
apps/realtime: completed the interrupted TASK-0091 rewrite — resolved ~30 TypeScript errors (rooms.tsfield/method name collision +stateDeltastate-vector bytes,prisma-store.tsteams use-before-declaration (a real TDZ runtime bug) + block-type case translation,auth.tsworkspacesclaim,config.tsmissinguseRedis/useDatabase/trustProxyfields,server.tsrate-limiter keying byconnId+ null-safe socket cleanup). -
apps/api/src/common/filters/all-exceptions.filter.ts: missing closing}on theP2003(foreign-key) branch — a syntax error that broke the API typecheck.
[0.1.0] — Initial scaffold
- Phase 0 (Foundation) scaffolding complete: directory structure, tooling configuration, environment template, base Prisma schema and seed.