toeverything/AFFiNE · warning · AlreadyInSpace

already_in_space

already_in_space

Error message

You have already joined in Space ${spaceId}.

What it means

Produced during inviteByEmail's pre-check loop when the target email belongs to an existing user that already has a workspaceUser record in this workspace (models.workspaceUser.get returns a row). The invite is skipped and the error is returned inside that email's InviteResult entry (results[index].error), not thrown to GraphQL. Code already_in_space, data.spaceId = workspaceId.

Solutions

  1. Refresh the member list before inviting and filter out emails of current members (including pending ones).
  2. Treat results[i].error.name === 'already_in_space' as informational — surface 'already a member' per row rather than a hard failure.
  3. If the pending invite should be resent, revoke/recreate that member entry instead of re-inviting the same email.

Example fix

// before
await inviteByEmail(ws.id, ['bob@x.com']); // bob already member -> per-item error

// after
const members = await getMembers(ws.id); // emails of all statuses
const memberEmails = new Set(members.map(m => m.email.toLowerCase()));
await inviteByEmail(ws.id, emails.filter(e => !memberEmails.has(e.toLowerCase())));
Defensive patterns

Strategy: validation

Validate before calling

// filter out existing members (any status) before inviting
const memberEmails = new Set(
  (await membersQuery({ workspaceId })).map(m => m.email.toLowerCase())
);
const toInvite = emails.filter(e => !memberEmails.has(e.trim().toLowerCase()));

Type guard

function isAlreadyInSpaceResult(result: { error?: unknown }): boolean {
  return String((result.error as { name?: string } | undefined)?.name ?? '').toLowerCase() === 'already_in_space';
}

Try / catch

// per-item result — handle it where you render results
const results = await inviteByEmail(workspaceId, toInvite);
for (const r of results) {
  if (isAlreadyInSpaceResult(r)) markRow(r.email, 'Already a member');
}

Prevention

When it happens

Trigger: Inviting an email whose user is already a member (any status — Pending, Accepted, AllocatingSeat, UnderReview counts, since the row exists). E.g. re-inviting someone whose invite is still pending, or inviting an existing member under a different email alias that resolves to the same user.

Common situations: Re-sending invites because the first email 'looked lost' but is still Pending; inviting a user who already joined via link; stale member lists in the client UI leading to duplicate invites.

Related errors


AI-assisted analysis of toeverything/AFFiNE@2af30773ae (2026-08-18). Data as JSON: /api/errors/765e27c4055e2963. Report an issue: GitHub.

Appendix: source

Thrown at packages/backend/server/src/core/workspaces/resolvers/member.ts:208

    const candidates: InviteCandidate[] = [];
    const seen = new Set<string>();
    for (const [index, email] of emails.entries()) {
      try {
        const normalizedEmail = email.trim().toLowerCase();
        validators.assertValidEmail(normalizedEmail);
        if (seen.has(normalizedEmail)) {
          throw new ActionForbidden('Duplicate invite email.');
        }
        seen.add(normalizedEmail);

        const target = await this.models.user.getUserByEmail(normalizedEmail);
        if (target) {
          const originRecord = await this.models.workspaceUser.get(
            workspaceId,
            target.id
          );
          if (originRecord) {
            throw new AlreadyInSpace({ spaceId: workspaceId });
          }
        }

        candidates.push({
          index,
          email,
          normalizedEmail,
          domain: emailDomain(normalizedEmail),
          target: target ? { id: target.id } : undefined,
        });
      } catch (error) {
        results[index] = {
          email,
          error: mapAnyError(error),
        };
      }
    }

View on GitHub (pinned to 2af30773ae)