The browser takes the internal door
+TableGrid calls useUpdateTableRow. That hook sends PATCH /api/table/[tableId]/rows/[rowId]. The route recognizes the logged-in session and hands the request to updateTableRow.
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-content.test.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-content.test.tsx
new file mode 100644
index 00000000000..67d97748a1f
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-content.test.tsx
@@ -0,0 +1,70 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act } from 'react'
+import { createTableColumn } from '@sim/testing'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { DisplayColumn } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/types'
+
+vi.mock(
+ '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render',
+ () => ({
+ resolveCellRender: () => ({ kind: 'empty' }),
+ CellRender: () => null,
+ })
+)
+
+vi.mock(
+ '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors',
+ () => ({ InlineEditor: () => })
+)
+
+import { CellContent } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-content'
+
+const COLUMN: DisplayColumn = {
+ ...createTableColumn({ id: 'col-name', name: 'Name', type: 'string' }),
+ key: 'col-name',
+ groupSize: 1,
+ groupStartColIndex: 0,
+ headerLabel: 'Name',
+ isGroupStart: true,
+}
+
+let container: HTMLDivElement
+let root: Root
+
+beforeEach(() => {
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ act(() => {
+ root = createRoot(container)
+ })
+})
+
+afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+})
+
+describe('CellContent', () => {
+ it('keeps the inline editor below the sticky table header', () => {
+ act(() => {
+ root.render(
+
You are looking at a hybrid system: it feels like a spreadsheet, is protected and queried like a database, and stores each row's changing cells as a JSON object. This guide builds that mental model first, then shows you exactly where to look in the repository.
+ + + +When you edit a cell, the request passes through a few distinct layers. Each layer has one job: render, transport, authorize, apply rules, or store.
+ +The most important correction to your starting idea is this: the module does use a database. The unusual part is that it does not create a brand-new SQL table every time a user clicks “New table.” Instead, every user-created table is represented inside shared Postgres records.
+The short answer: an endpoint is a door into Sim. A use case is the meaningful action performed after someone comes through that door. “Use case” is an architecture term used by this repository—it is not special TypeScript syntax.
+ +app/api/v2/tables/[tableId]/rows/[rowId]/route.ts is primarily called by an outside program—a customer script, server, command-line request, or SDK—using a Sim API key in the x-api-key header. The first-party table grid normally calls /api/table/... instead, using the logged-in browser session.
+ TableGrid calls useUpdateTableRow. That hook sends PATCH /api/table/[tableId]/rows/[rowId]. The route recognizes the logged-in session and hands the request to updateTableRow.
A script or server sends PATCH /api/v2/tables/[tableId]/rows/[rowId] with an x-api-key. The v2 route checks that key and then hands the request to the same updateTableRow action.
export const PATCH = defineV2JsonRoute({
+ contract: v2UpdateTableRowContract,
+ auth: v2ApiKeyAuth,
+ mapInput: ({ params, body }) => ({
+ tableId: params.tableId,
+ data: body.data,
+ ...
+ }),
+ useCase: updateTableRow,
+ present: (result) => ({
+ data: rowDataToExternal(...)
+ }),
+})
+ useCase: updateTableRow does not mean that line immediately calls the function. It gives the reusable action to defineV2JsonRoute. When a real PATCH request arrives, the route builder authenticates and validates it, then calls updateTableRow.execute(...).
| Part of your file | Plain-English meaning |
|---|---|
GET | An API client asks, “Give me this row.” The route runs readTableRow. |
PATCH | An API client asks, “Change part of this row.” The route runs updateTableRow. |
DELETE | An API client asks, “Delete this row.” The route runs deleteTableRow. |
Imagine the grid shows three columns—Company, Website, and Score—and one row. The friendly labels are for people; stable IDs are for storage.
+ +This says which columns exist and how each value should behave.
+{
+ "name": "Companies",
+ "schema": {
+ "columns": [
+ { "id": "col_a", "name": "Company", "type": "string" },
+ { "id": "col_b", "name": "Website", "type": "string" },
+ { "id": "col_c", "name": "Score", "type": "number" }
+ ]
+ }
+}
+ This is the flexible JSONB object stored in user_table_rows.data.
{
+ "id": "row_123",
+ "tableId": "tbl_456",
+ "data": {
+ "col_a": "Acme",
+ "col_b": "acme.test",
+ "col_c": 93
+ }
+}
+ // packages/db/schema.ts
+schema: jsonb('schema').notNull()
+
+// The same file, on user_table_rows
+data: jsonb('data').notNull()
+
+// apps/sim/lib/table/types.ts
+export type RowData = Record<string, JsonValue>
+ A column's name is a label you may change. Its ID is its permanent address. Keeping those separate prevents a simple rename from becoming a rewrite of every row.
+ +column-keys.ts is a toolbox of pure functions. A route or use case must explicitly say which vocabulary its caller speaks, build a map from the canonical table schema, and call the appropriate translator. “At the edge” means this deliberate handoff—not middleware that silently rewrites every object.
+ Suppose the canonical schema contains { id: "col_c", name: "Score" }. The map builders turn that one fact into the two dictionaries needed at different boundaries:
buildIdByName(schema)Map {
+ "Score" → "col_c"
+}
+ A public caller sends a recognizable name. The application converts it into the storage address.
+buildNameById(schema)Map {
+ "col_c" → "Score"
+}
+ A public response starts with stored IDs and converts them back into labels the caller knows.
+getColumnId(column) is the small compatibility rule underneath both dictionaries: use column.id when present, otherwise use column.name for an old pre-ID column whose rows were originally stored by name.
There is no actor called “a v2 API” spontaneously writing a small object. A person or another program first obtains a Sim API key and the IDs of the workspace, table, and row. That external program—perhaps a server, command-line script, or API client—then sends a complete HTTP request to Sim's v2 endpoint.
+ +Here is an illustrative request to change the existing row row_123 in table tbl_companies. The IDs and API key below are placeholders, but the request's structure matches the contract:
PATCH /api/v2/tables/tbl_companies/rows/row_123 HTTP/1.1
+x-api-key: <a valid Sim API key>
+Content-Type: application/json
+
+{
+ "workspaceId": "ws_sales",
+ "data": {
+ "Score": 97
+ }
+}
+
+ The complete JSON body has two top-level fields: workspaceId and data. Only the object nested under data describes cell changes. Score is the human-facing column name and 97 is the new value. The table ID and row ID are in the URL path; the API key is in a header; the workspace ID and cell patch are in the JSON body.
dataKeying or strictWrite. Those are private instructions added by the route after it has parsed the public request. They are not fields in the public HTTP body.
+ The external program sends the URL, API-key header, and full JSON body shown above.
The route builder authenticates the key and validates the path and body against v2UpdateTableRowContract.
mapInput copies body.data and adds dataKeying: 'names' plus strictWrite: true.
The use case translates Score → col_c. The row service validates 97 as a number and merges it into JSONB.
// app/api/v2/tables/[tableId]/rows/[rowId]/route.ts
+mapInput: ({ params, body }) => ({
+ tableId: params.tableId, // from the URL
+ rowId: params.rowId, // from the URL
+ assertedWorkspaceId: body.workspaceId,
+ data: body.data, // { "Score": 97 }
+ strictWrite: true, // added by route
+ dataKeying: 'names' as const, // added by route
+})
+
+// lib/table/application/rows.ts
+const data = rowDataToStorage(
+ input.data,
+ context.table,
+ input.dataKeying,
+ input.strictWrite
+)
+
+// Inside rowDataToStorage
+const idByName = buildIdByName(table.schema)
+if (strict) assertKnownColumnNames(data, idByName)
+return rowDataNameToId(data, idByName)
+ mapInput then reshapes those pieces into an internal work order.dataKeying flag? Feeding an already ID-keyed row through the name translator would treat every ID as unknown and lose the cells.rowDataNameToId omits names it cannot map. v2 checks first and returns an “Unknown column” error instead of silently dropping a typo.After the update succeeds, the endpoint responds to that same external program. The public response contains the row ID, the complete current row data translated back to column names, and timestamps. For example:
+ +HTTP/1.1 200 OK
+Content-Type: application/json
+
+{
+ "data": {
+ "id": "row_123",
+ "data": {
+ "Company": "Acme",
+ "Score": 97
+ },
+ "createdAt": "2026-08-20T16:00:00.000Z",
+ "updatedAt": "2026-08-25T21:31:53.000Z"
+ }
+}
+
+ This response is illustrative: a real row may contain different or additional cells, and its timestamps will differ. The important point is that the request's data is a partial patch, while the successful response describes the current row.
rows/route.tsYour open file handles the collection of rows rather than one row identified by rowId. Its callers still send complete requests. The exact public body depends on the operation:
| Operation | Public request body | Which values are name-keyed? |
|---|---|---|
Create one rowPOST /rows | { "workspaceId": "ws_sales", "data": { "Score": 97 } } | The nested data object. |
Create several rowsPOST /rows | { "workspaceId": "ws_sales", "rows": [{ "Score": 97 }, { "Score": 88 }] } | Each object inside rows. |
Update matching rowsPATCH /rows | { "workspaceId": "ws_sales", "filter": { "all": [{ "field": "Company", "op": "eq", "value": "Acme" }] }, "data": { "Score": 97 }, "limit": 10 } | The nested data object; filter.field also arrives as a column name and is translated separately. |
In every branch, dataKeying: 'names' is the route telling the use case how to interpret the cell objects it extracted from the larger request. The flag does not claim the entire request is { "Score": 97 }.
The external program sends a GET request to a v2 rows endpoint with its API key and workspace query parameter. The database and row service return stored cell data such as { "col_c": 97 }. Before Sim constructs the HTTP response, the route's presenter uses the authorized table schema to turn that into the public, name-keyed form { "Score": 97 }.
// app/api/v2/tables/[tableId]/rows/route.ts
+const toNamedRow = namedRowMapper(table.schema.columns)
+return {
+ data: rows.map((row) => toApiRow(row, toNamedRow))
+}
+
+// Stored → public
+{ "col_c": 97 }
+ ↓
+{ "Score": 97 }
+ cell-format.ts, not column-keys.ts.{ status: "opt_7" }.The first-party grid has already fetched the table definition and built each display column's internal key with getColumnId(column). When you edit a Score cell, the grid's mutation code constructs the smaller cell patch below. Its React Query hook then places that patch inside the full internal API request body with the workspace ID:
// table-grid.tsx; columnName is actually the stable column key here
+mutateRef.current({
+ rowId,
+ data: { [columnName]: value } // { "col_c": 97 }
+})
+
+ The internal request is sent to PATCH /api/table/[tableId]/rows/[rowId] using your logged-in browser session. Its full JSON body is shaped like { "workspaceId": "ws_sales", "data": { "col_c": 97 } }. The internal route chooses dataKeying: 'ids'. In rowDataToStorage, the ID branch simply returns the nested data object unchanged. The response presenter also returns ID-keyed data to the session caller, so React Query can merge it directly into the ID-keyed cache. No name round trip occurs.
| Caller | Vocabulary on the wire | Where conversion happens |
|---|---|---|
| Workspace grid | Column IDs | None for ordinary rows; the grid already uses getColumnId. |
| v2 public API | Column names | Inbound in application/rows.ts; outbound in route presenters via namedRowMapper. |
| Delegated workflow caller on internal routes | Column names | rowKeyingForPrincipal selects the name path; the same use case normalizes the write. |
| CSV and exports | Column names | Import/export boundaries build the maps once and convert rows while streaming. |
find route is a smaller round tripapps/sim/app/api/v2/tables/[tableId]/rows/find/route.ts passes the name-keyed predicate and sort to findTableRows. The use case converts predicate fields and sort fields to storage IDs before querying. A predicate needs one extra conversion: select option names must become stored option IDs, so it uses predicateToStorage rather than only predicateNamesToIds.
Predicate field Score, or select field/value names, arrive from v2.
The use case converts column names to IDs; select operands also become option IDs.
findRowMatches reports the matching stored column as col_c.
The presenter calls columnNameById(table.schema) and returns column: "Score".
That is the complete meaning of “translate at the edge”: each outward-facing surface chooses a human vocabulary, but translation only occurs at the specific inbound normalization or outbound presentation point where the authorized canonical schema is in hand.
+ +The system gives you an instant-looking edit while still letting the server be the final authority.
+ +TableGrid starts the mutation. React Query temporarily patches its cached row, so the interface feels immediate.
requestJson and the shared route contract agree on the request and response shape.
The application layer loads the canonical table, verifies workspace access, and chooses name-keyed or ID-keyed handling.
The row service merges the patch, coerces values, validates size and uniqueness, then updates the JSONB object.
// Simplified shape of the real update
+existing: { "col_a": "Acme", "col_c": 93 }
+patch: { "col_c": 97 }
+
+merged: { "col_a": "Acme", "col_c": 97 }
+
+// rows/service.ts persists a JSONB merge patch
+data = user_table_rows.data || patch
+ A “number column” does not create a Postgres numeric column. The value still lives inside JSONB, while a registry explains how that value should be edited, validated, displayed, filtered, sorted, and converted.
+ +| Registry responsibility | Plain-language meaning |
|---|---|
coerce | Can input such as "42" safely become the number 42? |
validateCell | Does the stored value actually fit this column's promise? |
formatForDisplay | What should the person see in the grid or an export? |
editor | Should the cell use a text box, date control, select menu, or toggle? |
jsonbCast | When sorting or comparing inside Postgres, should JSON text be treated as a number or timestamp? |
ownedMetadata | Does this type carry extra configuration, such as select options or a currency code? |
The registry lives in apps/sim/lib/table/column-types/. The available types are currently text, number, currency, boolean, date, JSON, and select. Each has its own file, and registry.ts is the completeness gate that makes TypeScript complain if a new type is only partially wired.
You can understand ordinary rows and columns with just user_table_definitions and user_table_rows. These side records explain the richer product behavior.
| Postgres record | Why it exists |
|---|---|
table_views | Saves a named filter, sort, hidden columns, widths, order, and pinned columns without mixing concurrent view edits into one big metadata blob. |
table_jobs | Tracks long-running imports, exports, bulk deletes, backfills, and updates, including progress and cancellation. |
table_row_executions | Tracks workflow or enrichment status for one row and one workflow group. Output values still land in the normal row JSON. |
table_run_dispatches | Tracks a user's “run this column / these rows” gesture while work is fanned out in batches. |
user_table_row_secret_provenance | Keeps security provenance beside the row without making the frequently-read cell JSON heavier. |
These are called “sidecars” in parts of the code: extra records attached to the main table or row for concerns that deserve their own indexes, lifecycle, or write pattern.
+Read these in this order. It moves from the storage truth, through domain rules, toward the interface you see.
+ +packages/db/schema.tsStart around userTableDefinitions and userTableRows. This is the physical Postgres shape and the clearest answer to “where does the data live?”
apps/sim/lib/table/types.tsRead ColumnDefinition, TableSchema, TableDefinition, TableRow, and RowData. This is the module's vocabulary.
apps/sim/lib/table/column-keys.tsLearn why storage uses column IDs and why public boundaries often use names. This file prevents renames from breaking references.
apps/sim/lib/table/column-types/See how a JSON value gains type-specific behavior across validation, editing, display, filters, sorting, and conversion.
apps/sim/lib/table/service.tsTable-level operations: create, fetch, list, rename, move, update metadata and locks, archive, and restore.
apps/sim/lib/table/rows/service.tsRow-level machinery: insert, query, paginate, update, upsert, and delete. This is large; begin with insertRow, queryRows, and updateRow.
apps/sim/lib/table/columns/service.tsSchema mutations: add, rename, retype, constrain, and delete columns. Notice that rename is metadata-only because values use stable IDs.
apps/sim/lib/table/application/The authorized use cases. These load canonical context, enforce access, call the lower-level services, record audits, and emit change signals.
apps/sim/lib/api/contracts/tables.tsThe shared HTTP promise between server and client: parameters, bodies, and response shapes.
apps/sim/app/api/table/Internal route adapters. A compact example is [tableId]/rows/[rowId]/route.ts: it maps HTTP into the shared row use cases.
apps/sim/hooks/queries/tables.tsThe client cache and mutations. Look at infinite row loading and the optimistic single-cell update.
apps/sim/app/workspace/[workspaceId]/tables/[tableId]/The page experience: table.tsx orchestrates the surface, while components/table-grid/ renders and edits the grid.
When you add a feature, place each part where its responsibility already lives. This is the architectural habit that matters more than memorizing individual functions.
+ +Do not try to understand the entire directory before making progress. These systems matter, but they are separate threads you can open only when your feature touches them.
+ +Rows have a fractional orderKey so inserting between two rows does not require renumbering the entire table. Infinite queries use cursors for efficient deep scrolling.
The query builder turns predicates into Postgres expressions that reach into JSONB. The column type registry supplies numeric and date casts.
schema.workflowGroups maps table columns to workflow inputs and outputs. Results go into normal row data; execution status goes into a sidecar.
Table events feed an SSE stream for schema, metadata, edit, workflow status, and view changes. Presence and remote selections use the collaboration room.
Imports, exports, filtered deletes, backfills, and large updates become jobs so the request does not have to stay open while thousands of rows are processed.
Mutation locks protect schema and row verbs. Secret provenance tracks whether values derived from secrets may safely re-enter model execution.
user_table_definitions.schema, and writes are coerced and validated against it.data field is JSONB keyed by stable column IDs.