# Todo app with shared workspaces

import { getBranchName } from '../../../../data/data.ts';
import EventlogModelingDiagram from '../../../_assets/diagrams/eventlog-modeling.tldr?tldraw';









Let's consider a fairly common application scenario: An app (in this case a todo app) with shared workspaces. For the sake of this guide, we'll keep things simple but you should be able to nicely extend this to a more complex app.

## Requirements

- There are multiple independent todo workspaces
- Each workspace is initially created by a single user
- Users can join the workspace by knowing the workspace id and get read and write access
- For simplicity, the user identity is chosen when the app initially starts (i.e. a username) but in a real app this would be handled by a proper auth setup

## Data model

- We are splitting up our data model into two kinds of stores (with respective eventlogs and SQLite databases): The `workspace` store and the `user` store.

### `workspace` store (one per workspace)

For the `workspace` store we have the following events:

- `workspaceCreated`
- `todoAdded`
- `todoCompleted`
- `todoDeleted`
- `userJoined`

And the following state model:

- `workspace` table (with a single row for the workspace itself)
- `todo` table (with one row per todo item)
- `member` table (with one row per user who has joined the workspace)

### `user` store (one per user)

For the `user` store we have the following events:

- `workspaceCreated`
- `workspaceJoined`

And the following state model:

- `user` table (with a single row for the user itself)

Note that the `workspaceCreated` event is used both in the `workspace` and the `user` store. This is because each eventlog should be "self-sufficient" and not rely on other eventlogs to be present to fulfill its purpose.

<EventlogModelingDiagram class="my-8" />

## Schemas

**User store:**


## `data-modeling/todo-workspaces/user.schema.ts`

```ts filename="data-modeling/todo-workspaces/user.schema.ts"
import { Events, makeSchema, Schema, State } from '@livestore/livestore'

// Emitted when this user creates a new workspace
const workspaceCreated = Events.synced({
  name: 'v1.WorkspaceCreated',
  schema: Schema.Struct({ workspaceId: Schema.String, name: Schema.String }),
})

// Emitted when this user joins an existing workspace
const workspaceJoined = Events.synced({
  name: 'v1.WorkspaceJoined',
  schema: Schema.Struct({ workspaceId: Schema.String, name: Schema.String }),
})

export const userEvents = { workspaceCreated, workspaceJoined }

// Table to store basic user info
// Contains only one row as this store is per-user.
const userTable = State.SQLite.table({
  name: 'user',
  columns: {
    // Assuming username is unique and used as the identifier
    username: State.SQLite.text({ primaryKey: true }),
  },
})

// Table to track which workspaces this user is part of
const userWorkspacesTable = State.SQLite.table({
  name: 'userWorkspaces',
  columns: {
    workspaceId: State.SQLite.text({ primaryKey: true }),
    name: State.SQLite.text(),
    // Could add role/permissions here later
  },
})

export const userTables = { user: userTable, userWorkspaces: userWorkspacesTable }

const materializers = State.SQLite.materializers(userEvents, {
  // When the user creates or joins a workspace, add it to their workspace table
  'v1.WorkspaceCreated': ({ workspaceId, name }) => userTables.userWorkspaces.insert({ workspaceId, name }),
  'v1.WorkspaceJoined': ({ workspaceId, name }) => userTables.userWorkspaces.insert({ workspaceId, name }),
})

const state = State.SQLite.makeState({ tables: userTables, materializers })

export const schema = makeSchema({ events: userEvents, state })
```

**Workspace store:**


## `data-modeling/todo-workspaces/workspace.schema.ts`

```ts filename="data-modeling/todo-workspaces/workspace.schema.ts"
import { Events, makeSchema, Schema, State } from '@livestore/livestore'

// Emitted when a new workspace is created (originates this store)
const workspaceCreated = Events.synced({
  name: 'v1.WorkspaceCreated',
  schema: Schema.Struct({
    workspaceId: Schema.String,
    name: Schema.String,
    createdByUsername: Schema.String,
  }),
})

// Emitted when a todo item is added to this workspace
const todoAdded = Events.synced({
  name: 'v1.TodoAdded',
  schema: Schema.Struct({ todoId: Schema.String, text: Schema.String }),
})

// Emitted when a todo item is marked as completed
const todoCompleted = Events.synced({
  name: 'v1.TodoCompleted',
  schema: Schema.Struct({ todoId: Schema.String }),
})

// Emitted when a todo item is deleted (soft delete)
const todoDeleted = Events.synced({
  name: 'v1.TodoDeleted',
  schema: Schema.Struct({ todoId: Schema.String, deletedAt: Schema.Date }),
})

// Emitted when a new user joins this workspace
const userJoined = Events.synced({
  name: 'v1.UserJoined',
  schema: Schema.Struct({ username: Schema.String }),
})

export const workspaceEvents = { workspaceCreated, todoAdded, todoCompleted, todoDeleted, userJoined }

// Table for the workspace itself (only one row as this store is per-workspace)
const workspaceTable = State.SQLite.table({
  name: 'workspace',
  columns: {
    workspaceId: State.SQLite.text({ primaryKey: true }),
    name: State.SQLite.text(),
    createdByUsername: State.SQLite.text(),
  },
})

// Table for the todo items in this workspace
const todosTable = State.SQLite.table({
  name: 'todos',
  columns: {
    todoId: State.SQLite.text({ primaryKey: true }),
    text: State.SQLite.text(),
    completed: State.SQLite.boolean({ default: false }),
    // Using soft delete by adding a deletedAt timestamp
    deletedAt: State.SQLite.integer({ nullable: true, schema: Schema.DateFromNumber }),
  },
})

// Table for members of this workspace
const membersTable = State.SQLite.table({
  name: 'members',
  columns: {
    username: State.SQLite.text({ primaryKey: true }),
    // Could add role/permissions here later
  },
})

export const workspaceTables = { workspace: workspaceTable, todos: todosTable, members: membersTable }

const materializers = State.SQLite.materializers(workspaceEvents, {
  'v1.WorkspaceCreated': ({ workspaceId, name, createdByUsername }) => [
    workspaceTables.workspace.insert({ workspaceId, name, createdByUsername }),
    // Add the creator as the first member
    workspaceTables.members.insert({ username: createdByUsername }),
  ],
  'v1.TodoAdded': ({ todoId, text }) => workspaceTables.todos.insert({ todoId, text }),
  'v1.TodoCompleted': ({ todoId }) => workspaceTables.todos.update({ completed: true }).where({ todoId }),
  'v1.TodoDeleted': ({ todoId, deletedAt }) => workspaceTables.todos.update({ deletedAt }).where({ todoId }),
  'v1.UserJoined': ({ username }) => workspaceTables.members.insert({ username }),
})

const state = State.SQLite.makeState({ tables: workspaceTables, materializers })

export const schema = makeSchema({ events: workspaceEvents, state })
```

## Store configuration

Now that we've defined our schemas, let's configure the stores:

**Workspace store:**


## `data-modeling/todo-workspaces/workspace.store.ts`

```ts filename="data-modeling/todo-workspaces/workspace.store.ts"
import { makePersistedAdapter } from '@livestore/adapter-web'
import sharedWorker from '@livestore/adapter-web/shared-worker?sharedworker'
import { storeOptions } from '@livestore/livestore'

import { schema } from './workspace.schema.ts'
import worker from './workspace.worker.ts?worker'

const adapter = makePersistedAdapter({
  storage: { type: 'opfs' },
  worker,
  sharedWorker,
})

// Define workspace store configuration
// Each workspace gets its own isolated store instance
export const workspaceStoreOptions = (workspaceId: string) =>
  storeOptions({
    storeId: `workspace-${workspaceId}`,
    schema,
    adapter,
    unusedCacheTime: 60_000, // Keep in memory for 60 seconds after last use
  })
```

### `data-modeling/todo-workspaces/workspace.schema.ts`

```ts filename="data-modeling/todo-workspaces/workspace.schema.ts"
import { Events, makeSchema, Schema, State } from '@livestore/livestore'

// Emitted when a new workspace is created (originates this store)
const workspaceCreated = Events.synced({
  name: 'v1.WorkspaceCreated',
  schema: Schema.Struct({
    workspaceId: Schema.String,
    name: Schema.String,
    createdByUsername: Schema.String,
  }),
})

// Emitted when a todo item is added to this workspace
const todoAdded = Events.synced({
  name: 'v1.TodoAdded',
  schema: Schema.Struct({ todoId: Schema.String, text: Schema.String }),
})

// Emitted when a todo item is marked as completed
const todoCompleted = Events.synced({
  name: 'v1.TodoCompleted',
  schema: Schema.Struct({ todoId: Schema.String }),
})

// Emitted when a todo item is deleted (soft delete)
const todoDeleted = Events.synced({
  name: 'v1.TodoDeleted',
  schema: Schema.Struct({ todoId: Schema.String, deletedAt: Schema.Date }),
})

// Emitted when a new user joins this workspace
const userJoined = Events.synced({
  name: 'v1.UserJoined',
  schema: Schema.Struct({ username: Schema.String }),
})

export const workspaceEvents = { workspaceCreated, todoAdded, todoCompleted, todoDeleted, userJoined }

// Table for the workspace itself (only one row as this store is per-workspace)
const workspaceTable = State.SQLite.table({
  name: 'workspace',
  columns: {
    workspaceId: State.SQLite.text({ primaryKey: true }),
    name: State.SQLite.text(),
    createdByUsername: State.SQLite.text(),
  },
})

// Table for the todo items in this workspace
const todosTable = State.SQLite.table({
  name: 'todos',
  columns: {
    todoId: State.SQLite.text({ primaryKey: true }),
    text: State.SQLite.text(),
    completed: State.SQLite.boolean({ default: false }),
    // Using soft delete by adding a deletedAt timestamp
    deletedAt: State.SQLite.integer({ nullable: true, schema: Schema.DateFromNumber }),
  },
})

// Table for members of this workspace
const membersTable = State.SQLite.table({
  name: 'members',
  columns: {
    username: State.SQLite.text({ primaryKey: true }),
    // Could add role/permissions here later
  },
})

export const workspaceTables = { workspace: workspaceTable, todos: todosTable, members: membersTable }

const materializers = State.SQLite.materializers(workspaceEvents, {
  'v1.WorkspaceCreated': ({ workspaceId, name, createdByUsername }) => [
    workspaceTables.workspace.insert({ workspaceId, name, createdByUsername }),
    // Add the creator as the first member
    workspaceTables.members.insert({ username: createdByUsername }),
  ],
  'v1.TodoAdded': ({ todoId, text }) => workspaceTables.todos.insert({ todoId, text }),
  'v1.TodoCompleted': ({ todoId }) => workspaceTables.todos.update({ completed: true }).where({ todoId }),
  'v1.TodoDeleted': ({ todoId, deletedAt }) => workspaceTables.todos.update({ deletedAt }).where({ todoId }),
  'v1.UserJoined': ({ username }) => workspaceTables.members.insert({ username }),
})

const state = State.SQLite.makeState({ tables: workspaceTables, materializers })

export const schema = makeSchema({ events: workspaceEvents, state })
```

**User store:**


## `data-modeling/todo-workspaces/user.store.ts`

```ts filename="data-modeling/todo-workspaces/user.store.ts"
import { makePersistedAdapter } from '@livestore/adapter-web'
import sharedWorker from '@livestore/adapter-web/shared-worker?sharedworker'
import { useStore } from '@livestore/react'

import { schema } from './user.schema.ts'
import worker from './user.worker.ts?worker'

const adapter = makePersistedAdapter({
  storage: { type: 'opfs' },
  worker,
  sharedWorker,
})

// Hook to access the current user's store
export const useCurrentUserStore = () =>
  useStore({
    storeId: 'user-current', // Backend should resolve this to the authenticated user's store
    schema,
    adapter,
    unusedCacheTime: Number.POSITIVE_INFINITY, // Keep user store in memory indefinitely
  })
```

### `data-modeling/todo-workspaces/user.schema.ts`

```ts filename="data-modeling/todo-workspaces/user.schema.ts"
import { Events, makeSchema, Schema, State } from '@livestore/livestore'

// Emitted when this user creates a new workspace
const workspaceCreated = Events.synced({
  name: 'v1.WorkspaceCreated',
  schema: Schema.Struct({ workspaceId: Schema.String, name: Schema.String }),
})

// Emitted when this user joins an existing workspace
const workspaceJoined = Events.synced({
  name: 'v1.WorkspaceJoined',
  schema: Schema.Struct({ workspaceId: Schema.String, name: Schema.String }),
})

export const userEvents = { workspaceCreated, workspaceJoined }

// Table to store basic user info
// Contains only one row as this store is per-user.
const userTable = State.SQLite.table({
  name: 'user',
  columns: {
    // Assuming username is unique and used as the identifier
    username: State.SQLite.text({ primaryKey: true }),
  },
})

// Table to track which workspaces this user is part of
const userWorkspacesTable = State.SQLite.table({
  name: 'userWorkspaces',
  columns: {
    workspaceId: State.SQLite.text({ primaryKey: true }),
    name: State.SQLite.text(),
    // Could add role/permissions here later
  },
})

export const userTables = { user: userTable, userWorkspaces: userWorkspacesTable }

const materializers = State.SQLite.materializers(userEvents, {
  // When the user creates or joins a workspace, add it to their workspace table
  'v1.WorkspaceCreated': ({ workspaceId, name }) => userTables.userWorkspaces.insert({ workspaceId, name }),
  'v1.WorkspaceJoined': ({ workspaceId, name }) => userTables.userWorkspaces.insert({ workspaceId, name }),
})

const state = State.SQLite.makeState({ tables: userTables, materializers })

export const schema = makeSchema({ events: userEvents, state })
```

## Set up the store registry

Create a [`StoreRegistry`](/framework-integrations/react-integration#new-storeregistryconfig) and provide it to your React app:


## `data-modeling/todo-workspaces/App.tsx`

```tsx filename="data-modeling/todo-workspaces/App.tsx"
import { type ReactNode, useState } from 'react'
import { unstable_batchedUpdates as batchUpdates } from 'react-dom'

import { StoreRegistry } from '@livestore/livestore'
import { StoreRegistryProvider } from '@livestore/react'

export const App = ({ children }: { children: ReactNode }) => {
  const [storeRegistry] = useState(
    () =>
      new StoreRegistry({
        defaultOptions: {
          batchUpdates,
        },
      }),
  )

  return <StoreRegistryProvider storeRegistry={storeRegistry}>{children}</StoreRegistryProvider>
}
```

## Accessing stores

Use the [`useStore()`](/framework-integrations/react-integration#usestoreoptions) hook to access specific workspace instances:


## `data-modeling/todo-workspaces/Workspace.tsx`

```tsx filename="data-modeling/todo-workspaces/Workspace.tsx"
import { useCallback } from 'react'

import { queryDb } from '@livestore/livestore'
import { useStore } from '@livestore/react'

import { userTables } from './user.schema.ts'
import { useCurrentUserStore } from './user.store.ts'
import { workspaceEvents, workspaceTables } from './workspace.schema.ts'
import { workspaceStoreOptions } from './workspace.store.ts'

// Component that accesses a specific workspace store
export const Workspace = ({ workspaceId }: { workspaceId: string }) => {
  const userStore = useCurrentUserStore()
  const workspaceStore = useStore(workspaceStoreOptions(workspaceId))

  // Check if this workspace exists in user's workspace list
  const [knownWorkspace] = userStore.useQuery(queryDb(userTables.userWorkspaces.select().where({ workspaceId })))

  // Query workspace data
  const [workspace] = workspaceStore.useQuery(queryDb(workspaceTables.workspace.select().limit(1)))
  const todos = workspaceStore.useQuery(queryDb(workspaceTables.todos.select()))

  // Workspace not in user's list → truly doesn't exist
  if (knownWorkspace == null) return <div>Workspace not found</div>

  // Workspace is in user's list but not yet initialized → loading state
  if (workspace == null) return <div>Loading workspace...</div>

  const addTodo = useCallback(
    (text: string) => {
      workspaceStore.commit(
        workspaceEvents.todoAdded({
          todoId: `todo-${Date.now()}`,
          text,
        }),
      )
    },
    [workspaceStore],
  )

  const addNewTodo = useCallback(() => addTodo('New todo'), [addTodo])

  return (
    <div>
      <h2>{workspace.name}</h2>
      <p>Created by: {workspace.createdByUsername}</p>
      <p>Store ID: {workspaceStore.storeId}</p>

      <h3>Todos ({todos.length})</h3>
      <ul>
        {todos.map((todo) => (
          <li key={todo.todoId}>
            {todo.text} {todo.completed === true ? '✓' : ''}
          </li>
        ))}
      </ul>

      <button type="button" onClick={addNewTodo}>
        Add Todo
      </button>
    </div>
  )
}
```

### `data-modeling/todo-workspaces/user.schema.ts`

```ts filename="data-modeling/todo-workspaces/user.schema.ts"
import { Events, makeSchema, Schema, State } from '@livestore/livestore'

// Emitted when this user creates a new workspace
const workspaceCreated = Events.synced({
  name: 'v1.WorkspaceCreated',
  schema: Schema.Struct({ workspaceId: Schema.String, name: Schema.String }),
})

// Emitted when this user joins an existing workspace
const workspaceJoined = Events.synced({
  name: 'v1.WorkspaceJoined',
  schema: Schema.Struct({ workspaceId: Schema.String, name: Schema.String }),
})

export const userEvents = { workspaceCreated, workspaceJoined }

// Table to store basic user info
// Contains only one row as this store is per-user.
const userTable = State.SQLite.table({
  name: 'user',
  columns: {
    // Assuming username is unique and used as the identifier
    username: State.SQLite.text({ primaryKey: true }),
  },
})

// Table to track which workspaces this user is part of
const userWorkspacesTable = State.SQLite.table({
  name: 'userWorkspaces',
  columns: {
    workspaceId: State.SQLite.text({ primaryKey: true }),
    name: State.SQLite.text(),
    // Could add role/permissions here later
  },
})

export const userTables = { user: userTable, userWorkspaces: userWorkspacesTable }

const materializers = State.SQLite.materializers(userEvents, {
  // When the user creates or joins a workspace, add it to their workspace table
  'v1.WorkspaceCreated': ({ workspaceId, name }) => userTables.userWorkspaces.insert({ workspaceId, name }),
  'v1.WorkspaceJoined': ({ workspaceId, name }) => userTables.userWorkspaces.insert({ workspaceId, name }),
})

const state = State.SQLite.makeState({ tables: userTables, materializers })

export const schema = makeSchema({ events: userEvents, state })
```

### `data-modeling/todo-workspaces/user.store.ts`

```ts filename="data-modeling/todo-workspaces/user.store.ts"
import { makePersistedAdapter } from '@livestore/adapter-web'
import sharedWorker from '@livestore/adapter-web/shared-worker?sharedworker'
import { useStore } from '@livestore/react'

import { schema } from './user.schema.ts'
import worker from './user.worker.ts?worker'

const adapter = makePersistedAdapter({
  storage: { type: 'opfs' },
  worker,
  sharedWorker,
})

// Hook to access the current user's store
export const useCurrentUserStore = () =>
  useStore({
    storeId: 'user-current', // Backend should resolve this to the authenticated user's store
    schema,
    adapter,
    unusedCacheTime: Number.POSITIVE_INFINITY, // Keep user store in memory indefinitely
  })
```

### `data-modeling/todo-workspaces/workspace.schema.ts`

```ts filename="data-modeling/todo-workspaces/workspace.schema.ts"
import { Events, makeSchema, Schema, State } from '@livestore/livestore'

// Emitted when a new workspace is created (originates this store)
const workspaceCreated = Events.synced({
  name: 'v1.WorkspaceCreated',
  schema: Schema.Struct({
    workspaceId: Schema.String,
    name: Schema.String,
    createdByUsername: Schema.String,
  }),
})

// Emitted when a todo item is added to this workspace
const todoAdded = Events.synced({
  name: 'v1.TodoAdded',
  schema: Schema.Struct({ todoId: Schema.String, text: Schema.String }),
})

// Emitted when a todo item is marked as completed
const todoCompleted = Events.synced({
  name: 'v1.TodoCompleted',
  schema: Schema.Struct({ todoId: Schema.String }),
})

// Emitted when a todo item is deleted (soft delete)
const todoDeleted = Events.synced({
  name: 'v1.TodoDeleted',
  schema: Schema.Struct({ todoId: Schema.String, deletedAt: Schema.Date }),
})

// Emitted when a new user joins this workspace
const userJoined = Events.synced({
  name: 'v1.UserJoined',
  schema: Schema.Struct({ username: Schema.String }),
})

export const workspaceEvents = { workspaceCreated, todoAdded, todoCompleted, todoDeleted, userJoined }

// Table for the workspace itself (only one row as this store is per-workspace)
const workspaceTable = State.SQLite.table({
  name: 'workspace',
  columns: {
    workspaceId: State.SQLite.text({ primaryKey: true }),
    name: State.SQLite.text(),
    createdByUsername: State.SQLite.text(),
  },
})

// Table for the todo items in this workspace
const todosTable = State.SQLite.table({
  name: 'todos',
  columns: {
    todoId: State.SQLite.text({ primaryKey: true }),
    text: State.SQLite.text(),
    completed: State.SQLite.boolean({ default: false }),
    // Using soft delete by adding a deletedAt timestamp
    deletedAt: State.SQLite.integer({ nullable: true, schema: Schema.DateFromNumber }),
  },
})

// Table for members of this workspace
const membersTable = State.SQLite.table({
  name: 'members',
  columns: {
    username: State.SQLite.text({ primaryKey: true }),
    // Could add role/permissions here later
  },
})

export const workspaceTables = { workspace: workspaceTable, todos: todosTable, members: membersTable }

const materializers = State.SQLite.materializers(workspaceEvents, {
  'v1.WorkspaceCreated': ({ workspaceId, name, createdByUsername }) => [
    workspaceTables.workspace.insert({ workspaceId, name, createdByUsername }),
    // Add the creator as the first member
    workspaceTables.members.insert({ username: createdByUsername }),
  ],
  'v1.TodoAdded': ({ todoId, text }) => workspaceTables.todos.insert({ todoId, text }),
  'v1.TodoCompleted': ({ todoId }) => workspaceTables.todos.update({ completed: true }).where({ todoId }),
  'v1.TodoDeleted': ({ todoId, deletedAt }) => workspaceTables.todos.update({ deletedAt }).where({ todoId }),
  'v1.UserJoined': ({ username }) => workspaceTables.members.insert({ username }),
})

const state = State.SQLite.makeState({ tables: workspaceTables, materializers })

export const schema = makeSchema({ events: workspaceEvents, state })
```

### `data-modeling/todo-workspaces/workspace.store.ts`

```ts filename="data-modeling/todo-workspaces/workspace.store.ts"
import { makePersistedAdapter } from '@livestore/adapter-web'
import sharedWorker from '@livestore/adapter-web/shared-worker?sharedworker'
import { storeOptions } from '@livestore/livestore'

import { schema } from './workspace.schema.ts'
import worker from './workspace.worker.ts?worker'

const adapter = makePersistedAdapter({
  storage: { type: 'opfs' },
  worker,
  sharedWorker,
})

// Define workspace store configuration
// Each workspace gets its own isolated store instance
export const workspaceStoreOptions = (workspaceId: string) =>
  storeOptions({
    storeId: `workspace-${workspaceId}`,
    schema,
    adapter,
    unusedCacheTime: 60_000, // Keep in memory for 60 seconds after last use
  })
```

## Loading multiple workspaces

To display all workspaces for a user, first load the user store to get their workspace list, then dynamically load each workspace:


## `data-modeling/todo-workspaces/WorkspaceList.tsx`

```tsx filename="data-modeling/todo-workspaces/WorkspaceList.tsx"
import { Suspense } from 'react'
import { ErrorBoundary } from 'react-error-boundary'

import { queryDb } from '@livestore/livestore'

import { userTables } from './user.schema.ts'
import { useCurrentUserStore } from './user.store.ts'
import { Workspace } from './Workspace.tsx'

const workspaceListErrorFallback = <div>Error loading workspaces</div>
const workspaceListLoadingFallback = <div>Loading workspaces...</div>

export const WorkspaceList = () => {
  const userStore = useCurrentUserStore()

  // Query all workspaces this user belongs to
  const workspaces = userStore.useQuery(queryDb(userTables.userWorkspaces.select()))

  return (
    <div>
      <h1>My Workspaces</h1>
      {workspaces.length === 0 ? (
        <p>No workspaces yet</p>
      ) : (
        <ErrorBoundary fallback={workspaceListErrorFallback}>
          <Suspense fallback={workspaceListLoadingFallback}>
            <ul>
              {workspaces.map((w) => (
                <li key={w.workspaceId}>
                  <Workspace workspaceId={w.workspaceId} />
                </li>
              ))}
            </ul>
          </Suspense>
        </ErrorBoundary>
      )}
    </div>
  )
}
```

### `data-modeling/todo-workspaces/user.schema.ts`

```ts filename="data-modeling/todo-workspaces/user.schema.ts"
import { Events, makeSchema, Schema, State } from '@livestore/livestore'

// Emitted when this user creates a new workspace
const workspaceCreated = Events.synced({
  name: 'v1.WorkspaceCreated',
  schema: Schema.Struct({ workspaceId: Schema.String, name: Schema.String }),
})

// Emitted when this user joins an existing workspace
const workspaceJoined = Events.synced({
  name: 'v1.WorkspaceJoined',
  schema: Schema.Struct({ workspaceId: Schema.String, name: Schema.String }),
})

export const userEvents = { workspaceCreated, workspaceJoined }

// Table to store basic user info
// Contains only one row as this store is per-user.
const userTable = State.SQLite.table({
  name: 'user',
  columns: {
    // Assuming username is unique and used as the identifier
    username: State.SQLite.text({ primaryKey: true }),
  },
})

// Table to track which workspaces this user is part of
const userWorkspacesTable = State.SQLite.table({
  name: 'userWorkspaces',
  columns: {
    workspaceId: State.SQLite.text({ primaryKey: true }),
    name: State.SQLite.text(),
    // Could add role/permissions here later
  },
})

export const userTables = { user: userTable, userWorkspaces: userWorkspacesTable }

const materializers = State.SQLite.materializers(userEvents, {
  // When the user creates or joins a workspace, add it to their workspace table
  'v1.WorkspaceCreated': ({ workspaceId, name }) => userTables.userWorkspaces.insert({ workspaceId, name }),
  'v1.WorkspaceJoined': ({ workspaceId, name }) => userTables.userWorkspaces.insert({ workspaceId, name }),
})

const state = State.SQLite.makeState({ tables: userTables, materializers })

export const schema = makeSchema({ events: userEvents, state })
```

### `data-modeling/todo-workspaces/user.store.ts`

```ts filename="data-modeling/todo-workspaces/user.store.ts"
import { makePersistedAdapter } from '@livestore/adapter-web'
import sharedWorker from '@livestore/adapter-web/shared-worker?sharedworker'
import { useStore } from '@livestore/react'

import { schema } from './user.schema.ts'
import worker from './user.worker.ts?worker'

const adapter = makePersistedAdapter({
  storage: { type: 'opfs' },
  worker,
  sharedWorker,
})

// Hook to access the current user's store
export const useCurrentUserStore = () =>
  useStore({
    storeId: 'user-current', // Backend should resolve this to the authenticated user's store
    schema,
    adapter,
    unusedCacheTime: Number.POSITIVE_INFINITY, // Keep user store in memory indefinitely
  })
```

### `data-modeling/todo-workspaces/workspace.schema.ts`

```ts filename="data-modeling/todo-workspaces/workspace.schema.ts"
import { Events, makeSchema, Schema, State } from '@livestore/livestore'

// Emitted when a new workspace is created (originates this store)
const workspaceCreated = Events.synced({
  name: 'v1.WorkspaceCreated',
  schema: Schema.Struct({
    workspaceId: Schema.String,
    name: Schema.String,
    createdByUsername: Schema.String,
  }),
})

// Emitted when a todo item is added to this workspace
const todoAdded = Events.synced({
  name: 'v1.TodoAdded',
  schema: Schema.Struct({ todoId: Schema.String, text: Schema.String }),
})

// Emitted when a todo item is marked as completed
const todoCompleted = Events.synced({
  name: 'v1.TodoCompleted',
  schema: Schema.Struct({ todoId: Schema.String }),
})

// Emitted when a todo item is deleted (soft delete)
const todoDeleted = Events.synced({
  name: 'v1.TodoDeleted',
  schema: Schema.Struct({ todoId: Schema.String, deletedAt: Schema.Date }),
})

// Emitted when a new user joins this workspace
const userJoined = Events.synced({
  name: 'v1.UserJoined',
  schema: Schema.Struct({ username: Schema.String }),
})

export const workspaceEvents = { workspaceCreated, todoAdded, todoCompleted, todoDeleted, userJoined }

// Table for the workspace itself (only one row as this store is per-workspace)
const workspaceTable = State.SQLite.table({
  name: 'workspace',
  columns: {
    workspaceId: State.SQLite.text({ primaryKey: true }),
    name: State.SQLite.text(),
    createdByUsername: State.SQLite.text(),
  },
})

// Table for the todo items in this workspace
const todosTable = State.SQLite.table({
  name: 'todos',
  columns: {
    todoId: State.SQLite.text({ primaryKey: true }),
    text: State.SQLite.text(),
    completed: State.SQLite.boolean({ default: false }),
    // Using soft delete by adding a deletedAt timestamp
    deletedAt: State.SQLite.integer({ nullable: true, schema: Schema.DateFromNumber }),
  },
})

// Table for members of this workspace
const membersTable = State.SQLite.table({
  name: 'members',
  columns: {
    username: State.SQLite.text({ primaryKey: true }),
    // Could add role/permissions here later
  },
})

export const workspaceTables = { workspace: workspaceTable, todos: todosTable, members: membersTable }

const materializers = State.SQLite.materializers(workspaceEvents, {
  'v1.WorkspaceCreated': ({ workspaceId, name, createdByUsername }) => [
    workspaceTables.workspace.insert({ workspaceId, name, createdByUsername }),
    // Add the creator as the first member
    workspaceTables.members.insert({ username: createdByUsername }),
  ],
  'v1.TodoAdded': ({ todoId, text }) => workspaceTables.todos.insert({ todoId, text }),
  'v1.TodoCompleted': ({ todoId }) => workspaceTables.todos.update({ completed: true }).where({ todoId }),
  'v1.TodoDeleted': ({ todoId, deletedAt }) => workspaceTables.todos.update({ deletedAt }).where({ todoId }),
  'v1.UserJoined': ({ username }) => workspaceTables.members.insert({ username }),
})

const state = State.SQLite.makeState({ tables: workspaceTables, materializers })

export const schema = makeSchema({ events: workspaceEvents, state })
```

### `data-modeling/todo-workspaces/workspace.store.ts`

```ts filename="data-modeling/todo-workspaces/workspace.store.ts"
import { makePersistedAdapter } from '@livestore/adapter-web'
import sharedWorker from '@livestore/adapter-web/shared-worker?sharedworker'
import { storeOptions } from '@livestore/livestore'

import { schema } from './workspace.schema.ts'
import worker from './workspace.worker.ts?worker'

const adapter = makePersistedAdapter({
  storage: { type: 'opfs' },
  worker,
  sharedWorker,
})

// Define workspace store configuration
// Each workspace gets its own isolated store instance
export const workspaceStoreOptions = (workspaceId: string) =>
  storeOptions({
    storeId: `workspace-${workspaceId}`,
    schema,
    adapter,
    unusedCacheTime: 60_000, // Keep in memory for 60 seconds after last use
  })
```

### `data-modeling/todo-workspaces/Workspace.tsx`

```tsx filename="data-modeling/todo-workspaces/Workspace.tsx"
import { useCallback } from 'react'

import { queryDb } from '@livestore/livestore'
import { useStore } from '@livestore/react'

import { userTables } from './user.schema.ts'
import { useCurrentUserStore } from './user.store.ts'
import { workspaceEvents, workspaceTables } from './workspace.schema.ts'
import { workspaceStoreOptions } from './workspace.store.ts'

// Component that accesses a specific workspace store
export const Workspace = ({ workspaceId }: { workspaceId: string }) => {
  const userStore = useCurrentUserStore()
  const workspaceStore = useStore(workspaceStoreOptions(workspaceId))

  // Check if this workspace exists in user's workspace list
  const [knownWorkspace] = userStore.useQuery(queryDb(userTables.userWorkspaces.select().where({ workspaceId })))

  // Query workspace data
  const [workspace] = workspaceStore.useQuery(queryDb(workspaceTables.workspace.select().limit(1)))
  const todos = workspaceStore.useQuery(queryDb(workspaceTables.todos.select()))

  // Workspace not in user's list → truly doesn't exist
  if (knownWorkspace == null) return <div>Workspace not found</div>

  // Workspace is in user's list but not yet initialized → loading state
  if (workspace == null) return <div>Loading workspace...</div>

  const addTodo = useCallback(
    (text: string) => {
      workspaceStore.commit(
        workspaceEvents.todoAdded({
          todoId: `todo-${Date.now()}`,
          text,
        }),
      )
    },
    [workspaceStore],
  )

  const addNewTodo = useCallback(() => addTodo('New todo'), [addTodo])

  return (
    <div>
      <h2>{workspace.name}</h2>
      <p>Created by: {workspace.createdByUsername}</p>
      <p>Store ID: {workspaceStore.storeId}</p>

      <h3>Todos ({todos.length})</h3>
      <ul>
        {todos.map((todo) => (
          <li key={todo.todoId}>
            {todo.text} {todo.completed === true ? '✓' : ''}
          </li>
        ))}
      </ul>

      <button type="button" onClick={addNewTodo}>
        Add Todo
      </button>
    </div>
  )
}
```

## Creating new workspaces

To create a new workspace, you commit the `workspaceCreated` event to the user store:


## `data-modeling/todo-workspaces/CreateWorkspace.tsx`

```tsx filename="data-modeling/todo-workspaces/CreateWorkspace.tsx"
import { useNavigate } from '@tanstack/react-router'
import { useCallback } from 'react'

import { nanoid } from '@livestore/livestore'

import { userEvents } from './user.schema.ts'
import { useCurrentUserStore } from './user.store.ts'

// Component for creating a new workspace
export const CreateWorkspace = () => {
  const userStore = useCurrentUserStore()
  const navigate = useNavigate()

  const createStore = useCallback(
    (formData: FormData) => {
      const name = formData.get('name') as string
      if (name.trim() === '') return

      const workspaceId = nanoid()

      userStore.commit(userEvents.workspaceCreated({ workspaceId, name }))

      navigate({ to: '/workspace/$workspaceId', params: { workspaceId } })
    },
    [navigate, userStore],
  )

  return (
    <form action={createStore}>
      <input type="text" name="name" placeholder="Workspace name" required />
      <button type="submit">Create Workspace</button>
    </form>
  )
}
```

### `data-modeling/todo-workspaces/user.schema.ts`

```ts filename="data-modeling/todo-workspaces/user.schema.ts"
import { Events, makeSchema, Schema, State } from '@livestore/livestore'

// Emitted when this user creates a new workspace
const workspaceCreated = Events.synced({
  name: 'v1.WorkspaceCreated',
  schema: Schema.Struct({ workspaceId: Schema.String, name: Schema.String }),
})

// Emitted when this user joins an existing workspace
const workspaceJoined = Events.synced({
  name: 'v1.WorkspaceJoined',
  schema: Schema.Struct({ workspaceId: Schema.String, name: Schema.String }),
})

export const userEvents = { workspaceCreated, workspaceJoined }

// Table to store basic user info
// Contains only one row as this store is per-user.
const userTable = State.SQLite.table({
  name: 'user',
  columns: {
    // Assuming username is unique and used as the identifier
    username: State.SQLite.text({ primaryKey: true }),
  },
})

// Table to track which workspaces this user is part of
const userWorkspacesTable = State.SQLite.table({
  name: 'userWorkspaces',
  columns: {
    workspaceId: State.SQLite.text({ primaryKey: true }),
    name: State.SQLite.text(),
    // Could add role/permissions here later
  },
})

export const userTables = { user: userTable, userWorkspaces: userWorkspacesTable }

const materializers = State.SQLite.materializers(userEvents, {
  // When the user creates or joins a workspace, add it to their workspace table
  'v1.WorkspaceCreated': ({ workspaceId, name }) => userTables.userWorkspaces.insert({ workspaceId, name }),
  'v1.WorkspaceJoined': ({ workspaceId, name }) => userTables.userWorkspaces.insert({ workspaceId, name }),
})

const state = State.SQLite.makeState({ tables: userTables, materializers })

export const schema = makeSchema({ events: userEvents, state })
```

### `data-modeling/todo-workspaces/user.store.ts`

```ts filename="data-modeling/todo-workspaces/user.store.ts"
import { makePersistedAdapter } from '@livestore/adapter-web'
import sharedWorker from '@livestore/adapter-web/shared-worker?sharedworker'
import { useStore } from '@livestore/react'

import { schema } from './user.schema.ts'
import worker from './user.worker.ts?worker'

const adapter = makePersistedAdapter({
  storage: { type: 'opfs' },
  worker,
  sharedWorker,
})

// Hook to access the current user's store
export const useCurrentUserStore = () =>
  useStore({
    storeId: 'user-current', // Backend should resolve this to the authenticated user's store
    schema,
    adapter,
    unusedCacheTime: Number.POSITIVE_INFINITY, // Keep user store in memory indefinitely
  })
```

Your backend should react to this event and create the corresponding workspace store.

The exact mechanism for how the backend creates the workspace store depends on your infrastructure. For an implementation example using Cloudflare Durable Objects, check out the <a href={`https://github.com/livestorejs/livestore/tree/${getBranchName()}/examples/web-email-client`}>`web-email-client` example</a>.

### Handling the loading state

There's a timing consideration: the browser might connect to the workspace store before it's created and initialized with data. When this happens, [`useStore()`](/framework-integrations/react-integration#usestoreoptions) resolves (the store exists), but queries may return empty results.

To distinguish between "still loading" and "workspace doesn't exist", check the user store first. Notice how the `<Workspace>` component [above](#accessing-stores) queries `userTables.userWorkspaces` before checking the workspace data:

- If the workspace is not in the user's list → truly doesn't exist
- If the workspace is in the user's list, but workspace data is empty → loading state

:::note[Future improvement]
We're working on improving this experience. See [#822](https://github.com/livestorejs/livestore/issues/822) for the discussion.
:::

## Further notes

To make this app more production-ready, we might want to do the following:
- Use a proper auth setup to enforce a trusted user identity
- Introduce a proper user invite process
- Introduce access levels (e.g. read-only, read-write)
- Introduce end-to-end encryption

### Individual todo stores for complex data

If each todo item has a lot of data (e.g. think of a GitHub/Linear issue with lots of details), it might make sense to split up each todo item into its own store.

This would create **3 store types** instead of 2:
- **User stores** (one per user) - unchanged
- **Workspace stores** (one per workspace) - only basic todo metadata
- **Todo stores** (one per todo item) - rich todo data

Your app would then have **N + M + K stores** total (N workspaces + M users + K todo items).

This pattern improves performance by only loading detailed todo data when specifically viewing that item, and prevents large todos from slowing down workspace syncing.