Skip to content

perf(table): collapse sequential round trips on the table read and write paths - #6808

Open
waleedlatif1 wants to merge 11 commits into
stagingfrom
refactor/table-rows-application-boundary
Open

perf(table): collapse sequential round trips on the table read and write paths#6808
waleedlatif1 wants to merge 11 commits into
stagingfrom
refactor/table-rows-application-boundary

Conversation

@waleedlatif1

Copy link
Copy Markdown
Collaborator

Summary

  • getTableById read the table and its latest job in two sequential round trips; the job is now read in the same statement as a correlated subquery. This loader is on essentially every table request. It can't simply be skipped — a table's reported rowCount is the stored count minus the job's pendingDeleteRemaining, so the count and the job row are one read.
  • getRowById issued the row lookup and its executions sidecar in series, though the sidecar keys on the row id the caller already supplied. Both now go out together.
  • The uniqueness probe ran whenever a table had any unique column, and re-checked all of them. It's now scoped to the columns a patch actually writes — a merge can't newly violate uniqueness on a column it leaves alone.
  • resolveActiveTableContext starts the workspace load alongside the table load when the caller asserts a workspace. The mismatch check is unchanged and still inspected first, so a returning path has always proven the assertion equals the canonical workspace id.
  • Both job reads projected the whole payload jsonb, but only one number is ever read from it, and the payload carries an unbounded excludeRowIds array. Now projects just that field.
  • Row-write use cases assumed every caller speaks column names. That holds for /api/v2 and the Copilot tools, but not for the first-party grid or the internal /api/table routes, which address cells by stable storage id — and the name remap drops keys it doesn't recognise, so id-keyed data would have stored nothing and reported success. dataKeying makes the wire an explicit, required part of the input.
  • Adds characterization tests for the single-row route, which had none despite being the hottest table write path, and shares one table-definition fixture factory across the route tests that had 13 copies of it.

Groundwork only — no route is migrated onto the application boundary here; that's a follow-up.

Type of Change

  • Bug fix
  • Performance improvement

Testing

Tested manually. Full lib/table, lib/api and table route suites pass (2,771 tests), type-check clean, all 29 audits pass including check:api-validation:strict, check:boundaries and check:block-registry. Each behavioural change was mutation-tested — the fix was reverted and the covering test confirmed to go red. The generated correlated subquery was additionally run against a real Postgres, since drizzle is globally mocked in the test suite.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

The single-row surface (GET/PATCH/DELETE) had no route-level tests despite
being the hottest table write path. Pin the behavior it emits today so the
migration onto the shared internal route builder is verifiable rather than
hopeful.

Covers status codes, body shapes, ISO-8601 timestamp serialization, the
access level each method demands, collaborator invocation, and the
dual-caller wire keying — session callers pass column ids through untouched
while internal-JWT callers translate names to ids in both directions.

Verified to fail: mutating the wire translator, the deleted count, and the
workspace-ownership guard each turn the corresponding tests red.
… cases

The row write use cases assumed every caller speaks column names. That holds
for /api/v2, /api/v1 and the Copilot tools, but not for the first-party grid
or the internal /api/table routes, which address cells by stable storage id.
Feeding id-keyed data through the name remap drops every key it does not
recognise — a storage id names no column name — so the write would store
nothing and still report success.

Make the wire an explicit, required property of the input rather than an
assumption. `dataKeying: 'names' | 'ids'` sits alongside `strictWrite` and is
required for the same reason: a new write surface must state which contract it
publishes. Strictness now means the same thing on either wire — an unknown
column id is refused exactly as an unknown column name already was.

Single-row writes also gain optional actor attribution, so the acting tab can
skip refetching its own write. It is optional and absent by default, so every
existing caller keeps broadcasting to all subscribers as before. Only the
single-row create, update and delete paths accept it; a batch write is not
reconciled locally by the actor and must still refetch.

The attribution pin moves with the behaviour: what selects the actor-scoped
signal is no longer which file calls it but which surface supplies an actor,
so that is now what the test pins.

Verified to fail: ignoring the keying discriminator, and dropping actor
attribution, each turn the corresponding tests red.
A single-cell PATCH spends far more time in sequential round trips to a remote
Postgres than in the UPDATE it issues. Prepared statements are disabled for
PgBouncer transaction mode, so every await is a full Parse/Bind/Execute.

Two of them were avoidable.

getRowById issued the row lookup and its executions sidecar in series, but the
sidecar is keyed on the row id the caller already supplied, so it never
depended on the lookup. Issuing both together makes it one round trip. A miss
now pays one redundant sidecar read, which is the rare path and costs no extra
wall time.

The uniqueness probe ran whenever the table had any unique column, passing the
fully merged row, so editing an unrelated cell re-probed every unique column —
its own transaction plus one query per column. It is now scoped to the columns
the patch actually writes. A merge cannot newly violate uniqueness on a column
it leaves alone: that value is the one already stored, and it satisfied the
constraint when it was written.

Sized before changing: few tables declare a unique column, but write traffic
concentrates in the ones that do, so this is the larger of the two savings in
practice.

Verified to fail: reverting the probe scoping turns the covering test red.
…space is asserted

resolveActiveTableContext ran two sequential round trips: load the table,
then load the workspace it turned out to live in. When the caller asserts a
workspace the second input is already in hand, so both can start together.

What makes that safe is unchanged: requireTable still compares the table's
canonical workspaceId against the assertion and reports a mismatch as
not_found. The table outcome is inspected first and unconditionally, so any
path that returns has proven the assertion equal to the canonical id, and a
failing workspace load can never replace the concealing not_found. A final
identity check on the loaded context restates the invariant at the point of
return, so even with the first check removed the function cannot hand back a
foreign workspace. Promise.allSettled keeps the discarded branch from
surfacing as an unhandled rejection.

With no asserted workspace the path stays sequential — the table load is what
reveals which workspace to load, so there is nothing to start early.

One existing assertion changed: it required the workspace load not to have
been issued yet on a mismatched assertion, which is internal sequencing rather
than caller-observable behaviour and is definitionally untrue once the loads
overlap. The observable half is kept, and two timing tests now cover the
sequencing directly.

Verified to fail: removing either mismatch check, reading the workspace
outcome first, and swapping allSettled for bare awaits each turn the
corresponding tests red.
getTableById issued the table SELECT and then awaited latestJobForTable, so
every table request paid two sequential round trips. With prepared statements
disabled for PgBouncer transaction mode every await is a full round trip, and
this loader is on essentially every table route.

The job read cannot be skipped: a table's reported rowCount is the stored
count minus the job's pendingDeleteRemaining, so dropping it would overstate
the count during a pending delete and could wrongly reject inserts as over
capacity. An opt-out flag would have made that a caller's trap. Instead the
job is read in the same statement, as a correlated jsonb subquery in the
select list — the select-list form of a LEFT JOIN LATERAL, which is what
drizzle can type here. Output is unchanged for every input.

latestJobForTable is deleted rather than left dangling: getTableById was its
only caller, and keeping it would have carried a third copy of the
exports-excluded / newest-started_at / limit-one rule. mapJobRow is now
exported so the batch path and the lateral share one implementation of the
doomedCount and pendingDeleteRemaining logic. The batch DISTINCT ON path used
by the list endpoint is untouched.

Verified to fail: dropping the export filter, reversing or re-keying the sort,
dropping the limit, dropping the correlation, loosening either doomedCount
condition, and removing the rowCount subtraction each turn tests red. Dropping
the lateral from the projection initially survived, because the shared db mock
returns queued rows regardless of predicate; a projection assertion now
covers it.
Authored as groundwork for migrating the internal row routes onto the shared
builder, which is not in this change. An exported contract nothing consumes is
dead code, so it lands with the migration that needs it instead.
assertKnownColumnIds read column.id directly, but a column id is optional —
pre-backfill columns have none and are stored under their name, which is why
getColumnId exists and is what every other consumer of the schema uses. Such columns still
exist, so a strict id-keyed write naming one would have been refused as
unknown.

Latent today: no surface yet combines dataKeying 'ids' with strictWrite. Fixed
before one does.

Verified to fail: reading column.id turns the covering test red.
Four parallel reviews (reuse, simplification, efficiency, altitude) converged
on the same set. Applied:

Removed actorClientId entirely. It had no supplier anywhere in the repo, so
every call reached signalTableRowsChangedByActor(id, undefined), which is
byte-identical to the broadcast it replaced — three optional fields, three
verbatim doc blocks and a pin test asserting the empty set, all inert. It
belongs with the route migration that supplies an actor. The attribution pin
is restored to its original form.

The uniqueness probe was only half-narrowed: the patched column list was
computed and then discarded, and the probe re-derived every unique column from
the full schema. It now receives only the columns the patch touched, so a
table with several unique columns runs one query instead of all of them.

assertKnownColumnIds hand-rolled an id index and duplicated the sibling
assert's message verbatim. It now reuses buildColumnNameById — which already
keys by getColumnId, so the legacy pre-backfill column case is handled by the
shared helper rather than by a special case here — and both asserts share one
message builder.

The job field list had become two copies, one drizzle-checked and one an
unchecked sql<T> cast that could silently return undefined for a renamed
field. The lateral now derives its jsonb pairs from JOB_PROJECTION, which
satisfies Record<keyof LatestJobRow, Column>. That compile-time guarantee
replaces the runtime drift test it makes redundant.

Also: hoisted the id index out of the batch loop to match the names path,
dropped a never-supplied parameter, narrowed an over-broad parameter type,
removed a dead timer cleanup and its now-unused import, replaced dynamic
re-imports with the static one already present, hoisted a repeated stub, and
documented why filters need no keying counterpart and how laxness differs
between the two wires.

Verified to fail: removing a field from JOB_PROJECTION breaks the build in two
places.
buildTable was copy-pasted into 13 route test files under app/api/table, each
a near-identical TableDefinition literal. A required field added to that type
would have failed 13 files individually.

packages/testing already owned createTableColumn and createTableRow but no
definition factory, and — as it turns out — did not export any of them from
the barrel, so they were unreachable from @sim/testing. Adds
createTableDefinition beside them and exports all three.

The fixture type is a structural stand-in rather than TableDefinition itself,
because packages/* must not import from apps/* — the same approach the
existing factories in that file already take.

No assertion changed. Call sites that varied a field pass it as an override;
the four files with several call sites hoist a shared options const and spread
it so each call still gets a fresh object.
Both job reads selected the whole payload jsonb, but mapJobRow reads exactly
one number out of it, and only for a running delete. The payload also carries
the delete job's filter and an unbounded excludeRowIds array, and the latest
non-export job is read on essentially every table request — a table that once
ran a large delete would ship that id list on every read, forever.

LatestJobRow.payload becomes doomedCount, extracted in SQL. Both readers share
JOB_PROJECTION so one edit reaches the batch DISTINCT ON and the correlated
subquery alike; the compile-time constraint widens to Column | SQL rather than
being dropped.

Behaviour is identical. `->` keeps the value jsonb, which postgres-js decodes
through its built-in JSON.parse handler, so it arrives as a number with no
boundary coercion. A null payload, a payload without the key, a non-object
payload and an explicit JSON null all collapse to the same `?? 0` the previous
optional chain produced.

Sized honestly before claiming a win: payloads are small in practice today, so
this is defensive rather than impactful — it removes an unbounded growth path,
not a measured cost.

Verified against a real Postgres, not just the mocked driver: the
generated correlated subquery returns doomedCount 12 for a delete job, null
for an import job, and a null row for a table with no job.

Also from the review pass: re-homes the strictWrite explanation onto
rowWriteOptions, where six {@link} references now point; records why
replaceProjectedWireRows carries no keying discriminator; notes the one case
the uniqueness-narrowing invariant does not cover; pins the lax id-wire
passthrough with a test; and renames a parameter that misled once only its
keys were read.
One restated the identifier below it (checkUniqueConstraintsDb), and one was a
section-divider banner in the factories barrel, which the repo's comment
convention rules out. Everything else that survives is a why the code cannot
express: round-trip rationale, sql.raw input safety, the narrowing invariant
and the one case it does not cover.
@vercel

vercel Bot commented Aug 18, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Aug 18, 2026 3:16am

Request Review

@cursor

cursor Bot commented Aug 18, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes core table read/write behavior (row counts, uniqueness scope, and row data keying) on heavily used paths; extensive tests mitigate regressions, but any missed caller of row use cases without the correct dataKeying could still mis-store data.

Overview
This PR cuts sequential DB round trips on hot table paths and fixes a silent row-write bug where id-keyed payloads could be treated as column names.

Reads: getTableById now loads the table and its latest non-export job in one SELECT via a correlated lateral (latestNonExportJobJson), so rowCount stays correct mid-delete without a second query. getRowById fetches the row and executions in parallel. Job reads project only payload.doomedCount instead of the full payload (avoiding large excludeRowIds on every request). resolveActiveTableContext can start the workspace load alongside the table load when a workspace is asserted.

Writes: Row use cases require dataKeying: 'names' | 'ids' so v2/Copilot name-keyed APIs and the grid/internal id-keyed routes do not cross wires. Uniqueness checks on updateRow run only for unique columns touched by the patch.

Tests/tooling: Adds characterization tests for GET/PATCH/DELETE on the single-row internal route; table route tests share createTableDefinition from @sim/testing.

Reviewed by Cursor Bugbot for commit 8bfaee4. Configure here.

@greptile-apps

greptile-apps Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR reduces sequential database round trips on table read and write paths while making row-data keying explicit at the application boundary.

  • Folds the latest non-export job into table reads and projects only the delete-count field needed to derive visible row counts.
  • Starts independent row/execution and asserted-workspace/table reads concurrently.
  • Limits uniqueness probes to unique columns actually modified by a patch.
  • Adds explicit name-keyed versus storage-ID-keyed row-write handling and updates current adapters.
  • Consolidates table fixtures and expands route and application-layer characterization tests.

Confidence Score: 5/5

The PR appears safe to merge; the optimized queries and explicit row-keying paths preserve the existing table contracts for all current callers examined.

The correlated and parallel reads retain prior filtering and return semantics, touched-column uniqueness checks do not remove validation from modified unique fields, and every current production row-write adapter supplies the keying that matches its input format.

Important Files Changed

Filename Overview
apps/sim/lib/table/application/rows.ts Introduces explicit row-data keying and consistently normalizes current name-keyed and storage-ID-keyed write surfaces.
apps/sim/lib/table/rows/service.ts Parallelizes independent row reads and restricts uniqueness probes to patched unique columns without changing reachable validation behavior.
apps/sim/lib/table/service.ts Replaces the sequential latest-job query with an equivalent correlated projection used to derive visible row counts.
apps/sim/lib/table/jobs/service.ts Shares a narrow latest-job projection and preserves pending-delete calculations while avoiding retrieval of unbounded payload data.
apps/sim/lib/table/application/context.ts Loads asserted workspace and table context concurrently while preserving canonical workspace matching and not-found concealment.
apps/sim/lib/table/column-keys.ts Adds the stable-ID index used to validate ID-keyed writes, including legacy columns whose storage key is their name.
packages/testing/src/factories/table.factory.ts Centralizes table-definition fixtures used across the expanded route tests.

Sequence Diagram

sequenceDiagram
  participant Caller
  participant App as Table application use case
  participant DB as PostgreSQL
  Caller->>App: Read table or row
  par Collapsed table read
    App->>DB: Table + correlated latest-job projection
    DB-->>App: Table and derived job fields
  and Parallel row read
    App->>DB: Row lookup
    App->>DB: Execution sidecar lookup
    DB-->>App: Row and executions
  end
  Caller->>App: Write name-keyed or ID-keyed row data
  App->>App: Normalize according to dataKeying
  App->>DB: Probe only touched unique columns
  App->>DB: Persist row and provenance
Loading

Reviews (1): Last reviewed commit: "chore(table): drop two comments the code..." | Re-trigger Greptile

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant