feat(sync): reconcile secondary Graph calendars

Read documented Graph v1 calendarView pages for secondary calendars and publish each finite window only after the complete bounded listing validates.

Add schema-v2 per-window event membership so moved-out occurrences are evicted without being mislabeled as remote tombstones, while overlapping windows retain shared instances.

Verified: 18/18 fresh normal, 18/18 -Werror, and 18/18 ASan/UBSan tests.
This commit is contained in:
2026-07-18 13:05:21 +01:00
parent 0582444c61
commit 98e07e287e
11 changed files with 1520 additions and 60 deletions

View File

@@ -30,6 +30,7 @@ using nocal::storage::CachedAccount;
using nocal::storage::CachedCalendar;
using nocal::storage::CachedEvent;
using nocal::storage::CachedEventInstance;
using nocal::storage::CompleteWindow;
using nocal::storage::PullPage;
using nocal::storage::SyncCache;
using nocal::storage::SyncCheckpoint;
@@ -211,6 +212,13 @@ PullPage page(CachedEvent changed_event = event(), CachedEventInstance changed_i
return PullPage{{std::move(changed_event)}, {std::move(changed_instance)}, std::move(next)};
}
CompleteWindow complete_window(std::vector<CachedEvent> events = {event()},
std::vector<CachedEventInstance> instances = {instance()},
SyncCheckpoint complete_checkpoint = checkpoint()) {
complete_checkpoint.complete = true;
return {std::move(events), std::move(instances), std::move(complete_checkpoint)};
}
void seed_calendar(SyncCache& cache, const CachedAccount& cached_account = account(),
const CachedCalendar& cached_calendar = calendar()) {
cache.upsert_account(cached_account);
@@ -249,11 +257,13 @@ void test_fresh_schema_permissions_and_secrets(const std::filesystem::path& root
const auto tables = inspection.first_column(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name");
for (const std::string_view required : {"accounts", "calendars", "events",
"event_instances", "sync_state", "outbox", "conflicts"}) {
"event_instances", "event_window_membership", "sync_state", "outbox",
"conflicts"}) {
require(contains(tables, required), std::string{"missing cache table: "}.append(required));
}
for (const std::string_view child : {"calendars", "events", "event_instances", "sync_state"}) {
for (const std::string_view child : {"calendars", "events", "event_instances",
"event_window_membership", "sync_state"}) {
require(inspection.scalar_int(
std::string{"SELECT COUNT(*) FROM pragma_foreign_key_list('"}
.append(child)
@@ -261,6 +271,11 @@ void test_fresh_schema_permissions_and_secrets(const std::filesystem::path& root
> 0,
std::string{"missing foreign key on "}.append(child));
}
require(inspection.scalar_int(
"SELECT COUNT(*) FROM pragma_index_list('event_window_membership') "
"WHERE name='event_window_membership_event_id_idx'")
== 1,
"complete-window event lookup index is missing");
const auto columns = inspection.first_column(
"SELECT lower(m.name || '.' || p.name) FROM sqlite_master AS m "
@@ -397,6 +412,176 @@ void test_pull_pages_replacement_tombstone_and_windows(const std::filesystem::pa
"checkpoints are not returned in deterministic scope order");
}
void test_complete_window_idempotence_revival_and_omission(
const std::filesystem::path& root) {
const auto path = root / "complete-window.db";
SyncCache cache{path};
seed_calendar(cache);
auto completed = complete_window();
cache.replace_complete_window(completed);
const auto initial = cache.snapshot();
require(initial.events.size() == 1 && initial.instances.size() == 1
&& initial.checkpoints.size() == 1 && initial.checkpoints[0].complete,
"complete window did not persist event, instance, and complete checkpoint");
cache.replace_complete_window(completed);
require(cache.snapshot() == initial, "complete window replacement was not idempotent");
auto tombstone = event();
tombstone.deleted = true;
tombstone.raw_payload.clear();
cache.apply_pull_page(PullPage{{tombstone}, {}, checkpoint("delta-tombstone")});
require(cache.snapshot().events[0].deleted && cache.snapshot().instances.empty(),
"delta tombstone fixture did not delete the event");
auto revived = event();
revived.etag = "revived-etag";
revived.raw_payload = R"({"event":"revived"})";
auto revived_instance = instance("instance-revived");
auto revival_checkpoint = checkpoint("complete-revival");
revival_checkpoint.complete = true;
cache.replace_complete_window({{revived}, {revived_instance}, revival_checkpoint});
auto snapshot = cache.snapshot();
require(!snapshot.events[0].deleted && snapshot.events[0].etag == "revived-etag"
&& snapshot.events[0].raw_payload == revived.raw_payload
&& snapshot.instances.size() == 1
&& snapshot.instances[0].id == "instance-revived",
"returned complete-window event was not revived and replaced");
auto empty_checkpoint = checkpoint("complete-empty");
empty_checkpoint.complete = true;
cache.replace_complete_window({{}, {}, empty_checkpoint});
snapshot = cache.snapshot();
require(snapshot.events.size() == 1 && !snapshot.events[0].deleted
&& snapshot.events[0].raw_payload == revived.raw_payload
&& snapshot.instances.empty(),
"complete-window omission acted as a tombstone or retained last-window instances");
require(snapshot.checkpoints[0].cursor == "complete-empty"
&& snapshot.checkpoints[0].complete,
"empty completed window did not advance its checkpoint");
SqliteConnection inspection{path};
require(inspection.scalar_int("SELECT COUNT(*) FROM event_window_membership") == 0,
"empty completed window retained membership rows");
}
void test_overlapping_complete_windows_preserve_instances(
const std::filesystem::path& root) {
const auto path = root / "overlapping-windows.db";
SyncCache cache{path};
seed_calendar(cache);
auto first_checkpoint = checkpoint("first-complete", "calendar-a", "first-start", "first-end");
first_checkpoint.complete = true;
cache.replace_complete_window({{event()}, {instance()}, first_checkpoint});
auto second_instance = instance("instance-second");
second_instance.title = "Second window representation";
auto second_checkpoint =
checkpoint("second-complete", "calendar-a", "second-start", "second-end");
second_checkpoint.complete = true;
cache.replace_complete_window({{event()}, {second_instance}, second_checkpoint});
require(cache.snapshot().instances.size() == 1
&& cache.snapshot().instances[0].id == "instance-second",
"overlapping returned event did not replace its instances");
first_checkpoint.cursor = "first-empty";
cache.replace_complete_window({{}, {}, first_checkpoint});
require(cache.snapshot().instances.size() == 1
&& cache.snapshot().instances[0].id == "instance-second",
"omission from one overlapping window evicted a still-referenced event");
second_checkpoint.cursor = "second-empty";
cache.replace_complete_window({{}, {}, second_checkpoint});
require(cache.snapshot().instances.empty(),
"removing the final complete-window membership did not evict instances");
require(cache.snapshot().events.size() == 1 && !cache.snapshot().events[0].deleted,
"final membership removal deleted or tombstoned the event row");
SqliteConnection inspection{path};
require_throws(
[&] {
inspection.execute("PRAGMA foreign_keys=ON");
inspection.execute(
"INSERT INTO accounts VALUES('other-account','provider','subject','Other')");
inspection.execute(
"INSERT INTO calendars VALUES('other-calendar','other-account','remote','Other','',0,1,'raw')");
inspection.execute(
"INSERT INTO event_window_membership VALUES('other-calendar','start','end','event-a')");
},
"cross-calendar membership bypassed the composite foreign key");
}
void test_complete_window_validation_and_sql_rollback(const std::filesystem::path& root) {
SyncCache cache{root / "complete-validation.db"};
seed_calendar(cache);
auto event_b = event("event-b");
event_b.remote_id = "remote-event-b";
event_b.raw_payload = R"({"event":"b"})";
auto instance_b = instance("instance-b", "event-b");
cache.replace_complete_window(complete_window({event(), event_b}, {instance(), instance_b}));
const auto baseline = cache.snapshot();
auto expect_unchanged = [&](auto&& operation, std::string_view message) {
require_throws(std::forward<decltype(operation)>(operation), message);
require(cache.snapshot() == baseline, "invalid complete window changed cache state");
};
auto incomplete = complete_window();
incomplete.checkpoint.complete = false;
expect_unchanged([&] { cache.replace_complete_window(incomplete); },
"incomplete complete-window checkpoint accepted");
for (const int missing_field : {0, 1, 2, 3}) {
auto invalid = complete_window();
if (missing_field == 0) invalid.checkpoint.calendar_id.clear();
if (missing_field == 1) invalid.checkpoint.window_start.clear();
if (missing_field == 2) invalid.checkpoint.window_end.clear();
if (missing_field == 3) invalid.checkpoint.cursor.clear();
expect_unchanged([&] { cache.replace_complete_window(invalid); },
"empty complete-window checkpoint field accepted");
}
auto deleted = event();
deleted.deleted = true;
expect_unchanged([&] { cache.replace_complete_window(complete_window({deleted}, {})); },
"deleted complete-window event accepted");
auto cross_calendar = event();
cross_calendar.calendar_id = "other-calendar";
expect_unchanged(
[&] { cache.replace_complete_window(complete_window({cross_calendar}, {})); },
"cross-calendar complete-window event accepted");
expect_unchanged(
[&] { cache.replace_complete_window(complete_window({event(), event()}, {})); },
"duplicate complete-window event accepted");
auto duplicate_remote = event("different-id");
expect_unchanged(
[&] { cache.replace_complete_window(complete_window({event(), duplicate_remote}, {})); },
"duplicate complete-window remote event accepted");
auto orphan = instance();
orphan.event_id = "not-returned";
expect_unchanged([&] { cache.replace_complete_window(complete_window({event()}, {orphan})); },
"orphan complete-window instance accepted");
auto invalid_interval = instance();
invalid_interval.end_epoch_microseconds = invalid_interval.start_epoch_microseconds - 1;
expect_unchanged(
[&] { cache.replace_complete_window(complete_window({event()}, {invalid_interval})); },
"invalid complete-window instance interval accepted");
auto changed = event();
changed.etag = "must-roll-back";
changed.raw_payload = R"({"event":"must-roll-back"})";
auto collision = instance("instance-b", "event-a");
auto collision_checkpoint = checkpoint("must-not-advance");
collision_checkpoint.complete = true;
expect_unchanged(
[&] { cache.replace_complete_window({{changed}, {collision}, collision_checkpoint}); },
"complete-window SQL instance collision was accepted");
cache.replace_calendars_after_complete_listing("account-a", {});
const auto inactive_baseline = cache.snapshot();
require_throws([&] { cache.replace_complete_window(complete_window()); },
"inactive calendar accepted a complete window");
require(cache.snapshot() == inactive_baseline,
"inactive-calendar complete window changed cache state");
}
void test_multiple_connections_serialize(const std::filesystem::path& root) {
const auto path = root / "connections.db";
SyncCache first{path};
@@ -721,6 +906,71 @@ void test_foreign_key_failure_rolls_back(const std::filesystem::path& root) {
require(cache.snapshot() == baseline, "foreign-key failure changed cached data");
}
void downgrade_fixture_to_v1(const std::filesystem::path& path) {
SqliteConnection database{path};
database.execute("DROP TABLE event_window_membership");
database.execute("DROP INDEX events_id_calendar_id_unique_idx");
database.execute("PRAGMA user_version=1");
}
void test_v1_to_v2_migration_preserves_data_and_serializes(
const std::filesystem::path& root) {
const auto path = root / "v1-upgrade.db";
CacheSnapshot before;
{
SyncCache cache{path};
seed_calendar(cache);
cache.apply_pull_page(page());
before = cache.snapshot();
}
downgrade_fixture_to_v1(path);
{
SyncCache upgraded{path};
require(upgraded.snapshot() == before, "v1 to v2 migration changed cached data");
}
{
SqliteConnection inspection{path};
require(inspection.scalar_int("PRAGMA user_version") == 2,
"v1 migration did not record schema version 2");
require(inspection.scalar_int(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' "
"AND name='event_window_membership'")
== 1,
"v1 migration did not create complete-window membership");
}
const auto concurrent_path = root / "v1-concurrent.db";
{
SyncCache cache{concurrent_path};
seed_calendar(cache);
}
downgrade_fixture_to_v1(concurrent_path);
std::atomic<bool> begin{false};
std::exception_ptr first_error;
std::exception_ptr second_error;
auto open = [&](std::exception_ptr& error) {
while (!begin.load(std::memory_order_acquire)) {
std::this_thread::yield();
}
try {
SyncCache cache{concurrent_path};
(void)cache.snapshot();
} catch (...) {
error = std::current_exception();
}
};
std::thread first{open, std::ref(first_error)};
std::thread second{open, std::ref(second_error)};
begin.store(true, std::memory_order_release);
first.join();
second.join();
require(first_error == nullptr && second_error == nullptr,
"concurrent v1 opens did not serialize schema migration");
SqliteConnection concurrent_inspection{concurrent_path};
require(concurrent_inspection.scalar_int("PRAGMA user_version") == 2,
"concurrent migration did not finish at schema version 2");
}
void test_migration_failures_are_non_destructive(const std::filesystem::path& root) {
const auto collision_path = root / "collision.db";
{
@@ -743,17 +993,46 @@ void test_migration_failures_are_non_destructive(const std::filesystem::path& ro
"failed migration left partially created v1 tables");
}
const auto v1_collision_path = root / "v1-collision.db";
{
SyncCache cache{v1_collision_path};
seed_calendar(cache);
cache.apply_pull_page(page());
}
downgrade_fixture_to_v1(v1_collision_path);
{
SqliteConnection database{v1_collision_path};
database.execute("CREATE TABLE event_window_membership (sentinel TEXT NOT NULL)");
database.execute("INSERT INTO event_window_membership VALUES ('keep-v1')");
}
require_throws([&] { SyncCache cache{v1_collision_path}; },
"incompatible v1 to v2 migration collision was accepted");
{
SqliteConnection database{v1_collision_path};
require(database.scalar_int("PRAGMA user_version") == 1,
"failed v2 migration changed schema version");
require(database.scalar_text("SELECT sentinel FROM event_window_membership") == "keep-v1",
"failed v2 migration changed preexisting data");
require(database.scalar_int(
"SELECT COUNT(*) FROM sqlite_master WHERE type='index' "
"AND name='events_id_calendar_id_unique_idx'")
== 0,
"failed v2 migration left a partially created index");
require(database.scalar_int("SELECT COUNT(*) FROM events") == 1,
"failed v2 migration changed v1 event data");
}
const auto future_path = root / "future.db";
{
SqliteConnection database{future_path};
database.execute("CREATE TABLE future_sentinel (value TEXT NOT NULL)");
database.execute("INSERT INTO future_sentinel VALUES ('future-data')");
database.execute("PRAGMA user_version=2");
database.execute("PRAGMA user_version=3");
}
require_throws([&] { SyncCache cache{future_path}; }, "future schema version was accepted");
{
SqliteConnection database{future_path};
require(database.scalar_int("PRAGMA user_version") == 2,
require(database.scalar_int("PRAGMA user_version") == 3,
"future schema rejection changed schema version");
require(database.scalar_text("SELECT value FROM future_sentinel") == "future-data",
"future schema rejection changed data");
@@ -804,11 +1083,15 @@ int main() {
test_existing_permissions_are_tightened(temporary.path());
test_account_calendars_and_deterministic_snapshot(temporary.path());
test_pull_pages_replacement_tombstone_and_windows(temporary.path());
test_complete_window_idempotence_revival_and_omission(temporary.path());
test_overlapping_complete_windows_preserve_instances(temporary.path());
test_complete_window_validation_and_sql_rollback(temporary.path());
test_multiple_connections_serialize(temporary.path());
test_input_validation_and_rollback(temporary.path());
test_sql_failure_rolls_back_snapshot_and_cursor(temporary.path());
test_checkpoint_calendar_and_shape_validation(temporary.path());
test_foreign_key_failure_rolls_back(temporary.path());
test_v1_to_v2_migration_preserves_data_and_serializes(temporary.path());
test_migration_failures_are_non_destructive(temporary.path());
test_invalid_files_are_refused(temporary.path());
} catch (const std::exception& error) {