Skip to main content
Sheetbase Docs
Concepts

Database Schema

Core auth and application tables, indexes, and Postgres roles in Sheetbase. Source: SQL migration files and current server modules.

Sheetbase uses Supabase as a Postgres host only — not Supabase Auth. All authentication is handled by better-auth. Two separate pg.Pool instances exist in the codebase (one in lib/db.ts for MCP tools, one inside lib/auth.ts for better-auth) — consolidating them is a known roadmap item.


Migration files

FileApplies
better-auth_migrations/2026-04-13T03-22-30.752Z.sqlHistorical Better Auth bootstrap without API keys; retained for provenance, not run by fresh installs
better-auth_migrations/2026-04-13T06-41-17.610Z.sqlAdds apikey to a complete copy of the historical Better Auth bootstrap; use this file for fresh installs instead of running both bootstrap files
migrations/2026-07-18-better-auth-oauth-forward.sqlArchives the legacy MCP OAuth tables and creates the current OAuth Provider, JWT, and rate-limit schema
migrations/2026-07-18-better-auth-oauth-rollback.sqlReviewed rollback for the OAuth Provider migration
scripts/slice1_write_history.sqlAdds write_history table + prune_write_history() function
scripts/slice2_sheet_snapshots.sqlAdds sheet_snapshots cache with RLS and no Data API grants
scripts/slice3_gin_index.sqlGIN index on sheet_snapshots.data
scripts/slice4_role_isolation.sqlCreates sheet_analyzer Postgres role

For the hosted founder-preview database, run migrations only through the reviewed migration workflow. Independent fresh installs should follow the ordered commands in Self-Deploy, not infer an order from this reference table.


Tables

user

Standard better-auth user table.

ColumnTypeNotes
idtext PK
nametext
emailtext unique
emailVerifiedboolean
imagetextnullable
createdAttimestamptz
updatedAttimestamptz

account

Stores OAuth tokens per provider per user. The Google OAuth token that Sheetbase uses for all Sheets/Drive API calls lives here.

ColumnTypeNotes
idtext PK
accountIdtextGoogle sub (user ID)
providerIdtext"google"
userIdtext FK → user.idcascade delete
accessTokentextCurrent Google access token
refreshTokentextUsed for proactive refresh
accessTokenExpiresAttimestamptzRefresh triggered if within 5 min
scopetextSpace-separated OAuth scopes

Warning: All column names in this table are camelCase and must be double-quoted in raw SQL queries. lib/mcp-google.ts uses "accessToken", "refreshToken", "accessTokenExpiresAt", "userId" — without quotes, Postgres silently folds to lowercase and returns no rows.


session

better-auth session tokens (used for browser cookie sessions).

ColumnType
idtext PK
expiresAttimestamptz
tokentext unique
userIdtext FK → user.id
ipAddresstext
userAgenttext

oauthClient

OAuth Provider client registrations for compatible MCP clients.

ColumnType
idtext PK
clientIdtext unique
clientSecrettext nullable
redirectUrisjsonb
scopesjsonb
userIdtext FK → user.id
typetext

oauthAccessToken

Resource-bound OAuth access tokens issued to MCP clients after consent. authenticateMcpRequest() verifies the Bearer token against the canonical issuer, MCP audience, scopes, and JWKS before returning the minimal MCP identity.

oauthRefreshToken

Refresh tokens associated with the registered client, user, session, expiry, and granted scopes.

oauthConsent

The scopes a user approved for a registered OAuth client.

jwks

Signing keys used to publish and verify the OAuth Provider JWT boundary.

rateLimit

Database-backed Better Auth request counters. Global auth, OAuth Provider, and API-key limits are enabled in src/lib/auth.ts.


apikey

API keys created from the dashboard. A valid x-api-key header resolves through the Better Auth API-key session path. OAuth access tokens use Authorization: Bearer; authenticateMcpRequest() rejects requests that mix both credential types.


sheet_snapshots

Dual-purpose cache table. Used for two distinct cache types identified by range_key:

range_key valueCache typeTTLSet byRead by
'__schema__:<sheet>'Sheet schema (frozen rows, protected ranges, ARRAYFORMULA columns)300 secondsgetSheetSchema()All write tools, read_range
A1 range string (e.g. Sheet1!A:F)Range data (JSONB objects)60 secondsanalyze_rangeanalyze_range only

Key columns:

ColumnTypeNotes
user_idtext
spreadsheet_idtext
range_keytext'__schema__:<sheet>' or normalized A1
row_countinteger0 for schema cache
datajsonbSchema object or JSONB row array
expires_attimestamptzChecked on every read (AND expires_at > now())
cached_attimestamptzUpdated on upsert

Unique constraint: (user_id, spreadsheet_id, range_key) — uses ON CONFLICT ... DO UPDATE for upserts.

GIN index on data column (jsonb_path_ops) — from slice3_gin_index.sql. Accelerates JSONB key lookups inside the SQL execution path.

Invalidation: value writes clear range caches so subsequent reads see fresh values. Structural writes clear all cache entries for the spreadsheet, including per-sheet schema cache entries.


write_history

Pre-write snapshots. Populated before every write_range and transform_range call.

ColumnTypeNotes
iduuid PKgen_random_uuid()
user_idtext
spreadsheet_idtext
range_keytextNormalized A1 range
tool_nametext'write_range' or 'transform_range'
before_valuesjsonb2D array of cell values before write
after_summaryjsonb{ updatedRows, columns } audit summary (nullable)
created_attimestamptz

Index: (user_id, spreadsheet_id, created_at DESC) — for fast snapshot listing per spreadsheet.

Retention: prune_write_history(user_id, spreadsheet_id) is called after every insert. Deletes all rows beyond the most recent 50 per user_id + spreadsheet_id.

Snapshot size guard: Snapshot is only saved if rows ≤ 1,000 AND JSON.stringify(values).length ≤ 2MB. Larger ranges skip the snapshot silently.


Postgres role: sheet_analyzer

Created by scripts/slice4_role_isolation.sql. Used exclusively by analyze_range and transform_range via SET LOCAL ROLE sheet_analyzer.

Permissions:

  • GRANT USAGE ON SCHEMA public — allows using Postgres built-in functions
  • All other privileges explicitly revoked
  • Cannot read: account, user, session, apikey, sheet_snapshots, write_history

This role is the final defense layer: even if the regex SQL injection guard is bypassed, the query runs as a role with no access to any application data.

-- Applied in every analyze_range + transform_range call:
BEGIN READ ONLY;
SET LOCAL ROLE sheet_analyzer;
-- user SQL executes here
ROLLBACK;

Warning: If sheet_analyzer role does not exist, all analyze_range and transform_range calls fail with role "sheet_analyzer" does not exist. Run scripts/slice4_role_isolation.sql and GRANT sheet_analyzer TO CURRENT_USER before first deployment.

On this page