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:
- User tables (default). All user-created tables share a single
user_table_rowstable. Each row is one Postgres row with a JSONBdatacolumn. This is the Notion / Airtable pattern — schemas live as metadata inuser_tables.columns, not as Postgres DDL. - People (native). The
peopletable is a real Postgres table with typed columns, B-tree and GIN indexes, and a generatedtsvectorfor full-text search. Surfaced in the Data mode as a virtual user table via anaddon_slug = '_system'marker row.DataProviderdetects system tables and routes CRUD throughapi.people.*instead ofapi.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:
| Type | Renders as |
|---|---|
text | Plain text |
number | Right-aligned numeric |
phone | Phone number |
email | |
price | Currency-formatted amount |
percent | Percentage |
date | Date picker |
datetime | Date + time picker |
time | Time picker |
boolean | Checkbox / toggle |
select | Single colored pill |
multi-select | Multiple colored tag pills |
link | Foreign-key link to another row |
child-link | One-to-many parent → children link |
formula | Computed 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_titletags(text array)source,status,opted_in_sms,opted_in_emailcity,state,countrylast_contacted_at,conversation_countcustom_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_column→to_table+to_columnrelation_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. - Operators —
and,or,not,+,-,*,/,mod, comparison. - Helpers —
count(),filter(),round(),if(),switch(),concat()(null-tolerant),filterEmpty(),filterPresent().
Compilation is cached per formula source (256-entry LRU).
Child-link columns and rollups
A child-link column stores a foreign-key relationship to rows in another table. The column spec includes:
| Field | Notes |
|---|---|
childTableId | The target table |
childColumnKey | The column on the target table that points back |
displayMode | count / list / sum / avg / min / max / formula |
aggColumnKey | The numeric column to aggregate (for sum/avg/min/max) |
formula | Per-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_tagsopt-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-memorywidthOverridesmap 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.