super-productivity/packages/super-sync-server/prisma
Johannes Millan d30fd5f434
fix(sync): conflict-check all entities of multi-entity ops (#8334) (#8377)
* fix(sync): conflict-check all entities of multi-entity ops #8334

Multi-entity ops (deleteTasks, moveToArchive, __updateMultipleTaskSimple,
round-time-spent, batch board/issue-provider actions) carry entityIds[], but
the server operations table only persisted the scalar entity_id (= entityIds[0]).
Once such an op was stored, only its first entity took part in future conflict
lookups, so a later stale write to a non-first entity found no prior writer and
was wrongly accepted instead of rejected as CONFLICT_SUPERSEDED/CONCURRENT.

- Add an entity_ids text[] column (populated for multi-entity ops only via
  getStoredEntityIds; single-entity ops store [] and use the scalar) + a GIN
  index. Migration is metadata-only with no backfill: pre-migration rows fall
  back to entity_id, so the fix is forward-only (entities 2..n of already-stored
  ops were never persisted and stay unrecoverable).
- detectConflictForEntity now runs two ordered LIMIT-1 lookups (scalar btree +
  entity_ids GIN) and takes the higher server_seq, preserving the fast ordered
  hot path instead of an OR's BitmapOr+sort.
- detectConflictForEntities / prefetchLatestEntityOpsForBatch match an entity as
  the scalar entity_id OR a member of entity_ids (unnest CASE + && / = ANY).
- Harden validateOp to bound entityIds (length + per-element), mirroring entityId.

Raw SQL validated against Postgres (PGlite) incl. GIN usage and two-query
correctness; single path + validation + migrations covered by unit tests. The
full conflict-detection.spec needs a generated Prisma client (CI), and the
hot-path round-trip tradeoff should be confirmed with a real-PG EXPLAIN.

* fix(sync): store entity_ids when a batch op dedups off the scalar #8334

Multi-review follow-up. getStoredEntityIds gated on `length > 1`, so a batch op
whose entityIds dedup to a single value that differs from entityId (the server
does not enforce entity_id === entityIds[0]) stored []  and that entity became
invisible to conflict lookups — reintroducing #8334 for it. Gate on "is the set
exactly [entity_id]?" instead, and cover it with unit tests.

Also: correct the array-branch comment/doc — a GIN(entity_ids) lookup has no
server_seq so it match-all-then-sorts (cheap only because multi-entity ops are
rare), it is not an ordered walk; drop a stale "OR filter" test docstring; add a
counter-note on getConflictEntityIds vs getStoredEntityIds to prevent swapping.

* refactor(sync): single OR lookup for entity conflict detection #8334

Third multi-review follow-up. Revert detectConflictForEntity from the two ordered
findFirst lookups back to a single Prisma OR [{entityId}, {entityIds:{has}}]. The
two-query split optimized the OR's BitmapOr+sort, but that is bounded by op-log
pruning (sub-ms in practice) while the split added a guaranteed extra round-trip on
the common single-entity path (doubled by the FIX-1.5 re-check) — a net-negative for
the median. The OR is simpler, fully typed/testable, and likely faster in aggregate;
a code comment documents the split as the escalation if a real-PG EXPLAIN ever shows
the OR is a problem.

Review polish (no behaviour change): correct the array-branch/getStoredEntityIds
comments (a GIN @> is match-all-then-sort, not an ordered walk; multi-entity-only
storage's win is GIN size + keeping single-entity inserts off the GIN, not sort
depth); note the batch unnest paths as the first EXPLAIN candidates under load; keep
the two batch queries' CASE/prefilter SQL inline (a shared fragment would shift the
positional params the conflict-detection.spec mock relies on) with a keep-in-sync
note; replace a non-ASCII <= in a client-facing validation error string.

* test(sync): update db mocks for OR + prefetch entity_ids lookups #8334

Running the full super-sync-server suite (with a generated Prisma client) surfaced
two specs whose inline db mocks hadn't tracked the new conflict-detection SQL:

- time-tracking-operations.spec: findFirst now models the single-entity lookup's
  OR: [{entityId}, {entityIds:{has}}] shape (it previously only matched the scalar
  entityId, so a concurrent single-entity update was wrongly "accepted").
- sync.service.spec: the prefetch $queryRaw mock parsed userId as the last param and
  flattened all params into the touched pairs; the #8334 prefilter adds idArray
  params after userId, so it now finds userId by type and reads the touched pairs
  from the VALUES join fragment only.

Production code unchanged; these are test-mock fidelity fixes. Full suite: 800 passing.
2026-06-13 15:40:10 +02:00
..
migrations fix(sync): conflict-check all entities of multi-entity ops (#8334) (#8377) 2026-06-13 15:40:10 +02:00
schema.prisma fix(sync): conflict-check all entities of multi-entity ops (#8334) (#8377) 2026-06-13 15:40:10 +02:00