Skip to content

fix(tables): log row writes to the change log instead of locking the table definition row - #8765

Open
TheodoreSpeaks wants to merge 1 commit into
stagingfrom
fix/table-row-change-triggers
Open

TheodoreSpeaks wants to merge 1 commit into
stagingfrom
fix/table-row-change-triggers

Conversation

@TheodoreSpeaks

Copy link
Copy Markdown
Collaborator

Summary

Type of Change

  • Bug fix

Testing

  • New row-writes.integration.ts case: a second session holds the definition row FOR NO KEY UPDATE (what a commit stuck in SyncRep holds) while insert, batch insert, upsert, update by id, update by filter, delete by filter, and replace each run. All seven commit (56 ms total) and live count = COUNT(*). Against the pre-change triggers the first insert fails at the 3 s lock timeout
  • The cascade-delete foreign-key bug was caught by the TTL suite's fixture cleanup before the guard was added
  • Table suites + TTL cleanup on a migrated DB: 148 passed; push-provisioned: 117 passed (migration-only cases skip as before). rows_version and TTL helpers now read stored + tail
  • bun run lint, check:audits, check:migrations origin/staging, block-registry check, docs-manifest:check, drizzle generate clean, 1,011 table unit tests; root bun run test green except one desktop terminal test that passes in isolation

Checklist

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

🤖 Generated with Claude Code

https://claude.ai/code/session_01CJxC2fgucGVpT59RV5Z7xv

@vercel

vercel Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
docs Ready Ready Preview Oct 7, 2026 10:02pm UTC

Request Review

@cubic-dev-ai

cubic-dev-ai Bot commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

We detected this is a high-risk PR and are running a free ultrareview. An ultrareview is a deeper, multi-pass review that catches hard-to-find bugs a standard review can miss. We'll post the findings when it completes.

This PR appears to change a database schema or migrate data, where a missed bug can corrupt or lose records, so a deeper multi-pass review is worth running.

Want an ultrareview on every high-risk PR? Set up automated ultrareviews.

@greptile-apps

greptile-apps Bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 4/5

[Critical risk] Rewrites how row count and version are tracked in the database.

Protect the development count repair from concurrent folds before merging.

Findings

  1. P1 Count repair undoes a fold ▶
  2. P2 Existing-row upserts go untested ▶

Summary

Row writes now append counts and version changes to user_table_row_changes instead of updating the shared definition row.

  • The migration preserves update filtering and dedupe, and skips logging for deleted definitions.
  • Tests now read saved values plus the pending log and cover writes under a held definition-row lock.
  • The development count repair needs protection from concurrent folds. The new test also misses upserts that change existing rows.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  W["Row write"] --> T["Insert/delete trigger or deferred update trigger"]
  T --> L["user_table_row_changes"]
  D["user_table_definitions"] --> R["Live count and version"]
  L --> R
  L --> F["Fold locks definition row"]
  F --> U["Add totals and remove log in one transaction"]
  U --> D
Loading

Reviews (1) · Last reviewed commit: "fix(tables): log row writes to the chang..." · Reviewed by Greptile

Comment on lines 72 to +78
UPDATE user_table_definitions d
SET row_count = actual.n
SET row_count = actual.n - actual.tail
FROM (
SELECT d2.id, count(r.id)::int AS n
SELECT d2.id,
(SELECT count(*) FROM user_table_rows r WHERE r.table_id = d2.id)::int AS n,
(SELECT coalesce(sum(c.row_delta), 0) FROM user_table_row_changes c
WHERE c.table_id = d2.id)::int AS tail

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Count repair undoes a fold

If the script runs during a fold, this query can read the pending log before waiting for the definition row's lock. The fold then removes that log and commits, but the query writes actual.n - actual.tail from its earlier snapshot. Ten real rows with a pending +3 can end up with a saved count of seven and an empty log. Later folds cannot recover that lost count.

Lock the definition rows first, then read the counts in a fresh statement while keeping those locks.

Comment on lines +1441 to +1442
data: { key: 'd', name: 'd' },
conflictTarget: 'key',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Existing-row upserts go untested

This call uses key d, but only keys a, b, and c exist at this point. It tests another insert, not an upsert that changes an existing row. The test could pass even if that separate write path still waits on the held definition row.

Add an upsert using an existing key and a changed value while the lock is held.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We detected this is a high-risk PR and ran a free ultrareview. An ultrareview is a deeper, multi-pass review that catches hard-to-find bugs a standard review can miss.

This PR appears to change a database schema or migrate data, where a missed bug can corrupt or lose records, so a deeper multi-pass review is worth running.

Want an ultrareview on every high-risk PR? Set up automated ultrareviews.

4 issues found across 9 files

Confidence score: 3/5

  • apply-dev-table-triggers.ts can permanently undercount if reconciliation uses a pre-fold actual value after the fold commits. Make reconciliation safe against a concurrent fold.
  • row-writes.integration.ts only detects the 0390 deferred UPDATE trigger, so on 0390/0396 it exercises the old definition-row triggers rather than the lock-free triggers installed by 0400. Ensure this test only runs against a database with the intended triggers.
  • row-writes.integration.ts accepts a lower bound that can miss a missing filter update; replaceTableRows should advance the version by exactly writes.length + 1. Assert that exact expected version.
  • row-writes.integration.ts uses d, which has not been inserted, so the upsert case only covers insertion and leaves conflict updates untested. Use an existing key with a changed value.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/sim/lib/table/rows/row-writes.integration.ts">

<violation number="1" location="apps/sim/lib/table/rows/row-writes.integration.ts:1388">
P2: `migrated` only detects the 0390 deferred UPDATE trigger, while the lock-free row-change triggers tested here are installed by 0400. On a 0390/0396 database, this block runs against the old definition-row triggers and times out under the holder instead of skipping; gate it on 0400's change-log trigger/table too.</violation>

<violation number="2" location="apps/sim/lib/table/rows/row-writes.integration.ts:1441">
P3: Use an existing key with a changed value here; `d` has not been inserted, so this tests only the insert side of the upsert and leaves the conflict-update path untested.</violation>

<violation number="3" location="apps/sim/lib/table/rows/row-writes.integration.ts:1521">
P3: Use an exact expected version here: `replaceTableRows` emits two statement-level entries (DELETE and INSERT), so the seven paths should advance by `writes.length + 1`. The current lower bound allows a missing filter-update zero-delta bump to pass.</violation>
</file>

<file name="packages/db/scripts/apply-dev-table-triggers.ts">

<violation number="1" location="packages/db/scripts/apply-dev-table-triggers.ts:73">
P1: This reconciliation can permanently undercount after racing with the fold: the UPDATE may use a pre-fold `actual` value after the fold has already deleted the log tail and committed its definition-row update. Reconcile under the same per-table lock/transaction, or re-read the count and tail after acquiring that lock.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Turn on auto-fix | Re-trigger cubic

const reconciled = await sql`
UPDATE user_table_definitions d
SET row_count = actual.n
SET row_count = actual.n - actual.tail

@cubic-dev-ai cubic-dev-ai Bot Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: This reconciliation can permanently undercount after racing with the fold: the UPDATE may use a pre-fold actual value after the fold has already deleted the log tail and committed its definition-row update. Reconcile under the same per-table lock/transaction, or re-read the count and tail after acquiring that lock.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/db/scripts/apply-dev-table-triggers.ts, line 73:

<comment>This reconciliation can permanently undercount after racing with the fold: the UPDATE may use a pre-fold `actual` value after the fold has already deleted the log tail and committed its definition-row update. Reconcile under the same per-table lock/transaction, or re-read the count and tail after acquiring that lock.</comment>

<file context>
@@ -82,14 +70,15 @@ try {
   const reconciled = await sql`
     UPDATE user_table_definitions d
-    SET row_count = actual.n
+    SET row_count = actual.n - actual.tail
     FROM (
-      SELECT d2.id, count(r.id)::int AS n
</file context>
Fix with cubic

})
})

describe.skipIf(!migrated)('a held definition row', () => {

@cubic-dev-ai cubic-dev-ai Bot Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: migrated only detects the 0390 deferred UPDATE trigger, while the lock-free row-change triggers tested here are installed by 0400. On a 0390/0396 database, this block runs against the old definition-row triggers and times out under the holder instead of skipping; gate it on 0400's change-log trigger/table too.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At apps/sim/lib/table/rows/row-writes.integration.ts, line 1388:

<comment>`migrated` only detects the 0390 deferred UPDATE trigger, while the lock-free row-change triggers tested here are installed by 0400. On a 0390/0396 database, this block runs against the old definition-row triggers and times out under the holder instead of skipping; gate it on 0400's change-log trigger/table too.</comment>

<file context>
@@ -1382,6 +1385,143 @@ describe('table row writes against real PostgreSQL', () => {
     })
   })
 
+  describe.skipIf(!migrated)('a held definition row', () => {
+    it('lets every row write path commit while another session holds the definition row', async () => {
+      const table = await createTable([
</file context>
Fix with cubic

FROM user_table_rows WHERE table_id = ${table.id}`
expect(count).toBe(1)
expect((await getTableById(table.id))?.rowCount).toBe(count)
expect(await rowsVersion(table.id)).toBeGreaterThanOrEqual(versionBefore + writes.length)

@cubic-dev-ai cubic-dev-ai Bot Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Use an exact expected version here: replaceTableRows emits two statement-level entries (DELETE and INSERT), so the seven paths should advance by writes.length + 1. The current lower bound allows a missing filter-update zero-delta bump to pass.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At apps/sim/lib/table/rows/row-writes.integration.ts, line 1521:

<comment>Use an exact expected version here: `replaceTableRows` emits two statement-level entries (DELETE and INSERT), so the seven paths should advance by `writes.length + 1`. The current lower bound allows a missing filter-update zero-delta bump to pass.</comment>

<file context>
@@ -1382,6 +1385,143 @@ describe('table row writes against real PostgreSQL', () => {
+        FROM user_table_rows WHERE table_id = ${table.id}`
+      expect(count).toBe(1)
+      expect((await getTableById(table.id))?.rowCount).toBe(count)
+      expect(await rowsVersion(table.id)).toBeGreaterThanOrEqual(versionBefore + writes.length)
+    })
+  })
</file context>
Suggested change
expect(await rowsVersion(table.id)).toBeGreaterThanOrEqual(versionBefore + writes.length)
expect(await rowsVersion(table.id)).toBe(versionBefore + writes.length + 1)
Fix with cubic

{
tableId: table.id,
workspaceId,
data: { key: 'd', name: 'd' },

@cubic-dev-ai cubic-dev-ai Bot Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Use an existing key with a changed value here; d has not been inserted, so this tests only the insert side of the upsert and leaves the conflict-update path untested.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At apps/sim/lib/table/rows/row-writes.integration.ts, line 1441:

<comment>Use an existing key with a changed value here; `d` has not been inserted, so this tests only the insert side of the upsert and leaves the conflict-update path untested.</comment>

<file context>
@@ -1382,6 +1385,143 @@ describe('table row writes against real PostgreSQL', () => {
+              {
+                tableId: table.id,
+                workspaceId,
+                data: { key: 'd', name: 'd' },
+                conflictTarget: 'key',
+                secretProvenance: undefined,
</file context>
Suggested change
data: { key: 'd', name: 'd' },
data: { key: 'a', name: 'a-updated' },
Fix with cubic

This branch was successfully deployed

1 active deployment
Preview — 1288e3ba Deployed Oct 7, 2026 by vercel[bot]
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