Commit graph

8 commits

Author SHA1 Message Date
Wavesonics
630dc1df30 Add integration coverage for server-originated entities
Proves the sync mechanics the editorial-review feature relies on:
server-minted drafts download to clean clients, offline ID collisions
re-ID via the existing client machinery, server-side scene rewrites
auto-download or surface the standard conflict.
2026-06-11 23:44:58 -07:00
Adam Brown
a51abd58fa
Fix phantom sync conflicts: lock the baseline + migration backfill + e2e sync tests (#582)
* Lock the sync baseline to the server-confirmed hash

Editing a scene through the editor stamps `lastEdited` (an autosave side
effect), and the sync conflict baseline was being re-derived from local
state at mark-dirty time. Because `lastEdited` had already moved, the
recorded baseline disagreed with what the server stored — so a single
client editing its own scene and resyncing raised a phantom conflict on
every sync, with no other device involved.

Stop re-deriving the baseline. The conflict baseline (`originalHash`) is
now the hash the server last confirmed it holds: recorded only on a
successful transfer (the exact bytes uploaded or downloaded) and read
back when an entity next goes dirty. A field that mutates locally after
a sync can no longer taint the baseline.

- ProjectSynchronizationData gains `syncedHashes` (per-entity confirmed
  hash). EntityOriginalState.originalHash is nullable: null means the
  server never confirmed a hash (new entity, or first edit after upgrade)
  so the server skips the conflict check and the baseline self-heals on
  that upload.
- SyncJournal.markEntityAsDirty reads the baseline from syncedHashes
  rather than taking a recomputed hash; recordSyncedHash sets it.
- EntitySynchronizer.uploadEntity reports the accepted hash via a
  callback; the transfer records it on upload and download.
- The upload sources `originalHash` from syncedHashes at transfer time,
  not the dirty entry's frozen copy — so a partial sync (some entities
  uploaded, a later one failed) can't leave a stale baseline that forges
  a phantom conflict on retry.
- FinalizeSync preserves syncedHashes written mid-transfer and prunes
  deleted ids (including server-driven deletions) from the map.
- Repositories no longer recompute an entity hash to mark for sync.

Adds an integration regression test: a single client editing a scene
through the editor and resyncing must not raise a conflict (red before
this change, green after).

* Add e2e sync resync scenarios and stabilize the integration harness

High-value round-trip coverage for the sync baseline beyond the
content-edit regression, plus the test-harness fixes the new tests
needed to be reliable.

Tests:
- SyncHashStabilityTest: after a sync, the client's hash for a scene
  equals the hash the server stored (baseline and server agree).
- ResyncBaselineScenariosTest: a single client must never raise a
  phantom conflict on resync regardless of how the baseline was set —
  metadata-only edit, rename, and an edit after a download establishes
  the baseline. Each asserts the server's stored hash actually changed,
  so a resync that uploaded nothing can't pass vacuously.

Harness (RoundTripTestBase):
- Bind the client IO/Default dispatchers to the same single-threaded
  dispatcher as Main. FakeFileSystem is not thread-safe (its open-files
  list is a plain MutableList), so the real multi-threaded dispatchers
  let concurrent opens across the repos' background scopes corrupt it,
  throwing intermittently from findOpenFile — the root of the suite's
  order-dependent flakiness.
- newClient() registers each client for teardown-close, so a test that
  throws before its own cleanup can't leak a project scope (and its
  background coroutine scopes) into the next test.
- Shared syncNoConflict()/serverEntityHash() helpers replace per-file
  copies.
- Close the per-test HttpClient in EndToEndTest.tearDown; it was created
  every test and never closed, leaking engine threads.

* Add per-entity-type sync resync matrix

The lock-the-baseline fix lives in the shared sync layer, so it must
hold for every entity type. EntityTypeResyncMatrixTest exercises
create → sync → edit → resync (assert no phantom conflict) for notes,
timeline events, and encyclopedia entries; a create → sync → resync for
immutable scene drafts; and a create → sync → delete → resync that
confirms the entity is removed from the server (covering the deletion
path and the synced-hash pruning).

* Fix timeline store race on sync finalize

TimeLineRepository.storeTimeline() read timelineFlow.replayCache.first(),
which throws NoSuchElementException when the timeline was never loaded.
finalizeSync runs it on every sync — including syncs of a project that
never touched the timeline — so whether the async timeline load had
emitted yet was a race that could fail the whole sync. Guard with
firstOrNull (nothing loaded means nothing to store), matching the
pattern correctEventOrder already uses. Surfaced as order-dependent
flakiness once the e2e suite grew.

* Add sync fuzz/property test

A single client driving a seeded random interleaving of editor content
edits, metadata edits, renames, and syncs must never raise a conflict —
there is no other device, so any conflict is a phantom. Catches ordering
edge cases the hand-written scenarios miss (a sync landing between a
content edit and its debounced autosave, a rename between edits). Seeds
are fixed and printed on failure so a failing sequence can be replayed.

* Backfill sync baselines on the first post-upgrade sync

A client upgrading to the lock-the-baseline version has an empty
syncedHashes map — the field did not exist in the old sync.json. With no
baseline a dirty entity uploads with originalHash = null, and the server
skips the conflict check (ServerEntitySynchronizer only conflicts when
originalHash != null), silently overwriting a concurrent edit from
another device.

FetchLocalData now establishes a baseline for every entity that still
lacks one, before any upload reads it: the current local hash for an
in-sync entity (it equals the server's hash for an agreed entity), or
the frozen pre-edit hash carried over from the old dirty list for an
entity that was already dirty at upgrade time. The local-hash source is
safe — if local has silently diverged from the server, the backfilled
baseline is the old hash, so the next edit's upload is correctly flagged
as a conflict rather than a silent overwrite. Existing recorded baselines
are never touched, so it is a one-time, idempotent migration.

Residual: an entity edited for the very first time post-upgrade, before
any sync has run the backfill, still has no recoverable baseline.

* Remove dead SceneDraftRepository.markForSynchronization

Drafts are immutable after creation, so this was never called (its own
comment said so). Removing it leaves syncJournal unused in the repo, so
drop that field and its import too.

* Persist + assert scene content in SceneTimestampsTest

The test set content via onContentChanged + an immediate storeSceneBuffer.
onContentChanged registers the editor buffer asynchronously, so the
storeSceneBuffer found no buffer ("no buffer present"), no-op'd, and the
scene reached the server empty. The test only asserted timestamps, so it
passed while never validating that content survives the round-trip.

Persist via the synchronous storeSceneMarkdownRaw path, and assert the
server-stored entity's content equals the uploaded content so the blind
spot can't silently return.
2026-06-10 17:48:35 -07:00
Adam Brown
d1e265a44c
Add Foundation primitives tier; rename Id/Sync/Settings repos (#558)
* Rename IdRepository to IdAllocator
It is a special foundational primative

* Rename SyncDataRepository to SyncJournal
It is a special foundational primative

* Rename GlobalSettingsRepository to GlobalSettingsStore
It is a special foundational primitive

* docs: add Foundation primitives tier to the architecture doc

Document IdAllocator, SyncJournal, and GlobalSettingsStore as a fixed set of
stateful, cross-cutting primitives that the whole data layer may depend on —
acyclic leaves (GlobalSettingsStore <- SyncJournal <- IdAllocator) that named
the dependency reality instead of treating it as a no-sibling violation.
2026-06-06 01:17:13 -07:00
Adam Brown
bc1a66fc1e
Decompose SceneEditorRepository into focused repositories + a service facade
Split the monolithic SceneEditorRepository into single-responsibility pieces:

- SceneRepository — scene tree, structure, ordering, paths, on-disk layout
- SceneContentRepository — in-memory buffers, autosave, dirty tracking
- SceneMetadataRepository — per-scene and project metadata
- SceneEditorService — the component-facing facade that orchestrates the three
and applies the cross-cutting side-effects (statistics, writing activity,
reference index) the repositories deliberately don't reach up to perform

Components and synchronizers now talk to SceneEditorService; the underlying
repositories hold no sibling-repo dependencies. Tests are reorganized to match
the new boundaries, and the scene-editing domain API is documented in 
docs/DESIGN_PATTERNS.md.
2026-06-06 00:57:35 -07:00
Adam Brown
dfaa3a9900
Clarify why SceneTimestampsTest can't pin server timestamps exactly
The previous comment claimed the range assertion proved the edit
"round-tripped instead of being stamped with NOW() server-side." It
doesn't — server-side NOW() during sync falls in the same window.

Replace with an honest description: storeAllBuffers inside prepareForSync
bumps lastEdited right before upload, and the debounced contentFlow can
fire again post-sync, so client-bumped-during-sync and server-stamped-
on-receipt are observationally identical without instrumenting the sync
pipeline. The range check still rules out drops, epoch-0 resets, and
clearly-wrong stamps.
2026-05-23 13:23:59 -07:00
Adam Brown
976d67926a
Fix integration tests against the Postgres backend
Two failures came up running :integrationTests:jvmTest after the
SQLite-to-Postgres migration.

1. RoundTripTestBase seeded server_config with a raw epoch integer:

       INSERT INTO server_config VALUES ('whitelist_enabled', 'false', 1704067200);

   server_config.updated_at is now TIMESTAMPTZ; Postgres refused to
   coerce the integer. Wrapped the value in to_timestamp(...).

2. SceneTimestampsTest's upload test asserted exact equality between a
   pre-sync local lastEdited snapshot and the server's stored value.
   SceneEditorRepository runs a debounced (~500ms) auto-save that calls
   recordSceneActivity → bumps lastEdited on every Editor-source store,
   and the storeAllBuffers pass inside sync's prepareForSync triggers
   another. The exact-equality assertion happened to pass against fast
   in-memory SQLite because the whole test finished inside the debounce
   window; with the slower embedded Postgres it doesn't.

   The test's intent is "edit timestamps round-trip to the server, not
   stamped with NOW() server-side." Reframed the assertion as "server's
   created and lastEdited both fall inside [beforeEdit, afterSync]" —
   which is what the test actually proves.
2026-05-22 23:18:25 -07:00
Adam Brown
bbfc27efa2
Track last-edited scene per project and surface in stats
Adds `created` and `lastEdited` timestamps to SceneMetadata, stamped
on every autosave tick in SceneEditorRepository.recordSceneActivity.
StatisticsService rolls them up into a new "Last Edited" tile on the
project home dashboard, and the Android widget reads the same field
from the stats cache.

The timestamps ride the sync wire on ApiProjectEntity.SceneEntity and
participate in EntityHasher.hashScene. On download, local values are
preserved when the server didn't ship them. No conflict resolution —
last write wins.

EntityHasher hash functions had their defaults removed so call sites
can't silently miss a newly added field; this caught a latent bug in
the second markForSynchronization hash call that was relying on
defaults for archived/confirmedReferences/dismissedReferences.
2026-05-17 22:57:10 -07:00
Adam Brown
4fda8d495a
Add round-trip sync integration test suite
Spins up a real Jetty server and a real headless client in the same JVM,
runs the actual sync protocol over HTTP, and asserts against the shared
FakeFileSystem. Covers seven scenarios: smoke (handshake), client upload,
server download, independent edits, conflict resolved to server, conflict
resolved to client, and client delete.

- New :integrationTests Gradle module with the RoundTripTestBase harness,
  HeadlessClient driver, and seven scenario tests.
- :server enables java-test-fixtures so EndToEndTest, E2eTestData, and
  SqliteTestDatabase are shared with the new module instead of duplicated.
- CI: build.yml runs the suite on every PR/develop push; prepare-release.yml
  adds an integration-tests job that gates all seven package jobs, so a
  broken sync protocol blocks the release before any artifact is built.
2026-05-13 22:28:19 -07:00