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
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:
23
packages/dotcom-shared/README.md
Normal file
23
packages/dotcom-shared/README.md
Normal 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).
|
||||
40
packages/dotcom-shared/package.json
Normal file
40
packages/dotcom-shared/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
13
packages/dotcom-shared/src/capabilities.ts
Normal file
13
packages/dotcom-shared/src/capabilities.ts
Normal 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]
|
||||
66
packages/dotcom-shared/src/commentMentions.test.ts
Normal file
66
packages/dotcom-shared/src/commentMentions.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
29
packages/dotcom-shared/src/commentMentions.ts
Normal file
29
packages/dotcom-shared/src/commentMentions.ts
Normal 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]
|
||||
}
|
||||
15
packages/dotcom-shared/src/constants.ts
Normal file
15
packages/dotcom-shared/src/constants.ts
Normal 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'
|
||||
3
packages/dotcom-shared/src/index.test.ts
Normal file
3
packages/dotcom-shared/src/index.test.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
it('works', () => {
|
||||
// we need a test for vitest to pass.
|
||||
})
|
||||
13
packages/dotcom-shared/src/index.ts
Normal file
13
packages/dotcom-shared/src/index.ts
Normal 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'
|
||||
4
packages/dotcom-shared/src/license.ts
Normal file
4
packages/dotcom-shared/src/license.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
const getLicenseKey = () =>
|
||||
process.env.TLDRAW_LICENSE ||
|
||||
'tldraw-tldraw-2027-07-10/WyJ2UFhWM3pQXyIsWyIqLnRsZHJhdy5jb20iLCIqLnRsZHJhdy5kZXYiLCIqLnRsZHJhdy5jbHViIiwiKi50bGRyYXcud29ya2Vycy5kZXYiXSw3MywiMjAyNy0wNy0xMCJd.FrzdF5VBeeeGIqQELpXvNAyIy/Ow9ZJJT5qkuRD42atd5FyhlR0xYasIZvaQcG9tSAFgjq8DMcc/yopspHmWyw'
|
||||
export default getLicenseKey
|
||||
1585
packages/dotcom-shared/src/mutators.test.ts
Normal file
1585
packages/dotcom-shared/src/mutators.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
665
packages/dotcom-shared/src/mutators.ts
Normal file
665
packages/dotcom-shared/src/mutators.ts
Normal 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
|
||||
}
|
||||
72
packages/dotcom-shared/src/queries.test.ts
Normal file
72
packages/dotcom-shared/src/queries.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
261
packages/dotcom-shared/src/queries.ts
Normal file
261
packages/dotcom-shared/src/queries.ts
Normal 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
|
||||
59
packages/dotcom-shared/src/roles.test.ts
Normal file
59
packages/dotcom-shared/src/roles.test.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
46
packages/dotcom-shared/src/roles.ts
Normal file
46
packages/dotcom-shared/src/roles.ts
Normal 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)
|
||||
}
|
||||
56
packages/dotcom-shared/src/routes.ts
Normal file
56
packages/dotcom-shared/src/routes.ts
Normal 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'
|
||||
333
packages/dotcom-shared/src/shapeClusters.test.ts
Normal file
333
packages/dotcom-shared/src/shapeClusters.test.ts
Normal 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',
|
||||
])
|
||||
})
|
||||
})
|
||||
445
packages/dotcom-shared/src/shapeClusters.ts
Normal file
445
packages/dotcom-shared/src/shapeClusters.ts
Normal 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
|
||||
}
|
||||
84
packages/dotcom-shared/src/thumbnailScreenshot.ts
Normal file
84
packages/dotcom-shared/src/thumbnailScreenshot.ts
Normal 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
|
||||
}
|
||||
551
packages/dotcom-shared/src/tlaSchema.ts
Normal file
551
packages/dotcom-shared/src/tlaSchema.ts
Normal 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
|
||||
516
packages/dotcom-shared/src/types.ts
Normal file
516
packages/dotcom-shared/src/types.ts
Normal 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
|
||||
}
|
||||
}
|
||||
14
packages/dotcom-shared/tsconfig.json
Normal file
14
packages/dotcom-shared/tsconfig.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
6
packages/dotcom-shared/vitest.config.ts
Normal file
6
packages/dotcom-shared/vitest.config.ts
Normal 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, {})
|
||||
Reference in New Issue
Block a user