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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user