Core Concepts

Data tables

Airtable-style typed tables for your workspace — schemas, rows, and the native People table.

A data table is an Airtable-style table you create in the Data mode. Each table has typed columns and rows stored as JSONB. They're the structured-data surface of Project88 — anywhere an agent needs a list of contacts, products, leads, tickets, anything tabular.

Two storage strategies

There are two physical patterns under the hood:

  1. User tables (default). All user-created tables share a single user_table_rows table. Each row is one Postgres row with a JSONB data column. This is the Notion / Airtable pattern — schemas live as metadata in user_tables.columns, not as Postgres DDL.
  2. People (native). The people table is a real Postgres table with typed columns, B-tree and GIN indexes, and a generated tsvector for full-text search. Surfaced in the Data mode as a virtual user table via an addon_slug = '_system' marker row. DataProvider detects system tables and routes CRUD through api.people.* instead of api.userTableRows.*.

The split exists because People can grow to 100k+ rows per org; the JSONB pattern doesn't scale to that with per-column indexes.

Column types

The single source of truth lives in src/modals/recordEditor/colTypes.jsx. There are 15 column types, each with a type-aware cell renderer and editor:

TypeRenders as
textPlain text
numberRight-aligned numeric
phonePhone number
emailEmail
priceCurrency-formatted amount
percentPercentage
dateDate picker
datetimeDate + time picker
timeTime picker
booleanCheckbox / toggle
selectSingle colored pill
multi-selectMultiple colored tag pills
linkForeign-key link to another row
child-linkOne-to-many parent → children link
formulaComputed value from a formula expression

You define columns via the Add column sheet in the Data mode. New column types register automatically once they're added to COL_TYPES — every picker in the app picks them up without further wiring.

CRUD

CRUD happens inline on the canvas. The Data mode's CanvasDataCard provides:

  • Inline cell editing
  • Add row / add column / delete table toolbar
  • Filters and sorting (planned UI parity across all types)
  • Drag-to-reorder rows
  • Optimistic updates with rollback on error

Behind the scenes, DataProvider watches the current org and re-fetches on org change. Row CRUD routes through api.userTableRows.* (or api.people.* for system tables). Operations are optimistic with rollback-on-error.

The People table

Created automatically the first time you enter an org via ensure_people_user_table(). Native columns include:

  • first_name, last_name, email, phone, company, job_title
  • tags (text array)
  • source, status, opted_in_sms, opted_in_email
  • city, state, country
  • last_contacted_at, conversation_count
  • custom_fields (JSONB for user-defined columns)

upsert_person() lets you insert-or-merge by email — the SMS mode and Campaigns mode use this to ingest contacts without creating duplicates.

Relations

User tables can declare relations in user_table_relations:

  • from_table + from_columnto_table + to_column
  • relation_type: one-to-one, one-to-many, many-to-many

These are user-defined and currently inform UI hints; the storage is still JSONB-on-rows, not real Postgres foreign keys.

Formula columns

Formula columns evaluate an expression against the row's data and render the result. The engine lives in src/lib/formula/rowFormula.js with helpers shared across the app via SHARED_FORMULA_HELPERS (src/lib/formula/helpers.js).

Syntax:

  • Same-table refs{columnLabel} reads another column's value from the current row.
  • One-hop traversal{linkLabel.target} follows a link column and reads a column from the linked row.
  • Operatorsand, or, not, +, -, *, /, mod, comparison.
  • Helperscount(), filter(), round(), if(), switch(), concat() (null-tolerant), filterEmpty(), filterPresent().

Compilation is cached per formula source (256-entry LRU).

A child-link column stores a foreign-key relationship to rows in another table. The column spec includes:

FieldNotes
childTableIdThe target table
childColumnKeyThe column on the target table that points back
displayModecount / list / sum / avg / min / max / formula
aggColumnKeyThe numeric column to aggregate (for sum/avg/min/max)
formulaPer-parent formula (for displayMode = 'formula')

Aggregators are NaN-safe (non-numeric children are skipped). Formula mode runs a per-parent expression against {children} — useful for things like "average price of completed child invoices."

ChildLinkCell.jsx batches rollup fetches via RPC to avoid N+1 reads; sorting and filtering work on rollup output too.

Inline child creation — clicking + Add child opens an inline form that creates the child row and links it in one step.

Value sets — shared option sets

Reusable option sets for select / multi-select columns. Stored in the user_value_sets table; managed from Settings → Values.

  • Each value set is org or workspace scoped.
  • Items have label, color, sort order.
  • Columns can reference a value set instead of defining their own options inline — change the set once and every column updates.
  • On record conversion (ConvertRecordsModal), inherit_tags opt-in carries tag assignments from source rows to destination rows.

Filtering: trees, presets, inline chips

Data-table filters are trees, not flat lists. Schema:

  • Rule node{ kind: 'rule', field, operator, value }
  • Group node{ kind: 'group', conjunction: 'and' | 'or', negate, children }

Groups can nest. The UI for the tree lives in src/components/canvas-page/widgets/shared/filters/. Legacy flat-array filters are normalised on read for backward compatibility.

Filter presets — save a filter tree to the filter_presets table and share it across widgets. Presets are scoped per (org, workspace, entity_type_key) so the right ones surface for each widget type. Presets are lazy-loaded on first reference and cached.

Inline filter chips — a compact chip row above the table that mirrors active filters; click any chip to edit or remove.

Multi-sort — sort by multiple columns at once. Sort state persists on the widget.

Frozen and resizable columns

  • Frozen — set per-column via the column-header menu. Frozen columns pin to the left with a divider shadow and a stacked z-index.
  • Resizable — drag the column edge. Final width persists onto the column metadata via updateTable. Live drag uses an in-memory widthOverrides map so the resize is smooth.

Sensitive columns — column-level encryption

Any value-storing column type (text, number, email, phone, etc.) can be marked sensitive. The column stores sensitive: true in its schema and the database tier handles encryption at write time. Reading a sensitive value requires a reveal RPC that writes an audit log row.

Formula, child-link, and link columns can't be marked sensitive — they don't store the value directly.

This complements Supabase Vault (used for provider keys and integration tokens) — Vault is for application secrets, column encryption is for end-user data inside tables.

Where to go next

On this page