Files
upstream-tldraw/packages/dotcom-shared/src/mutators.ts
upstream-mirror a774ac673a
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
snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
2026-09-23 16:12:52 +05:00

666 lines
24 KiB
TypeScript

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
}