Skip to main content

Multi-User Apps

Build multi-tenant applications with isolated memory spaces per user.

Overview

Multi-user applications need complete data isolation between users while sharing common infrastructure. Trix's space-based architecture makes this straightforward: each user gets their own space, and memories never leak across boundaries.

This guide covers patterns for user isolation, shared resources, access control, and lifecycle management in production multi-tenant applications.

One Space Per User

The fundamental pattern for multi-user apps: create a dedicated space for each user. This provides complete isolation at the data layer.

import { Trix } from '@trixdb/client';

const client = new Trix({ apiKey: process.env.TRIX_API_KEY });

class UserMemoryService {
  async onUserCreated(userId: string, userName: string) {
    const space = await client.spaces.create({
      name: `user-${userId}`,
      description: `Memory space for ${userName}`
    });
    await db.users.update(userId, { trixSpaceId: space.id });
    return space;
  }

  async getSpaceId(userId: string): Promise<string> {
    const user = await db.users.get(userId);
    return user.trixSpaceId;
  }

  async remember(userId: string, content: string, metadata?: object) {
    const spaceId = await this.getSpaceId(userId);
    return client.memories.create({ content, spaceId, metadata });
  }

  async recall(userId: string, query: string, limit = 10) {
    const spaceId = await this.getSpaceId(userId);
    return client.memories.list({ q: query, spaceId, limit });
  }
}

With this pattern, a query for User A's memories will never return User B's data, even if the query text matches.

Shared vs. User Memories

Most apps need both user-specific and shared content. Use separate spaces for each concern.

class HybridMemoryService {
  private sharedSpaceId: string;

  constructor(sharedSpaceId: string) {
    this.sharedSpaceId = sharedSpaceId;
  }

  async recall(userId: string, query: string) {
    const userSpaceId = await this.getSpaceId(userId);
    const [userMemories, sharedMemories] = await Promise.all([
      client.memories.list({ q: query, spaceId: userSpaceId, limit: 5 }),
      client.memories.list({ q: query, spaceId: this.sharedSpaceId, limit: 5 })
    ]);
    return {
      user: userMemories.data,
      shared: sharedMemories.data,
      combined: [...userMemories.data, ...sharedMemories.data]
    };
  }

  async addSharedKnowledge(content: string, category: string) {
    return client.memories.create({
      content,
      spaceId: this.sharedSpaceId,
      tags: ['shared', category],
      metadata: { type: 'shared_knowledge' }
    });
  }
}

Common use cases for shared spaces:

  • Product documentation - FAQ, help articles, feature guides
  • Company policies - Terms, procedures, standards
  • Domain knowledge - Industry information, best practices
  • System prompts - Reusable prompt templates

Hierarchical Isolation

For complex organizations, implement hierarchical isolation: organization → team → user.

interface SpaceHierarchy {
  orgSpaceId: string;
  teamSpaceId: string;
  userSpaceId: string;
}

class HierarchicalMemoryService {
  async getHierarchy(userId: string): Promise<SpaceHierarchy> {
    const user = await db.users.get(userId);
    const team = await db.teams.get(user.teamId);
    const org = await db.orgs.get(team.orgId);
    return {
      orgSpaceId: org.trixSpaceId,
      teamSpaceId: team.trixSpaceId,
      userSpaceId: user.trixSpaceId
    };
  }

  async recall(userId: string, query: string) {
    const hierarchy = await this.getHierarchy(userId);
    const [userResults, teamResults, orgResults] = await Promise.all([
      client.memories.list({ q: query, spaceId: hierarchy.userSpaceId, limit: 5 }),
      client.memories.list({ q: query, spaceId: hierarchy.teamSpaceId, limit: 3 }),
      client.memories.list({ q: query, spaceId: hierarchy.orgSpaceId, limit: 2 })
    ]);
    return [
      ...userResults.data.map(m => ({ ...m, source: 'user' })),
      ...teamResults.data.map(m => ({ ...m, source: 'team' })),
      ...orgResults.data.map(m => ({ ...m, source: 'org' }))
    ];
  }
}

Access Control Patterns

Use Trix API keys with space-scoped permissions for fine-grained access control.

async function createUserApiKey(userId: string) {
  const userSpaceId = await getSpaceId(userId);
  const apiKey = await adminClient.apiKeys.create({
    name: `user-${userId}-key`,
    permissions: {
      spaces: [{ spaceId: userSpaceId, access: 'read_write' }]
    }
  });
  return apiKey;
}

async function createSessionToken(userId: string) {
  const userSpaceId = await getSpaceId(userId);
  return adminClient.tokens.create({
    spaceId: userSpaceId,
    access: 'read',
    expiresIn: '1h'
  });
}

Never expose your admin API key to clients. Create scoped keys or short-lived tokens for client-side access.

User Onboarding

Initialize new user spaces with helpful starting content.

async function onboardUser(userId: string, profile: UserProfile) {
  const space = await client.spaces.create({
    name: `user-${userId}`,
    description: `Memories for ${profile.name}`
  });
  const initialMemories = [
    { content: `User's name is ${profile.name}`, tags: ['profile'] },
    { content: `User's email is ${profile.email}`, tags: ['profile'] },
    { content: `User signed up on ${new Date().toISOString()}`, tags: ['profile'] }
  ];
  await Promise.all(
    initialMemories.map(m =>
      client.memories.create({ ...m, spaceId: space.id })
    )
  );
  return space;
}

User Offboarding

Handle account deletion and data export for GDPR compliance.

class UserDataService {
  async exportUserData(userId: string) {
    const spaceId = await this.getSpaceId(userId);
    const allMemories = [];
    let cursor: string | undefined;
    do {
      const result = await client.memories.list({ spaceId, limit: 100, cursor });
      allMemories.push(...result.data);
      cursor = result.next_cursor;
    } while (cursor);
    return {
      userId,
      exportedAt: new Date().toISOString(),
      memoryCount: allMemories.length,
      memories: allMemories
    };
  }

  async deleteUserData(userId: string) {
    const spaceId = await this.getSpaceId(userId);
    await client.spaces.delete(spaceId);
    await db.users.update(userId, { trixSpaceId: null });
    return { deleted: true, spaceId };
  }
}

Deleting a space automatically deletes all memories within it. This makes GDPR compliance straightforward.

Session-Based Isolation

For applications where each conversation should be isolated, use clusters within a user's space.

class ConversationMemoryService {
  async startConversation(userId: string) {
    const spaceId = await this.getSpaceId(userId);
    const conversationId = crypto.randomUUID();
    const cluster = await client.clusters.create({
      name: `conversation-${conversationId}`,
      spaceId,
      metadata: { startedAt: new Date().toISOString() }
    });
    return { conversationId, clusterId: cluster.id };
  }

  async rememberInConversation(userId: string, clusterId: string, content: string) {
    const spaceId = await this.getSpaceId(userId);
    return client.memories.create({
      content, spaceId, clusterId,
      metadata: { source: 'conversation' }
    });
  }
}

Cross-User Analytics

Aggregate insights across users without exposing individual data.

class AnalyticsService {
  async getUsageStats() {
    const spaces = await client.spaces.list();
    const stats = await Promise.all(
      spaces.data.map(async (space) => {
        const details = await client.spaces.get(space.id);
        return {
          spaceId: space.id,
          memoryCount: details.memory_count,
          createdAt: space.created_at
        };
      })
    );
    return {
      totalSpaces: spaces.data.length,
      totalMemories: stats.reduce((sum, s) => sum + s.memoryCount, 0),
      averageMemoriesPerUser: stats.reduce((sum, s) => sum + s.memoryCount, 0) / stats.length
    };
  }
}

Space-level statistics provide useful product metrics without accessing individual memory content.

Performance at Scale

Strategies for handling thousands of users efficiently.

  • Cache space IDs - Map user IDs to space IDs in Redis to avoid database lookups
  • Batch operations - When creating multiple memories, use parallel requests
  • Connection pooling - Reuse the Trix client instance across requests
  • Async processing - Store memories asynchronously after responding to users
class CachedUserService {
  private redis: Redis;
  private CACHE_TTL = 3600;

  async getSpaceId(userId: string): Promise<string> {
    const cacheKey = `trix:space:${userId}`;
    let spaceId = await this.redis.get(cacheKey);
    if (spaceId) return spaceId;
    const user = await db.users.get(userId);
    spaceId = user.trixSpaceId;
    await this.redis.setex(cacheKey, this.CACHE_TTL, spaceId);
    return spaceId;
  }
}

Best Practices

  • Always scope by space - Never query without a spaceId in multi-user apps
  • Use meaningful space names - Include user identifiers for easier debugging
  • Handle missing spaces gracefully - Check if space exists before querying
  • Implement rate limiting per user - Prevent abuse in shared infrastructure
  • Log space IDs, not content - Audit trails without privacy concerns
  • Plan for data portability - Design export/delete from day one