Commit graph

127 commits

Author SHA1 Message Date
Johannes Millan
85c7543f49 fix(supersync): dedupe WS connections by clientId to evict stale sockets
A reconnecting device kept appending alongside its stale entry instead of
replacing it, so userSet would fill to MAX_CONNECTIONS_PER_USER and reject
legitimate reconnects with 4008 until the 30s+10s heartbeat cycle caught up.
addConnection now evicts any existing entry with the same clientId before
adding the new socket; the cap still applies to genuinely distinct clientIds.
2026-05-13 19:48:43 +02:00
Johannes Millan
3a56d9595b fix(supersync): guard snapshot retry idempotency lookup by userId
The DUPLICATE_OPERATION->success conversion in the snapshot route looked
up the existing op by primary key alone. `isSameDuplicateOperation`
already enforces ownership upstream, so this can't be tripped today, but
keeping the userId filter on the route's own lookup keeps the
idempotency conversion correct even if that upstream invariant ever
changes. Switches `findUnique({id})` to `findFirst({id, userId})`;
`id` is still the primary key so the plan is unchanged.
2026-05-13 17:29:43 +02:00
Johannes Millan
40ebce57a8 fix(supersync): aggregate prior clocks for full-state ops and deterministic retry lookup
Two correctness fixes on top of the persisted-full-state-clock optimization.

1. Persist the aggregate of prior history (merged with the snapshot op's own
   clock) instead of just the snapshot op's clock. BACKUP_IMPORT uses a fresh
   `{ newClient: 1 }` clock by design, so storing it raw left downloading
   clients with a baseline that did not cover pre-import ops still alive in
   the server-side conflict-detection set; their first post-restore edit could
   be rejected as CONCURRENT. The aggregate scan moves from per-download to
   per-snapshot, which is a net win — full-state ops are rare.

2. Look up the SYNC_IMPORT idempotency target by exact opId first, then fall
   back to an ordered (`serverSeq DESC`) findFirst. Postgres ordering is
   undefined without ORDER BY, so the previous `findFirst` could non-
   deterministically return a different full-state op (e.g. a later
   BACKUP_IMPORT) and flip the idempotency check incorrectly.
2026-05-13 17:01:39 +02:00
Johannes Millan
4f15df8f40 feat(supersync): persist full-state vector clock and add snapshot retry idempotency
Two related improvements to snapshot/full-state handling:

- Persist the incoming op's vector clock as `user_sync_state.latest_full_state_vector_clock`
  so downloads can skip the expensive aggregate scan when the snapshot clock is
  still valid; falls back to the legacy aggregate when missing or invalid.
- Treat retried snapshot uploads (same opId) as idempotent successes instead of
  409/DUPLICATE_OPERATION so a dropped response doesn't push clients into the
  download-and-merge path. Per-namespace request dedup keeps ops and snapshot
  caches isolated.
2026-05-13 17:01:39 +02:00
Johannes Millan
25d2407e70 fix(supersync): mark for reconcile on first successful cleanup batch
Previously, the storage-counter reconcile marker was set only after the
per-user drain loop finished. With the new multi-batch flow, a transient
DB failure between batch 1 and batch 2 commits batch 1's deletes but
exits before the marker call — leaving the counter stale-high until the
next daily pass or process restart, instead of self-healing on the next
sync request.

Move the marker (and affectedUserIds push) to fire after the first
successful batch so any survivor deletes are reconciled. Cover with a
test that spies the private batch helper to throw on the second call
and asserts the user is still marked.
2026-05-13 14:11:15 +02:00
Johannes Millan
15a5b8d91e perf(supersync): throttle daily old-ops cleanup so it cannot stall the server
Daily retention cleanup previously issued one unbounded `deleteMany` per user
that covered every operation older than the retention cutoff. On large
backlogs this monopolized Postgres and made user-facing sync slow during the
window, which matched the production "sync is still slow" reports.

- Cap per-batch deletes (default 5k) and per-run total deletes (default 25k),
  with a select-then-delete-by-id pattern that keeps each statement short.
- Drain each user across batches before moving on so a fresh user with a tiny
  backlog isn't blocked behind a large one until next pass.
- Order candidates by `snapshotAt asc` so the stalest users win the budget
  when it's tight.
- Defer the initial cleanup pass after startup to 30 min in production (was
  10s) so retention work doesn't compete with deploy/restart warmup; call
  `unref()` on cleanup timers so they never hold the process open.
- Expose all knobs via env vars wired through docker-compose, env examples,
  and the Helm chart, with a strict positive-integer parser and clamped
  maxima so a misconfiguration cannot unwind the bound.
2026-05-13 14:11:15 +02:00
Johannes Millan
8dd188b054 fix(sync): harden SuperSync downloads and websocket connects 2026-05-13 14:11:15 +02:00
Johannes Millan
47ce4b304c perf(super-sync): optimize status and conflict checks 2026-05-13 11:40:43 +02:00
johannesjo
ca660a0585 fix(supersync): harden production server deploy 2026-05-13 02:10:46 +02:00
johannesjo
30da81ea5a fix supersync retry idempotency 2026-05-13 00:14:35 +02:00
Johannes Millan
88961485fd fix(supersync): harden deploy database migration 2026-05-12 23:24:13 +02:00
Johannes Millan
e0cc783bba Merge branch 'master' into feat/carefully-review-all-super-sync-related-9daf6d
* master:
  fix(sync): restore InvalidFilePrefixError privacy log at app-shim site
  test(sync-providers): restore WebDAV parser namespace + server-format specs
  refactor(sync-providers): @xmldom/xmldom to devDeps + global DOMParser
  refactor(sync-providers): broaden WebDAV CORS heuristic for real browsers
  refactor(sync-providers): testWebdavConnection helper, drop adapter+api from barrel
  refactor(sync-providers): tighten WebDAV slice after multi-review
  refactor(sync-providers): move WebDAV + Nextcloud providers into package
  docs(sync-core-plan): fold Gemini review dissent into consensus
  refactor(sync-providers): promote shared log helpers
  docs(sync-core-plan): record WebDAV slice multi-review consensus
  docs(sync-core-plan): draft WebDAV slice design for multi-review
  docs(sync-core-plan): summarize Fifth Slice and renumber remaining
  fix(sync): harden supersync server edge cases
  fix reviewed calendar and duplicate edge cases
  test(electron): align TODAY filter spec with isTodayWithOffset impl
  refactor(sync-providers): tighten Dropbox port after review
  refactor(sync-providers): move Dropbox provider into package
  refactor(sync-providers): move provider error classes

# Conflicts:
#	packages/super-sync-server/src/sync/services/operation-download.service.ts
#	packages/super-sync-server/src/sync/sync.routes.ts
2026-05-12 22:35:37 +02:00
Johannes Millan
29da578760 fix(supersync): order cleanup reconcile stalest-first and add C1 regression tests
S1: replace the Fisher-Yates random shuffle in cleanup.ts with a
deterministic orderBy snapshotAt asc at the source. When the affected
user list exceeds the reconcile budget, the most-drifted users are
reconciled first; the freshest tail rolls over to the next pass. No
more Math.random, no probabilistic starvation, and the test stub for
Math.random goes away.

C1 regression tests: add coverage that proves the prototype-pollution
guard works for CRT, UPD, MOV, BATCH single-entity, and BATCH entities
map. Each test asserts state.TASK's prototype is still Object.prototype
and the malicious payload doesn't leak via the prototype chain.
2026-05-12 22:25:18 +02:00
Johannes Millan
5438b87c85 fix(supersync): address multi-review findings (C1, W2, W4, W9, W11, S6)
C1: guard CRT/UPD/MOV/BATCH (and BATCH-entities) replay against
__proto__/constructor/prototype entityIds — bracket-assignment to those
keys invokes the __proto__ setter and swaps the entity map's prototype.
Shares the new isUnsafeEntityKey helper with the full-state branch.

W2: stop deleting storageUsageLocks[userId] in clearForUser. The
identity-guarded finally in runWithStorageUsageLock self-deletes when
the chain drains, and removing the head while a follower is queued lets
a fresh caller see no previous and race the in-flight chain. Replaces
the deprecated assertion with a race regression test.

W4: when _generateSnapshotImpl's regenerated blob exceeds the hard
MAX_SNAPSHOT_SIZE_BYTES ceiling, mirror cacheSnapshot's clear-stale
logic via a race-safe updateMany. Without this an oversize regen left
a poisoned older blob serving GET /snapshot.

W9: reject layered or non-gzip Content-Encoding values with 415 in the
content-type parser instead of falling through to JSON.parse on raw
gzip bytes (which produced a misleading 400 invalid-json).

W11: computeOpStorageBytes now returns { bytes, fallback }; route gate
and uploadOps both Logger.warn the count of unserializable ops (never
the content) so the previously-lost observability is back.

S6: drop the no-op Math.max around estimatedOpStorageBytes for
uncacheable snapshots — stateBytes is always > MAX_SNAPSHOT_SIZE_BYTES
when \!cacheable, so the floor never engaged.
2026-05-12 22:22:14 +02:00
Johannes Millan
0286804fd3 test(supersync): tighten prototype-pollution test invariants
The previous SYNC_IMPORT prototype-pollution regression test asserted
`Object.prototype.polluted === undefined` and `({}).polluted === undefined`.
Both assertions are tautological: `Object.assign({}, {__proto__: x})` uses
[[Set]] on the target, which triggers the `__proto__` setter only on the
target itself — it swaps `state`'s prototype reference, but does NOT
pollute the global `Object.prototype`. The old test therefore passed even
against the buggy implementation it was meant to catch.

Assert the real invariant instead:
- `Object.getPrototypeOf(result) === Object.prototype` (state's prototype
  was not swapped — this is what flips under the buggy code).
- `result.polluted` is undefined via the prototype chain.
- `__proto__` is not an own property on the result.

Verified the new assertions fail against `Object.assign(state, fullState)`
and pass against the current key-by-key copy with a __proto__/constructor/
prototype skip list. Kept the global-pollution checks as defense in depth
and the `finally` cleanup in case of future regressions.
2026-05-12 22:06:37 +02:00
Johannes Millan
63b68b5041 Merge branch 'feat/carefully-review-all-super-sync-related-b22f81'
* feat/carefully-review-all-super-sync-related-b22f81:
  fix(sync): harden supersync server edge cases
2026-05-12 21:55:30 +02:00
Johannes Millan
948d9c7db3 fix(sync): harden supersync server edge cases 2026-05-12 21:53:49 +02:00
Johannes Millan
30a0809d84 fix(supersync): tighten routes — close quota double-count, snapshot gate, rate-limit, bomb-cap, content-encoding
B3: remove now-redundant post-commit `applyStorageUsageDelta` writes in
the routes. Wave-1 commit 9af17e460e moved the storage-counter write
INTO `uploadOps`'s `$transaction`, so the route adding a second delta
would double-count every accepted op. /ops drops the post-commit
increment entirely; POST /snapshot now only writes `cacheResult.deltaBytes`
(the snapshot-cache portion). The route's `computeOpsStorageBytes` now
delegates to the shared `computeOpStorageBytes` helper in `sync.const.ts`
to guarantee gate-vs-increment parity.

B4: when `preparedSnapshot.cacheable === false`, do not subtract
`previousBytes` from the gate, and use `Math.max(stateBytes,
MAX_SNAPSHOT_SIZE_BYTES)` as the gate value so the worst-case op-row
write cannot slip past on a raw-JSON estimate that doesn't capture
JSONB/TOAST overhead.

B5: GET /snapshot now passes a `maxCacheBytes` cap (the user's remaining
quota) down through `generateSnapshot`. When the freshly compressed
snapshot would exceed the cap, the cache is NOT written but the
in-memory snapshot is still returned to the caller. A user already at
quota can still catch up reads without growing on-disk storage further.
Adds a regression test in `snapshot.service.spec.ts`.

B8: add `rateLimit: { max: 10, timeWindow: '15 minutes' }` to POST
/snapshot to match the other write-heavy operations (DELETE /data,
/restore/:seq). Snapshot uploads are expensive (30MB body,
`prepareSnapshotCache` zlib + JSON.stringify, full-state op replay)
so burst-uploads from one user can pin a worker.

B9: split `MAX_DECOMPRESSED_SIZE` into per-endpoint caps
(30 MB for /ops, 60 MB for /snapshot) so a 10 MB compressed body cannot
expand to 100 MB of JSON.parse work and stall the event loop.

B10: normalize `Content-Encoding` via a shared `isSingleTokenGzipEncoding`
helper so RFC-valid values like `Gzip`, ` gzip `, or `['gzip']` match.
Layered encodings (`gzip, deflate`) are rejected. Replaces all three
strict `=== 'gzip'` comparisons in `sync.routes.ts`.

Updates `sync-compressed-body.routes.spec.ts` for the new post-commit
delta contract: the snapshot route only writes the cache portion now;
the op-row portion is owned by `uploadOps`'s transaction.
2026-05-12 21:49:50 +02:00
Johannes Millan
7d654d0be1 fix(supersync): align snapshot service spec mocks with upsert
Two negative-path generateSnapshot tests declared `update` on the mock tx
and asserted `expect(update).not.toHaveBeenCalled()`. Production calls
`tx.userSyncState.upsert` (not update) inside `_generateSnapshotImpl`, so
the assertion was tautological — `update` is never invoked regardless of
whether the rejection guard ran. Renaming to `upsert` makes the
"no cache write happened" assertion meaningful again.

The module-level `prisma.userSyncState.update` mock stays — production
still calls `update` from `_invalidateCachedSnapshot`, exercised by the
decompression-error test.
2026-05-12 21:36:54 +02:00
Johannes Millan
9af17e460e fix(supersync): sanitize errors, exact full-state quota, atomic counter
- B7: replace leaky `Transaction rolled back: <raw Prisma msg>` per-op
  error with a generic 'Transaction failed - please retry'. The raw
  exception text is still logged via `Logger.error`, but never returned
  to the client (could leak SQL fragments, FK / column names).
- B6: in `deleteOldestRestorePointAndOps`, measure the exact bytes for
  the deleted full-state op (SYNC_IMPORT / BACKUP_IMPORT / REPAIR) row
  via `pg_column_size` BEFORE deletion. Bounded to 0-1 rows per call,
  so it doesn't reintroduce the DoS from scanning every delta op. Delta
  ops keep the cheap `count * APPROX_BYTES_PER_OP` approximation;
  full-state payloads (up to 20MB) no longer undercount by ~20000x and
  leave the counter permanently low if reconcile fails. Docstring on
  APPROX_BYTES_PER_OP clarifies it is ONLY valid for delta cleanup.
- I6: inside `deleteOldSyncedOpsForAllUsers`, call
  `storageQuotaService.markNeedsReconcile(userId)` for each user whose
  cleanup deleted rows. A crash mid-loop now leaves surviving users
  with a forced-reconcile marker so their next request self-heals,
  instead of waiting for the daily pass. TODO left for persisting the
  marker in a DB column for cross-restart / multi-instance safety.
- B3 (service side): write `users.storage_used_bytes` atomically
  inside the upload `$transaction` using `$executeRaw` with the same
  GREATEST(...) clamp pattern as `storage-quota.service.ts`. Shared
  `computeOpStorageBytes` helper keeps the gate and the increment in
  agreement about per-op size. Clean-slate path SETs the counter to
  the new total rather than incrementing onto the just-reset row.
- B2 companion: add `RequestDeduplicationService.clearForUser(userId)`
  and call it from both `deleteAllUserData` and the uploadOps
  clean-slate path so a retry from before the wipe cannot return
  cached results that reference now-deleted state.
2026-05-12 21:36:49 +02:00
Johannes Millan
9764502f86 fix(supersync): use effectiveSinceSeq for Case-2 gap detection
The minSeq-based gap check compared the raw sinceSeq, so a client
requesting from before retention purge would get gapDetected=true
even when the snapshot already supersedes that purged history. Case 3
already used effectiveSinceSeq; this aligns Case 2 so the snapshot
fast-forward suppresses the false positive and avoids triggering an
unnecessary full restore on the client.
2026-05-12 21:36:45 +02:00
Johannes Millan
a2bbe5d0f5 fix(supersync): randomize cleanup reconcile order to prevent user starvation
The deferred-reconcile budget caps at 720 users per pass
(RECONCILE_BUDGET_MS / RECONCILE_INTERVAL_MS = 3.6M / 5K). With a stable
DB iteration order, the first 720 affectedUserIds got reconciled every
daily cleanup and any tail beyond that drifted indefinitely.

Shuffle the affected-user list with Fisher-Yates before slicing the
budget window so each pass picks a different random subset, and include
both numbers in the warn log so operators can spot servers that have
outgrown the per-pass budget.
2026-05-12 21:36:44 +02:00
Johannes Millan
b7332238a7 fix(supersync): clear all per-user quota state on user wipe
clearForUser now also clears forcedReconciles (would force a spurious
extra scan on the next quota check) and storageUsageLocks (orphaned
queued lock chain) alongside the existing inflightReconciles cleanup.

Also document the known byte-counting mismatch between pg_column_size
(TOAST-compressed) and the hot-path Buffer.byteLength(JSON.stringify)
in calculateStorageUsage. The proper fix needs a payload_bytes column
populated at insert time; in-place switching to octet_length(text)
would re-detoast every row and resurrect the disk-I/O DoS.
2026-05-12 21:36:41 +02:00
Johannes Millan
b4812a2b30 fix reviewed calendar and duplicate edge cases 2026-05-12 21:34:02 +02:00
Johannes Millan
99fc98a1ab fix(supersync): harden snapshot cache writes and replay against races and prototype pollution
Address three review findings on snapshot.service.ts:

- B1: _generateSnapshotImpl no longer unconditionally upserts the cache.
  A concurrent upload that bumped lastSnapshotSeq beyond our latestSeq
  could be clobbered. Switched to the same race-safe updateMany+create
  pattern as cacheSnapshot, and pin cacheDelta to 0 when the race is
  lost so the onCacheDelta hook does not over-credit storage.

- I2: Always run _assertCachedSnapshotBaseReplayable before serving a
  cached snapshot, including the startSeq>=latestSeq fast path.
  A legacy build that failed to reject encrypted ops could have
  produced a poisoned cache that the fast path would serve forever.

- I4: replayOpsToState no longer uses Object.assign for full-state op
  payloads. A malicious SYNC_IMPORT/BACKUP_IMPORT/REPAIR payload with
  a __proto__ key would pollute Object.prototype via the [[Set]]
  semantics of Object.assign. Copy keys explicitly and skip
  __proto__/constructor/prototype.

I3 (RepeatableRead transaction spans gzip/replay/gzip and can starve
the DB pool under load) is acknowledged but left for a focused PR —
restructuring requires moving the cache write outside the transaction
and updating multiple integration tests.

Updated unit-test mocks to provide updateMany/create on the
transaction client, and added regression coverage for the prototype-
pollution and fast-path-validation cases.
2026-05-12 21:33:38 +02:00
Johannes Millan
40c354d4ef fix(supersync): scrub merge debris and align deploy defaults
- B11: Remove orphan deploy-db-scalar.mjs and dead RUN_POST_MIGRATION_INDEXES
  references in env.example and README. Rewrite README deploy section to
  match what scripts/deploy.sh actually does (drop false claims about
  empty migration SQL, failed-migration recovery, and optional index builds).
- B12: Default RUN_MIGRATIONS_ON_STARTUP to false in docker-compose.yml so
  the compose, Dockerfile ENV, and env.example agree on the safer default
  (manual deploy.sh runs migrations once before app restart). Update the
  migration-sql.spec assertion to match.
- I9: Wrap prisma migrate deploy in deploy.sh with a MIGRATION_TIMEOUT
  (default 900s). CREATE INDEX CONCURRENTLY can block on long-running
  transactions; on timeout (exit 124) the script now fails loudly with a
  clear error rather than hanging.
2026-05-12 21:26:32 +02:00
Johannes Millan
99f5dbb259 Merge branch 'feat/our-latest-super-sync-server-update'
* feat/our-latest-super-sync-server-update-c362d8:
  test(snapshot): switch FIX 1.7 mock to upsert to match service
  fix(supersync): address review findings on snapshot replay and body parsing
  fix(supersync): harden snapshot replay and quota accounting
  fix(sync): reject snapshots backed by encrypted ops
  fix(electron): respect TODAY_TAG virtual membership in local REST filter
  fix(supersync): harden snapshot replay and bound op queries
  fix(supersync): tighten optional index checks
  fix(supersync): resolve deploy helper imports
  fix(supersync): harden deploy recovery
  fix supersync external db recovery
  fix supersync deploy migrations

# Conflicts:
#	packages/super-sync-server/src/sync/services/snapshot.service.ts
#	packages/super-sync-server/src/sync/sync.routes.ts
#	packages/super-sync-server/src/sync/sync.service.ts
#	packages/super-sync-server/tests/snapshot.service.spec.ts
#	packages/super-sync-server/tests/sync-compressed-body.routes.spec.ts
2026-05-12 21:03:03 +02:00
Johannes Millan
9d4012d14e test(snapshot): switch FIX 1.7 mock to upsert to match service 2026-05-12 20:40:51 +02:00
Johannes Millan
fb940c1d4a Merge branch 'master' into feat/our-latest-super-sync-server-update
Resolves conflicts in supersync after master's parallel work:
- Schema: adopt master's 4-col entity_sequence_index + partial restore-point
  index migration. Drops our out-of-band index hack — master's migration runs
  CREATE INDEX CONCURRENTLY directly.
- deploy.sh: take master's simpler 169-line version; keep our hardening
  (set -euo pipefail, guarded ${VAR:-}, diagnostic pipeline || true).
- snapshot.service.ts: keep our EncryptedOpsNotSupportedError, cache stale-
  overwrite guard (updateMany+create+P2002), invalidate corrupt cache,
  async gzip helpers, _resolveExpectedFirstSeq leading-gap acceptance,
  full-state reset semantics, Buffer.byteLength replay size check. Adopt
  master's REPLAY_OPERATION_SELECT, ReplayOperationRow type, and
  assertContiguousReplayBatch module-level helper.
- compressed-body-parser.ts: keep our invalid-json reason, single base64
  decode, and shared gzip.ts helper. Adopt master's improved
  isDecompressedPayloadTooLargeError check (covers 'Cannot create a Buffer
  larger than').
- operation-download.service.ts: take master's latestSeq==0 fast-path and
  conditional minSeq aggregate.
- local-rest-api-handler.service.ts: take master's shared
  isTaskInToday + isTodayWithOffset (offset-aware) over our private method.
- tests: align spec mocks with upsert + cacheSnapshotIfReplayable +
  REPLAY_OPERATION_SELECT, take master's migration-sql.spec.ts.

587/587 supersync tests pass; checkFile clean on all touched .ts files.
2026-05-12 20:40:26 +02:00
Johannes Millan
d1918b342b fix(sync): close quota deadlock and counter drift from multi-review
Deadlock: updateStorageUsage now bypasses the inflightReconciles dedupe
when already inside the per-user lock, so a reentrant caller cannot
await a non-reentrant promise queued behind the lock it holds.

Counter drift: GET /snapshot's cache rewrite feeds its byte delta into
the storage-usage counter via an onCacheDelta hook; failed counter
writes mark the user for forced reconcile on next quota check;
oversized snapshots clear the stale row and credit the freed bytes.

Event loop: zlib gzip/gunzip moved off the synchronous fast path so
large snapshots stop blocking other tenants.

Hardening: /ops bodyLimit set to MAX_COMPRESSED_SIZE_OPS; snapshot
pre-gate reconciles before rejecting a stale-high counter; all 413
responses funnel through one helper using errorCode consistently;
reconcileTimers leak fixed by self-removing fired timers.

Includes regression tests for each new behavior.
2026-05-12 20:33:44 +02:00
Johannes Millan
9b965f2c35 fix(supersync): address review findings on snapshot replay and body parsing
- Use upsert for userSyncState to avoid RecordNotFound on first-time users
- Reset (not merge) state on full-state ops; throw on malformed payloads
- Add typed EncryptedOpsNotSupportedError; stop leaking op count to clients
- Split JSON.parse vs decompress error paths (new invalid-json reason)
- Measure base64 transport size against decoded binary; decode once
- Guard concurrent cacheSnapshot writes against stale overwrite
- Move encryption-skip policy from route into cacheSnapshotIfReplayable
- Invalidate corrupt cached snapshot blobs instead of re-logging forever
- Use Buffer.byteLength for replay state size (was UTF-16 vs byte limit)
- Skip cache-replayability assert on the cache-hit fast-path
- Extract async gunzip/gzip into gzip.ts; stop blocking the event loop
- Replace inline tx structural types with Prisma.TransactionClient
- deploy.sh: set -euo pipefail with guarded vars and pipeline-safe diagnostics
2026-05-12 20:22:24 +02:00
Johannes Millan
0cee968048 Merge branch 'master' into feat/debug-why-our-production-super-sync-3795e1
* master:
  feat(sync): extract sync-core ports and fix replay date preservation
  docs(sync-core-extraction): add PR 7 optional polish section
  docs(sync-providers): plan remaining p5 slices
  refactor(sync-providers): move pkce helper
  refactor(sync-providers): move file sync envelope types
  refactor(sync-providers): scaffold provider package
  test(sync): scope keep-local validation assertion
  feat(e2e): add keyboard, mobile, and shorts video variants
  Optimize super sync server paths
  refactor(sync-core): prepare core boundary for providers
  test: repair 5 stale spec assertions
  style(calendar-integration): fix prettier formatting in regex filter spec
  fix(electron): support today filter in local REST API
  refactor(sync): sanitize invalid menu node logging
  refactor(sync): address extraction review findings
  refactor(sync): sanitize validation failure logging
  refactor(sync): sanitize menu tree repair logging
  refactor(sync): sanitize typia auto-fix logging
  refactor(sync): prove ui and config port adapters
  refactor(sync-core): add ui and config port contracts

# Conflicts:
#	packages/super-sync-server/src/sync/sync.routes.ts
#	packages/super-sync-server/tests/sync-compressed-body.routes.spec.ts
#	src/app/features/config/global-config.service.spec.ts
2026-05-12 19:51:48 +02:00
Johannes Millan
274385145b fix(supersync): harden snapshot replay and quota accounting
- Accept leading-gap replays only when first surviving op is a full-state op
  (SYNC_IMPORT / BACKUP_IMPORT / REPAIR); fixes SNAPSHOT_REPLAY_INCOMPLETE after
  clean-slate uploads and retention pruning. Mid-stream gaps still throw.
- Snapshot transactions now run at RepeatableRead so encrypted-op guards
  cannot be raced by a concurrent writer.
- Restore getParsedBodySizeBytes for storage-quota accounting: JSON.stringify
  .length counts UTF-16 code units and re-serializes the payload; prefer the
  parser-reported decompressed byte count, then Content-Length, then a
  Buffer.byteLength fallback.
- Extend in-memory tx mocks with count + opType-aware findFirst so the new
  encrypted-op guards exercise cleanly under vitest.
2026-05-12 19:31:20 +02:00
Johannes Millan
66893e837d fix(sync): reject snapshots backed by encrypted ops 2026-05-12 19:18:43 +02:00
Johannes Millan
f5a6235cbb fix supersync storage quota races 2026-05-12 19:17:20 +02:00
Johannes Millan
a1dd293968 fix(supersync): harden snapshot replay and bound op queries
- Snapshot replay throws on gaps, max-ops overflow, and encrypted ops
  instead of silently truncating state.
- Operation download bounds all queries by userSyncState.lastSeq.
- Compressed body parser enforces maxOutputLength to reject zip bombs.
2026-05-12 19:03:46 +02:00
Johannes Millan
76eee862dd perf(sync): dedupe concurrent storage-usage reconciles per user
`updateStorageUsage` runs a SUM(pg_column_size) scan that TOAST-detoasts
every operation row for a user — slow under load. When several concurrent
requests for the same user near quota hit the cache-miss path in
`enforceStorageQuota`, each independently triggered its own full scan,
multiplying disk I/O.

Add an in-flight promise map keyed by userId so concurrent callers share
a single scan; the lock is released in `finally` so sequential callers
(notably the iterative reconcile inside `freeStorageForUpload`) still
see fresh results. Wire the new `clearForUser` into the clean-slate
cleanup path next to the existing rate-limit and snapshot lock clears.

Adds tests covering: concurrent dedupe, sequential refresh, lock release
on scan failure, and `clearForUser` no-op.
2026-05-12 18:21:17 +02:00
Johannes Millan
b30b6d5a6a test(sync): mock $executeRaw + $queryRaw in cleanup specs
decrementStorageUsage now runs $executeRaw with a clamped UPDATE
(storage-quota.service.ts), so cleanup tests that exercise
deleteOldSyncedOpsForAllUsers crashed with "prisma.$executeRaw is not a
function". Add the missing prisma mock entries to keep the cleanup tests
green.
2026-05-12 18:18:30 +02:00
Johannes Millan
d18515598e fix(supersync): tighten optional index checks 2026-05-12 18:10:30 +02:00
Johannes Millan
c429086312 fix(sync): tighten supersync storage quota accounting 2026-05-12 18:07:05 +02:00
Johannes Millan
37891c4d9e fix(supersync): resolve deploy helper imports 2026-05-12 17:19:49 +02:00
Johannes Millan
212e6d6e4a fix(supersync): harden deploy recovery 2026-05-12 17:12:53 +02:00
Johannes Millan
aee0b331c5 Optimize super sync server paths 2026-05-12 16:57:28 +02:00
Johannes Millan
43c10ed73b refactor(sync): polish storage delta tracking and update tests
Picked up the remaining items from the multi-agent review:

- Hoist Operation / ServerOperation imports in sync.routes.ts so the
  upload path no longer needs `as unknown as import('./sync.types').*`
  double casts. The Set + filter pattern for accepted ops becomes an
  index-zip loop, dropping two iterations + the Set allocation.

- Pull APPROX_BYTES_PER_OP out of two inline locals in sync.service.ts
  into sync.const.ts so the constant is a single source of truth and
  the trade-off (over-estimate vs observed median op size) is documented
  in one place.

- Log when computeOpsStorageBytes silently skips an op whose payload
  fails JSON.stringify (BigInt, circular refs). Counter still
  under-counts but the cause is visible in logs.

- Update storage-quota docstrings to reflect that calculateStorageUsage /
  updateStorageUsage are now legitimately called from the
  freeStorageForUpload reconcile path (rare cleanup events), not only
  offline.

Tests:

- cleanup.spec.ts: assert updateStorageUsage is NOT called after
  deleteOldSyncedOpsForAllUsers (the per-user SUM loop was the DoS).
- storage-quota-cleanup.spec.ts: drop the assertion that
  deleteOldestRestorePointAndOps issues a pg_column_size query (it does
  not anymore); add a $executeRaw mock that mutates testUsers so the
  iterative-cleanup test exercises the real decrement path; tune
  starting storage to within a few KB of quota so cleanup's
  approximate decrement can detect progress within a few iterations.
- storage-quota.service.spec.ts: add the $executeRaw prisma mock and
  unit tests for incrementStorageUsage / decrementStorageUsage
  (positive path, Math.floor of non-integer deltas, no-op on
  NaN/Infinity/zero/negative, clamped UPDATE for decrement).
2026-05-12 16:46:38 +02:00
Johannes Millan
6201b8493d fix supersync external db recovery 2026-05-12 16:31:03 +02:00
Johannes Millan
6af85b6892 fix supersync deploy migrations 2026-05-12 15:39:26 +02:00
johannesjo
2a802bfc72 perf(sync): speed up SuperSync uploads 2026-05-12 00:37:00 +02:00
Johannes Millan
7b34820b74 refactor low-risk sync cleanup 2026-05-09 18:11:40 +02:00
Johannes Millan
68db1ab229 test(sync): add SECTION to ALLOWED_ENTITY_TYPES expectations 2026-04-29 18:06:15 +02:00
Johannes Millan
0d5a562aba test(sync-server): freeze clock in 1ms-over-drift clamp test
The "just 1ms over max clock drift boundary" test captured Date.now()
once in the test and again inside uploadOps. If the second sample
advanced by >=1ms, the op was within the drift window from the
service's perspective, clamping never fired, and toBeLessThan failed
with equal values.

Freeze time with vi.useFakeTimers/setSystemTime so both samples
match, and tighten the assertion to the exact clamped value.
2026-04-21 15:03:41 +02:00