Skip to content

Commit bbfbf4e

Browse files
authored
fix(files): cancel resumed runs across replicas, unify anchored file edits, and harden workspace, integration, and presence paths (#9035)
* fix(projects): share the Project lock during fork creation, archive every banned-user workspace, and drop dead membership guards - requireForkProject takes the Project lock shared, so sibling file writers and other forks no longer wait out a fork's content copy; exclusive lifecycle holders (archive, split, transfer, departure) are still fenced - disableUserResources keeps archiving after one workspace fails, logs the failed ids, and throws an AggregateError at the end - remove null guards on workspace.project_id, which is NOT NULL - name release v0.9.18 instead of a PR number in Project backfill operator errors - log workspace and workflow ids per archive repair in the 0032 script migration (cherry picked from commit 5831241070f08536eb923a4de012b0a58bdb5ff3) * fix(files): resolve the end anchor after the start, keep line endings, share one anchored edit The end anchor is now the first match after the selected start anchor, so a closing line earlier in the file no longer fails the edit. Copilot's apply_file_edit and its streaming preview route anchored patches through applyWorkspaceFileContentEdit instead of two divergent copies. Anchored edits keep each untouched line's own ending; inserted lines take the anchor's. (cherry picked from commit fb3f81a1b7810c1dc8763f31b5a1479769f60803) * fix(files): resolve a named file in a folder scope by exact match, not by listing the folder resolveScopedFileReference listed up to 5000 files to find one name, so a named write, append, or edit inside a larger folder failed payload_too_large. The id and name lookups are now exact SQL predicates under the same folder scope and list authorization, with the same not-found and ambiguity errors. (cherry picked from commit 63a06782eb3327a6d858bc337600d88d76ce46c8) * fix(files): treat the root with its subfolders as unscoped instead of listing every folder id A root scope including subfolders expanded to every active folder id, so file listing and content search sent an IN list of up to every folder in the workspace. It now resolves to no folder scope, which selects the same files: archiving a folder archives the files in it. The two identical folder-scope predicate builders become one workspaceFileFolderScopeCondition. (cherry picked from commit 8020c141e6462cb2c97dfe2c992225ed3baad3ab) * chore(files): drop history narration and overstated folder-scope claims, log context kind only Folder scope narrows a search; authorization is workspace-level, so the comments no longer call it an isolation boundary. A failed chat context now logs its kind rather than the whole context, which for a file selection carries the user's selected text. The overlap-dedupe docstring keeps its invariants. (cherry picked from commit 4c22ff11d982a0f57156746f6d07bce584b1d587) * refactor(files): move the anchored-edit mapper out of the intent store, probe the id alongside folder resolution (cherry picked from commit 0df6468f6372168cfbe9f3668eae213cee091b6c) * fix(files-ui): stop folder-scope pruning writes, memoize resource folders, rename untitled files on heading exit - file-upload: drop the effect that pruned out-of-scope selections from every open editor; flag them for removal instead - use-resource-folders: memoize on stable query fields; fix byPath doc - rich-markdown-editor: name an untitled file after its heading when the cursor leaves it, on blur, or on unmount (once), not after an idle pause; caller resolves the file by id - table-grid: hoist guarded context row instead of non-null assertions - drop a formula-restating test and trim a home.tsx comment (cherry picked from commit 419520a1e42c3462b31ccbf2e68f7f8d6af4ae9d) * fix(resume): abort an active resume attempt when Stop is recorded on another replica The resumed engine subscribes to cancellation under the durable run id, while Stop records it under the attempt id. Subscribe the attempt's abort to its own id, and assert the durable snapshot id through executeWorkflowCore instead of a log message. (cherry picked from commit facbad586a01aaeb624150af896dceb1a6430c47) * fix(agent): reject evaluation models as fallbacks at authoring time and fail saved ones without retry (cherry picked from commit 80d07588a0c2b6b5edbd130497471f8f6594b801) * fix(agent): skip a cross-provider fallback that needs its own family credentials, as Router and Evaluator do (cherry picked from commit 55eb1c8ba930d006bc130fa2de584dfa0f23291a) * refactor(agent): drop the fallback normalization from the Agent block's unused params mapping (cherry picked from commit 6a7ccc285ceb5181ce29a43d0e653c48d0a07e64) * improvement(deployments): skip the defensive clone for an uncached comparison materialization (cherry picked from commit 462df0ac2a03a7b52928c4333a7e33caa7b00287) * fix(zoho-desk): fail permanent webhook registration errors terminally Zoho's permanent 4xx registration failures carried a duck-typed status that the stable registration path lost inside its AggregateError, so they retried until max attempts. Throw WebhookDeploymentConfigurationError instead and classify by class (including an all-configuration AggregateError). Also guard webhook and tool path segments with safeUrlPathSegment, share one Desk base validator from host-allowlist, bound the JWKS cache with LRUCache, and log only profile keys when a connector profile is invalid. (cherry picked from commit 7aa532557e23a3edc67ee64272811d936cd7eba7) * fix(sailpoint): cache tokens with lru-cache, guard path ids, reject unknown operations Replace the hand-rolled token Map, manual LRU, and coalescing map with one LRUCache whose fetchMethod sets each entry's TTL from expires_in, drop the test-only state exports, route path ids through safeUrlPathSegment, share the schema's page limits with the tool definitions, and throw on an unknown block operation instead of silently running a search. (cherry picked from commit 663953dc0677c06cbf6c3eac7fa20c1f35b596e9) * fix(outlook): bind calendar page tokens to event collections and guard path ids Share assertGraphNextPageUrlForCollection from the sharepoint Graph utils so the calendar list rejects a Graph nextLink for any other endpoint, compare segments case-insensitively as Graph does, route event and calendar ids through safeUrlPathSegment, and drop calendar tests that restated URL and body builders. (cherry picked from commit 92f96630ab762b4411556a9dce914829fb1f1250) * improvement(ashby): share the error extractor and sync cursor description The webhook provider now imports ashbyErrorMessage from tools/ashby/utils (type-only imports, so the webhook graph stays within the registry-boundary baseline) instead of carrying a copy. Every list tool describes nextSyncCursor with one constant, and tests that restated response mappings are removed. (cherry picked from commit f6f5605cc9c0d757feb2cd7c047fa5524dae457a) * refactor(webhooks): classify every trigger configuration failure through one helper (cherry picked from commit 76265ec284edb751dab31df9c9b6a33b52ba0a29) * fix(tables): list built-in agent tools by base type and teach boolean range rejection The tool picker split Built-in Tools from Integrations with an exact-version list while admission matched base types, so workflow_input landed under Integrations. Split with isCoreAgentToolBlock over the same base-type set and delete BUILT_IN_TOOL_TYPES. The v2 query enrichment now derives its operator list from FILTER_OPS and states that boolean columns reject range operators. Drops the unused select-ops re-export from lib/table/sql.ts. (cherry picked from commit 39fd5f19a5fb598f01929cb43906343501b8e42d) * fix(logs): treat archived workflows as unopenable in the logs list and details List and detail summaries now carry the workflow's archivedAt, and resolveLogWorkflowId returns null for an archived workflow, so the row, its context menu, and the details panel stop linking to a workflow that silently redirects. The row and panel share getLogWorkflowLabel; archived workflows use the deleted label, matching the v2 logs API. (cherry picked from commit fa5a4ac66f72ea1610cee8e958784f807d841fd6) * fix(embeddings): admit tool calls on hosted keys through the shared hosted bucket The tool layer injects a hosted key as an explicit apiKey, which embed() treated as BYOK: tool traffic bypassed the hosted:<provider> admission bucket KB indexing shares, and hosted quota errors read as the customer's key. The tool layer now passes usesHostedKey through the internal operation call to embed(). Billing is unchanged: the response still reports total tokens and the tool layer still decides hosted billing. (cherry picked from commit c1a2d32965e4927af37d63786ddac2c7fa052063) * fix(knowledge): keep removing disabled images past a page that removed nothing removeDisabledImages stopped at the first page whose rows were no longer removable, then cleared imageRemovalPending with later images still indexed. The keyset cursor already advances and the loop ends on an empty page. Docs now say the image cap applies per sync run, with each member-accounts continuation run getting its own budget. (cherry picked from commit 494a999b22b82ee86da1abdb796f6b775633e237) * chore(sim-cli): drop unused Node proxy-support constants from the update check test (cherry picked from commit abaebd6731cd795d52a902ca5dc9e45b108650c4) * fix(realtime): keep file-editor presence fresh when adapter reads time out Skip retrying a fetchSockets adapter timeout (requestsTimeout is fixed at 5s; per-call timeouts are ignored), re-read instead of dropping a roster change that arrived during a failed read, and emit each fetched snapshot before re-reading so sustained churn cannot starve clients. Register the cross-replica presence relay once at startup, share one clearFileDocPresence helper, drop unused FileDocOwner identity fields, and add a two-replica Redis-adapter integration test for the shared roster. (cherry picked from commit 492e6b6151d3d2c6a56e91f85205bc56309fa129) * perf(db): index chat and legacy workspace_file owners for member handoff (cherry picked from commit 60bf0f43128ff9640eaf5730d6619d9e2a58bf87) * perf(files): load creator-handoff version heads in one query (cherry picked from commit ecd2a71c53ecd2dd96899a796fa1763be2823da9) * refactor(workspaces): drop unreachable revoke refusal and name unresolved workspace ids (cherry picked from commit a45a4370a39b2aa9384a5db6ec5ed4f5abcfc411) * docs(forking): note policy readers wait on the sync-default flip (cherry picked from commit a60b4b715ac32b17800708a6378a2de01b1c26ae) * refactor(workflows): share the tool param visibility map between fork sync collectors (cherry picked from commit 3085f20b91bf6ff89bc406b41580075c95619163) * perf(files): fetch only head rows when batching version heads (cherry picked from commit 2962b5b4e590a4aeeff4c2125a1ae0da284908be) * fix(workspaces): disclose inbound source-org custom blocks on admin move and tidy the move flow (cherry picked from commit 29a4eb2015c58ec62943b47ee5bfd39fddcecfec) * test(billing): drop organization-index removal case covered by the handoff integration suite (cherry picked from commit 79a7a3b90710b76a3c325bcfc6f5d6e02e44c1ee) * revert(files): keep the file picker's out-of-scope prune The File block passes picked ids and names to the run without re-checking the folder scope, so the prune is what keeps a stale pick from overriding the folder. * fix(resume): keep the pause retryable when the cancellation subscription is unavailable * test(outlook): cover calendar scoping, the blank-calendar default, and path-id rejection * chore: regenerate Ashby docs and complete the removal test's sync result * fix: cap in-flight SailPoint token exchanges, bound anchored edits, and restore Ashby cursor docs - SailPoint: an evicted lru-cache fetch keeps running, so cap concurrent token exchanges with a counter alongside the cache. - Anchored file edits: rebuild line arrays with slices instead of spreading every inserted line into splice, which overflows the call stack on large inserts; an emptied replace_between keeps the start anchor's own line ending. - Outlook: list events continues only calendarView nextLinks. - Ashby: share nextSyncCursor as a full output const in types.ts so the docs generator resolves its description. - Fix the admin-move fixture's duplicate workflow names, the embeddings test's missing projectInputs, and shrink the Ashby test-types baseline. * revert: keep hosted-key Embeddings tool calls on their own admission bucket This reverts commit 07bd9b9. Routing tool calls that use an injected hosted key through the hosted:<provider> bucket merges hosted Embeddings tool capacity into the bucket knowledge-base indexing shares. * revert(ashby): restore per-tool nextSyncCursor descriptions The docs generator resolves an output description only from a string literal, so routing the description through a shared constant blanked it in the generated integration docs. The shared error extractor stays. * revert(sailpoint): fall back to search for an unrecognized block operation Throwing on an unknown operation in tools.config.tool makes the serializer fall back to the block's first access entry, the approve tool, instead of search. * docs(files): state that occurrence selects the start anchor and the end anchor is the next match after it * fix(workspaces): keep the admin-api actor email fallback when replaying a move's source audit * test(knowledge): complete the disabled-image removal test's sync result and shrink its type baseline * docs(knowledge): say each member-accounts continuation run can read up to the image budget * fix(realtime): restart presence retries after a membership change and cover read races A failed presence read that is not retried still re-reads when membership moved during it; that re-read now starts with a fresh retry allowance. Unit tests cover a membership change during a successful read and a non-retried adapter timeout, and the cross-replica integration polls with an explicit timeout. * test(resume): keep the pause retryable when the cancellation subscription cannot be opened Also documents that non-retryable execution errors cover deterministic configuration failures as well as possibly side-effecting operations. * fix(db): log each archive repair after its retry loop so a logging failure cannot fail a completed repair * fix(files): name an untitled file from its heading only after a real local edit Opening the editor emits an empty update from setEditable, which marked the heading title pending, so leaving an untouched file renamed it. Only updates that change the document mark it now, and a window blur no longer commits a half-typed heading. * fix(logs): keep an archived workflow's name on its logs Only a hard-deleted workflow, which leaves no workflow id, is labeled deleted; an archived workflow keeps its name and stays unopenable. * fix(logs): accept archivedAt on the workflow label input * fix(files): lock the workspace row before the directory lock and the payer only for a non-zero delta Bulk file-folder archive, restore, and delete-by-path took the directory lock before the workspace row, while uploads and registrations take the workspace row while preparing accounting and the directory lock after it. The two orders deadlocked in one workspace. Bulk operations now take the Project lock, the workspace row, then the directory lock, the order every other file writer uses, and the storage docs state that single order. Workspace accounting preparation locks only the workspace row; the payer row is locked when a non-zero delta is applied, so zero-delta and collab-state saves no longer serialize on the organization row or fail when a personal payer has no stats row. A zero workspace delta returns undefined; Project accounting is unchanged. Content commits drop a repeated Project lock that accounting already holds, a folder-id upload target is checked with a point query instead of loading every folder path, and the list query keeps one file-id filter. * fix(retention): isolate per-owner failures in unbudgeted runs and lock only owners with work Without cleanup budgets, a failing workspace file-folder cleanup or a failing Project retention pass is logged and the run continues, so later targets (knowledge bases, folder and resource targets, orphan bindings, chat external cleanup) still run; budgeted runs still fail whole. Workspace file-folder cleanup selects the workspaces that have an expired file folder first and opens locked transactions only for those. Project version and folder retention no longer lock the storage payer; only archived-file purges, which apply a delta, prepare the mutation. The free-plan superseded-version cap is one shared constant. Tests: an unbudgeted folder deletion failure resolves, rolls its tree back, and still purges a later expired row; the archive release asserts the workspace ledger; the unsupported-owner case that production cannot produce is removed. * fix(files): log a deferred inline live-doc reconcile as a warning and tighten its tests An exception from the inline live-document reconcile is retried by the outbox, so it logs at warn again; dead-letter and not-found results stay errors. The content-effects test keeps the one behavior (an inline exception resolves), and the soft-delete tests assert that unbilled and organization purges open no billed transaction instead of checking billing mocks that are no longer on the path. * fix(outlook): encode a slash in Graph event and calendar ids instead of rejecting it Graph ids can contain '/', so event and calendar ids are trimmed, empty and dot-segment ids are refused by name, and the rest is encoded as one path segment ('/' becomes %2F). The request transport still rejects any dot segment in the final URL. * refactor(tools): encode opaque Graph ids through safeUrlPathSegment with separators allowed Outlook event and calendar ids share the guarded path-segment helper (type guards, lone-surrogate check, dot-segment rejection) instead of a local copy, opting in to percent-encoding / and \. The storage lock-order note says the Project lock step applies to workspace writers only. * test(files): scope the bulk-archive lock barrier to waiters queued behind the directory holder * refactor(outlook): mark the Graph id segment options as const * fix(retention): charge committed Project version batches to the shared run budget Project version cleanup commits one transaction per batch. An unbudgeted run isolates a failing owner, which reported zero rows even when batches before the failure had committed, so later owners received the full remaining limit again. Cleanup now charges each committed batch to one RowBudget shared across the run, matching how archive cleanup decrements its budgets in place.
1 parent 22f096b commit bbfbf4e

166 files changed

Lines changed: 34227 additions & 2097 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/docs/content/docs/integrations/file.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ Apply one precise edit to an existing text file without rewriting it. Use search
198198
| `anchor` | string | No | For insert_after, the complete line after which content is inserted. |
199199
| `startAnchor` | string | No | For delete_between, the complete first line to delete. The start anchor is removed. |
200200
| `endAnchor` | string | No | For delete_between, the complete ending boundary line. The end anchor remains in the file. |
201-
| `occurrence` | number | No | For anchored edits, which matching anchor occurrence to use, starting at 1. Defaults to 1. |
201+
| `occurrence` | number | No | For anchored edits, which start or insert anchor match to use, starting at 1. Defaults to 1. The end anchor is the first match after it. |
202202
| `expectedRevision` | string | No | Refuse the edit unless the file still holds the content this revision names, as returned by Get File or an earlier write. Use it so an edit computed from what you read cannot overwrite someone else’s change. |
203203

204204
#### Output

‎apps/docs/content/docs/knowledgebase/connectors.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ Images smaller than **2 KiB**, SVG, HEIC, and AVIF files are not indexed through
200200

201201
New knowledge-base Confluence connections enable image reading by default. Connections created before image support keep it off until you choose **Read with AI**; their next sync then indexes images.
202202

203-
The default admission limit is **500 new or changed images per sync**. Remaining images wait for another sync. Self-hosted operators can configure `KB_IMAGE_EXTRACTIONS_PER_SYNC`. Unchanged images reuse their extraction, including during a full resync; transient or malformed responses can cause additional model requests.
203+
The default admission limit is **500 new or changed images per sync**. Remaining images wait for another sync. With **Member accounts**, each run that continues a sync can read up to 500. Self-hosted operators can configure `KB_IMAGE_EXTRACTIONS_PER_SYNC`. Unchanged images reuse their extraction, including during a full resync; transient or malformed responses can cause additional model requests.
204204

205205
Image extraction follows [model billing](/platform/costs): BYOK usage is billed by the provider, while platform-key usage follows the deployment's Sim billing policy. Choose **Don't index** to stop image indexing; the next sync removes indexed images. Switching back processes them again and incurs the applicable model usage.
206206

‎apps/docs/content/docs/platform/self-hosting/environment-variables.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ For Ollama on the Docker host, use `OLLAMA_URL=http://host.docker.internal:11434
124124
| Variable | Description |
125125
|----------|-------------|
126126
| `KB_IMAGE_EXTRACTION_MODEL` | Gemini model that reads Confluence image attachments into searchable text. Defaults to `gemini-3.8-flash`; a value that is not a Gemini 3 or later model in the catalog falls back to the default. Needs `GEMINI_API_KEY` (or a workspace Gemini key); without one, image attachments are left out of syncs |
127-
| `KB_IMAGE_EXTRACTIONS_PER_SYNC` | New or changed images one connector sync sends for reading, at least `1`. Defaults to `500`; the rest wait for the next sync, which reports as partial |
127+
| `KB_IMAGE_EXTRACTIONS_PER_SYNC` | New or changed images one connector sync run sends for reading, at least `1`. Defaults to `500`; the rest wait for the next sync, which reports as partial. With member accounts, each continuation run can read up to this many |
128128
| `KB_CONFIG_VISION_REQUESTS_PER_MINUTE` | Image reads per minute per Gemini key, shared by every sync on the deployment. Defaults to `300`; lower it to match a key with a smaller Gemini quota |
129129
| `KB_EMBEDDING_MODEL` | Embedding model for new knowledge bases. Defaults to `text-embedding-3-small`; use `ollama/<model>` for a model on your own Ollama. An unrecognised hosted model id falls back to the default, but an `ollama/` id is taken at face value — if that model is not on the server, knowledge-base creation fails rather than falling back |
130130
| `EMBEDDING_OUTPUT_DIMS` | Stored vector width: `384`, `768`, `1024`, `1536`, or `3072`. Hosted models default to `1536` when supported, otherwise their largest storable width. Invalid hosted widths fall back with a warning; Ollama can infer its actual width when unset |

‎apps/docs/openapi-v2-files-audit.json‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6920,7 +6920,7 @@
69206920
"description": "Replacement text. An empty string clears the interior."
69216921
},
69226922
"occurrence": {
6923-
"description": "Matching anchor occurrence, starting at 1. Defaults to 1.",
6923+
"description": "Which beforeAnchor match to use, starting at 1. Defaults to 1. afterAnchor is the first match after it.",
69246924
"type": "integer",
69256925
"minimum": 1,
69266926
"maximum": 9007199254740991
@@ -6948,7 +6948,7 @@
69486948
"description": "Text to insert."
69496949
},
69506950
"occurrence": {
6951-
"description": "Matching anchor occurrence, starting at 1. Defaults to 1.",
6951+
"description": "Which anchor match to insert after, starting at 1. Defaults to 1.",
69526952
"type": "integer",
69536953
"minimum": 1,
69546954
"maximum": 9007199254740991
@@ -6976,7 +6976,7 @@
69766976
"description": "Ending boundary line. This end anchor remains."
69776977
},
69786978
"occurrence": {
6979-
"description": "Matching anchor occurrence, starting at 1. Defaults to 1.",
6979+
"description": "Which startAnchor match to use, starting at 1. Defaults to 1. endAnchor is the first match after it.",
69806980
"type": "integer",
69816981
"minimum": 1,
69826982
"maximum": 9007199254740991
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import { mkdir, writeFile } from 'node:fs/promises'
2+
import { createServer, type Server as HttpServer } from 'node:http'
3+
import { dirname, resolve } from 'node:path'
4+
import {
5+
FILE_DOC_EVENTS,
6+
FILE_DOC_SCHEMA_VERSION,
7+
FILE_DOC_SEED,
8+
type FileDocPresence,
9+
type JoinFileDocSuccess,
10+
} from '@sim/realtime-protocol/file-doc'
11+
import { getErrorMessage } from '@sim/utils/errors'
12+
import { generateId } from '@sim/utils/id'
13+
import { createAdapter } from '@socket.io/redis-adapter'
14+
import { createClient, type RedisClientType } from 'redis'
15+
import { Server } from 'socket.io'
16+
import { io as connect, type Socket } from 'socket.io-client'
17+
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
18+
import * as Y from 'yjs'
19+
import type { AuthenticatedSocket } from '@/middleware/auth'
20+
21+
const redisUrl = process.env.REDIS_URL
22+
if (!redisUrl) throw new Error('Integration setup must provide disposable Redis')
23+
const internalSecret = process.env.INTERNAL_API_SECRET
24+
25+
const projectId = generateId()
26+
const fileId = generateId()
27+
const roomName = `project-file-doc:${projectId}/${fileId}`
28+
const docId = generateId()
29+
const seedDoc = new Y.Doc()
30+
seedDoc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.flag, true)
31+
seedDoc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.docIdKey, docId)
32+
const seed = Buffer.from(Y.encodeStateAsUpdate(seedDoc)).toString('base64')
33+
seedDoc.destroy()
34+
35+
interface Replica {
36+
io: Server
37+
url: string
38+
pub: RedisClientType
39+
sub: RedisClientType
40+
shutdownStore: () => Promise<void>
41+
}
42+
43+
const checks: { name: string; status: string; durationMs: number; error?: string }[] = []
44+
const clients: Socket[] = []
45+
const replicas: Replica[] = []
46+
let app: HttpServer
47+
48+
async function listen(server: HttpServer) {
49+
await new Promise<void>((done) => server.listen(0, '127.0.0.1', done))
50+
const address = server.address()
51+
if (!address || typeof address === 'string') throw new Error('Missing fixture port')
52+
return `http://127.0.0.1:${address.port}`
53+
}
54+
55+
/**
56+
* One realtime replica: a fresh module graph (its own rooms, owners and document store, as in a
57+
* separate process) behind its own Socket.IO server on the shared Redis adapter.
58+
*/
59+
async function startReplica(): Promise<Replica> {
60+
vi.resetModules()
61+
const fileDoc = await import('@/handlers/file-doc')
62+
const fileDocStore = await import('@/handlers/file-doc-store')
63+
const { MemoryRoomManager } = await import('@/rooms')
64+
await fileDocStore.initFileDocStore(redisUrl)
65+
const pub: RedisClientType = createClient({ url: redisUrl })
66+
const sub: RedisClientType = createClient({ url: redisUrl })
67+
await Promise.all([pub.connect(), sub.connect()])
68+
const http = createServer()
69+
const io = new Server(http)
70+
io.adapter(createAdapter(pub, sub))
71+
fileDoc.registerFileDocPresenceRelay(io)
72+
const manager = new MemoryRoomManager(io)
73+
io.on('connection', (socket) => {
74+
const authed = socket as AuthenticatedSocket
75+
authed.userId = socket.handshake.auth.actor
76+
authed.userName = `Fixture ${socket.handshake.auth.actor}`
77+
authed.userImage = 'https://fixture.invalid/avatar.png'
78+
fileDoc.setupWorkspaceFileDocHandlers(authed, manager)
79+
socket.on('disconnect', () => fileDoc.cleanupFileDocForSocket(socket.id, io, true))
80+
})
81+
const replica = {
82+
io,
83+
url: await listen(http),
84+
pub,
85+
sub,
86+
shutdownStore: () => fileDocStore.getFileDocStore().shutdown(),
87+
}
88+
replicas.push(replica)
89+
return replica
90+
}
91+
92+
beforeAll(async () => {
93+
app = createServer((request, response) => {
94+
response.setHeader('Content-Type', 'application/json')
95+
const prefix = `/api/internal/project-file-doc/${projectId}/${fileId}/`
96+
if (request.headers['x-api-key'] !== internalSecret || !request.url?.startsWith(prefix)) {
97+
response.writeHead(401).end('{}')
98+
return
99+
}
100+
const action = request.url.slice(prefix.length)
101+
if (action === 'access') {
102+
response.end(JSON.stringify({ projectId, fileId, canRead: true, canWrite: true, docId }))
103+
} else if (action === 'seed') {
104+
response.end(JSON.stringify({ update: seed, version: 1 }))
105+
} else if (action === 'persist') {
106+
response.end(JSON.stringify({ status: 'persisted', version: 2 }))
107+
} else {
108+
response.writeHead(404).end('{}')
109+
}
110+
})
111+
process.env.NEXT_PUBLIC_APP_URL = await listen(app)
112+
})
113+
114+
/** Joins the shared file through `replica`, recording every roster the client receives. */
115+
async function joinThrough(replica: Replica, actor: string) {
116+
const socket = connect(replica.url, { transports: ['websocket'], auth: { actor } })
117+
clients.push(socket)
118+
const rosters: string[][] = []
119+
socket.on(FILE_DOC_EVENTS.PRESENCE, (payload: FileDocPresence) => {
120+
if (payload.fileId === fileId) rosters.push(payload.users.map((user) => user.socketId).sort())
121+
})
122+
await new Promise<void>((done) => socket.once('connect', done))
123+
const joined = new Promise<JoinFileDocSuccess>((done, fail) => {
124+
socket.once(FILE_DOC_EVENTS.JOIN_SUCCESS, done)
125+
socket.once(FILE_DOC_EVENTS.JOIN_ERROR, (error) => fail(new Error(error.code)))
126+
})
127+
const clientDoc = new Y.Doc()
128+
const clientId = clientDoc.clientID
129+
clientDoc.destroy()
130+
socket.emit(FILE_DOC_EVENTS.JOIN, {
131+
fileId,
132+
projectId,
133+
clientId,
134+
schemaVersion: FILE_DOC_SCHEMA_VERSION,
135+
})
136+
await joined
137+
const id = socket.id
138+
if (!id) throw new Error('Connected socket has no id')
139+
return { socket, id, latestRoster: () => rosters.at(-1) }
140+
}
141+
142+
function check(name: string, run: () => Promise<void>) {
143+
it(name, async () => {
144+
const start = performance.now()
145+
try {
146+
await run()
147+
checks.push({ name, status: 'passed', durationMs: performance.now() - start })
148+
} catch (error) {
149+
checks.push({
150+
name,
151+
status: 'failed',
152+
durationMs: performance.now() - start,
153+
error: getErrorMessage(error),
154+
})
155+
throw error
156+
}
157+
})
158+
}
159+
160+
describe('File editor presence across replicas sharing the Redis adapter', () => {
161+
check(
162+
'each replica lists collaborators joined through the other, and drops a departed one',
163+
async () => {
164+
const first = await startReplica()
165+
const second = await startReplica()
166+
const alice = await joinThrough(first, 'fixture-alice')
167+
const bob = await joinThrough(second, 'fixture-bob')
168+
const both = [alice.id, bob.id].sort()
169+
170+
await expect.poll(alice.latestRoster, { timeout: 5_000 }).toEqual(both)
171+
await expect.poll(bob.latestRoster, { timeout: 5_000 }).toEqual(both)
172+
173+
bob.socket.disconnect()
174+
await expect.poll(alice.latestRoster, { timeout: 5_000 }).toEqual([alice.id])
175+
}
176+
)
177+
})
178+
179+
afterAll(async () => {
180+
for (const client of clients) client.disconnect()
181+
for (const replica of replicas) {
182+
await replica.io.close()
183+
await replica.shutdownStore()
184+
}
185+
const cleanup = replicas[0]?.pub
186+
if (cleanup?.isOpen) {
187+
const keys = await cleanup.keys(`filedoc:*:${roomName}`)
188+
if (keys.length) await cleanup.del(keys)
189+
}
190+
for (const replica of replicas) {
191+
for (const client of [replica.pub, replica.sub]) if (client.isOpen) await client.quit()
192+
}
193+
await new Promise<void>((done) => app.close(() => done()))
194+
const reportPath =
195+
process.env.FILE_DOC_PRESENCE_REPORT_PATH ?? resolve('test-results/file-doc-presence.json')
196+
await mkdir(dirname(reportPath), { recursive: true })
197+
await writeFile(reportPath, JSON.stringify({ checks }, null, 2))
198+
})

‎apps/realtime/src/handlers/file-doc.redis-store.test.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,6 @@ function createIo(sockets: FakeSocket[]) {
160160
in: vi.fn(() => ({ socketsLeave: () => {} })),
161161
local: { to },
162162
sockets: { sockets: { get: (id: string) => sockets.find((socket) => socket.id === id) } },
163-
on: vi.fn(),
164163
serverSideEmit: vi.fn(),
165164
} as unknown as IRoomManager['io']
166165
}

‎apps/realtime/src/handlers/file-doc.test.ts‎

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,10 @@ interface SentMessage {
6161
}
6262

6363
/** An `io` mock that records every server-originated emit with its target/except. */
64-
function createIo(deliver?: (message: SentMessage) => void) {
64+
function createIo(
65+
deliver?: (message: SentMessage) => void,
66+
fetchSockets = async () => [...testSockets.values()]
67+
) {
6568
const sent: SentMessage[] = []
6669
const emit = (message: SentMessage) => {
6770
sent.push(message)
@@ -72,7 +75,7 @@ function createIo(deliver?: (message: SentMessage) => void) {
7275
const channel = (target: string, excluded: string[] = []) => ({
7376
except: (exclude: string) => channel(target, [...excluded, exclude]),
7477
emit: (event: string, payload: unknown) => emit({ target, except: excluded, event, payload }),
75-
fetchSockets: async () => [...testSockets.values()],
78+
fetchSockets,
7679
})
7780
const to = vi.fn((target: string) => channel(target))
7881
const inFn = vi.fn((socketId: string) => ({
@@ -87,7 +90,6 @@ function createIo(deliver?: (message: SentMessage) => void) {
8790
in: inFn,
8891
local: { to },
8992
sockets: { sockets: testSockets },
90-
on: vi.fn(),
9193
serverSideEmit: vi.fn(),
9294
} as unknown as IRoomManager['io'],
9395
sent,
@@ -1107,6 +1109,71 @@ describe('setupWorkspaceFileDocHandlers', () => {
11071109
expect(sent.some((m) => m.event === FILE_DOC_EVENTS.MESSAGE)).toBe(false)
11081110
})
11091111

1112+
it('delivers a roster change that arrived while the last presence read was failing', async () => {
1113+
vi.useFakeTimers()
1114+
try {
1115+
let reads = 0
1116+
const { io, sent } = createIo(undefined, async () => {
1117+
reads += 1
1118+
if (reads === 3) await b.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 2 })
1119+
if (reads <= 3) throw new Error('Redis publish failed')
1120+
return [...testSockets.values()]
1121+
})
1122+
const a = setup('socket-a', io, { userId: 'user-a' })
1123+
const b = setup('socket-b', io, { userId: 'user-b' })
1124+
1125+
await a.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 })
1126+
await vi.advanceTimersByTimeAsync(1_000)
1127+
1128+
const roster = sent.filter((m) => m.event === FILE_DOC_EVENTS.PRESENCE).at(-1)?.payload as
1129+
| { users: Array<{ socketId: string }> }
1130+
| undefined
1131+
expect(roster?.users.map((user) => user.socketId).sort()).toEqual(['socket-a', 'socket-b'])
1132+
} finally {
1133+
vi.useRealTimers()
1134+
}
1135+
})
1136+
1137+
it('re-reads the roster when membership changes during a successful presence read', async () => {
1138+
let reads = 0
1139+
const { io, sent } = createIo(undefined, async () => {
1140+
reads += 1
1141+
const snapshot = [...testSockets.values()]
1142+
if (reads === 1) await b.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 2 })
1143+
return snapshot
1144+
})
1145+
const a = setup('socket-a', io, { userId: 'user-a' })
1146+
const b = setup('socket-b', io, { userId: 'user-b' })
1147+
1148+
await a.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 })
1149+
await vi.waitFor(() => expect(reads).toBe(2))
1150+
1151+
const roster = sent.filter((m) => m.event === FILE_DOC_EVENTS.PRESENCE).at(-1)?.payload as
1152+
| { users: Array<{ socketId: string }> }
1153+
| undefined
1154+
expect(roster?.users.map((user) => user.socketId).sort()).toEqual(['socket-a', 'socket-b'])
1155+
})
1156+
1157+
it('does not retry a presence read the adapter timed out', async () => {
1158+
vi.useFakeTimers()
1159+
try {
1160+
let reads = 0
1161+
const { io, sent } = createIo(undefined, async () => {
1162+
reads += 1
1163+
throw new Error('timeout reached while waiting for fetchSockets response')
1164+
})
1165+
const a = setup('socket-a', io, { userId: 'user-a' })
1166+
1167+
await a.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 })
1168+
await vi.advanceTimersByTimeAsync(1_000)
1169+
1170+
expect(reads).toBe(1)
1171+
expect(sent.some((m) => m.event === FILE_DOC_EVENTS.PRESENCE)).toBe(false)
1172+
} finally {
1173+
vi.useRealTimers()
1174+
}
1175+
})
1176+
11101177
it('broadcasts a server-authenticated presence roster on join, one entry per session', async () => {
11111178
const { io, sent } = createIo()
11121179
const a = setup('socket-a', io, { userId: 'user-a', userName: 'Ada', userImage: 'ada.png' })

0 commit comments

Comments
 (0)