Skip to main content

Table Access Permissions

SpacetimeDB controls data access through table visibility and context-based permissions. Tables can be public or private, and different execution contexts (reducers, views, clients) have different levels of access.

Public and Private Tables

Tables are private by default. Private tables can only be accessed by reducers and views running on the server. Clients cannot query, subscribe to, or see private tables.

Public tables are exposed to clients for read access through subscriptions and queries. Clients can see public table data but can only modify it by calling reducers.

// Private table (default) - only accessible from server-side code
const internalConfig = table(
  { name: 'internal_config' },
  {
    key: t.string().primaryKey(),
    value: t.string(),
  }
);

// Public table - clients can subscribe and query
const player = table(
  { name: 'player', public: true },
  {
    id: t.u64().primaryKey().autoInc(),
    name: t.string(),
    score: t.u64(),
  }
);

Use private tables for:

  • Internal configuration or state that clients should not see
  • Sensitive data like password hashes or API keys
  • Intermediate computation results

Use public tables for:

  • Data that clients need to display or interact with
  • Game state, user profiles, or other user-facing data

Reducers - Read-Write Access

Reducers receive a ReducerContext which provides full read-write access to all tables (both public and private). They can perform all CRUD operations: insert, read, update, and delete.

spacetimedb.reducer('example', {}, (ctx) => {
  // Insert
  ctx.db.user.insert({ id: 0, name: 'Alice', email: 'alice@example.com' });

  // Read: iterate all rows
  for (const user of ctx.db.user.iter()) {
    console.log(user.name);
  }

  // Read: find by unique column
  const foundUser = ctx.db.user.id.find(123);
  if (foundUser) {
    // Update
    foundUser.name = 'Bob';
    ctx.db.user.id.update(foundUser);
  }

  // Delete
  ctx.db.user.id.delete(456);
});

Procedures with Transactions - Read-Write Access

Procedures receive a ProcedureContext and can access tables through transactions. Unlike reducers, procedures must explicitly open a transaction to read from or modify the database.

spacetimedb.procedure('updateUserProcedure', { userId: t.u64(), newName: t.string() }, t.unit(), (ctx, { userId, newName }) => {
  // Must explicitly open a transaction
  ctx.withTx(ctx => {
    // Full read-write access within the transaction
    const user = ctx.db.user.id.find(userId);
    if (user) {
      user.name = newName;
      ctx.db.user.id.update(user);
    }
  });
  // Transaction is committed when the function returns
  return {};
});

See the Procedures documentation for more details on using procedures, including making HTTP requests to external services.

Views - Read-Only Access

Views receive a ViewContext or AnonymousViewContext which provides read-only access to all tables (both public and private). They can query and iterate tables, but cannot insert, update, or delete rows.

spacetimedb.view(
  { name: 'findUsersByName', public: true },
  t.array(user.rowType),
  (ctx) => {
    // Can read and filter
    return Array.from(ctx.db.user.name.filter('Alice'));

    // Cannot insert, update, or delete
    // ctx.db.user.insert(...) // ❌ Method not available
  });

See the Views documentation for more details on defining and querying views.

Using Views for Fine-Grained Access Control

While table visibility controls whether clients can access a table at all, views provide fine-grained control over which rows and columns clients can see. Views can read from private tables and expose only the data appropriate for each client.

note

Views can only access table data through indexed lookups, not by scanning all rows. This restriction ensures views remain performant. See the Views documentation for details.

Filtering Rows by Caller

Use views with ViewContext to return only the rows that belong to the caller. The view accesses the caller's identity through ctx.sender() and uses it to look up rows via an index.

import { table, t, schema } from 'spacetimedb/server';

// Private table containing all messages
const message = table(
  { name: 'message' },  // Private by default
  {
    id: t.u64().primaryKey().autoInc(),
    sender: t.identity().index('btree'),
    recipient: t.identity().index('btree'),
    content: t.string(),
    timestamp: t.timestamp(),
  }
);

const spacetimedb = schema(message);

// Public view that only returns messages the caller can see
spacetimedb.view(
  { name: 'my_messages', public: true },
  t.array(message.rowType),
  (ctx) => {
    // Look up messages by index where caller is sender or recipient
    const sent = Array.from(ctx.db.message.sender.filter(ctx.sender));
    const received = Array.from(ctx.db.message.recipient.filter(ctx.sender));
    return [...sent, ...received];
  }
);

Clients querying my_messages will only see their own messages, even though all messages are stored in the same table.

Hiding Sensitive Columns

Use views to return a custom type that omits sensitive columns. The view reads from a table with sensitive data and returns a projection containing only the columns clients should see.

import {schema, t, table} from 'spacetimedb/server';

// Private table with sensitive data
const userAccount = table(
  { name: 'user_account' },  // Private by default
  {
    id: t.u64().primaryKey().autoInc(),
    identity: t.identity().unique(),
    username: t.string(),
    email: t.string(),
    passwordHash: t.string(),  // Sensitive
    apiKey: t.string(),        // Sensitive
    createdAt: t.timestamp(),
  }
);

const spacetimedb = schema(userAccount);

// Public type without sensitive columns
const publicUserProfile = t.row('PublicUserProfile', {
  id: t.u64(),
  username: t.string(),
  createdAt: t.timestamp(),
});

// Public view that returns the caller's profile without sensitive data
spacetimedb.view(
  { name: 'my_profile', public: true },
  t.option(publicUserProfile),
  (ctx) => {
    // Look up the caller's account by their identity (unique index)
    const user = ctx.db.userAccount.identity.find(ctx.sender);
    if (!user) return null;
    return {
      id: user.id,
      username: user.username,
      createdAt: user.createdAt,
      // email, passwordHash, and apiKey are not included
    };
  }
);

Clients can query my_profile to see their username and creation date, but never see their email address, password hash, or API key.

Combining Both Techniques

Views can combine row filtering and column projection. This example returns colleagues in the same department as the caller, with salary information hidden:

import { table, t, schema } from 'spacetimedb/server';

// Private table with all employee data
const employee = table(
  { name: 'employee' },
  {
    id: t.u64().primaryKey(),
    identity: t.identity().unique(),
    name: t.string(),
    department: t.string().index('btree'),
    salary: t.u64(),           // Sensitive
  }
);

const spacetimedb = schema(employee);

// Public type for colleagues (no salary)
const colleague = t.row('Colleague', {
  id: t.u64(),
  name: t.string(),
  department: t.string(),
});

// View that returns colleagues in the caller's department, without salary info
spacetimedb.view(
  { name: 'my_colleagues', public: true },
  t.array(colleague),
  (ctx) => {
    // Find the caller's employee record by identity (unique index)
    const me = ctx.db.employee.identity.find(ctx.sender);
    if (!me) return [];

    // Look up employees in the same department
    return Array.from(ctx.db.employee.department.filter(me.department)).map(emp => ({
      id: emp.id,
      name: emp.name,
      department: emp.department,
      // salary is not included
    }));
  }
);

Client Access - Read-Only Access

Clients connect to databases and can access public tables and views through subscriptions and queries. They cannot access private tables directly. See the Subscriptions documentation for details on client-side table access.