Skip to content

fix(tables): match unique JSON values exactly and validate row writes against the live schema - #8394

Merged
waleedlatif1 merged 4 commits into
stagingfrom
fix/table-json-unique-equality
Sep 29, 2026
Merged

waleedlatif1 merged 4 commits into
stagingfrom
fix/table-json-unique-equality

Conversation

@waleedlatif1

@waleedlatif1 waleedlatif1 commented Sep 29, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

  • Unique JSON columns matched by containment. The write-time unique checks and the upsert conflict probe used data @> {col: v}. For objects and arrays that isn't equality:

    • {"a":1} was rejected because a row held {"a":1,"b":2}, and [1] because a row held [1,2,3]
    • upsert on a JSON conflict target overwrote a row that merely contained the target
    • the make-unique scan already compared exact values, so the two disagreed

    A new uniqueValuePredicate keeps containment as the GIN-indexed leading clause and adds data -> $key = $v::jsonb for objects and arrays. It's used by the single and batch unique checks and the upsert probe. In-batch duplicate keys use uniqueValueKey, which ignores key order and keeps array order, the same as jsonb equality. JSON values now take per-value locks like other types, so the exclusive table-lock fallback is gone.

  • Row writes validated against a stale schema. Writers validated against a definition read before their transaction. A concurrent make-unique, make-required, retype, or column delete could therefore commit violating data (a duplicate in a unique column, an empty required cell, a stale-typed value, a cell in a deleted column).

    • Script migration 0026_user_table_schema_for_write installs user_table_schema_for_write(table_id). It takes the table's user_table_schema advisory lock shared, the key withLockedTable holds exclusively, then returns the live schema. It's VOLATILE, so the read sees everything committed before the lock was granted. It's a script migration rather than a Drizzle SQL migration because it has to exist under both db:migrate and db:push, the same pattern as script migration 0025.
    • Each write calls it in the statement it already runs first. Its lock wait is bounded by the caller's statement_timeout, not the 3 s lock_timeout, so a write behind a long schema change waits it out instead of failing. Writes stay parallel with each other.
    • When the schema moved, writers rebuild the row from the caller's raw input (not the snapshot-coerced value) against the live schema: they drop cells of deleted columns, coerce, validate, and re-check the row size. Trigger payloads and workflow-run clearing use the live schema too.
    • Guarded paths: insert, upsert, update, batch insert, batch update, update-by-filter, replace, and the import batch. Paths already under withLockedTable/guardBatch don't take it twice. Deletes and executions-only writes don't depend on the schema.
  • Background bulk update derives each batch's patch from the live schema. It uses the same helper as the inline bulk update (deriveBulkUpdatePatch), under the schema lock. A column made unique or required mid-job is refused without retrying, a retyped column is coerced to its new type, and a deleted column is dropped. A retry now does exactly what the first attempt would.

  • Behaviour changes:

    • an upsert whose target column is no longer unique is refused
    • a bulk update that writes a newly unique column across several rows is refused
    • updateRow is unchanged apart from the guard

Type of Change

  • Bug fix

Testing

  • row-writes.integration.ts covers:

    • exact JSON matching: contained values accepted, key order ignored, upsert doesn't overwrite a containing row
    • every guarded write path against a stale snapshot (unique, required, deleted column)
    • a write waiting out an in-flight schema change and then validating against it
    • the lock-timeout bound and restore
    • background jobs with a column made unique or required mid-job, retyped mid-job, deleted mid-job, an epoch-number date, and a select option added mid-job
  • Each guard was reverted on its own and its tests went red. Making the function STABLE fails the in-flight test.

  • The lock-waiter helper is now scoped to the test's table. It had counted waiters database-wide, which flaked under parallel runs. Stress: 4 parallel instances, 0 failures.

  • A/B against staging on fresh databases, real service functions, alternating runs:

    Scenario Result
    inserts, batch inserts, bulk paths within noise
    updateRow +0.3 ms p50: its one added statement
  • Raw-input refit and the size re-check are covered on insert, upsert, update, batch update, and the import batch.

  • Verified on both provisions CI uses: db:migrate and db:push. On db:push, the rows_version tests and the TTL-cleanup file skip as before; they need migration-only triggers.

  • Type-check, lint, check:audits, and check:migrations origin/staging pass. Table unit suites: 1212 tests. Table integration suites pass.

Follow-up

  • A background CSV import converts each batch with the schema it read at job start, so if a column is retyped between batches, the refit starts from that conversion rather than the CSV text. This is still stricter than before, when those rows were inserted unchecked. The fix is to have the import batch hand back the live schema and re-convert the CSV rows when it moved.

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)

… against the live schema

- Unique checks and the upsert conflict probe matched JSON objects and arrays by containment, so
  `{"a":1}` counted as a duplicate of `{"a":1,"b":2}` and upsert could overwrite a row that only
  contained its target. They now also require exact jsonb equality, keeping containment as the
  GIN-indexed leading clause; JSON values take per-value locks like other types
- Row writes validated against a schema snapshot read before their transaction, so a concurrent
  make-unique, make-required, retype, or column delete could commit violating data. Each write
  now reads the live schema under the table's schema lock (shared) through
  `user_table_schema_for_write`, in the statement it already runs first, and validates against it
- The background update runner derives each batch's patch from the raw payload against the live
  schema, through the same helper as the inline bulk update, and refuses unique and required-null
  patches under the lock
@vercel

vercel Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated
docs Skipped Skipped Sep 29, 2026 1:50am UTC

Request Review

@greptile-apps

greptile-apps Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

[High risk] Adds schema validation to row writes and changes unique-value matching logic.

The PR appears safe to merge based on the changes since the previous review.

Summary

The PR makes unique JSON comparisons exact and validates row writes against the schema held under a shared lock. It also re-derives background-update patches as the schema changes and expands integration coverage. Both previous findings—raw-input refitting and the post-refit size check—are fixed in the current code; their threads are resolved.

Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Row write] --> B[Lock and read live schema]
  B --> C[Refit if schema changed]
  C --> D[Validate size and constraints]
  D --> E[Write row]
  F[Background update batch] --> G[Read schema under lock]
  G --> H[Re-derive and validate patch]
  H --> E
Loading

Reviews (3) · Last reviewed commit: "fix(tables): read the remaining row cell..."

Comment thread apps/sim/lib/table/rows/service.ts
Comment thread apps/sim/lib/table/rows/service.ts
@greptile-apps

This comment has been minimized.

@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.

All reported issues were addressed across 22 files

Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

Comment thread apps/sim/lib/table/update-runner.ts Outdated
Comment thread apps/sim/lib/table/rows/secret-provenance.integration.ts
Comment thread apps/sim/lib/table/rows/service.ts
Comment thread apps/sim/lib/table/validation.ts Outdated
Comment thread apps/sim/lib/table/import-data.ts Outdated
Comment thread apps/sim/lib/table/import-data.ts Outdated
Comment thread apps/sim/lib/table/rows/row-writes.integration.ts
…w input

- user_table_schema_for_write moves from Drizzle migration 0391 to script
  migration 0026, which db:push runs too. A db:push database (local dev,
  the CI push provision) never applied 0391, so every guarded row write
  failed there. The journal ends at 0390 again; the function body and its
  comments are unchanged.
- A writer that finds the schema moved rebuilds the row from the caller's
  raw input instead of the value it coerced against its snapshot, so a
  "007" sent while a column changed from number to text is stored as
  "007", not "7". This covers insert, upsert, update, batch update and the
  import batch.
- The refit re-checks the row's size, which a coercion to a wider type can
  grow past the limit after the pre-lock check passed.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cubic-dev-ai review this PR

@cubic-dev-ai

cubic-dev-ai Bot commented Sep 29, 2026

Copy link
Copy Markdown
Contributor

@cubic-dev-ai review this PR

@waleedlatif1 I have started the AI code review. It will take a few minutes to complete.

@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.

All reported issues were addressed across 24 files

Requires human review: Auto-approval blocked because this review re-detected 1 unresolved issue already reported by Cubic.
Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.

Fix all with cubic | Re-trigger cubic

Comment thread apps/sim/lib/table/import-data.ts
- The background update runner re-reads a batch's rows inside the batch
  transaction and re-validates them merged with the re-derived patch when
  the schema moved since the page was checked, as the inline bulk update
  does; an unchanged schema adds no query. updatePageByIds takes an async
  per-batch hook with the transaction for this.
- batchInsertRowsWithTx returns the definition it validated against, and
  batchInsertRows dispatches its insert triggers with it.
- Row cells and patch keys are read as own properties (Object.hasOwn), so a
  legacy column keyed by a prototype name such as `constructor` is not
  seen in rows or patches that do not hold it.
- The long schema-wait test asserts the write is waiting on the schema lock
  before the holder commits.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cubic-dev-ai review this PR

@cubic-dev-ai

cubic-dev-ai Bot commented Sep 29, 2026

Copy link
Copy Markdown
Contributor

@cubic-dev-ai review this PR

@waleedlatif1 I have started the AI code review. It will take a few minutes to complete.

@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.

All reported issues were addressed across 24 files

Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.

Fix all with cubic | Re-trigger cubic

Comment thread apps/sim/lib/table/rows/row-writes.integration.ts
@waleedlatif1
waleedlatif1 merged commit 66e5705 into staging Sep 29, 2026
32 checks passed
@waleedlatif1
waleedlatif1 deleted the fix/table-json-unique-equality branch September 29, 2026 02:29

This branch was previously deployed

1 inactive deployment
Preview — 04569de1 Deployed Sep 29, 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