snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-23 16:12:52 +05:00
commit a774ac673a
4541 changed files with 770499 additions and 0 deletions

View File

@@ -0,0 +1,23 @@
# @tldraw/dotcom-shared
This is a small packaged used by [apps/dotcom](https://github.com/tldraw/tldraw/tree/main/apps/dotcom) and by [apps/dotcom-worker](https://github.com/tldraw/tldraw/tree/main/apps/dotcom-worker) to share constants across our different apps.
## Contribution
Found a bug? Please [submit an issue](https://github.com/tldraw/tldraw/issues/new).
## License
The code in this folder is Copyright (c) 2024-present tldraw Inc. The tldraw SDK is provided under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md).
## Trademarks
Copyright (c) 2024-present tldraw Inc. The tldraw name and logo are trademarks of tldraw. Please see our [trademark guidelines](https://github.com/tldraw/tldraw/blob/main/TRADEMARKS.md) for info on acceptable usage.
## Contact
Find us on Twitter/X at [@tldraw](https://twitter.com/tldraw).
## Community
Have questions, comments or feedback? [Join our discord](https://discord.tldraw.com/?utm_source=github&utm_medium=readme&utm_campaign=sociallink). For the latest news and release notes, visit [tldraw.dev](https://tldraw.dev).

View File

@@ -0,0 +1,40 @@
{
"name": "@tldraw/dotcom-shared",
"version": "2.0.0",
"private": true,
"/* NOTE */": "These `main` and `types` fields are rewritten by the build script. They are not the actual values we publish",
"main": "./src/index.ts",
"types": "./.tsbuild/index.d.ts",
"/* GOTCHA */": "files will include ./dist and index.d.ts by default, add any others you want to include in here",
"files": [],
"type": "module",
"dependencies": {
"@rocicorp/zero": "1.9.0",
"@tldraw/state": "workspace:*",
"@tldraw/store": "workspace:*",
"@tldraw/tlschema": "workspace:*",
"@tldraw/utils": "workspace:*",
"@tldraw/validate": "workspace:*",
"tldraw": "workspace:*"
},
"peerDependencies": {
"react": "^18.2.0 || ^19.2.1",
"react-dom": "^18.2.0 || ^19.2.1"
},
"devDependencies": {
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"vitest": "^5.0.0"
},
"scripts": {
"test-ci": "yarn run -T vitest run --passWithNoTests",
"test": "yarn run -T vitest --passWithNoTests",
"lint": "yarn run -T tsx ../../internal/scripts/lint.ts",
"test-coverage": "yarn run -T vitest run --coverage --passWithNoTests"
},
"engines": {
"node": ">=22.12.0"
}
}

View File

@@ -0,0 +1,13 @@
/**
* Every capability — a single thing someone can do in a workspace. Exported as a
* runtime list so all capabilities can be enumerated; the {@link Capability}
* type is derived from it. Roles grant subsets of these — see `roles.ts`.
*/
export const capabilities = [
'accessFiles', // open and edit the workspace's files (any member)
'addFiles', // move a file into the workspace
'removeFiles', // remove a file from the workspace
'manageWorkspace', // manage the workspace: its invite link, members' roles, name, and deletion (owners)
] as const
export type Capability = (typeof capabilities)[number]

View File

@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest'
import { extractMentionIds } from './commentMentions'
describe('extractMentionIds', () => {
it('collects mention node ids wherever they nest, deduped, in first-seen order', () => {
const body = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'hi ' },
{ type: 'mention', attrs: { id: 'user_a', label: 'A' } },
{ type: 'mention', attrs: { id: 'user_b', label: 'B' } },
{ type: 'mention', attrs: { id: 'user_a', label: 'A again' } },
],
},
{
type: 'bulletList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'mention', attrs: { id: 'user_c' } }],
},
],
},
],
},
],
}
expect(extractMentionIds(body)).toEqual(['user_a', 'user_b', 'user_c'])
})
it('returns an empty array for a body without mentions', () => {
expect(
extractMentionIds({
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'plain' }] }],
})
).toEqual([])
})
it('ignores mention nodes without a string id', () => {
expect(
extractMentionIds({
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'mention', attrs: { id: 123 } }, { type: 'mention' }],
},
],
})
).toEqual([])
})
it('tolerates non-conforming input', () => {
expect(extractMentionIds(null)).toEqual([])
expect(extractMentionIds(undefined)).toEqual([])
expect(extractMentionIds('a string')).toEqual([])
expect(extractMentionIds({ type: 'doc' })).toEqual([])
})
})

View File

@@ -0,0 +1,29 @@
/**
* Collects the member ids `@`-mentioned in a comment's rich-text body. Mentions are TipTap
* `mention` nodes embedded in the body JSON — `{ type: 'mention', attrs: { id, label } }` — where
* `attrs.id` is the mentioned member's user id. Duplicate mentions of the same member collapse to
* one id.
*
* Shared between the sync-worker (which extracts mention rows into the `comment_mention` table
* when draining comment records to Postgres, so the notifications query can filter "comments that
* mention me" server-side) and the client (which labels notification rows). The body is typed
* `unknown` because callers hold it as different JSON types (TLRichText, ReadonlyJSONValue, raw
* Postgres JSONB); any non-conforming input just yields no mentions.
*/
export function extractMentionIds(body: unknown): string[] {
const ids = new Set<string>()
const visit = (node: unknown) => {
if (typeof node !== 'object' || node === null) return
const { type, attrs, content } = node as {
type?: unknown
attrs?: { id?: unknown }
content?: unknown
}
if (type === 'mention' && typeof attrs?.id === 'string') ids.add(attrs.id)
if (Array.isArray(content)) {
for (const child of content) visit(child)
}
}
visit(body)
return [...ids]
}

View File

@@ -0,0 +1,15 @@
export const MAX_NUMBER_OF_FILES = 200
export const MAX_NUMBER_OF_WORKSPACES = 20
export const MAX_WORKSPACE_NAME_LENGTH = 100
export const ROOM_SIZE_LIMIT_MB = 25
/**
* `createSource` value for a new workspace's first file. The sync worker resolves it to the
* current welcome template's published snapshot (or a committed default if none is set),
* forking that content into the new file. Unlike the other `createSource` prefixes this is a
* fixed marker with no id — the worker decides which file is the welcome template — so the
* client never needs to know the template's slug and an admin can retarget it without a
* client change.
*/
export const WELCOME_CREATE_SOURCE = 'welcome'

View File

@@ -0,0 +1,3 @@
it('works', () => {
// we need a test for vitest to pass.
})

View File

@@ -0,0 +1,13 @@
/* eslint-disable tldraw/no-export-star */
export * from './capabilities'
export * from './commentMentions'
export * from './constants'
export * from './roles'
export * from './shapeClusters'
export { default as getLicenseKey } from './license'
export * from './mutators'
export * from './queries'
export * from './routes'
export * from './thumbnailScreenshot'
export * from './tlaSchema'
export * from './types'

View File

@@ -0,0 +1,4 @@
const getLicenseKey = () =>
process.env.TLDRAW_LICENSE ||
'tldraw-tldraw-2027-07-10/WyJ2UFhWM3pQXyIsWyIqLnRsZHJhdy5jb20iLCIqLnRsZHJhdy5kZXYiLCIqLnRsZHJhdy5jbHViIiwiKi50bGRyYXcud29ya2Vycy5kZXYiXSw3MywiMjAyNy0wNy0xMCJd.FrzdF5VBeeeGIqQELpXvNAyIy/Ow9ZJJT5qkuRD42atd5FyhlR0xYasIZvaQcG9tSAFgjq8DMcc/yopspHmWyw'
export default getLicenseKey

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,665 @@
import { createBuilder, type CustomMutatorDefs, type Transaction } from '@rocicorp/zero'
import {
assert,
getIndexBelow,
IndexKey,
sortByIndex,
sortByMaybeIndex,
uniqueId,
} from '@tldraw/utils'
import { MAX_NUMBER_OF_WORKSPACES, MAX_WORKSPACE_NAME_LENGTH } from './constants'
import { Role, can, isRole } from './roles'
import { FILE_PREFIX } from './routes'
import {
immutableColumns,
schema,
TlaFile,
TlaFilePartial,
TlaFileState,
TlaFileStatePartial,
TlaFlags,
TlaSchema,
TlaUser,
TlaUserPartial,
} from './tlaSchema'
import { ZErrorCode } from './types'
/** Query builder for mutators - uses Zero's createBuilder API */
const zql = createBuilder(schema)
type Tx = Transaction<TlaSchema>
/**
* Parse a flags string into an array of individual flags.
* Supports flags separated by commas, spaces, or both.
* @param flags - The flags string to parse (e.g., "flag1,flag2" or "flag1 flag2")
* @returns Array of individual flag strings
*/
export function parseFlags(flags: string | null | undefined): string[] {
return flags?.split(/[,\s]+/).filter(Boolean) ?? []
}
/**
* Check if a flags string contains a specific flag.
* @param flags - The flags string to check
* @param flag - The flag to look for
* @returns true if the flag is present
*/
export function userHasFlag(flags: string | null | undefined, flag: TlaFlags): boolean {
return parseFlags(flags).includes(flag)
}
function disallowImmutableMutations<
S extends TlaFilePartial | TlaFileStatePartial | TlaUserPartial,
>(data: S, immutableColumns: Set<keyof S>) {
for (const immutableColumn of immutableColumns) {
assert(!(immutableColumn in data), ZErrorCode.forbidden)
}
}
export type TlaMutators = ReturnType<typeof createMutators>
// A client timestamp more than 5s stale, or in the future, is replaced by the server's clock.
function ensureSensibleTimestamp(time: number) {
// if a mutation took more than 5 seconds to reach the server, or is in the future, let's use the server's time
const now = Date.now()
return time < now - 5000 || time > now ? now : time
}
/**
* Resolve the user's role in a workspace, or null if they aren't a member. Pair it
* with `can` from ./roles: `const role = await getRole(...); assert(can(role, 'x'))`.
*/
async function getRole(
tx: Tx,
userId: string,
workspaceId: string | null | undefined
): Promise<Role | null> {
if (!workspaceId) return null
// A user's personal/home workspace has an id equal to their userId; they own it.
if (workspaceId === userId) return 'owner'
const workspaceUser = await tx.run(
zql.group_user.where('userId', '=', userId).where('groupId', '=', workspaceId).one()
)
return workspaceUser?.role ?? null
}
/**
* The home workspace (group id === its owner's user id) is private: it can't be
* invited to, left, deleted, or have its members managed. (It can be renamed.)
* Throw if `workspaceId` is a home workspace, i.e. a user exists with a matching id.
*/
async function assertNotHomeWorkspace(tx: Tx, workspaceId: string) {
const user = await tx.run(zql.user.where('id', '=', workspaceId).one())
assert(!user, ZErrorCode.forbidden)
}
function assertValidId(id: string) {
assert(id.match(/^[a-zA-Z0-9_-]+$/), ZErrorCode.bad_request)
assert(id.length <= 32, ZErrorCode.bad_request)
assert(id.length >= 16, ZErrorCode.bad_request)
}
/**
* Check if a user has the required permissions for a file.
* @param tx - The transaction
* @param userId - The user ID to check permissions for
* @param file - The file to check permissions on
* @param allowGuestAccess - If true, shared files are accessible even if the user isn't a member
*/
async function assertUserCanAccessFileInternal(
tx: Tx,
userId: string,
file: TlaFile,
allowGuestAccess: boolean
) {
assert(file, ZErrorCode.bad_request)
assert(!file.isDeleted, ZErrorCode.bad_request)
// If shared and we allow shared access, grant access immediately
if (allowGuestAccess && file.shared) return
assert(file.owningGroupId, ZErrorCode.bad_request)
const role = await getRole(tx, userId, file.owningGroupId)
assert(can(role, 'accessFiles'), ZErrorCode.forbidden)
}
/**
* Check if a user can access (read) a file.
* A user can access a file if:
* - They are a member of the owning workspace (new model: user is in file.owningGroupId)
* - The file is shared
*/
async function assertUserCanAccessFile(tx: Tx, userId: string, file: TlaFile) {
await assertUserCanAccessFileInternal(tx, userId, file, true)
}
/**
* Check if a user can update (write to) a file.
* A user can update a file if:
* - They are a member of the owning workspace (new model: user is in file.owningGroupId)
* Note: Sharing only grants read access, not write access
*/
async function assertUserCanUpdateFile(tx: Tx, userId: string, file: TlaFile) {
await assertUserCanAccessFileInternal(tx, userId, file, false)
}
async function assertUserCanAccessFileById(tx: Tx, userId: string, fileId: string) {
const file = await tx.run(zql.file.where('id', '=', fileId).one())
await assertUserCanAccessFile(tx, userId, file!)
}
export function createMutators(userId: string) {
const mutators = {
user: {
/** @deprecated */
insert: async (tx: Tx, user: TlaUser) => {
assert(userId === user.id, ZErrorCode.forbidden)
await tx.mutate.user.insert(user)
},
update: async (tx: Tx, user: TlaUserPartial) => {
assert(userId === user.id, ZErrorCode.forbidden)
disallowImmutableMutations(user, immutableColumns.user)
await tx.mutate.user.update(user)
},
},
file: {
update: async (tx: Tx, _file: TlaFilePartial) => {
disallowImmutableMutations(_file, immutableColumns.file)
const file = await tx.run(zql.file.where('id', '=', _file.id).one())
await assertUserCanUpdateFile(tx, userId, file!)
await tx.mutate.file.update({
..._file,
id: file!.id,
})
},
},
file_state: {
/** @deprecated now update creates if not exists */
insert: async (tx: Tx, fileState: TlaFileState) => {
assert(fileState.userId === userId, ZErrorCode.forbidden)
if (tx.location === 'server') {
// Verify the user has access to this file
await assertUserCanAccessFileById(tx, userId, fileState.fileId)
}
// use upsert under the hood here for a little fault tolerance
await tx.mutate.file_state.upsert(fileState)
},
update: async (tx: Tx, fileState: TlaFileStatePartial) => {
assert(fileState.userId === userId, ZErrorCode.forbidden)
disallowImmutableMutations(fileState, immutableColumns.file_state)
if (tx.location === 'server') {
// Verify the user has access to this file
await assertUserCanAccessFileById(tx, userId, fileState.fileId)
}
const exists = await tx.run(
zql.file_state.where('fileId', '=', fileState.fileId).where('userId', '=', userId).one()
)
// if the file state does not exist, do nothing
if (!exists) return
await tx.mutate.file_state.upsert(fileState)
},
},
comment: {
/**
* Mark a comment as read by the current user. Row present = read; readAt is stored so
* an "edits reset unread" rule can later be added client-side without a migration.
*/
markRead: async (tx: Tx, { commentId, readAt }: { commentId: string; readAt: number }) => {
if (tx.location === 'server') {
// Verify the comment exists and the user can access its file
const comment = await tx.run(zql.comment.where('id', '=', commentId).one())
assert(comment, ZErrorCode.bad_request)
await assertUserCanAccessFileById(tx, userId, comment.fileId)
}
await tx.mutate.comment_read.upsert({
userId,
commentId,
readAt: ensureSensibleTimestamp(readAt),
})
},
/**
* Mark a batch of comments as read by the current user in one mutation — opening a thread
* or "mark all read" would otherwise pay a mutation (comment lookup, file lookup, access
* check) per comment. One access check per distinct fileId covers the whole batch.
*/
markManyRead: async (
tx: Tx,
{ commentIds, readAt }: { commentIds: string[]; readAt: number }
) => {
const uniqueIds = [...new Set(commentIds)]
if (uniqueIds.length === 0) return
if (tx.location === 'server') {
// Verify every comment exists and the user can access each involved file
const comments = await tx.run(zql.comment.where('id', 'IN', uniqueIds))
assert(comments.length === uniqueIds.length, ZErrorCode.bad_request)
for (const fileId of new Set(comments.map((comment) => comment.fileId))) {
await assertUserCanAccessFileById(tx, userId, fileId)
}
}
const timestamp = ensureSensibleTimestamp(readAt)
for (const commentId of uniqueIds) {
await tx.mutate.comment_read.upsert({ userId, commentId, readAt: timestamp })
}
},
/** Mark a comment as unread by deleting the current user's read row. Own-row-only by construction. */
markUnread: async (tx: Tx, { commentId }: { commentId: string }) => {
await tx.mutate.comment_read.delete({ userId, commentId })
},
},
createFile: async (
tx: Tx,
{
fileId,
workspaceId,
name,
time,
createSource,
}: {
fileId: string
workspaceId: string
name: string
time: number
createSource: string | null
}
) => {
time = ensureSensibleTimestamp(time)
// Security: when a new file is seeded from another app file (the Duplicate
// action sets `createSource` to `${FILE_PREFIX}/${sourceFileId}`), the user
// must be able to read that source file. The content copy happens later in
// the worker (handleFileCreateFromSource), which trusts `createSource`
// verbatim, so the authorization has to happen here at creation time.
// Without this, a user who can still see a file they've lost access to — or
// who merely knows its id — could duplicate it and obtain an owned, editable
// copy of content they cannot read. Checked on the server only, matching the
// other file-access checks in this file (the optimistic client run may not
// have the source file synced). Other `createSource` prefixes (published,
// legacy rooms, local files) are intentionally not gated here.
if (tx.location === 'server' && createSource) {
const [prefix, sourceFileId] = createSource.split('/')
if (prefix === FILE_PREFIX) {
await assertUserCanAccessFileById(tx, userId, sourceFileId)
}
}
const file = await tx.run(zql.file.where('id', '=', fileId).one())
assert(!file, ZErrorCode.bad_request)
assertValidId(fileId)
assertValidId(workspaceId)
assert(name.trim(), ZErrorCode.bad_request)
// Authorize via getRole, not a raw group_user lookup: the home workspace
// (id === userId) is implicitly owned and may have no group_user row, yet
// the user must still be able to create files in it. A raw row check would
// reject that case, leaving an empty home un-switchable (selecting it from
// the switcher creates-then-opens a file, which silently fails). Every other
// workspace op already authorizes through getRole/can for the same reason.
const role = await getRole(tx, userId, workspaceId)
assert(can(role, 'addFiles'), ZErrorCode.forbidden)
// create file row, group_file row, file_state row
await tx.mutate.file.insert({
id: fileId,
name,
owningGroupId: workspaceId,
ownerName: '',
thumbnail: '',
shared: true,
sharedLinkType: 'edit',
isEmpty: true,
published: false,
lastPublished: 0,
publishedSlug: uniqueId(),
createdAt: time,
updatedAt: time,
isDeleted: false,
createSource,
})
await tx.mutate.group_file.insert({
fileId,
groupId: workspaceId,
createdAt: time,
updatedAt: time,
index: null,
})
await tx.mutate.file_state.insert({
fileId,
userId,
lastEditAt: null,
lastVisitAt: null,
firstVisitAt: null,
lastSessionState: null,
})
},
pinFile: async (
tx: Tx,
{ fileId, workspaceId, index }: { fileId: string; workspaceId: string; index?: IndexKey }
) => {
assert(fileId, ZErrorCode.bad_request)
assert(typeof index === 'string' || index == null, ZErrorCode.bad_request)
assert(workspaceId, ZErrorCode.bad_request)
// Without this any signed-in user who knows a (fileId, workspaceId) pair — a guest who
// opened a shared link, a removed member — could rewrite that workspace's pin order.
assert(can(await getRole(tx, userId, workspaceId), 'accessFiles'), ZErrorCode.forbidden)
// Pinned files are group_file rows with a non-null index.
// New pins go above the workspace's current top pinned file.
let indexToUse = index
if (indexToUse == null) {
const allWorkspaceFiles = await tx.run(zql.group_file.where('groupId', '=', workspaceId))
const otherPinnedFiles = allWorkspaceFiles
.filter((gf) => gf.index !== null)
.sort(sortByMaybeIndex)
indexToUse = getIndexBelow(otherPinnedFiles[0]?.index)
}
await tx.mutate.group_file.update({
fileId,
groupId: workspaceId,
index: indexToUse,
})
},
unpinFile: async (tx: Tx, { fileId, workspaceId }: { fileId: string; workspaceId: string }) => {
assert(fileId, ZErrorCode.bad_request)
assert(workspaceId, ZErrorCode.bad_request)
// See pinFile.
assert(can(await getRole(tx, userId, workspaceId), 'accessFiles'), ZErrorCode.forbidden)
await tx.mutate.group_file.update({
fileId,
groupId: workspaceId,
index: null,
})
},
removeFileFromWorkspace: async (
tx: Tx,
{ fileId, workspaceId }: { fileId: string; workspaceId: string }
) => {
assert(fileId, ZErrorCode.bad_request)
assert(workspaceId, ZErrorCode.bad_request)
const role = await getRole(tx, userId, workspaceId)
assert(can(role, 'removeFiles'), ZErrorCode.forbidden)
const file = await tx.run(zql.file.where('id', '=', fileId).one())
assert(file, ZErrorCode.bad_request)
await tx.mutate.file_state.delete({ fileId, userId })
await tx.mutate.group_file.delete({ fileId, groupId: workspaceId })
if (file.owningGroupId === workspaceId) {
await tx.mutate.file.update({ id: fileId, isDeleted: true })
}
},
onEnterFile: async (tx: Tx, { fileId, time }: { fileId: string; time: number }) => {
assert(fileId, ZErrorCode.bad_request)
time = ensureSensibleTimestamp(time)
const file = await tx.run(zql.file.where('id', '=', fileId).one())
// Verify the user has permission to access this file
if (tx.location === 'server') {
await assertUserCanAccessFile(tx, userId, file!)
}
// If we get here, the user has legitimate access to the file
await tx.mutate.file_state.upsert({ fileId, userId, firstVisitAt: time })
// Add a visited file to the user's home group so it shows as a "guest file" in the
// sidebar — unless it's already visible to the user somewhere else. A file is listed
// in a workspace only when that workspace actually OWNS it (getWorkspaceFilesSorted
// lists a non-home workspace's file only when owningGroupId === workspaceId), so the
// only thing that should suppress the home link is the file being owned by a
// workspace the user belongs to.
//
// We deliberately do NOT key off "any group_file row in one of my groups": a
// stale/mislinked row (e.g. a leftover from the removed drag-to-link feature,
// #9107/#9254, or a create-workspace-race mirror) points at a workspace that does
// not own the file, so it shows the file nowhere. Counting it here would skip the
// home link and make the file invisible in the sidebar entirely.
const alreadyLinkedInHome = await tx.run(
zql.group_file.where('fileId', '=', fileId).where('groupId', '=', userId).one()
)
if (!alreadyLinkedInHome) {
// getRole returns null when the user isn't a member of the file's owning
// workspace (and for a null owningGroupId), so this is true only when the file
// is genuinely owned by a workspace the user belongs to.
const ownedByOneOfMyWorkspaces = (await getRole(tx, userId, file?.owningGroupId)) !== null
if (!ownedByOneOfMyWorkspaces) {
await tx.mutate.group_file.insert({
fileId,
groupId: userId,
createdAt: time,
updatedAt: time,
index: null,
})
}
}
},
createWorkspace: async (tx: Tx, { id, name }: { id: string; name: string }) => {
assertValidId(id)
const clampedName = name.trim().slice(0, MAX_WORKSPACE_NAME_LENGTH)
assert(clampedName, ZErrorCode.bad_request)
// Enforce the workspace limit before creating anything.
const existingWorkspaces = await tx.run(zql.group_user.where('userId', '=', userId))
assert(
existingWorkspaces.length < MAX_NUMBER_OF_WORKSPACES,
ZErrorCode.max_workspaces_reached
)
await tx.mutate.group.insert({
id,
name: clampedName,
inviteSecret: tx.location === 'server' ? uniqueId() : null,
inviteLinkEnabled: true,
isDeleted: false,
createdAt: Date.now(),
updatedAt: Date.now(),
})
// Use tldraw's fractional indexing to place the new workspace at the top
// (the workspace list sorts ascending, so the lowest index renders first)
const lowest = existingWorkspaces.sort(sortByIndex)[0]?.index
// First workspace gets 'a1'; otherwise generate a new index below the current lowest
const index = lowest === undefined ? ('a1' as IndexKey) : getIndexBelow(lowest)
await tx.mutate.group_user.insert({
userId,
groupId: id,
// these are set by the trigger
userName: '',
userColor: '#000000',
role: 'owner',
createdAt: Date.now(),
updatedAt: Date.now(),
index,
})
},
updateWorkspace: async (tx: Tx, { id, name }: { id: string; name: string }) => {
assert(id, ZErrorCode.bad_request)
assert(name && name.trim(), ZErrorCode.bad_request)
// The home workspace can be renamed (unlike other home-workspace actions),
// so it's intentionally not guarded by assertNotHomeWorkspace here.
const role = await getRole(tx, userId, id)
assert(can(role, 'manageWorkspace'), ZErrorCode.forbidden)
await tx.mutate.group.update({
id,
name: name.trim().slice(0, MAX_WORKSPACE_NAME_LENGTH),
})
},
regenerateWorkspaceInviteSecret: async (tx: Tx, { id }: { id: string }) => {
assert(id, ZErrorCode.bad_request)
await assertNotHomeWorkspace(tx, id)
const role = await getRole(tx, userId, id)
assert(can(role, 'manageWorkspace'), ZErrorCode.forbidden)
if (tx.location === 'server') {
await tx.mutate.group.update({ id, inviteSecret: uniqueId() })
}
},
setWorkspaceInviteLinkEnabled: async (
tx: Tx,
{ id, enabled }: { id: string; enabled: boolean }
) => {
assert(id, ZErrorCode.bad_request)
await assertNotHomeWorkspace(tx, id)
const role = await getRole(tx, userId, id)
assert(can(role, 'manageWorkspace'), ZErrorCode.forbidden)
// Flip the flag only; inviteSecret is preserved so re-enabling restores the
// same link.
await tx.mutate.group.update({ id, inviteLinkEnabled: enabled })
},
setWorkspaceMemberRole: async (
tx: Tx,
{
workspaceId,
targetUserId,
role: targetRole,
}: { workspaceId: string; targetUserId: string; role: Role }
) => {
assert(workspaceId, ZErrorCode.bad_request)
assert(targetUserId, ZErrorCode.bad_request)
assert(isRole(targetRole), ZErrorCode.bad_request)
await assertNotHomeWorkspace(tx, workspaceId)
const role = await getRole(tx, userId, workspaceId)
assert(can(role, 'manageWorkspace'), ZErrorCode.forbidden)
// Target must be a member
const targetMembership = await tx.run(
zql.group_user.where('userId', '=', targetUserId).where('groupId', '=', workspaceId).one()
)
assert(targetMembership, ZErrorCode.bad_request)
if (targetMembership.role === targetRole) return
// Invariant (not a capability): a group must always keep at least one
// owner, so the last owner can't be demoted away from owner.
if (targetMembership.role === 'owner' && targetRole !== 'owner') {
const owners = await tx.run(
zql.group_user.where('groupId', '=', workspaceId).where('role', '=', 'owner')
)
assert(owners.length > 1, ZErrorCode.forbidden)
}
await tx.mutate.group_user.update({
userId: targetUserId,
groupId: workspaceId,
role: targetRole,
})
},
removeWorkspaceMember: async (
tx: Tx,
{ workspaceId, targetUserId }: { workspaceId: string; targetUserId: string }
) => {
assert(workspaceId, ZErrorCode.bad_request)
assert(targetUserId, ZErrorCode.bad_request)
const role = await getRole(tx, userId, workspaceId)
assert(can(role, 'manageWorkspace'), ZErrorCode.forbidden)
// Target must be a member
const targetMembership = await tx.run(
zql.group_user.where('userId', '=', targetUserId).where('groupId', '=', workspaceId).one()
)
assert(targetMembership, ZErrorCode.bad_request)
// Invariant (not a capability): a group must always keep at least one
// owner, so the last owner can't be removed.
if (targetMembership.role === 'owner') {
const owners = await tx.run(
zql.group_user.where('groupId', '=', workspaceId).where('role', '=', 'owner')
)
assert(owners.length > 1, ZErrorCode.forbidden)
}
await tx.mutate.group_user.delete({ userId: targetUserId, groupId: workspaceId })
},
leaveWorkspace: async (tx: Tx, { workspaceId }: { workspaceId: string }) => {
assert(workspaceId, ZErrorCode.bad_request)
await assertNotHomeWorkspace(tx, workspaceId)
const owners = await tx.run(
zql.group_user.where('groupId', '=', workspaceId).where('role', '=', 'owner')
)
const isOnlyOwner = owners.length === 1 && owners[0].userId === userId
// Invariant (not a capability): a group must always keep at least one
// owner, so the last owner can't leave — they must delete the group instead.
assert(!isOnlyOwner, ZErrorCode.forbidden)
await tx.mutate.group_user.delete({ userId, groupId: workspaceId })
},
deleteWorkspace: async (tx: Tx, { id }: { id: string }) => {
assert(id, ZErrorCode.bad_request)
await assertNotHomeWorkspace(tx, id)
const role = await getRole(tx, userId, id)
assert(can(role, 'manageWorkspace'), ZErrorCode.forbidden)
// Delete all workspace files
const workspaceFileRows = await tx.run(zql.group_file.where('groupId', '=', id))
for (const workspaceFile of workspaceFileRows) {
await tx.mutate.group_file.delete({ fileId: workspaceFile.fileId, groupId: id })
}
// Mark all files owned by this workspace as deleted
const files = await tx.run(zql.file.where('owningGroupId', '=', id))
for (const file of files) {
await tx.mutate.file.update({ id: file.id, isDeleted: true })
}
await tx.mutate.group.update({ id: id, isDeleted: true })
if (tx.location !== 'server') {
await tx.mutate.group_user.delete({ userId, groupId: id })
}
},
moveFileToWorkspace: async (
tx: Tx,
{ fileId, workspaceId }: { fileId: string; workspaceId: string }
) => {
assert(fileId, ZErrorCode.bad_request)
assert(workspaceId, ZErrorCode.bad_request)
const file = await tx.run(zql.file.where('id', '=', fileId).one())
assert(file, ZErrorCode.bad_request)
// No-op if file is already in the target workspace
if (file.owningGroupId === workspaceId) return
// User must be allowed to take the file out of its current workspace and
// to add files to the destination workspace.
const fromRole = await getRole(tx, userId, file.owningGroupId)
assert(can(fromRole, 'removeFiles'), ZErrorCode.forbidden)
const toRole = await getRole(tx, userId, workspaceId)
assert(can(toRole, 'addFiles'), ZErrorCode.forbidden)
// Remove file from current group association if it exists
if (file.owningGroupId) {
await tx.mutate.group_file.delete({ fileId, groupId: file.owningGroupId })
}
// Point the file at its new workspace
await tx.mutate.file.update({
id: fileId,
owningGroupId: workspaceId,
updatedAt: Date.now(),
})
await tx.mutate.group_file.insert({
fileId,
groupId: workspaceId,
createdAt: Date.now(),
updatedAt: Date.now(),
index: null,
})
},
} as const satisfies CustomMutatorDefs
return mutators
}

View File

@@ -0,0 +1,72 @@
import { createBuilder } from '@rocicorp/zero'
import { describe, expect, it } from 'vitest'
import { queries, ZeroContext } from './queries'
import { schema } from './tlaSchema'
const zql = createBuilder(schema)
const ctx: ZeroContext = { userId: 'user_test' }
/**
* How many correlated-subquery hops it takes to reach `table` from the query root — the property
* that decides what one of these queries costs.
*
* The file-access gate (`file` and its `states`/`groupFiles` relations) is the expensive part of
* every feed query: `file`, `file_state` and `group_file` each hold hundreds of thousands of rows.
* At depth 1 the fileId correlation is pushed into those relations and the gate touches only the
* handful of files the query concerns. Deeper, it isn't, and the query traverses them wholesale —
* `reactions` rooted at `comment_reaction` put the gate at depth 2 and took ~150s to materialize
* in production (while `comment_reaction` held ~50 rows), outrunning the sync connection's 60s
* auth token so that no client could complete a first sync. Neither unit tests nor the PR preview
* deploy can catch that: previews run against a fresh database with no file_state volume.
*
* Scans only the root `where` tree; gates inside `related` branches aren't measured.
*/
export function accessGateDepth(ast: any, table: string): number {
// the deepest occurrence, not the shallowest: one cheap path to `file` doesn't redeem a second
// path that reaches it two hops down
let deepest = 0
const visit = (condition: any, depth: number) => {
if (!condition || typeof condition !== 'object') return
if (condition.type === 'correlatedSubquery' && condition.related?.subquery) {
const subquery = condition.related.subquery
if (subquery.table === table) deepest = Math.max(deepest, depth + 1)
visit(subquery.where, depth + 1)
}
for (const nested of condition.conditions ?? []) visit(nested, depth)
}
visit(ast.where, 0)
return deepest
}
function astOf(name: keyof typeof queries, args: object = {}) {
const query = (queries as any)[name].fn({ ctx, args })
return JSON.parse(JSON.stringify(query.ast ?? query))
}
describe('feed query shape', () => {
// Not a style preference: this is the regression guard for the outage described on
// accessGateDepth. A query that reaches `file` more than one hop from its root is the shape
// that took production down, however small the table it is rooted at.
it.each([
['comments', {}],
['reactions', {}],
['fileComments', { fileId: 'file:1' }],
])('keeps the file access gate one hop from the root: %s', (name, args) => {
const ast = astOf(name as keyof typeof queries, args)
expect(accessGateDepth(ast, 'file')).toBe(1)
})
it('roots the reactions feed at comment, not comment_reaction', () => {
expect(astOf('reactions').table).toBe('comment')
})
// proves the guard above can actually fail: reaching `file` through an intermediate subquery,
// the way the pre-fix `reactions` query reached it through `comment`, measures one hop deeper
it('measures a gate behind an intermediate subquery as depth 2', () => {
const gateBehindThread = zql.comment
.where('authorId', '=', ctx.userId)
.whereExists('thread', (t) => t.whereExists('file', (f) => f.where('isDeleted', '=', false)))
const ast = JSON.parse(JSON.stringify((gateBehindThread as any).ast ?? gateBehindThread))
expect(accessGateDepth(ast, 'file')).toBe(2)
})
})

View File

@@ -0,0 +1,261 @@
import { createBuilder, defineQueriesWithType, defineQueryWithType, Query } from '@rocicorp/zero'
import { schema, TlaSchema } from './tlaSchema'
const zql = createBuilder(schema)
/** Context provided by server - contains authenticated user ID */
export interface ZeroContext {
userId: string
}
/** Typed defineQuery with schema and context */
const defineQuery = defineQueryWithType<TlaSchema, ZeroContext>()
/** Typed defineQueries with schema */
const defineQueries = defineQueriesWithType<TlaSchema>()
/**
* The file-access gate: the user has opened the file (has a file_state) or belongs to a workspace
* it's in. Ownership is checked separately by callers. Always applied directly under `exists('file')`
* from the query root — see the depth note on the `reactions` query.
*/
const canAccessFile = (userId: string) => (file: Query<'file', TlaSchema>) =>
file.where(({ or, exists }) =>
or(
exists('states', (s) => s.where('userId', '=', userId)),
exists('groupFiles', (gf) =>
gf.whereExists('groupMembers', (gm) => gm.where('userId', '=', userId))
)
)
)
/** Upper bound on the comments notifications feed, so the synced set stays finite as files accrue. */
const RECENT_COMMENTS_LIMIT = 50
/** Bound on the reactions feed, counted in reacted-to comments — one row per comment of the
* caller's that someone else has reacted to, however many reactions it carries. */
export const REACTED_COMMENTS_LIMIT = 200
/**
* Upper bound on the per-file @-mention roster of past viewers, so a heavily-viewed public board
* doesn't stream an unbounded set to every collaborator. The composer's autocomplete only ever
* shows a handful (see filterMentionMembers / MAX_SUGGESTIONS); this caps what reaches the client,
* most-recent viewers first.
*/
const MENTIONABLE_VISITORS_LIMIT = 100
/**
* Synced Queries with permission logic.
* These replace the old definePermissions API.
* Permissions are enforced via ctx.userId which is set server-side.
*/
export const queries = defineQueries({
/** Current user's own record (single) */
user: defineQuery(({ ctx }) => zql.user.where('id', '=', ctx.userId).one()),
/** User's file states with related file data */
fileStates: defineQuery(({ ctx }) =>
zql.file_state.where('userId', '=', ctx.userId).related('file', (file) => file.one())
),
/** User's workspace memberships with related group, files, and members */
workspaceMemberships: defineQuery(({ ctx }) =>
zql.group_user
.where('userId', '=', ctx.userId)
.related('group', (group) => group.one())
.related('groupFiles', (gf) => gf.related('file', (file) => file.one()))
.related('groupMembers')
),
/**
* Recent comments that concern the current user, for the app-level notifications feed. Someone
* else's comment qualifies when it matches at least one of three categories:
*
* - it's on a board in the user's own home workspace
* - it's in a thread the user is a part of (started, or has commented in) and on a file they
* can still access — a reply
* - it `@`-mentions the user (via the `comment_mention` rows the file's Durable Object
* extracts from the body, since mentions live inside rich-text JSON that ZQL can't reach),
* provided the user can access the file: they have a file_state for it, or it belongs to a
* workspace they're a member of. A home board carries its own access evidence; replies and
* mentions need an explicit current-access gate because historical thread participation can
* outlive access to the file.
*
* "Reacted to your comment" entries come from the separate {@link reactions} query, not here.
*
* Filtering here (server-side) rather than on the client is what keeps out-of-category
* comments off the wire entirely. One gate stays client-side: `categorizeCommentNotifications`
* drops reply-category comments from before the user joined the thread (ZQL can't compare
* createdAt across correlated rows).
*
* Bounded to the most recent {@link RECENT_COMMENTS_LIMIT} so the synced set stays finite as a
* workspace ages, rather than growing without limit. This is a display feed — the canvas comment
* layer reads {@link fileComments} instead, which is scoped to one file and unbounded so every
* unread pin resolves regardless of age.
*/
comments: defineQuery(({ ctx }) =>
zql.comment
.where('authorId', '!=', ctx.userId)
// soft-deleted comments and comments of soft-deleted threads stay in Postgres (see
// TLComment.isDeleted) but must never surface as notifications
.where('isDeleted', '=', false)
.whereExists('thread', (t) => t.where('isDeleted', '=', false))
// same for soft-deleted boards: their comment rows persist, but a notification would
// navigate to a file the user can no longer open
.whereExists('file', (f) => f.where('isDeleted', '=', false))
.where(({ and, or, exists }) =>
or(
// on a board in the user's own home workspace (home group id === user id)
exists('file', (f) => f.where('owningGroupId', '=', ctx.userId)),
// a reply: in a thread the user started or has commented in, on a file they
// can still access
and(
exists('thread', (t) =>
t.where(({ cmp, or, exists }) =>
or(
cmp('createdBy', '=', ctx.userId),
// live comments only: soft-deleted rows persist, and deleting your
// last comment in a thread must end the reply subscription with it
exists('comments', (c) =>
c.where('authorId', '=', ctx.userId).where('isDeleted', '=', false)
)
)
)
),
exists('file', canAccessFile(ctx.userId))
),
// @-mentions the user, on a file they can access (opened it, or workspace member)
and(
exists('mentions', (m) => m.where('userId', '=', ctx.userId)),
exists('file', canAccessFile(ctx.userId))
)
)
)
.related('file', (file) => file.one())
// only the caller's own comments, so the client can tell when they joined the thread.
// The client gate depends on this relation — a client shipped without a worker that
// syncs it drops reply notifications for threads the user didn't start
.related('thread', (thread) =>
thread
.one()
.related('comments', (c) =>
c.where('authorId', '=', ctx.userId).where('isDeleted', '=', false)
)
)
// the caller's read receipt (at most one row: PK is (userId, commentId) and we filter
// on userId); absent (for others' comments) = unread
.related('read', (read) => read.where('userId', '=', ctx.userId).one())
// every reaction to the comment, for the inert reaction pills on notification rows. A
// comment's reactions are naturally few, so the set syncs unbounded
.related('reactions')
.orderBy('createdAt', 'desc')
.limit(RECENT_COMMENTS_LIMIT)
),
/**
* The caller's own comments that someone else has reacted to, for the notifications feed's
* "reacted to your comment" entries. Uses the same access building blocks as {@link comments}
* (file state, group membership). Ordering by reaction time is client-side:
* `buildReactionNotifications` stamps each entry with its newest foreign reaction and
* `mergeNotifications` sorts on it.
*
* Rooted at `comment`, *not* at `comment_reaction`, so the file-access gate sits one level from
* the root exactly as it does in {@link comments}. Rooting at the reaction put that gate behind
* a second correlated subquery, and the fileId correlation then stopped being pushed down into
* `file`'s `states`/`groupFiles` relations: the query traversed those tables — hundreds of
* thousands of rows — rather than the handful of files it actually concerned. It materialized in
* ~150s against production data while `comment_reaction` held ~50 rows, which outran the sync
* connection's 60s auth token and left every client unable to finish a first sync. The cost of
* one of these queries is set by how deep the file gate sits, not by how much comment data
* exists, so keep it at depth 1.
*
* Bounded to {@link REACTED_COMMENTS_LIMIT} by comment recency rather than reaction recency, so
* a reaction on a comment older than the window doesn't surface. The window counts only the
* caller's own reacted-to comments, so it's far slacker than the reaction-counted bound it
* replaced.
*/
reactions: defineQuery(({ ctx }) =>
zql.comment
.where('authorId', '=', ctx.userId)
// soft-deleted comments and comments of soft-deleted threads stay in Postgres but must
// never surface as notifications, same as in `comments`
.where('isDeleted', '=', false)
.whereExists('thread', (t) => t.where('isDeleted', '=', false))
.whereExists('file', (f) => f.where('isDeleted', '=', false))
// somebody else reacted — the entry's whole reason for existing. Without this the feed
// would sync the caller's most recent comments whether or not anyone reacted
.whereExists('reactions', (r) => r.where('userId', '!=', ctx.userId))
// having authored a comment doesn't outlive access to the board it's on
.where(({ or, exists }) =>
or(
exists('file', (f) =>
f.where(({ or, exists }) =>
or(
exists('states', (s) => s.where('userId', '=', ctx.userId)),
exists('groupFiles', (gf) =>
gf.whereExists('groupMembers', (gm) => gm.where('userId', '=', ctx.userId))
)
)
)
)
)
)
.related('file', (file) => file.one())
.related('thread', (thread) => thread.one())
.related('read', (read) => read.where('userId', '=', ctx.userId).one())
// every reaction incl. the caller's own: pills need exact counts and the own-reaction
// highlight
.related('reactions')
.orderBy('createdAt', 'desc')
.limit(REACTED_COMMENTS_LIMIT)
),
/**
* Every comment on a single file, for the canvas comment layer's read receipts and author-name
* resolution. Scoped to the file the user is viewing and access-checked against their file_state,
* and deliberately unbounded — one file's comments are naturally finite, and the canvas must
* resolve an unread pin for every comment however old. The cross-file feed uses {@link comments}.
*/
fileComments: defineQuery(({ ctx, args }: { ctx: ZeroContext; args: { fileId: string } }) =>
zql.comment
.where('fileId', '=', args.fileId)
// soft-deleted comments and comments of soft-deleted threads stay in Postgres but
// never reach the canvas layer
.where('isDeleted', '=', false)
.whereExists('thread', (t) => t.where('isDeleted', '=', false))
.whereExists('file', (file) =>
file.whereExists('states', (s) => s.where('userId', '=', ctx.userId))
)
.related('read', (read) => read.where('userId', '=', ctx.userId).one())
// so the canvas can flag own comments with fresh foreign reactions as unread, and its
// thread-view auto-mark-read can clear the reaction notification on view
.related('reactions')
),
/**
* Everyone (besides the caller) who has opened a single file, for the comment composer's
* @-mention roster — so signed-in board viewers, not just workspace members, can be mentioned.
* Reads from file_visitor, a shareable projection of file_state maintained by Postgres triggers
* (migration 044): a deliberately separate table, because file_state also holds private per-user
* data (lastSessionState, visit timestamps) that whole-row sync would leak to every collaborator.
* A file_visitor row exists only for an authenticated user who opened the file, so this is
* inherently signed-in-only; anonymous visitors have none. Identity is denormalized onto the row,
* so no private user row is joined or synced.
*
* Access-gated exactly like {@link fileComments}: the viewer list is exposed only to someone who
* has themselves opened the file. Bounded to {@link MENTIONABLE_VISITORS_LIMIT} most-recent
* viewers so the synced set stays finite on heavily-viewed public boards.
*/
fileVisitors: defineQuery(({ ctx, args }: { ctx: ZeroContext; args: { fileId: string } }) =>
zql.file_visitor
.where('fileId', '=', args.fileId)
.where('userId', '!=', ctx.userId)
.whereExists('file', (file) =>
file.whereExists('states', (s) => s.where('userId', '=', ctx.userId))
)
.orderBy('lastVisitAt', 'desc')
.limit(MENTIONABLE_VISITORS_LIMIT)
),
})
export type TlaQueries = typeof queries

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest'
import { capabilities } from './capabilities'
import { can, isRole } from './roles'
describe('capabilities', () => {
it('is the expected set, with no duplicates', () => {
expect([...capabilities]).toEqual(['accessFiles', 'addFiles', 'removeFiles', 'manageWorkspace'])
expect(new Set(capabilities).size).toBe(capabilities.length)
})
})
describe('can', () => {
it('grants owners every capability', () => {
for (const capability of capabilities) {
expect(can('owner', capability)).toBe(true)
}
})
it('grants members the non-administrative capabilities only', () => {
const memberCapabilities = capabilities.filter((capability) => can('member', capability))
expect(memberCapabilities).toEqual(['accessFiles', 'addFiles', 'removeFiles'])
})
it('denies members the administrative capabilities', () => {
expect(can('member', 'manageWorkspace')).toBe(false)
})
it("members' capabilities are a subset of owners'", () => {
for (const capability of capabilities) {
if (can('member', capability)) {
expect(can('owner', capability)).toBe(true)
}
}
})
it('denies unknown, empty, or missing roles every capability', () => {
// `admin` is included deliberately: it was renamed to `member`, so it is no
// longer a valid role.
const notRoles = [null, undefined, '', 'admin', 'nope', 'OWNER', 'toString', '__proto__']
for (const role of notRoles) {
for (const capability of capabilities) {
expect(can(role, capability)).toBe(false)
}
}
})
})
describe('isRole', () => {
it('accepts known role strings', () => {
expect(isRole('owner')).toBe(true)
expect(isRole('member')).toBe(true)
})
it('rejects unknown, empty, or missing values', () => {
for (const value of [null, undefined, '', 'admin', 'nope', 'toString', '__proto__']) {
expect(isRole(value)).toBe(false)
}
})
})

View File

@@ -0,0 +1,46 @@
import { Capability } from './capabilities'
/**
* Workspace authorization, expressed as capabilities rather than role names.
*
* Authorization asks `can(role, capability)` — never `role === 'owner'` — so the
* meaning of a role lives in exactly one place: the `roles` table below. Read a
* role's list to see what it can do; edit the list, or add a role, to change it.
*
* The role is stored in the DB as a plain string (`group_user.role`);
* capabilities are never persisted — they're derived from that string here.
*/
/**
* What each role can do — the single source of truth. The role name is the key,
* and {@link Role} is derived from these keys. Today the only difference between
* `member` and `owner` is the `manageWorkspace` capability: owners administer the
* workspace (invite link, members' roles, name, deletion); members only work with
* its files.
*/
const roles = {
member: ['accessFiles', 'addFiles', 'removeFiles'],
owner: ['accessFiles', 'addFiles', 'removeFiles', 'manageWorkspace'],
} satisfies Record<string, readonly Capability[]>
/** A role a member can have in a workspace — the string stored in `group_user.role`. */
export type Role = keyof typeof roles
/**
* Whether a role grants a capability.
*
* `role` is intentionally loose (`string`): it flows straight from the DB or the
* synced store, and any unknown or null value returns `false` rather than
* throwing, so callers never have to validate it first.
*/
export function can(role: string | null | undefined, capability: Capability): boolean {
if (!isRole(role)) return false
// `satisfies` narrows each array to a tuple of its own literals, so the cast
// widens it back to the full Capability union for `includes`.
return (roles[role] as readonly Capability[]).includes(capability)
}
/** Whether a string is a known role name. */
export function isRole(role: string | null | undefined): role is Role {
return role != null && Object.hasOwn(roles, role)
}

View File

@@ -0,0 +1,56 @@
/** @public */
export const ROOM_OPEN_MODE = {
READ_ONLY: 'readonly',
READ_ONLY_LEGACY: 'readonly-legacy',
READ_WRITE: 'read-write',
} as const
export type RoomOpenMode = (typeof ROOM_OPEN_MODE)[keyof typeof ROOM_OPEN_MODE]
/** @public */
export const READ_ONLY_PREFIX = 'ro'
/** @public */
export const READ_ONLY_LEGACY_PREFIX = 'v'
/** @public */
export const ROOM_PREFIX = 'r'
/** @public */
export const SNAPSHOT_PREFIX = 's'
/** @public */
export const FILE_PREFIX = 'f'
/** @public */
export const PUBLISH_PREFIX = 'p'
/** @public */
export const LOCAL_FILE_PREFIX = 'lf'
/**
* Query param that opts a request out of the social preview crawler routing. Some in-app browsers
* used by real people carry a crawler token in their user-agent (WhatsApp, Pinterest), so they get
* routed to the social preview stub; the stub redirects them back to the board with this param set,
* and the Vercel crawler route ignores requests that carry it.
*
* The param must be set with a non-empty value (we use `no_preview=1`), never bare. Vercel's
* `has`/`missing` matching treats a param with no expected value as present only when its actual
* value is truthy (`!hasItem.value && value` in Next.js's matchHas, which mirrors the proxy), so a
* bare `?no_preview` parses to an empty string and reads as absent — the `missing` condition would
* still pass, the crawler route would match again, and the stub would redirect to itself forever.
*
* @public
*/
export const SOCIAL_PREVIEW_BYPASS_PARAM = 'no_preview'
/** @public */
export const RoomOpenModeToPath: Record<RoomOpenMode, string> = {
[ROOM_OPEN_MODE.READ_ONLY]: READ_ONLY_PREFIX,
[ROOM_OPEN_MODE.READ_ONLY_LEGACY]: READ_ONLY_LEGACY_PREFIX,
[ROOM_OPEN_MODE.READ_WRITE]: ROOM_PREFIX,
}
/** @public */
export const APP_ASSET_UPLOAD_ENDPOINT = '/api/app/uploads/'
/**
* The client route that renders a board for Browser Run thumbnail capture. The sync worker builds
* this URL with a signed render token; the page exchanges the token for snapshot data.
*
* @public
*/
export const THUMBNAIL_RENDER_PATH = '/__thumbnail-render'

View File

@@ -0,0 +1,333 @@
import { TLShape } from '@tldraw/tlschema'
import { describe, expect, it } from 'vitest'
import {
boundsGapDistance,
getShapeText,
labelClusters,
ClusterBounds,
computeAutoEps,
getShapeClusters,
MAX_CLUSTERING_ATOMS,
} from './shapeClusters'
const PAGE = 'page:a'
const shape = (id: string, parentId: string = PAGE) => ({ id, parentId }) as unknown as TLShape
const arrow = (id: string) => ({ id, parentId: PAGE, type: 'arrow' }) as unknown as TLShape
const box = (minX: number, minY: number, w = 10, h = 10): ClusterBounds => ({
minX,
minY,
maxX: minX + w,
maxY: minY + h,
})
/** A row of top-level shapes at the given x positions, with matching bounds. */
function row(positions: Record<string, number>) {
const shapes = Object.keys(positions).map((id) => shape(id))
const bounds = Object.fromEntries(Object.entries(positions).map(([id, x]) => [id, box(x, 0)]))
return { shapes, bounds }
}
const idsOf = (clusters: { shapes: TLShape[] }[]) =>
clusters
.map((c) =>
c.shapes
.map((s) => s.id)
.sort()
.join(',')
)
.sort()
describe('getShapeClusters without bounds', () => {
// The Worker has no editor, so hierarchy is all the structure available to it.
it('makes each top-level shape its own cluster', () => {
const clusters = getShapeClusters([shape('a'), shape('b'), shape('c')], PAGE)
expect(idsOf(clusters)).toEqual(['a', 'b', 'c'])
})
it('keeps a frame and its descendants together', () => {
const clusters = getShapeClusters(
[shape('frame'), shape('child', 'frame'), shape('grandchild', 'child'), shape('loose')],
PAGE
)
expect(idsOf(clusters)).toEqual(['child,frame,grandchild', 'loose'])
})
})
describe('getShapeClusters with bounds', () => {
it('merges atoms that sit near each other', () => {
const { shapes, bounds } = row({ a: 0, b: 20, c: 40, far: 3000 })
expect(idsOf(getShapeClusters(shapes, PAGE, bounds))).toEqual(['a,b,c', 'far'])
})
// The property that distinguishes single linkage: membership chains, so a long row is one
// cluster even though its ends are far apart.
it('chains through intermediates rather than requiring mutual closeness', () => {
const { shapes, bounds } = row({ a: 0, b: 40, c: 80, d: 120 })
expect(boundsGapDistance(bounds.a, bounds.d)).toBe(110)
expect(idsOf(getShapeClusters(shapes, PAGE, bounds))).toEqual(['a,b,c,d'])
})
it('splits where the gap is large relative to the page', () => {
const { shapes, bounds } = row({ a: 0, b: 20, c: 40, x: 2000, y: 2020, z: 2040 })
expect(idsOf(getShapeClusters(shapes, PAGE, bounds))).toEqual(['a,b,c', 'x,y,z'])
})
it('carries a frame’s descendants into the merged cluster', () => {
const shapes = [shape('frame'), shape('child', 'frame'), shape('near')]
const bounds = { frame: box(0, 0, 100, 100), child: box(10, 10), near: box(110, 0) }
expect(idsOf(getShapeClusters(shapes, PAGE, bounds))).toEqual(['child,frame,near'])
})
it('puts every shape in exactly one cluster', () => {
const { shapes, bounds } = row({ a: 0, b: 20, lonely: 9000 })
const clusters = getShapeClusters(shapes, PAGE, bounds)
expect(clusters.flatMap((c) => c.shapes.map((s) => s.id)).sort()).toEqual(['a', 'b', 'lonely'])
})
// A shape the caller could not measure has no position to cluster on, so it is left alone rather
// than given an invented one.
it('leaves an unmeasured atom as its own cluster', () => {
const { shapes, bounds } = row({ a: 0, b: 20 })
shapes.push(shape('unmeasured'))
expect(idsOf(getShapeClusters(shapes, PAGE, bounds))).toEqual(['a,b', 'unmeasured'])
})
it('is independent of the order shapes arrive in', () => {
const { shapes, bounds } = row({ a: 0, b: 20, far: 3000 })
expect(idsOf(getShapeClusters([...shapes].reverse(), PAGE, bounds))).toEqual(
idsOf(getShapeClusters(shapes, PAGE, bounds))
)
})
// Ids are derived from membership, so the same page always produces the same handles — that is
// what lets one MCP call hand an id to the next.
it('gives the same ids on every call, and new ids when membership changes', () => {
const { shapes, bounds } = row({ a: 0, b: 20, far: 3000 })
const first = getShapeClusters(shapes, PAGE, bounds).map((c) => c.id)
expect(getShapeClusters(shapes, PAGE, bounds).map((c) => c.id)).toEqual(first)
const regrouped = getShapeClusters(shapes, PAGE, { ...bounds, far: box(40, 0) })
expect(regrouped).toHaveLength(1)
expect(regrouped[0].id).not.toBe(first[0])
})
it('returns atoms unmerged past the cap rather than doing the O(n²) work', () => {
const positions: Record<string, number> = {}
for (let i = 0; i <= MAX_CLUSTERING_ATOMS; i++) positions[`s${i}`] = i * 5
const { shapes, bounds } = row(positions)
expect(getShapeClusters(shapes, PAGE, bounds)).toHaveLength(shapes.length)
})
it('handles empty and single-shape pages', () => {
expect(getShapeClusters([], PAGE, {})).toEqual([])
expect(getShapeClusters([shape('only')], PAGE, { only: box(0, 0) })).toHaveLength(1)
})
})
// An arrow drawn between two distant groups has bounds spanning the whole gap, so its gap distance
// to both ends is zero. With single-linkage chaining, one arrow would otherwise pull two unrelated
// groups — and everything they touch — into a single cluster.
describe('arrows do not bridge clusters', () => {
it('keeps two distant groups apart when an arrow spans them', () => {
const shapes = [shape('a1'), shape('a2'), shape('b1'), shape('b2'), arrow('link')]
const bounds = {
a1: box(0, 0),
a2: box(20, 0),
b1: box(3000, 0),
b2: box(3020, 0),
// Spans the gap, touching both groups.
link: box(10, 0, 3000, 2),
}
const clusters = idsOf(getShapeClusters(shapes, PAGE, bounds))
expect(clusters).toContain('a1,a2')
expect(clusters).toContain('b1,b2')
})
it('still gives the arrow a cluster of its own, so every shape stays addressable', () => {
const shapes = [shape('a'), arrow('link')]
const bounds = { a: box(0, 0), link: box(10, 0, 3000, 2) }
const all = getShapeClusters(shapes, PAGE, bounds).flatMap((c) => c.shapes.map((s) => s.id))
expect(all.sort()).toEqual(['a', 'link'])
})
// Arrows are left out of the threshold too: their long thin boxes sit on top of their endpoints
// and would drag the median nearest-neighbour distance down.
it('excludes arrows from the eps calculation', () => {
const shapes = [shape('a'), shape('b'), arrow('link')]
const withArrow = { a: box(0, 0), b: box(600, 0), link: box(5, 0, 600, 2) }
const withoutArrow = { a: box(0, 0), b: box(600, 0) }
expect(idsOf(getShapeClusters(shapes, PAGE, withArrow)).filter((k) => k !== 'link')).toEqual(
idsOf(getShapeClusters([shape('a'), shape('b')], PAGE, withoutArrow))
)
})
})
describe('containment', () => {
it('pulls a shape fully inside another into its cluster', () => {
const shapes = [shape('outer'), shape('inner'), shape('far')]
const bounds = { outer: box(0, 0, 500, 500), inner: box(100, 100, 50, 50), far: box(9000, 0) }
expect(idsOf(getShapeClusters(shapes, PAGE, bounds))).toContain('inner,outer')
})
// Containment cannot bridge: a container only ever absorbs what it encloses.
it('does not merge two shapes that merely overlap', () => {
const shapes = [shape('a'), shape('b')]
const bounds = { a: box(0, 0, 100, 100), b: box(90, 0, 100, 100) }
// These two are adjacent, so proximity may merge them — but not via containment.
expect(getShapeClusters(shapes, PAGE, bounds).length).toBeLessThanOrEqual(2)
})
})
describe('computeAutoEps', () => {
it('scales with the spacing of the page', () => {
const tight = Array.from({ length: 6 }, (_, i) => box(i * 20, 0))
const sparse = Array.from({ length: 6 }, (_, i) => box(i * 400, 0))
expect(computeAutoEps(sparse)).toBeGreaterThan(computeAutoEps(tight))
})
// The point of deriving eps: the same arrangement at a different scale clusters the same way,
// rather than a fixed threshold swallowing a zoomed-out board whole.
it('clusters a layout the same way at 10x scale', () => {
const small = row({ a: 0, b: 20, c: 600 })
const large = {
shapes: small.shapes,
bounds: { a: box(0, 0, 100, 100), b: box(200, 0, 100, 100), c: box(6000, 0, 100, 100) },
}
expect(idsOf(getShapeClusters(large.shapes, PAGE, large.bounds))).toEqual(
idsOf(getShapeClusters(small.shapes, PAGE, small.bounds))
)
})
it('stays within its clamps', () => {
expect(computeAutoEps([box(0, 0), box(0, 0)])).toBeGreaterThanOrEqual(8)
expect(computeAutoEps([box(0, 0), box(1e6, 0)])).toBeLessThanOrEqual(5000)
})
})
describe('getShapeText', () => {
const withProps = (props: object) => ({ type: 'text', props }) as unknown as TLShape
it('reads a plain text prop', () => {
expect(getShapeText(withProps({ text: 'hello world' }))).toBe('hello world')
})
it('walks a rich text document for its text nodes', () => {
const rich = withProps({
richText: {
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'first' }] },
{ type: 'paragraph', content: [{ type: 'text', text: 'second' }] },
],
},
})
expect(getShapeText(rich)).toBe('first second')
})
it('returns empty for a shape carrying no text', () => {
expect(getShapeText(withProps({ w: 10, h: 10 }))).toBe('')
})
})
describe('labelClusters', () => {
const text = (value: string) => ({ type: 'text', props: { text: value } }) as unknown as TLShape
const frame = (name: string) => ({ type: 'frame', props: { name } }) as unknown as TLShape
// The reason c-TF-IDF is used at all: a term shared by every cluster carries no information about
// which cluster you are looking at, so at equal frequency it loses to a term confined to one.
it('prefers a distinguishing term over an equally frequent shared one', () => {
const [login, pricing] = labelClusters([
[text('button'), text('password')],
[text('button'), text('invoice')],
[text('button'), text('shipping')],
[text('button'), text('avatar')],
])
expect(login.keywords[0]).toBe('password')
expect(pricing.keywords[0]).toBe('invoice')
})
// The discount is a discount, not a veto: log(1 + n/df) spans only log2..log3 across two clusters,
// so a shared term at twice the frequency still wins. It separates properly as the cluster count
// grows. Worth knowing before reading a two-cluster label as meaningless.
it('lets a much more frequent shared term win when there are only two clusters', () => {
const [labelled] = labelClusters([
[text('button'), text('button'), text('password')],
[text('button'), text('button'), text('invoice')],
])
expect(labelled.keywords[0]).toBe('button')
})
it('uses a frame name verbatim as the label', () => {
const [labelled] = labelClusters([[frame('Login flow'), text('email'), text('password')]])
expect(labelled.label).toBe('Login flow')
expect(labelled.frameNames).toEqual(['Login flow'])
})
it('joins multiple frame names in one cluster', () => {
expect(labelClusters([[frame('Header'), frame('Footer')]])[0].label).toBe('Header + Footer')
})
it('falls back to keywords when there is no frame', () => {
const [labelled] = labelClusters([
[text('checkout payment'), text('checkout payment')],
[text('onboarding tour')],
])
expect(labelled.label).toContain('checkout')
})
it('gives an empty label to a cluster with no text at all', () => {
expect(labelClusters([[{ type: 'geo', props: {} } as unknown as TLShape]])[0]).toEqual({
label: '',
keywords: [],
frameNames: [],
})
})
it('drops stopwords, short tokens and bare numbers', () => {
const [labelled] = labelClusters([[text('the and of a 42 ok signup')]])
expect(labelled.keywords).toContain('signup')
for (const noise of ['the', 'and', 'of', 'a', '42', 'ok']) {
expect(labelled.keywords).not.toContain(noise)
}
})
it('prefers a bigram and suppresses the unigrams it covers', () => {
const [labelled] = labelClusters([
[text('user research'), text('user research')],
[text('billing')],
])
expect(labelled.keywords[0]).toBe('user research')
expect(labelled.keywords).not.toContain('user')
})
// Two unrelated labels sitting next to each other never wrote a phrase between them.
it('does not form bigrams across shape boundaries', () => {
expect(labelClusters([[text('alpha'), text('beta')]])[0].keywords).not.toContain('alpha beta')
})
it('is deterministic for the same input', () => {
const build = () => labelClusters([[text('alpha alpha'), text('beta')], [text('gamma')]])
expect(build()).toEqual(build())
})
})
describe('clusters carry their labels', () => {
it('labels each cluster relative to the others on the page', () => {
const shapes = [
{ id: 'a1', parentId: PAGE, type: 'text', props: { text: 'password reset' } },
{ id: 'a2', parentId: PAGE, type: 'text', props: { text: 'password' } },
{ id: 'b1', parentId: PAGE, type: 'text', props: { text: 'invoice total' } },
{ id: 'b2', parentId: PAGE, type: 'text', props: { text: 'invoice' } },
] as unknown as TLShape[]
const bounds = { a1: box(0, 0), a2: box(20, 0), b1: box(3000, 0), b2: box(3020, 0) }
const clusters = getShapeClusters(shapes, PAGE, bounds)
expect(clusters).toHaveLength(2)
expect(clusters.map((c) => c.label).sort()).toEqual([
'invoice · invoice total',
'password · password reset',
])
})
})

View File

@@ -0,0 +1,445 @@
import { TLShape } from '@tldraw/tlschema'
import { getHashForString } from '@tldraw/utils'
export interface ShapeCluster {
id: string
numberOfShapes: number
/** Short title: a frame name when the cluster has one, otherwise its top keywords. */
label: string
/** Terms that distinguish this cluster from the others on its page, best first. */
keywords: string[]
shapes: TLShapeWithPlainText[]
}
/**
* A shape carrying the plain text its ShapeUtil reports.
*
* `getText` is shape behaviour, not a field on the record — a text shape's string lives in
* `props.richText` as a document, a geo shape's label is somewhere else again, and some shapes
* compute it. Only an editor can answer it properly, so the measure render answers it once and
* attaches it here, and everything downstream reads `plainText` instead of guessing from props.
*/
export type TLShapeWithPlainText = TLShape & { plainText?: string }
/** Axis-aligned page-space bounds. A local stand-in for Box, which lives in @tldraw/editor. */
export interface ClusterBounds {
minX: number
minY: number
maxX: number
maxY: number
}
// Spatial linkage is O(n²) — a full Prim pass plus the nearest-neighbour scan that derives eps. At a
// few thousand shapes that is milliseconds; on a very large board it would blow a Worker's CPU
// budget, so past the cap the atoms are returned unmerged rather than the call timing out.
export const MAX_CLUSTERING_ATOMS = 2500
/**
* Group a page's shapes into clusters.
*
* Always starts from the hierarchy the board already stores: every top-level shape is one atom and
* carries its descendants, so frames and groups stay whole and ungrouped shapes stay addressable.
*
* When `bounds` is supplied, atoms that sit near each other are then merged by single-linkage
* clustering (see below), which is what makes a loose arrangement of shapes read as one thing.
* Without it, each atom is its own cluster — the hierarchy is all the structure available, since
* sizing a shape needs an editor and font metrics that a Worker does not have.
*/
export function getShapeClusters(
shapes: TLShapeWithPlainText[],
pageId: string,
bounds?: Record<string, ClusterBounds>
): ShapeCluster[] {
const byId = new Map(shapes.map((shape) => [shape.id, shape]))
const atomsByRoot = new Map<string, TLShapeWithPlainText[]>()
for (const shape of shapes) {
let root = shape
for (let depth = 0; depth < 100; depth++) {
if (root.parentId === pageId) break
const parent = byId.get(root.parentId as TLShape['id'])
if (!parent) break
root = parent
}
const members = atomsByRoot.get(root.id)
if (members) members.push(shape)
else atomsByRoot.set(root.id, [shape])
}
const atoms = [...atomsByRoot.values()]
const clusters = bounds ? mergeNearbyAtoms(atoms, bounds) : atoms
// Ids hash the sorted member ids, so the same membership always yields the same handle. Hash
// collisions are numbered in key order to keep that stable too.
const keys = clusters.map(getClusterKey)
const ids = new Array<string>(clusters.length)
const collisions = new Map<string, number>()
const byKey = keys
.map((_, i) => i)
.sort((a, b) => (keys[a] < keys[b] ? -1 : keys[a] > keys[b] ? 1 : 0))
for (const index of byKey) {
const base = `cluster:${(Number(getHashForString(keys[index])) >>> 0).toString(36)}`
const collision = collisions.get(base) ?? 0
collisions.set(base, collision + 1)
ids[index] = collision === 0 ? base : `${base}-${collision}`
}
// Labelled together, never one at a time: a term's weight depends on how many of the page's
// *other* clusters use it, so the whole set has to be in hand.
const labels = labelClusters(clusters)
return clusters.map((clusterShapes, index) => ({
id: ids[index],
numberOfShapes: clusterShapes.length,
label: labels[index].label,
keywords: labels[index].keywords,
shapes: clusterShapes,
}))
}
// --- Keyword labelling (c-TF-IDF) ----------------------------------------------------------------
//
// Cheap, embedding-free cluster naming: no model call, no network, just counting.
//
// Each cluster is pooled into one bag of terms and scored by term frequency inside the cluster,
// discounted by how many clusters contain the term. The discount is the whole point: a word in every
// cluster says nothing about *which* cluster you are looking at, so it scores near zero, while a word
// confined to one rises even at low frequency. On a board of UI mockups that is the difference
// between labelling everything "button" and labelling one cluster "password · email".
const STOPWORDS = new Set(
(
'a an and are as at be but by for from has have how i if in into is it its of on or ' +
'that the their there these they this to was we were what when where which who will ' +
'with you your not no yes can could should would may might must do does did done ' +
'my our us them he she his her him me so than then too very just also more most some ' +
'any all each other another new one two three about over under up down out off here'
).split(' ')
)
/** How many keywords a cluster reports. */
const MAX_KEYWORDS = 5
/** Frame names are deliberate, human-written titles, so they outweigh incidental text. */
const FRAME_NAME_WEIGHT = 3
/** "user research" is a better label than "user" and "research" apart. */
const BIGRAM_WEIGHT = 1.6
function tokenize(text: string): string[] {
return text
.toLowerCase()
.split(/[^\p{L}\p{N}']+/u)
.map((token) => token.replace(/^'+|'+$/g, ''))
.filter((token) => token.length >= 3 && !STOPWORDS.has(token) && !/^\d+$/.test(token))
}
function addTokens(bag: Map<string, number>, tokens: string[], weight: number) {
for (const token of tokens) bag.set(token, (bag.get(token) ?? 0) + weight)
// Bigrams only from tokens adjacent within one shape's text, never across shape boundaries — two
// unrelated labels sitting next to each other must not invent a phrase between them.
for (let i = 0; i < tokens.length - 1; i++) {
const bigram = `${tokens[i]} ${tokens[i + 1]}`
bag.set(bigram, (bag.get(bigram) ?? 0) + weight * BIGRAM_WEIGHT)
}
}
/**
* Plain text from a shape record.
*
* The editor would use `ShapeUtil.getText`, but this has to run in a Worker too, where only the
* stored record exists. So it reads the two places text is persisted: `props.richText` (a ProseMirror
* document, walked for its text nodes) and `props.text` on shapes that still store a plain string.
*/
export function getShapeText(shape: TLShapeWithPlainText): string {
// The editor's own answer when a measure render supplied one. Everything below is the fallback
// for callers with only the stored record, and is necessarily an approximation of it.
if (typeof shape.plainText === 'string') return shape.plainText
const props = shape.props as Record<string, unknown>
if (typeof props?.text === 'string') return props.text
const richText = props?.richText as { content?: unknown } | undefined
if (richText && typeof richText === 'object') {
const parts: string[] = []
const walk = (node: unknown) => {
if (!node || typeof node !== 'object') return
const record = node as { type?: unknown; text?: unknown; content?: unknown }
if (record.type === 'text' && typeof record.text === 'string') parts.push(record.text)
if (Array.isArray(record.content)) for (const child of record.content) walk(child)
}
walk(richText)
// Joined with spaces so tokens from adjacent nodes stay separate words.
return parts.join(' ')
}
return ''
}
/** A frame's name, which is a title someone typed rather than content. */
function getFrameName(shape: TLShapeWithPlainText): string | null {
if (shape.type !== 'frame') return null
const name = (shape.props as { name?: unknown }).name
return typeof name === 'string' && name.trim() ? name.trim() : null
}
export interface ClusterLabel {
label: string
keywords: string[]
frameNames: string[]
}
/**
* Label every cluster at once. The scores are comparative — a term's weight depends on how many other
* clusters use it — so this cannot be done a cluster at a time.
*/
export function labelClusters(clusters: TLShapeWithPlainText[][]): ClusterLabel[] {
const bags = clusters.map((shapes) => {
const tokens = new Map<string, number>()
const frameNames: string[] = []
for (const shape of shapes) {
const frameName = getFrameName(shape)
if (frameName) {
frameNames.push(frameName)
addTokens(tokens, tokenize(frameName), FRAME_NAME_WEIGHT)
continue
}
const text = getShapeText(shape)
if (text) addTokens(tokens, tokenize(text), 1)
}
return { tokens, frameNames }
})
// How many clusters contain each term. This corpus-wide count is what makes the weight
// comparative rather than a plain frequency.
const documentFrequency = new Map<string, number>()
for (const bag of bags) {
for (const term of bag.tokens.keys()) {
documentFrequency.set(term, (documentFrequency.get(term) ?? 0) + 1)
}
}
const clusterCount = Math.max(1, bags.length)
return bags.map((bag) => {
const scored = [...bag.tokens.entries()]
.map(([term, frequency]) => ({
term,
score: frequency * Math.log(1 + clusterCount / (documentFrequency.get(term) ?? 1)),
}))
// Ties broken alphabetically, so the same board always produces the same label rather than
// depending on Map insertion order.
.sort((a, b) => b.score - a.score || (a.term < b.term ? -1 : 1))
const keywords: string[] = []
for (const { term } of scored) {
if (keywords.length >= MAX_KEYWORDS) break
// Skip a unigram already contained in a chosen bigram — "user" adds nothing once "user
// research" is on the list.
const covered = keywords.some(
(chosen) => chosen.includes(' ') && !term.includes(' ') && chosen.split(' ').includes(term)
)
if (!covered) keywords.push(term)
}
const uniqueFrames = [...new Set(bag.frameNames)]
const label = uniqueFrames.length
? uniqueFrames.slice(0, 2).join(' + ')
: keywords.length
? keywords.slice(0, 2).join(' · ')
: ''
return { label, keywords, frameNames: uniqueFrames }
})
}
function getClusterKey(shapes: TLShapeWithPlainText[]) {
return shapes
.map((shape) => shape.id)
.sort()
.join(',')
}
/** Shortest distance between two boxes; 0 when they touch or overlap. */
export function boundsGapDistance(a: ClusterBounds, b: ClusterBounds): number {
const dx = Math.max(0, Math.max(a.minX, b.minX) - Math.min(a.maxX, b.maxX))
const dy = Math.max(0, Math.max(a.minY, b.minY) - Math.min(a.maxY, b.maxY))
return Math.hypot(dx, dy)
}
function unionBounds(boxes: ClusterBounds[]): ClusterBounds {
return {
minX: Math.min(...boxes.map((b) => b.minX)),
minY: Math.min(...boxes.map((b) => b.minY)),
maxX: Math.max(...boxes.map((b) => b.maxX)),
maxY: Math.max(...boxes.map((b) => b.maxY)),
}
}
/**
* Derive the merge threshold from the page itself, so "near" scales with the content: a page of
* dense wireframes gets a small eps and each wireframe becomes a cluster, while a page of large
* scattered groups gets a proportionally larger one.
*
* Within-group structure shows up as small nearest-neighbour distances, so
* `eps = max(median nearest-neighbour × 2, median diagonal × 0.4) + 20`. The medians are robust to a
* few far-flung outliers, and the diagonal term keeps eps proportional to shape size on sparse pages
* where nearest-neighbour distances alone would under-estimate it.
*/
export function computeAutoEps(boxes: ClusterBounds[]): number {
if (boxes.length < 2) return 128
const nearest: number[] = []
const diagonals: number[] = []
for (let i = 0; i < boxes.length; i++) {
let best = Infinity
for (let j = 0; j < boxes.length; j++) {
if (i === j) continue
best = Math.min(best, boundsGapDistance(boxes[i], boxes[j]))
}
nearest.push(best)
diagonals.push(Math.hypot(boxes[i].maxX - boxes[i].minX, boxes[i].maxY - boxes[i].minY))
}
nearest.sort((a, b) => a - b)
diagonals.sort((a, b) => a - b)
const eps =
Math.max(
nearest[Math.floor(nearest.length / 2)] * 2,
diagonals[Math.floor(diagonals.length / 2)] * 0.4
) + 20
return Math.min(5000, Math.max(8, eps))
}
/** Does box `a` fully contain box `b`, allowing a little slack? */
function containsBox(a: ClusterBounds, b: ClusterBounds, tolerance = 2): boolean {
return (
a.minX - tolerance <= b.minX &&
a.minY - tolerance <= b.minY &&
a.maxX + tolerance >= b.maxX &&
a.maxY + tolerance >= b.maxY &&
(a.maxX - a.minX > b.maxX - b.minX || a.maxY - a.minY > b.maxY - b.minY)
)
}
/**
* Merge atoms that belong together, by proximity and by containment.
*
* Proximity is single-linkage clustering cut at eps. Cutting a minimum spanning tree at a threshold
* *is* single linkage: the MST holds, for every pair of groups, exactly the shortest edge between
* them, so dropping every edge longer than eps leaves groups in which each member is within eps of
* some other member. Membership therefore chains — a long row of shapes is one cluster even though
* its two ends are far apart — which is the property that makes loose arrangements read as one thing.
*
* Arrows take no part in it. An arrow drawn between two distant shapes has bounds spanning the gap,
* so its gap distance to *both* ends is zero; with chaining, one arrow is enough to pull two
* unrelated groups — and then everything they touch — into a single cluster. Excluding them from the
* threshold as well as the linkage matters for the same reason: their long thin boxes sit right on
* top of their endpoints, dragging the median nearest-neighbour distance down.
*
* Containment runs over everything, arrows included, because a shape genuinely inside another cannot
* bridge anything — it can only join the thing that encloses it.
*/
function mergeNearbyAtoms(
atoms: TLShapeWithPlainText[][],
bounds: Record<string, ClusterBounds>
): TLShapeWithPlainText[][] {
const boxes = atoms.map((atom) => boundsForAtom(atom, bounds))
const parent = atoms.map((_, i) => i)
const find = (i: number): number => {
while (parent[i] !== i) {
parent[i] = parent[parent[i]]
i = parent[i]
}
return i
}
const union = (a: number, b: number) => {
const rootA = find(a)
const rootB = find(b)
if (rootA !== rootB) parent[rootB] = rootA
}
// An atom the caller could not measure has no position to cluster on, and an arrow would bridge
// unrelated groups, so neither takes part in the proximity pass.
const linkable: number[] = []
for (let i = 0; i < atoms.length; i++) {
if (!boxes[i]) continue
if (atoms[i].every((shape) => shape.type === 'arrow')) continue
linkable.push(i)
}
if (linkable.length >= 2 && linkable.length <= MAX_CLUSTERING_ATOMS) {
const linkableBoxes = linkable.map((i) => boxes[i]!)
const eps = computeAutoEps(linkableBoxes)
// Prim's algorithm, computing distances on demand rather than filling an n×n matrix — at the
// cap above a matrix would be tens of megabytes of doubles for no benefit.
const n = linkable.length
const inTree = new Array<boolean>(n).fill(false)
const bestCost = new Array<number>(n).fill(Infinity)
const bestFrom = new Array<number>(n).fill(-1)
inTree[0] = true
for (let i = 1; i < n; i++) {
bestCost[i] = boundsGapDistance(linkableBoxes[0], linkableBoxes[i])
bestFrom[i] = 0
}
for (let added = 1; added < n; added++) {
let next = -1
for (let i = 0; i < n; i++) {
if (!inTree[i] && (next === -1 || bestCost[i] < bestCost[next])) next = i
}
if (next === -1) break
inTree[next] = true
// Only edges surviving the cut are merged, so the components are exactly single-linkage
// clusters at this threshold.
if (bestCost[next] <= eps) union(linkable[bestFrom[next]], linkable[next])
for (let i = 0; i < n; i++) {
if (inTree[i]) continue
const distance = boundsGapDistance(linkableBoxes[next], linkableBoxes[i])
if (distance < bestCost[i]) {
bestCost[i] = distance
bestFrom[i] = next
}
}
}
}
// Containment: a shape sitting fully inside another joins whatever encloses it.
if (atoms.length <= MAX_CLUSTERING_ATOMS) {
for (let i = 0; i < atoms.length; i++) {
const outer = boxes[i]
if (!outer) continue
for (let j = 0; j < atoms.length; j++) {
const inner = boxes[j]
if (i === j || !inner) continue
if (containsBox(outer, inner)) union(i, j)
}
}
}
// Rebuilt in the atoms' original order, so an atom that merged with nothing keeps its place.
const merged: TLShapeWithPlainText[][] = []
const positionOfGroup = new Map<number, number>()
for (let i = 0; i < atoms.length; i++) {
const group = find(i)
const existing = positionOfGroup.get(group)
if (existing === undefined) {
positionOfGroup.set(group, merged.length)
merged.push([...atoms[i]])
} else {
merged[existing].push(...atoms[i])
}
}
return merged
}
/** An atom's extent is the union of its members', so a frame covers everything inside it. */
function boundsForAtom(
shapes: TLShapeWithPlainText[],
bounds: Record<string, ClusterBounds>
): ClusterBounds | null {
const boxes = shapes.map((shape) => bounds[shape.id]).filter(Boolean)
return boxes.length ? unionBounds(boxes) : null
}

View File

@@ -0,0 +1,84 @@
// The Browser Rendering `/screenshot` request contract shared by the sync-worker's thumbnail
// captures (thumbnailRender.ts, via the BROWSER binding's Quick Action) and the dev capture script
// (apps/dotcom/client/scripts/browser-run-thumbnail.ts, via the REST API). Both drive the same
// render pages, so the wait strategy, terminal selectors, and capture target live here — one place
// — and the local harness can't drift from production.
// The render page marks a terminal state on <body>/<html>: success sets data-thumbnail-ready once
// the exported image has painted; any failure (bad token, snapshot load, export, or image decode)
// sets data-thumbnail-error. The screenshot wait resolves on EITHER, so a failed render returns as
// soon as it errors instead of burning the whole render timeout holding scarce Browser Run
// capacity.
export const THUMBNAIL_SETTLED_SELECTOR = '[data-thumbnail-ready="true"], [data-thumbnail-error]'
// The element the screenshot actually captures. It exists only on the success path, so when the
// wait above resolves on a failure there is nothing to screenshot and the capture returns an error
// immediately (surfaced as a render failure) rather than capturing the error page. Scoped to
// <body> so it resolves to a single element (both <html> and <body> carry the ready marker).
const THUMBNAIL_CAPTURE_SELECTOR = 'body[data-thumbnail-ready="true"]'
// Builds the `/screenshot` request body. `timeoutMs` is granted to navigation and to the
// settle+export wait *individually* — these are per-phase timers, not a cap on the call. They
// exist so Browser Run gives up on a dead page early; bounding the call as a whole is the
// sync-worker's job. The render page sizes its own settle budget under the same value
// (THUMBNAIL_SETTLE_TIMEOUT_MS), so pass THUMBNAIL_RENDER_TIMEOUT_MS to keep the deadlines from
// drifting.
export function getThumbnailScreenshotRequestBody({
renderUrl,
width,
height,
timeoutMs,
}: {
renderUrl: string
width: number
height: number
timeoutMs: number
}) {
const headers = getThumbnailScreenshotExtraHeaders(renderUrl)
return {
url: renderUrl,
...(headers ? { setExtraHTTPHeaders: headers } : null),
viewport: {
width,
height,
deviceScaleFactor: 1,
},
// Waiting for a terminal selector is the real completion signal; waiting on network activity is
// fragile because background app requests (e.g. feature-flag polling) can keep the network
// busy indefinitely. `load` is a milder form of that same trap, so it stops here at
// domcontentloaded: `load` does not fire until every subresource settles, and one stalled image
// request is enough to hold it open until this timeout — at which point the capture fails
// without ever reaching the waitForSelector below, even though the page had marked itself ready
// long before. A board whose bookmark preview image points back at that board's own OG image
// route does exactly this. Nothing is lost by not waiting for `load`: the render page's settle
// wait (THUMBNAIL_SETTLE_TIMEOUT_MS) and the SDK's asset-inlining delay (maxExportDelayMs) are
// separately bounded, so the page reaches a terminal state on its own schedule regardless.
gotoOptions: {
waitUntil: 'domcontentloaded',
timeout: timeoutMs,
},
// Resolve as soon as the render page reaches either terminal state (ready or error), so a
// failed render doesn't hold Browser Run capacity for the full timeout.
waitForSelector: {
selector: THUMBNAIL_SETTLED_SELECTOR,
timeout: timeoutMs,
},
// Capture the success-only element (it fills the viewport, so this matches a full-viewport
// screenshot). On a failure it's absent, so the capture errors out immediately instead of
// screenshotting the error page. `selector` targets an element without waiting (waitForSelector
// above is the wait), so a missing element fails fast rather than re-waiting the timeout.
selector: THUMBNAIL_CAPTURE_SELECTOR,
screenshotOptions: {
type: 'png',
},
}
}
function getThumbnailScreenshotExtraHeaders(renderUrl: string) {
const { hostname } = new URL(renderUrl)
if (hostname.endsWith('.ngrok-free.dev')) {
return {
'ngrok-skip-browser-warning': 'true',
}
}
return null
}

View File

@@ -0,0 +1,551 @@
import {
boolean,
createSchema,
enumeration,
json,
number,
relationships,
Row,
string,
table,
} from '@rocicorp/zero'
import { IndexKey } from '@tldraw/utils'
import { Role } from './roles'
export interface ZColumn {
optional?: boolean
type: 'string' | 'number' | 'boolean' | 'json'
}
export const user = table('user')
.columns({
id: string(),
name: string(),
email: string(),
avatar: string(),
color: string(),
exportFormat: string(),
exportTheme: string(),
exportBackground: boolean(),
exportPadding: boolean(),
createdAt: number(),
updatedAt: number(),
flags: string(),
locale: string().optional(),
animationSpeed: number().optional(),
areKeyboardShortcutsEnabled: boolean().optional(),
edgeScrollSpeed: number().optional(),
colorScheme: string().optional(),
isSnapMode: boolean().optional(),
isWrapMode: boolean().optional(),
isDynamicSizeMode: boolean().optional(),
isPasteAtCursorMode: boolean().optional(),
inputMode: string().optional(),
enhancedA11yMode: boolean().optional(),
isZoomDirectionInverted: boolean().optional(),
allowAnalyticsCookie: boolean().optional(),
})
.primaryKey('id')
export const file_state = table('file_state')
.columns({
userId: string(),
fileId: string(),
firstVisitAt: number().optional(),
lastEditAt: number().optional(),
lastSessionState: string().optional(),
lastVisitAt: number().optional(),
})
.primaryKey('userId', 'fileId')
// A shareable, per-file record of everyone who has opened a board, denormalized from file_state by
// Postgres triggers (migration 044). Its own table — not columns on file_state — because file_state
// also carries private per-user data (lastSessionState = camera/selection, visit timestamps) that
// must never sync to other users; every column here is safe to expose to any file collaborator, so
// the comment composer can offer past viewers (not just workspace members) as @-mention targets.
// Trigger-written only, so identity fields are non-optional (cf. comment.authorName), and
// lastVisitAt is always stamped (falling back to "now") so most-recent-first ordering never
// meets a null.
export const file_visitor = table('file_visitor')
.columns({
userId: string(),
fileId: string(),
userName: string(),
userColor: string(),
lastVisitAt: number(),
})
.primaryKey('userId', 'fileId')
export const file = table('file')
.columns({
id: string(),
name: string(),
owningGroupId: string().optional(),
ownerName: string(),
thumbnail: string(),
shared: boolean(),
sharedLinkType: string(),
published: boolean(),
lastPublished: number(),
publishedSlug: string(),
createdAt: number(),
updatedAt: number(),
isEmpty: boolean(),
isDeleted: boolean(),
createSource: string().optional(),
})
.primaryKey('id')
export const group = table('group')
.columns({
id: string(),
name: string(),
inviteSecret: string().optional(),
// Whether the invite link currently lets people join. Toggling this off
// disables the link without discarding inviteSecret, so re-enabling restores
// the same link (cf. a file's `shared` flag). Optional so older cached rows
// without the column read as enabled (defaulted true) until they refetch.
inviteLinkEnabled: boolean().optional(),
isDeleted: boolean(),
createdAt: number(),
updatedAt: number(),
})
.primaryKey('id')
export const group_user = table('group_user')
.columns({
userId: string(),
groupId: string(),
createdAt: number(),
updatedAt: number(),
role: enumeration<Role>(),
userName: string(),
userColor: string(),
index: string<IndexKey>(),
})
.primaryKey('userId', 'groupId')
export const group_file = table('group_file')
.columns({
fileId: string(),
groupId: string(),
createdAt: number(),
updatedAt: number(),
index: string<IndexKey>().optional(),
})
.primaryKey('fileId', 'groupId')
// Client-visible subset of the comment record; the full column set adds the persistence-only
// columns (see CommentPersistenceColumns and DB below). Zero replicates this per user for the
// app-level /comments view; the in-document view reads from the file room instead. Server-written
// only. Thread anchor/resolution is read via the `thread` relationship rather than a denormalized
// `shapeId` column here, since a thread can be re-anchored after the comment is created.
export const comment = table('comment')
.columns({
id: string(),
fileId: string(),
threadId: string(),
pageId: string(),
authorId: string(),
// Author display fields, denormalized by Postgres triggers (migration 040, same pattern
// as file.ownerName) — joining the user row would sync private fields to every reader.
authorName: string(),
authorColor: string(),
// Not shown anywhere yet: users can't change their avatar, so we don't render avatars
// until they can.
authorAvatar: string(),
body: json(),
createdAt: number(),
// soft-deletion flag; queries must filter deleted comments out
isDeleted: boolean(),
updatedAt: number(),
})
.primaryKey('id')
// The comment thread's anchor location and resolution state, for app-level Zero queries (e.g.
// filtering resolved threads). Server-written only. Persistence-only columns
// (CommentThreadPersistenceColumns: anchor/resolvedBy/meta/lastChangedClock) are deliberately
// absent here so they never replicate to clients.
export const comment_thread = table('comment_thread')
.columns({
id: string(),
fileId: string(),
pageId: string(),
// only set for shape-anchored threads; other anchor kinds have no shape
shapeId: string().optional(),
resolvedAt: number().optional(),
// soft-deletion flag; queries must filter deleted threads (and their comments) out
isDeleted: boolean(),
createdBy: string(),
createdAt: number(),
})
.primaryKey('id')
// Per-user read receipts for comments, for the app-level /comments view. Row present = read;
// marking unread deletes the row. Written via the comment.markRead/markUnread custom mutators
// (client-written, unlike comment/comment_thread which are server-written by the file's DO).
// Authors' own comments have no row: the client treats authorId === me as implicitly read.
export const comment_read = table('comment_read')
.columns({
userId: string(),
commentId: string(),
readAt: number(),
})
.primaryKey('userId', 'commentId')
// One row per (comment, @-mentioned user), extracted from the comment's rich-text body by the
// file's Durable Object when it drains comment records to Postgres (mentions live inside the
// body JSON, which ZQL can't reach — this table is what lets the notifications query filter
// "comments that mention me" server-side). Server-written only, like comment/comment_thread;
// rows are reconciled on every comment upsert and cascade away with their comment.
export const comment_mention = table('comment_mention')
.columns({
commentId: string(),
userId: string(),
})
.primaryKey('commentId', 'userId')
// One row per (comment, reacting user, emoji) — a user may react with several emoji. Written by
// the file's Durable Object from the room's comment-reaction records, like comment/comment_thread.
// The in-document reaction UI reads reactions over the sync object-lane, not from here; this table
// feeds the app-level `reactions` query (the notifications feed's "reacted to your comment"
// category) and the reaction pills on notification rows.
export const comment_reaction = table('comment_reaction')
.columns({
id: string(),
fileId: string(),
commentId: string(),
threadId: string(),
pageId: string(),
userId: string(),
// denormalized user display name, stamped and kept fresh by Postgres triggers (045)
userName: string(),
emoji: string(),
createdAt: number(),
})
.primaryKey('id')
const fileRelationships = relationships(file, ({ many }) => ({
states: many({
sourceField: ['id'],
destField: ['fileId'],
destSchema: file_state,
}),
groupFiles: many({
sourceField: ['id'],
destField: ['fileId'],
destSchema: group_file,
}),
}))
const fileStateRelationships = relationships(file_state, ({ one }) => ({
file: one({
sourceField: ['fileId'],
destField: ['id'],
destSchema: file,
}),
user: one({
sourceField: ['userId'],
destField: ['id'],
destSchema: user,
}),
}))
const fileVisitorRelationships = relationships(file_visitor, ({ one }) => ({
file: one({
sourceField: ['fileId'],
destField: ['id'],
destSchema: file,
}),
}))
const groupRelationships = relationships(group, ({ many }) => ({
groupMembers: many({
sourceField: ['id'],
destField: ['groupId'],
destSchema: group_user,
}),
groupFiles: many({
sourceField: ['id'],
destField: ['groupId'],
destSchema: group_file,
}),
}))
const groupUserRelationships = relationships(group_user, ({ one, many }) => ({
user: one({
sourceField: ['userId'],
destField: ['id'],
destSchema: user,
}),
group: one({
sourceField: ['groupId'],
destField: ['id'],
destSchema: group,
}),
groupFiles: many({
sourceField: ['groupId'],
destField: ['groupId'],
destSchema: group_file,
}),
groupMembers: many({
sourceField: ['groupId'],
destField: ['groupId'],
destSchema: group_user,
}),
}))
const groupFileRelationships = relationships(group_file, ({ one, many }) => ({
file: one({
sourceField: ['fileId'],
destField: ['id'],
destSchema: file,
}),
group: one({
sourceField: ['groupId'],
destField: ['id'],
destSchema: group,
}),
groupMembers: many({
sourceField: ['groupId'],
destField: ['groupId'],
destSchema: group_user,
}),
}))
// No author relationship — author info comes from the denormalized authorName/authorColor/
// authorAvatar columns (see the comment table definition).
const commentRelationships = relationships(comment, ({ one, many }) => ({
file: one({
sourceField: ['fileId'],
destField: ['id'],
destSchema: file,
}),
thread: one({
sourceField: ['threadId'],
destField: ['id'],
destSchema: comment_thread,
}),
// singular name despite many() cardinality (one row per user): every consumer scopes it to
// one user and calls .one(), yielding at most one row (see the comments query). Always scope
// it to ctx.userId in synced queries — unscoped it replicates every user's receipts.
read: many({
sourceField: ['id'],
destField: ['commentId'],
destSchema: comment_read,
}),
// the users this comment @-mentions; used with whereExists in the comments query, never
// synced as a related row set
mentions: many({
sourceField: ['id'],
destField: ['commentId'],
destSchema: comment_mention,
}),
// every reaction to this comment, one row per reacting user. Joined on fileId too: a reaction's
// id is derived from (comment, user, emoji), so unlike a comment or a thread it carries no proof
// that its client-supplied commentId belongs to the file the reaction was written in. Matching
// fileId keeps a reaction written elsewhere off this comment, whatever its commentId claims —
// the reaction's own fileId is stamped by the owning room's drain, not by the client.
reactions: many({
sourceField: ['id', 'fileId'],
destField: ['commentId', 'fileId'],
destSchema: comment_reaction,
}),
}))
const commentThreadRelationships = relationships(comment_thread, ({ one, many }) => ({
file: one({
sourceField: ['fileId'],
destField: ['id'],
destSchema: file,
}),
// the thread's messages; used with whereExists and as a related row set in the comments
// query. Always scope to ctx.userId when syncing — unscoped it replicates everyone's comments
comments: many({
sourceField: ['id'],
destField: ['threadId'],
destSchema: comment,
}),
}))
export type TlaFilePartial = Partial<TlaFile> & {
id: TlaFile['id']
}
export type TlaFileStatePartial = Partial<TlaFileState> & {
fileId: TlaFileState['fileId']
userId: TlaFileState['userId']
}
export type TlaUserPartial = Partial<TlaUser> & {
id: TlaUser['id']
}
export type TlaGroupPartial = Partial<TlaGroup> & {
id: TlaGroup['id']
}
export type TlaGroupUserPartial = Partial<TlaGroupUser> & {
userId: TlaGroupUser['userId']
groupId: TlaGroupUser['groupId']
}
export type TlaGroupFilePartial = Partial<TlaGroupFile> & {
fileId: TlaGroupFile['fileId']
groupId: TlaGroupFile['groupId']
}
export type TlaCommentPartial = Partial<TlaComment> & {
id: TlaComment['id']
}
export type TlaCommentThreadPartial = Partial<TlaCommentThread> & {
id: TlaCommentThread['id']
}
export type TlaCommentReadPartial = Partial<TlaCommentRead> & {
userId: TlaCommentRead['userId']
commentId: TlaCommentRead['commentId']
}
export type TlaCommentMentionPartial = Partial<TlaCommentMention> & {
commentId: TlaCommentMention['commentId']
userId: TlaCommentMention['userId']
}
export const immutableColumns = {
user: new Set<keyof TlaUser>(['email', 'createdAt', 'updatedAt', 'avatar']),
file: new Set<keyof TlaFile>([
'ownerName',
'owningGroupId',
'publishedSlug',
'thumbnail',
'isDeleted',
'createSource',
'updatedAt',
'createdAt',
]),
file_state: new Set<keyof TlaFileState>(['firstVisitAt']),
} as const
export function isColumnMutable(tableName: keyof typeof immutableColumns, column: string) {
return !immutableColumns[tableName].has(column as never)
}
export interface TlaAsset {
objectName: string
fileId: string
userId: string | null
}
export interface TlaEffectOutbox {
id: number
tableName: string
entityId: string
command: 'insert' | 'update' | 'delete'
payload: unknown
prevPayload: unknown | null
attempts: number
createdAt: Date
nextRetryAt: Date | null
/** Latest failed attempt's error text; survives an admin retry. */
lastError: string | null
}
/**
* The welcome-template pointer (see migration 035). Worker-side config only — not a Zero
* table (absent from `createSchema` below), so it never replicates to clients.
*/
export interface TlaWelcomeTemplate {
id: boolean
fileId: string
publishedSlug: string
updatedAt: number
}
/**
* Sync-worker-only persistence columns, absent from the Zero table definitions above so they
* never replicate to clients. Together with the Zero-visible columns they carry every field of
* the corresponding TLRecord, so the file's Durable Object can rebuild its room's comment
* records from rows alone (see commentRows.ts in sync-worker).
*/
export interface CommentThreadPersistenceColumns {
anchor: unknown
resolvedBy: string | null
meta: unknown
lastChangedClock: number
}
export interface CommentPersistenceColumns {
editedAt: number | null
meta: unknown
lastChangedClock: number
}
export interface CommentReactionPersistenceColumns {
meta: unknown
lastChangedClock: number
}
export interface DB {
file: TlaFile
file_state: TlaFileState
file_visitor: TlaFileVisitor
user: TlaUser
group: TlaGroup
group_user: TlaGroupUser
group_file: TlaGroupFile
asset: TlaAsset
welcome_template: TlaWelcomeTemplate
comment: TlaComment & CommentPersistenceColumns
comment_thread: TlaCommentThread & CommentThreadPersistenceColumns
comment_read: TlaCommentRead
comment_mention: TlaCommentMention
comment_reaction: TlaCommentReaction & CommentReactionPersistenceColumns
effect_outbox: TlaEffectOutbox
}
export const schema = createSchema({
tables: [
user,
file,
file_state,
file_visitor,
group,
group_user,
group_file,
comment,
comment_thread,
comment_read,
comment_mention,
comment_reaction,
],
relationships: [
fileRelationships,
fileStateRelationships,
fileVisitorRelationships,
groupRelationships,
groupUserRelationships,
groupFileRelationships,
commentRelationships,
commentThreadRelationships,
],
})
export type TlaSchema = typeof schema
export type TlaUser = Row<typeof schema.tables.user>
export type TlaFile = Row<typeof schema.tables.file>
export type TlaFileState = Row<typeof schema.tables.file_state>
export type TlaFileVisitor = Row<typeof schema.tables.file_visitor>
export type TlaGroup = Row<typeof schema.tables.group>
export type TlaGroupUser = Row<typeof schema.tables.group_user>
export type TlaGroupFile = Row<typeof schema.tables.group_file>
export type TlaComment = Row<typeof schema.tables.comment>
export type TlaCommentThread = Row<typeof schema.tables.comment_thread>
export type TlaCommentRead = Row<typeof schema.tables.comment_read>
export type TlaCommentMention = Row<typeof schema.tables.comment_mention>
export type TlaCommentReaction = Row<typeof schema.tables.comment_reaction>
// Permissions are now handled via Synced Queries in queries.ts
// No feature flags are currently defined. The user's `flags` column is kept as a
// free-form, comma/space-separated string (see parseFlags/userHasFlag); to add a flag,
// switch this back to a `stringEnum('flag_a', 'flag_b')` union.
export type TlaFlags = string

View File

@@ -0,0 +1,516 @@
import { stringEnum } from '@tldraw/utils'
import type { SerializedSchema, SerializedStore, TLRecord } from 'tldraw'
import { TlaEffectOutbox, TlaFile, TlaUser } from './tlaSchema'
export interface Snapshot {
schema: SerializedSchema
snapshot: SerializedStore<TLRecord>
}
export interface CreateRoomRequestBody {
origin: string
snapshot: Snapshot
}
export type CreateSnapshotResponseBody =
| {
error: false
roomId: string
}
| {
error: true
message: string
}
export interface GetReadonlySlugResponseBody {
slug: string
isLegacy: boolean
}
export interface HistoryResponseBody {
timestamps: string[]
hasMore: boolean
}
/* ----------------------- App ---------------------- */
export interface CreateFilesRequestBody {
origin: string
snapshots: Snapshot[]
}
export type CreateFilesResponseBody =
| {
error: false
slugs: string[]
}
| {
error: true
message: string
}
export type PublishFileResponseBody =
| {
error: false
}
| {
error: true
message: string
}
export type UnpublishFileResponseBody =
| {
error: false
}
| {
error: true
message: string
}
export type GetInviteInfoResponseBody =
| {
error: false
workspaceId: string
workspaceName: string
isValid: true
inviteSecret: string
}
| {
error: true
message: string
}
export type AcceptInviteResponseBody =
| {
error: false
message: string
workspaceId: string
workspaceName: string
success: true
}
| {
error: false
message: string
workspaceId: string
workspaceName: string
alreadyMember: true
}
| {
error: true
message: string
}
/**
* The most pages the board-info tool will enumerate for a board. Bounds the page list the MCP
* server returns and the valid page-ordinal range for screenshots.
*/
export const MAX_THUMBNAIL_PAGES = 40
// Thumbnail output dimensions, shared by the worker (cache keys, render tokens) and the client
// render page (page sizing, clamping) so both sides agree on the size and bounds.
export const DEFAULT_THUMBNAIL_WIDTH = 1200
export const DEFAULT_THUMBNAIL_HEIGHT = 630
export const MIN_THUMBNAIL_DIMENSION = 200
export const MAX_THUMBNAIL_DIMENSION = 1600
// Browser Run screenshot deadlines, shared so the worker and the client render page stay in sync.
// The worker's screenshot waits THUMBNAIL_RENDER_TIMEOUT_MS after navigation for the render page to
// set `data-thumbnail-ready`. That whole window is the render page's budget; it must spend most of
// it on the export itself (editor.toImage + base64 + paint), so it caps only the pre-export settle
// wait (fonts/asset warmup) at the much smaller THUMBNAIL_SETTLE_TIMEOUT_MS. Keeping the two derived
// from one place stops them drifting into a state where settle can starve the export of the window.
export const THUMBNAIL_RENDER_TIMEOUT_MS = 45_000
export const THUMBNAIL_SETTLE_TIMEOUT_MS = 10_000
export interface ThumbnailRenderParams {
/**
* `content` fits the page's content to the requested output size. When omitted, the render page
* sets the x/y/z viewport below directly. Every surface mints `content` today; the viewport path
* is kept because the render page and the worker deploy separately (see ThumbnailRenderJob).
*/
camera?: 'content'
/** The TLPageId of the single page to render. When omitted, the page the snapshot opens to. */
pageId?: string
/**
* Restricts the export to these shapes: the camera fits their common bounds and only they are
* drawn, so neighbouring shapes never leak into the frame. When omitted the whole page renders.
*/
shapeIds?: string[]
/**
* `live` means: prune the page to `shapeIds`, settle and fit as normal, then signal ready
* without running `editor.toImage` — the screenshotting browser rasterizes the live canvas
* instead of the page rasterizing itself. Skips the export phase (the expensive part on heavy
* boards) at the cost of the export path's pixel-exact sizing, so only agent-facing surfaces
* opt in. Absent means export as always.
*/
capture?: 'live'
/** `measure` means: skip the export, POST the page's measured geometry back, then signal ready. */
mode?: 'screenshot' | 'measure'
x: number
y: number
z: number
width: number
height: number
theme: 'light' | 'dark'
}
/**
* What the editor reports for one shape: its page-space box, and the plain text its ShapeUtil says it
* holds. Both are things only an editor can answer — sizing needs font metrics, and getText is shape
* behaviour rather than something readable off the record.
*/
export interface ThumbnailShapeMeasurement {
x: number
y: number
w: number
h: number
/** `ShapeUtil.getText(shape)`, absent when the shape has no text. */
text?: string
}
/**
* The render page's phase timings, POSTed to the result route as a fire-and-forget beacon once the
* page is ready. All values are `performance.now()` stamps (ms since navigation start), so the
* deltas between them are the phase costs a worker-side clock cannot see: script boot, snapshot
* fetch, editor mount, the settle wait, and the export itself (on a `live` capture there is no
* export, and `exportedAt` is the ready stamp).
*/
export interface ThumbnailRenderTimingsRequestBody {
token: string
timings: {
bootAt: number
dataAt: number
mountAt: number
settledAt: number
exportedAt: number
}
}
/** Body of POST /app/thumbnail-render/result — `shapeId -> measurement`, as the editor measured it. */
export interface ThumbnailRenderResultRequestBody {
token: string
bounds: Record<string, ThumbnailShapeMeasurement>
}
export type ThumbnailSnapshotResponseBody =
| {
error: false
records: TLRecord[]
schema: SerializedSchema
renderParams: ThumbnailRenderParams
}
| {
error: true
message: string
}
export const ZErrorCode = stringEnum(
'publish_failed',
'unpublish_failed',
'republish_failed',
'unknown_error',
'offline_error',
'client_too_old',
'forbidden',
'bad_request',
'rate_limit_exceeded',
'max_workspaces_reached',
'max_files_reached'
)
export type ZErrorCode = keyof typeof ZErrorCode
export const UserPreferencesKeys = [
'locale',
'animationSpeed',
'areKeyboardShortcutsEnabled',
'edgeScrollSpeed',
'colorScheme',
'isSnapMode',
'isWrapMode',
'isDynamicSizeMode',
'isPasteAtCursorMode',
'enhancedA11yMode',
'inputMode',
'isZoomDirectionInverted',
'name',
'color',
] as const satisfies Array<keyof TlaUser>
export interface SubmitFeedbackRequestBody {
description: string
allowContact: boolean
url: string
}
export const MAX_PROBLEM_DESCRIPTION_LENGTH = 2000
export type TLCustomServerEvent =
| { type: 'persistence_good' }
| { type: 'persistence_bad' }
// Sent once to a session that connected with a `loadId`, so the client's first_load report
// can show the server side of that same load. All durations in ms; boot fields only on a cold boot.
| {
type: 'first_load_server'
loadId: string
cold: boolean
auth_ms?: number
file_record_ms?: number
get_room_ms: number
total_ms: number
boot_r2_ms?: number
boot_comments_ms?: number
boot_total_ms?: number
}
/* ----------------------- Feature Flags ---------------------- */
export const FEATURE_FLAG_KEYS = [
'rum_enabled',
'first_load_rum',
'commenting_enabled',
'mcp_server_access',
'version_chain',
'version_chain_legacy_writes',
] as const
export type FeatureFlagKey = (typeof FEATURE_FLAG_KEYS)[number]
export type FeatureFlagValue = BooleanFeatureFlag | PercentageFeatureFlag | AllowlistFeatureFlag
export interface BooleanFeatureFlag {
type: 'boolean'
enabled: boolean
description: string
}
export interface PercentageFeatureFlag {
type: 'percentage'
/** 0–100. Server evaluates per-user: enabled when hash(userId+flag) < percentage. */
percentage: number
/** Master toggle — when false, disabled for all users regardless of percentage. */
enabled: boolean
description: string
}
/**
* Names the users a flag is on for, one person at a time.
*
* Distinct from a percentage rollout, which cannot express this: a percentage buckets users by
* `hash(userId + flagName)`, so it yields *a* subset of the requested size but never *the* subset
* someone picked. A hand-chosen group — a friends-and-family rollout, a set of design partners —
* needs the people themselves.
*
* Matching is on `userId`, never on email. `evaluateFlagForUser` already takes a userId and every
* caller has one, whereas matching on email would mean a user lookup per evaluation. Admins enter
* emails anyway — ids are opaque — so the admin save resolves each address to a user id up front,
* and the email rides along in the entry purely so the admin panel can show a readable list.
*/
export interface AllowlistFeatureFlag {
type: 'allowlist'
/** The users the flag is on for. Anyone not named here evaluates false. */
users: AllowlistEntry[]
/** Master toggle — when false, disabled for everyone regardless of the list. */
enabled: boolean
description: string
}
/** One person on an allowlist flag. Matched by id; the email is only a label. */
export interface AllowlistEntry {
userId: string
email: string
/**
* Set by the admin GET only, on an entry whose `userId` no longer resolves to an account — a
* deleted user, or an id that was never one. Never stored: matching is by id and an unresolvable
* id matches nobody, so this exists purely so the panel can show a dead grant as dead rather than
* as an ordinary line the admin would then fail to re-save.
*/
missing?: boolean
}
/**
* How many people one allowlist may name. A hand-maintained list of design partners is tens of
* people; a save an order of magnitude past that is a mistake — a pasted address book, a script gone
* wrong — and it would be stored in a single KV value that every evaluation reads.
*/
export const MAX_ALLOWLIST_ENTRIES = 200
/**
* Parses admin input into the emails an allowlist save should resolve: one per line or comma, blanks
* and duplicates dropped, lowercased so the lookup and the dedupe agree. Anything that isn't an email
* address is rejected rather than sent to the lookup, so the admin gets "that isn't an email" instead
* of the blanker "no account for that".
*
* Shared rather than implemented either side, because the admin panel counts what it is about to send
* and the worker decides what to store: two implementations disagreeing means a save that reports
* "1 user" and stores five. A comma-separated paste is exactly that case.
*/
export function parseAllowlistEmails(input: unknown): string[] {
const raw = Array.isArray(input)
? input.map((entry) => String(entry))
: String(input ?? '').split(/[\n,]/)
const emails: string[] = []
for (const value of raw) {
const email = value.trim().toLowerCase()
if (!email) continue
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
throw new Error(`"${value.trim()}" is not an email address`)
}
if (!emails.includes(email)) emails.push(email)
}
if (emails.length > MAX_ALLOWLIST_ENTRIES) {
throw new Error(
`An allowlist can name at most ${MAX_ALLOWLIST_ENTRIES} people; that is ${emails.length}`
)
}
return emails
}
/** Returned by the user-facing endpoint — just the evaluated result, no server internals. */
export interface EvaluatedFeatureFlag {
enabled: boolean
}
/** One unassociated or unverifiable asset in an admin asset-diagnostics report. */
export interface AdminFileAssetProblem {
assetId: string
objectName: string
src: string
fileIdMeta: string | null
/** null = the bucket head check failed, not a confirmed absence */
inBucket: boolean | null
dbRow: { fileId: string } | null
}
/** Response of the admin file-assets diagnostics endpoint. */
export interface AdminFileAssetsResponseBody {
file: Pick<TlaFile, 'id' | 'name' | 'owningGroupId' | 'isDeleted' | 'createSource'> | null
/** null exists = not checked (prefix needs slug translation) or the check failed */
source: { raw: string; exists: boolean | null } | null
shapes: {
total: number
byType: Record<string, number>
}
assets: {
/** Every asset record in the snapshot, including `external` ones */
total: number
associated: number
pending: number
/** Assets the association pass can't act on: bookmarks, non-http srcs, R2-invalid names */
external: number
oldFormatUrls: number
missingInBucket: number
headFailures: number
/** Sums sizes of assets found in the uploads bucket; missing or failed heads contribute 0 */
totalSizeBytes: number
largestSizeBytes: number
problems: AdminFileAssetProblem[]
}
dbRows: { forThisFile: number; orphaned: number }
warnings: string[]
}
/**
* Response of the admin board-stats endpoint: the shape of a board without its contents.
*
* Every field is a count, an enum tally, a size, or a timestamp. Nothing here is text a user typed,
* a URL they pasted, a shape id, or a person's id — so a report can be pasted into an issue, a
* Sentry thread, or a support reply without leaking what's on the board or who made it. Anything
* added here has to hold that line; use the asset diagnostics report when you need identifiers.
*/
export interface AdminFileStatsResponseBody {
/** null when no `file` row exists — legacy rooms have a snapshot but no row */
file: {
ownerType: 'group' | 'none'
createdAt: number
updatedAt: number
isDeleted: boolean
isEmpty: boolean
published: boolean
shared: boolean
sharedLinkType: string
/** Only the prefix (`file`, `room`, `publish`, `local`, …); the id half would name a board */
createSourceKind: string | null
} | null
snapshot: {
/** Stored size of the snapshot object in R2; null when the head check failed */
sizeBytes: number | null
clock: number | null
documentClock: number | null
tombstones: number
records: number
recordsByTypeName: Record<string, number>
/** Serialized schema version, for spotting boards stuck behind a migration */
schemaVersion: number | null
/** Per-record-type sequence numbers from the serialized schema, when it has them */
sequences: Record<string, number> | null
}
pages: { total: number; maxShapesOnAPage: number; empty: number }
shapes: {
total: number
byType: Record<string, number>
/** Deepest parent chain; 1 means every shape sits directly on a page */
maxDepth: number
locked: number
rotated: number
/**
* Shapes whose parent chain doesn't end at a page: a missing or unusable `parentId`, a chain
* that stops on a record that isn't a page, or a chain long enough to be a cycle.
*/
orphaned: number
/**
* Bounds covering the shapes parented directly to a page, from x/y plus w/h where the shape
* has them. Nested shapes are left out — their x/y is relative to their frame or group, so
* mixing the two would give a meaningless box. Rotation is ignored.
*/
extent: { width: number; height: number } | null
}
/** Lengths only — never the text itself */
text: { shapesWithText: number; totalCharacters: number; longestCharacters: number }
bindings: {
total: number
byType: Record<string, number>
/**
* The first three partition every arrow shape by how many of its terminals have a binding
* record. Whether the bound shape still exists is the separate `dangling` count, so an arrow
* bound to a deleted shape shows up in both `boundOneEnd` and `dangling`.
*/
arrows: {
boundBothEnds: number
boundOneEnd: number
unbound: number
/** Bindings pointing at a shape that isn't in the snapshot */
dangling: number
}
}
/** Value tallies for the enum style props, keyed by prop name then value */
styles: Record<string, Record<string, number>>
assets: { total: number; byType: Record<string, number>; totalSizeBytes: number }
collaboration: { visitors: number; commentThreads: number; comments: number }
warnings: string[]
}
/** One row of the admin effect-outbox listing, with derived fields the UI needs. */
export interface AdminOutboxRow extends Omit<TlaEffectOutbox, 'createdAt' | 'nextRetryAt'> {
/** ISO string: crosses the wire as JSON, not a kysely Date. */
createdAt: string
/** ISO string: crosses the wire as JSON, not a kysely Date. */
nextRetryAt: string | null
ageSeconds: number
parked: boolean
/** Current `file` row for tableName 'file' rows; null if hard-deleted or not a file row */
currentEntity: TlaFile | null
}
/** Response of the admin effect-outbox row listing endpoint. */
export interface AdminOutboxRowsResponseBody {
rows: AdminOutboxRow[]
}
/** Response of the admin effect-outbox stats endpoint. */
export interface AdminOutboxStatsResponseBody {
outbox: {
pending: number
parked: number
oldestPendingAgeSeconds: number | null
}
}

View File

@@ -0,0 +1,14 @@
{
"extends": "../../internal/config/tsconfig.base.json",
"include": ["src"],
"exclude": ["node_modules", ".tsbuild*"],
"compilerOptions": { "outDir": "./.tsbuild", "rootDir": "src" },
"references": [
{ "path": "../state" },
{ "path": "../store" },
{ "path": "../tldraw" },
{ "path": "../tlschema" },
{ "path": "../utils" },
{ "path": "../validate" }
]
}

View File

@@ -0,0 +1,6 @@
/// <reference types="vitest" />
import { mergeConfig } from 'vitest/config'
import baseConfig from '../../internal/config/vitest/node-preset'
// dotcom-shared package uses the standard node preset with no overrides
export default mergeConfig(baseConfig, {})