diff --git a/LiteCore/tests/CMakeLists.txt b/LiteCore/tests/CMakeLists.txt index 78b4a49ec..90cade323 100644 --- a/LiteCore/tests/CMakeLists.txt +++ b/LiteCore/tests/CMakeLists.txt @@ -90,6 +90,7 @@ add_executable( ${TOP}Crypto/CertificateTest.cc ${TOP}Networking/tests/CookieStoreTest.cc ${TOP}Replicator/tests/DBAccessTestWrapper.cc + ${TOP}Replicator/tests/ParsedSequenceIDTest.cc ${TOP}Replicator/tests/PropertyEncryptionTests.cc ${TOP}Replicator/tests/ReplicatorLoopbackTest.cc ${TOP}Replicator/tests/ReplicatorAPITest.cc diff --git a/Replicator/Checkpoint.cc b/Replicator/Checkpoint.cc index b0d3e97db..1e496c851 100644 --- a/Replicator/Checkpoint.cc +++ b/Replicator/Checkpoint.cc @@ -114,19 +114,25 @@ namespace litecore::repl { if ( _remote && _remote != remoteSequences._remote ) { LogTo(SyncLog, "Remote sequence mismatch: I had '%s', remote had '%s'", _remote.toJSONString().c_str(), remoteSequences._remote.toJSONString().c_str()); - if ( _remote.isInt() && remoteSequences._remote.isInt() ) { - if ( _remote.intValue() > remoteSequences._remote.intValue() ) { + + ParsedSequenceID localParsed, remoteParsed; + bool localParseable = _remote.toParsedSequenceID(localParsed); + bool remoteParseable = remoteSequences._remote.toParsedSequenceID(remoteParsed); + if ( localParseable && remoteParseable ) { + if ( remoteParsed.before(localParsed) ) { LogTo(SyncLog, "Rolling back to earlier remote sequence from server, some redundant changes may be " "proposed..."); _remote = remoteSequences._remote; match = false; } else { - LogTo(SyncLog, "Ignoring remote sequence on server since client side is older, some redundant " - "changes may be proposed..."); + LogTo(SyncLog, "Ignoring remote sequence on server since client side is older or equal, some " + "redundant changes may be proposed..."); } } else { - Warn("Non-numeric remote sequence detected, resetting replication back to start. Redundant changes " - "will be proposed..."); + Warn("Unparseable remote sequence: locally-saved remote seq '%s' is %s, " + "server-side remote seq '%s' is %s. Resetting replication.", + _remote.toJSONString().c_str(), localParseable ? "parseable" : "UNPARSEABLE", + remoteSequences._remote.toJSONString().c_str(), remoteParseable ? "parseable" : "UNPARSEABLE"); _remote = {}; match = false; } diff --git a/Replicator/ParsedSequenceID.hh b/Replicator/ParsedSequenceID.hh new file mode 100644 index 000000000..2ea4ace83 --- /dev/null +++ b/Replicator/ParsedSequenceID.hh @@ -0,0 +1,180 @@ +// +// ParsedSequenceID.hh +// +// Copyright 2026-Present Couchbase, Inc. +// +// Use of this software is governed by the Business Source License included +// in the file licenses/BSL-Couchbase.txt. As of the Change Date specified +// in that file, in accordance with the Business Source License, use of this +// software will be governed by the Apache License, Version 2.0, included in +// the file licenses/APL2.txt. +// + +#pragma once +#include "StringUtil.hh" +#include "slice_stream.hh" +#include +#include + +namespace litecore::repl { + + /** + * Represents a parsed Sync Gateway compound sequence ID. + * + * Sync Gateway emits sequence IDs in three formats on the _changes feed: + * + * - Simple: "Seq" e.g. "42" + * A plain database sequence number. + * + * - Backfill: "TriggeredBy:Seq" e.g. "100:35" + * A revision (seq 35) is being sent retroactively because an access-grant + * change at sequence 100 gave the user access to its channel. + * + * - LowSeq: "LowSeq:TriggeredBy:Seq" e.g. "20:100:35" + * Same as backfill, but also records the lowest contiguous sequence (20) + * that the client has fully processed on the feed. TriggeredBy may be + * omitted (e.g. "20::35") when there is no access-grant trigger. + * + * For checkpoint comparison the "comparison value" of each format is: + * - Simple → seq + * - Backfill → triggeredBy + * - LowSeq → lowSeq + * + * The before() method implements the ordering rules that match Sync Gateway's + * SequenceID.Before(), so that LiteCore and SG always agree on which checkpoint is older. + */ + + class ParsedSequenceID { + public: + ParsedSequenceID() = default; + + ParsedSequenceID(uint64_t seq, uint64_t triggeredBy, uint64_t lowSeq) + : _seq(seq), _triggeredBy(triggeredBy), _lowSeq(lowSeq) {} + + [[nodiscard]] uint64_t seq() const { return _seq; } + + [[nodiscard]] uint64_t triggeredBy() const { return _triggeredBy; } + + [[nodiscard]] uint64_t lowSeq() const { return _lowSeq; } + + /// "Seq" — plain sequence, no trigger, no lowSeq. + [[nodiscard]] bool isSimpleRevision() const { return _lowSeq == 0 && _triggeredBy == 0; } + + /// "TriggeredBy:Seq" — sent retroactively due to an access-grant, no lowSeq tracking. + /// triggeredBy is the primary comparison value in before(). + [[nodiscard]] bool isTriggeredRevision() const { return _lowSeq == 0 && _triggeredBy > 0; } + + /// "LowSeq:TriggeredBy:Seq" or "LowSeq::Seq" — includes lowSeq feed-position tracking. + /// lowSeq is the primary comparison value in before(). May or may not include a trigger. + [[nodiscard]] bool isLowSeqRevision() const { return _lowSeq > 0; } + + /** + * Parse a sequence string into a ParsedSequenceID. + * + * Accepted formats: + * "42" → {seq=42, triggeredBy=0, lowSeq=0} + * "100:35" → {seq=35, triggeredBy=100, lowSeq=0} + * "20:100:35" → {seq=35, triggeredBy=100, lowSeq=20} + * "20::35" → {seq=35, triggeredBy=0, lowSeq=20} + * + * @param str The string to parse. + * @param out Receives the parsed result on success. + * @return true on success, false if the string is empty, malformed, or has + * an unexpected number of components. + */ + static bool parse(const std::string& str, ParsedSequenceID& out) { + if ( str.empty() || str.back() == ':' ) return false; // litecore::split drops trailing empty tokens + + auto parts = litecore::split(str, ":"); + if ( parts.size() < 1 || parts.size() > 3 ) return false; + + // Parse a single component as uint64. Empty is only valid for middle part of "LowSeq::Seq". + auto readUInt = [](std::string_view sv, uint64_t& val) -> bool { + if ( sv.empty() ) return false; + fleece::slice_istream stream(sv.data(), sv.size()); + val = stream.readDecimal(); + return stream.eof(); + }; + + uint64_t v[3] = {}; + switch ( parts.size() ) { + case 1: + if ( !readUInt(parts[0], v[0]) ) return false; + out = ParsedSequenceID(v[0], 0, 0); // Seq + return true; + case 2: + if ( !readUInt(parts[0], v[0]) || !readUInt(parts[1], v[1]) ) return false; + out = ParsedSequenceID(v[1], v[0], 0); // TriggeredBy:Seq + return true; + case 3: + if ( !readUInt(parts[0], v[0]) ) return false; + if ( !parts[1].empty() && !readUInt(parts[1], v[1]) ) return false; // empty → 0 for "LowSeq::Seq" + if ( !readUInt(parts[2], v[2]) ) return false; + out = ParsedSequenceID(v[2], v[1], v[0]); // LowSeq:TriggeredBy:Seq + return true; + default: + return false; + } + } + + /** + * Returns true if this sequence is strictly before \p seqID2. + * + * Implements the same ordering as Sync Gateway's SequenceID.Before(). + * Each format has a "comparison value" (CV): + * - Simple → CV = seq + * - Backfill → CV = triggeredBy + * - LowSeq → CV = lowSeq + * + * Cross-format rules (a.before(b)): + * + * a \ b | Simple | Backfill | LowSeq + * ───────────────|──────────────|─────────────────|──────────────── + * Simple | seq < seq | seq < trig | seq <= low + * Backfill | trig <= seq | trig < trig | trig <= low + * | | (tie: seq= 2 && s.front() == '"' && s.back() == '"' ) s = s.substr(1, s.size() - 2); + return ParsedSequenceID::parse(s, out); + } + bool operator<(const RemoteSequence& other) const noexcept FLPURE { if ( isInt() ) return !other.isInt() || intValue() < other.intValue(); else diff --git a/Replicator/tests/ParsedSequenceIDTest.cc b/Replicator/tests/ParsedSequenceIDTest.cc new file mode 100644 index 000000000..9e1dfa187 --- /dev/null +++ b/Replicator/tests/ParsedSequenceIDTest.cc @@ -0,0 +1,526 @@ +// +// ParsedSequenceIDTest.cc +// +// Copyright 2026-Present Couchbase, Inc. +// +// Use of this software is governed by the Business Source License included +// in the file licenses/BSL-Couchbase.txt. As of the Change Date specified +// in that file, in accordance with the Business Source License, use of this +// software will be governed by the Apache License, Version 2.0, included in +// the file licenses/APL2.txt. +// + +#include "ParsedSequenceID.hh" +#include "RemoteSequence.hh" +#include "c4Test.hh" + +using namespace litecore::repl; + +/** Parse str and assert it succeeds, returning the result. */ +static ParsedSequenceID mustParse(const std::string& str) { + ParsedSequenceID out; + REQUIRE(ParsedSequenceID::parse(str, out)); + return out; +} + +/** Assert that parse(str) fails. */ +static void mustFail(const std::string& str) { + ParsedSequenceID out; + CHECK_FALSE(ParsedSequenceID::parse(str, out)); +} + +TEST_CASE("ParsedSequenceID parse - simple revision", "[ParsedSequenceID]") { + // Format: "Seq" + // Fields: seq=N, triggeredBy=0, lowSeq=0 + + SECTION("single digit") { + auto s = mustParse("5"); + CHECK(s.seq() == 5); + CHECK(s.triggeredBy() == 0); + CHECK(s.lowSeq() == 0); + CHECK(s.isSimpleRevision()); + CHECK_FALSE(s.isTriggeredRevision()); + CHECK_FALSE(s.isLowSeqRevision()); + } + + SECTION("multi digit") { + auto s = mustParse("12345"); + CHECK(s.seq() == 12345); + CHECK(s.triggeredBy() == 0); + CHECK(s.lowSeq() == 0); + CHECK(s.isSimpleRevision()); + } + + SECTION("zero") { + auto s = mustParse("0"); + CHECK(s.seq() == 0); + CHECK(s.isSimpleRevision()); + } + + SECTION("large uint64 value") { + auto s = mustParse("18446744073709551615"); // UINT64_MAX + CHECK(s.seq() == UINT64_MAX); + CHECK(s.isSimpleRevision()); + } +} + +TEST_CASE("ParsedSequenceID parse - backfill (TriggeredBy:Seq)", "[ParsedSequenceID]") { + // Format: "TriggeredBy:Seq" + // Fields: seq=Seq, triggeredBy=TriggeredBy, lowSeq=0 + + SECTION("basic backfill") { + auto s = mustParse("100:35"); + CHECK(s.triggeredBy() == 100); + CHECK(s.seq() == 35); + CHECK(s.lowSeq() == 0); + CHECK(s.isTriggeredRevision()); + CHECK_FALSE(s.isSimpleRevision()); + CHECK_FALSE(s.isLowSeqRevision()); + } + + SECTION("both components equal") { + auto s = mustParse("50:50"); + CHECK(s.triggeredBy() == 50); + CHECK(s.seq() == 50); + CHECK(s.isTriggeredRevision()); + } +} + +TEST_CASE("ParsedSequenceID parse - LowSeq with TriggeredBy (LowSeq:TriggeredBy:Seq)", "[ParsedSequenceID]") { + // Format: "LowSeq:TriggeredBy:Seq" + // Fields: seq=Seq, triggeredBy=TriggeredBy, lowSeq=LowSeq + + SECTION("full three-part") { + auto s = mustParse("20:100:35"); + CHECK(s.lowSeq() == 20); + CHECK(s.triggeredBy() == 100); + CHECK(s.seq() == 35); + CHECK(s.isLowSeqRevision()); + CHECK_FALSE(s.isTriggeredRevision()); + CHECK_FALSE(s.isSimpleRevision()); + } + + SECTION("all parts equal") { + auto s = mustParse("10:10:10"); + CHECK(s.lowSeq() == 10); + CHECK(s.triggeredBy() == 10); + CHECK(s.seq() == 10); + CHECK(s.isLowSeqRevision()); + } +} + +TEST_CASE("ParsedSequenceID parse - LowSeq without TriggeredBy (LowSeq::Seq)", "[ParsedSequenceID]") { + // Format: "LowSeq::Seq" — TriggeredBy is empty, treated as 0 + + SECTION("basic lowseq without triggeredby") { + auto s = mustParse("20::35"); + CHECK(s.lowSeq() == 20); + CHECK(s.triggeredBy() == 0); + CHECK(s.seq() == 35); + CHECK(s.isLowSeqRevision()); + } + + SECTION("lowseq large values") { + auto s = mustParse("1000::9999"); + CHECK(s.lowSeq() == 1000); + CHECK(s.triggeredBy() == 0); + CHECK(s.seq() == 9999); + CHECK(s.isLowSeqRevision()); + } +} + +// ══════════════════════════════════════════════════════════════════ +// parse() — invalid inputs (safety net) +// ══════════════════════════════════════════════════════════════════ + +TEST_CASE("ParsedSequenceID parse - invalid inputs", "[ParsedSequenceID]") { + SECTION("empty string") { mustFail(""); } + SECTION("non-numeric single") { mustFail("abc"); } + SECTION("leading alpha") { mustFail("abc:35"); } + SECTION("trailing alpha") { mustFail("100:abc"); } + SECTION("too many parts (4)") { mustFail("1:2:3:4"); } + SECTION("too many parts (5)") { mustFail("1:2:3:4:5"); } + SECTION("trailing colon two-part") { mustFail("100:"); } + SECTION("leading colon") { mustFail(":35"); } + SECTION("empty lowseq in three-part") { mustFail("::35"); } + SECTION("empty seq in three-part") { mustFail("20:100:"); } + SECTION("float value") { mustFail("1.5"); } + SECTION("negative value") { mustFail("-1"); } + SECTION("spaces") { mustFail("100 : 35"); } +} + +TEST_CASE("ParsedSequenceID format classification", "[ParsedSequenceID]") { + SECTION("simple is exclusively simple") { + auto s = mustParse("42"); + CHECK(s.isSimpleRevision()); + CHECK_FALSE(s.isTriggeredRevision()); + CHECK_FALSE(s.isLowSeqRevision()); + } + + SECTION("backfill is exclusively backfill") { + auto s = mustParse("100:35"); + CHECK_FALSE(s.isSimpleRevision()); + CHECK(s.isTriggeredRevision()); + CHECK_FALSE(s.isLowSeqRevision()); + } + + SECTION("lowseq with triggeredby is exclusively lowseq") { + auto s = mustParse("20:100:35"); + CHECK_FALSE(s.isSimpleRevision()); + CHECK_FALSE(s.isTriggeredRevision()); + CHECK(s.isLowSeqRevision()); + } + + SECTION("lowseq without triggeredby is still lowseq") { + auto s = mustParse("20::35"); + CHECK_FALSE(s.isSimpleRevision()); + CHECK_FALSE(s.isTriggeredRevision()); + CHECK(s.isLowSeqRevision()); + } +} + +TEST_CASE("ParsedSequenceID before - Simple vs Simple", "[ParsedSequenceID]") { + // Both plain "Seq": compare seq directly. + + SECTION("a < b → a.before(b) = true") { CHECK(mustParse("42").before(mustParse("100"))); } + + SECTION("a > b → a.before(b) = false") { CHECK_FALSE(mustParse("100").before(mustParse("42"))); } + + SECTION("a == b → a.before(b) = false (not strictly before)") { + CHECK_FALSE(mustParse("42").before(mustParse("42"))); + } + + SECTION("zero is before any positive") { CHECK(mustParse("0").before(mustParse("1"))); } +} + +TEST_CASE("ParsedSequenceID before - Backfill vs Backfill", "[ParsedSequenceID]") { + // Both "TriggeredBy:Seq": compare triggeredBy first, seq as tie-break. + + SECTION("different triggeredBy: smaller triggeredBy is before") { + // "100:35" before "200:55" + CHECK(mustParse("100:35").before(mustParse("200:55"))); + CHECK_FALSE(mustParse("200:55").before(mustParse("100:35"))); + } + + SECTION("same triggeredBy, different seq: smaller seq is before") { + // "100:35" before "100:55" + CHECK(mustParse("100:35").before(mustParse("100:55"))); + CHECK_FALSE(mustParse("100:55").before(mustParse("100:35"))); + } + + SECTION("identical → not before") { CHECK_FALSE(mustParse("100:35").before(mustParse("100:35"))); } + + SECTION("same triggeredBy, same seq → not before") { CHECK_FALSE(mustParse("50:50").before(mustParse("50:50"))); } +} + +TEST_CASE("ParsedSequenceID before - LowSeq vs LowSeq", "[ParsedSequenceID]") { + // Both "LowSeq:TB:Seq": compare lowSeq first, then inner pair as tie-break. + + SECTION("same lowSeq, backfill inner vs simple inner at boundary") { + // "20:100:35" vs "20::100": + // lowSeq equal (20); recurse → backfill(100:35).before(simple(100)) + // → triggeredBy(100) <= seq(100) → true + CHECK(mustParse("20:100:35").before(mustParse("20::100"))); + // Reverse: simple(100).before(backfill(100:35)) + // → seq(100) < triggeredBy(100) → false (strict <) + CHECK_FALSE(mustParse("20::100").before(mustParse("20:100:35"))); + } + + SECTION("different lowSeq: smaller lowSeq is before") { + // "20:100:35" before "25:100:40" + CHECK(mustParse("20:100:35").before(mustParse("25:100:40"))); + CHECK_FALSE(mustParse("25:100:40").before(mustParse("20:100:35"))); + } + + SECTION("same lowSeq, different triggeredBy: smaller triggeredBy is before") { + // "20:100:35" before "20:200:35" + CHECK(mustParse("20:100:35").before(mustParse("20:200:35"))); + } + + SECTION("same lowSeq, same triggeredBy, different seq: smaller seq is before") { + // "20:100:35" before "20:100:55" (tie-break into inner pair) + CHECK(mustParse("20:100:35").before(mustParse("20:100:55"))); + CHECK_FALSE(mustParse("20:100:55").before(mustParse("20:100:35"))); + } + + SECTION("identical → not before") { CHECK_FALSE(mustParse("20:100:35").before(mustParse("20:100:35"))); } + + SECTION("LowSeq without triggeredBy vs LowSeq with triggeredBy, same lowSeq") { + // "20::35" vs "20:100:55" → inner: simple(35) vs backfill(100:55) + // simple.before(backfill): 35 < 100 → true + CHECK(mustParse("20::35").before(mustParse("20:100:55"))); + } + + SECTION("LowSeq::Seq vs LowSeq::Seq, smaller lowSeq first") { + CHECK(mustParse("20::35").before(mustParse("25::40"))); + } +} + +// before() — Cross-format comparisons (the new enhancement) + +TEST_CASE("ParsedSequenceID before - Simple vs Backfill", "[ParsedSequenceID]") { + // Rule: simple.seq < backfill.triggeredBy (strict <) + // A plain seq N sorts BEFORE a backfill triggered at N, + // meaning "N" < "N:M" for any M. + + SECTION("simple seq < triggeredBy → simple is before") { + // "42" before "100:35": 42 < 100 → true + CHECK(mustParse("42").before(mustParse("100:35"))); + } + + SECTION("simple seq == triggeredBy → simple is NOT before (equal tier boundary)") { + // "100" vs "100:35": 100 < 100 → false + CHECK_FALSE(mustParse("100").before(mustParse("100:35"))); + } + + SECTION("simple seq > triggeredBy → simple is not before") { + // "150" vs "100:35": 150 < 100 → false + CHECK_FALSE(mustParse("150").before(mustParse("100:35"))); + } +} + +TEST_CASE("ParsedSequenceID before - Backfill vs Simple", "[ParsedSequenceID]") { + // Rule: backfill.triggeredBy <= simple.seq (<=) + // A backfill triggered at N comes at-or-before simple seq N. + + SECTION("triggeredBy < simple seq → backfill is before") { + // "100:35" before "150": 100 <= 150 → true + CHECK(mustParse("100:35").before(mustParse("150"))); + } + + SECTION("triggeredBy == simple seq → backfill IS before (<=)") { + // "100:35" before "100": 100 <= 100 → true + CHECK(mustParse("100:35").before(mustParse("100"))); + } + + SECTION("triggeredBy > simple seq → backfill is not before") { + // "100:35" vs "42": 100 <= 42 → false + CHECK_FALSE(mustParse("100:35").before(mustParse("42"))); + } +} + +TEST_CASE("ParsedSequenceID before - Simple vs LowSeq", "[ParsedSequenceID]") { + // Rule: simple.seq <= lowseq.lowSeq (<=) + + SECTION("simple seq < lowSeq → simple is before") { + // "15" before "20:100:35": 15 <= 20 → true + CHECK(mustParse("15").before(mustParse("20:100:35"))); + } + + SECTION("simple seq == lowSeq → simple IS before (<=)") { + // "20" before "20:100:35": 20 <= 20 → true + CHECK(mustParse("20").before(mustParse("20:100:35"))); + } + + SECTION("simple seq > lowSeq → simple is not before") { + // "25" vs "20:100:35": 25 <= 20 → false + CHECK_FALSE(mustParse("25").before(mustParse("20:100:35"))); + } + + SECTION("simple vs LowSeq::Seq") { + CHECK(mustParse("10").before(mustParse("20::35"))); + CHECK_FALSE(mustParse("30").before(mustParse("20::35"))); + } +} + +TEST_CASE("ParsedSequenceID before - LowSeq vs Simple", "[ParsedSequenceID]") { + // Rule: lowseq.lowSeq < simple.seq (strict <) + + SECTION("lowSeq < simple seq → lowseq is before") { + // "20:100:35" before "25": 20 < 25 → true + CHECK(mustParse("20:100:35").before(mustParse("25"))); + } + + SECTION("lowSeq == simple seq → lowseq is NOT before") { + // "20:100:35" vs "20": 20 < 20 → false + CHECK_FALSE(mustParse("20:100:35").before(mustParse("20"))); + } + + SECTION("lowSeq > simple seq → lowseq is not before") { + CHECK_FALSE(mustParse("20:100:35").before(mustParse("10"))); + } +} + +TEST_CASE("ParsedSequenceID before - Backfill vs LowSeq", "[ParsedSequenceID]") { + // Rule: backfill.triggeredBy <= lowseq.lowSeq (<=) + + SECTION("triggeredBy < lowSeq → backfill is before") { + // "100:35" before "20:200:55"? 100 <= 20 → false. Use "10:35" before "20:200:55" + CHECK(mustParse("10:35").before(mustParse("20:200:55"))); + } + + SECTION("triggeredBy == lowSeq → backfill IS before (<=)") { + // "20:35" before "20:100:55": 20 <= 20 → true + CHECK(mustParse("20:35").before(mustParse("20:100:55"))); + } + + SECTION("triggeredBy > lowSeq → backfill is not before") { + // "100:35" before "20:200:55": 100 <= 20 → false + CHECK_FALSE(mustParse("100:35").before(mustParse("20:200:55"))); + } +} + +TEST_CASE("ParsedSequenceID before - LowSeq vs Backfill", "[ParsedSequenceID]") { + // Rule: lowseq.lowSeq < backfill.triggeredBy (strict <) + + SECTION("lowSeq < triggeredBy → lowseq is before") { + // "20:100:35" before "100:35"? 20 < 100 → true + CHECK(mustParse("20:100:35").before(mustParse("100:35"))); + } + + SECTION("lowSeq == triggeredBy → lowseq is NOT before") { + // "20:100:35" vs "20:35": 20 < 20 → false + CHECK_FALSE(mustParse("20:100:35").before(mustParse("20:35"))); + } + + SECTION("lowSeq > triggeredBy → lowseq is not before") { + CHECK_FALSE(mustParse("50:100:35").before(mustParse("20:35"))); + } +} + +TEST_CASE("ParsedSequenceID checkpoint resolution scenarios", "[ParsedSequenceID]") { + SECTION("Scenario 1 - both int, local older → keep local") { + // local=42, remote=100 → remote.before(local) = 100<42 = false + CHECK_FALSE(mustParse("100").before(mustParse("42"))); + } + + SECTION("Scenario 2 - both int, remote older → roll back") { + // local=150, remote=42 → remote.before(local) = 42<150 = true + CHECK(mustParse("42").before(mustParse("150"))); + } + + SECTION("Scenario 3 - local int, remote backfill, local older → keep local") { + // local=42, remote=100:35 → remote.before(local): triggeredBy(100) <= seq(42) = false + CHECK_FALSE(mustParse("100:35").before(mustParse("42"))); + } + + SECTION("Scenario 4 - local int, remote backfill, remote older → roll back") { + // local=150, remote=100:35 → remote.before(local): 100 <= 150 = true + CHECK(mustParse("100:35").before(mustParse("150"))); + } + + SECTION("Scenario 5 - same triggeredBy, remote has lower seq → roll back") { + // local=100:55, remote=100:35 → remote.before(local): TB equal, 35<55 = true + CHECK(mustParse("100:35").before(mustParse("100:55"))); + } + + SECTION("Scenario 6 - same triggeredBy, remote has higher seq → keep local") { + // local=100:35, remote=100:55 → remote.before(local): TB equal, 55<35 = false + CHECK_FALSE(mustParse("100:55").before(mustParse("100:35"))); + } + + SECTION("Scenario 7 - three-part: different lowSeq, remote lower → roll back") { + // local=25:100:40, remote=20:100:35 → remote.before(local): 20<25 = true + CHECK(mustParse("20:100:35").before(mustParse("25:100:40"))); + } + + SECTION("Scenario 8 - three-part: same lowSeq tie-break, remote lower seq → roll back") { + // local=20:100:55, remote=20:100:35 → tie on lowSeq, 35<55 = true → roll back + CHECK(mustParse("20:100:35").before(mustParse("20:100:55"))); + } + + SECTION("Scenario 9 - LowSeq::Seq format: remote lower → roll back") { + // local=25::40, remote=20::35 → 20<25 = true → roll back + CHECK(mustParse("20::35").before(mustParse("25::40"))); + } + + SECTION("Scenario 10 - exact match → not before (fast-path, no rollback)") { + CHECK_FALSE(mustParse("100:35").before(mustParse("100:35"))); + } +} + +TEST_CASE("ParsedSequenceID before - asymmetry at tier boundary", "[ParsedSequenceID]") { + // A backfill triggered at N is considered at-or-after simple sequence N. + // Verify the < vs <= asymmetry is correct at the exact boundary. + + SECTION("simple(N) is NOT before backfill(N:M) — equal boundary") { + // "100" vs "100:35": simple 100 < triggeredBy 100 → false (strict <) + CHECK_FALSE(mustParse("100").before(mustParse("100:35"))); + } + + SECTION("backfill(N:M) IS before simple(N) — equal boundary") { + // "100:35" vs "100": triggeredBy 100 <= seq 100 → true (<=) + CHECK(mustParse("100:35").before(mustParse("100"))); + } + + SECTION("simple(N) IS before backfill(N+1:M)") { + // "100" before "101:35": 100 < 101 → true + CHECK(mustParse("100").before(mustParse("101:35"))); + } +} + +TEST_CASE("RemoteSequence toParsedSequenceID - quoted compound sequences (Value constructor)", "[RemoteSequence]") { + // Sequences arriving via the Value constructor go through val.toJSON(), which wraps + // string values in JSON quotes. toParsedSequenceID must strip these before parsing. + + SECTION("backfill - quoted") { + RemoteSequence rs(fleece::slice("\"100:35\"")); + ParsedSequenceID parsed; + REQUIRE(rs.toParsedSequenceID(parsed)); + CHECK(parsed.triggeredBy() == 100); + CHECK(parsed.seq() == 35); + CHECK(parsed.isTriggeredRevision()); + } + + SECTION("three-part - quoted") { + RemoteSequence rs(fleece::slice("\"20:100:35\"")); + ParsedSequenceID parsed; + REQUIRE(rs.toParsedSequenceID(parsed)); + CHECK(parsed.lowSeq() == 20); + CHECK(parsed.triggeredBy() == 100); + CHECK(parsed.seq() == 35); + CHECK(parsed.isLowSeqRevision()); + } + + SECTION("LowSeq::Seq - quoted") { + RemoteSequence rs(fleece::slice("\"20::35\"")); + ParsedSequenceID parsed; + REQUIRE(rs.toParsedSequenceID(parsed)); + CHECK(parsed.lowSeq() == 20); + CHECK(parsed.triggeredBy() == 0); + CHECK(parsed.seq() == 35); + CHECK(parsed.isLowSeqRevision()); + } + + SECTION("simple integer as quoted string") { + RemoteSequence rs(fleece::slice("\"42\"")); + ParsedSequenceID parsed; + REQUIRE(rs.toParsedSequenceID(parsed)); + CHECK(parsed.seq() == 42); + CHECK(parsed.isSimpleRevision()); + } +} + +TEST_CASE("RemoteSequence toParsedSequenceID - empty/null returns false", "[RemoteSequence]") { + SECTION("default constructed") { + RemoteSequence rs; + ParsedSequenceID parsed; + CHECK_FALSE(rs.toParsedSequenceID(parsed)); + } + + SECTION("empty slice") { + RemoteSequence rs(fleece::slice("")); + ParsedSequenceID parsed; + CHECK_FALSE(rs.toParsedSequenceID(parsed)); + } +} + +TEST_CASE("RemoteSequence toParsedSequenceID - invalid sequences remain unparseable", "[RemoteSequence]") { + SECTION("non-numeric") { + RemoteSequence rs(fleece::slice("abc")); + ParsedSequenceID parsed; + CHECK_FALSE(rs.toParsedSequenceID(parsed)); + } + + SECTION("quoted non-numeric") { + RemoteSequence rs(fleece::slice("\"abc\"")); + ParsedSequenceID parsed; + CHECK_FALSE(rs.toParsedSequenceID(parsed)); + } + + SECTION("single quote only") { + RemoteSequence rs(fleece::slice("\"")); + ParsedSequenceID parsed; + CHECK_FALSE(rs.toParsedSequenceID(parsed)); + } +} diff --git a/Xcode/LiteCore.xcodeproj/project.pbxproj b/Xcode/LiteCore.xcodeproj/project.pbxproj index faf8b2559..bf1b4e245 100644 --- a/Xcode/LiteCore.xcodeproj/project.pbxproj +++ b/Xcode/LiteCore.xcodeproj/project.pbxproj @@ -719,6 +719,9 @@ 93CD010F1E933BE100AFB3FA /* Pusher.cc in Sources */ = {isa = PBXBuildFile; fileRef = 27CCC7E21E52965200CE1989 /* Pusher.cc */; }; 93CD01101E933BE100AFB3FA /* Checkpoint.cc in Sources */ = {isa = PBXBuildFile; fileRef = 2773FCF41E6783A000108780 /* Checkpoint.cc */; }; 93CD01121E933BE100AFB3FA /* c4Replicator.cc in Sources */ = {isa = PBXBuildFile; fileRef = 275CE0E11E57B7E70084E014 /* c4Replicator.cc */; }; + 99158D7D2FD35A610044D7E3 /* ParsedSequenceIDTest.cc in Sources */ = {isa = PBXBuildFile; fileRef = 99158D7C2FD35A610044D7E3 /* ParsedSequenceIDTest.cc */; }; + 99158D7E2FD35A610044D7E3 /* ParsedSequenceIDTest.cc in Sources */ = {isa = PBXBuildFile; fileRef = 99158D7C2FD35A610044D7E3 /* ParsedSequenceIDTest.cc */; }; + 99158D7F2FD35A610044D7E3 /* ParsedSequenceIDTest.cc in Sources */ = {isa = PBXBuildFile; fileRef = 99158D7C2FD35A610044D7E3 /* ParsedSequenceIDTest.cc */; }; D6F99A0428E4EFB200D2DC63 /* ReplicatorCollectionSGTest.cc in Sources */ = {isa = PBXBuildFile; fileRef = D6F999FF28E4EFB200D2DC63 /* ReplicatorCollectionSGTest.cc */; }; D6F99A0528E4F02000D2DC63 /* ReplicatorCollectionSGTest.cc in Sources */ = {isa = PBXBuildFile; fileRef = D6F999FF28E4EFB200D2DC63 /* ReplicatorCollectionSGTest.cc */; }; D6F99A0628E4F02400D2DC63 /* ReplicatorCollectionSGTest.cc in Sources */ = {isa = PBXBuildFile; fileRef = D6F999FF28E4EFB200D2DC63 /* ReplicatorCollectionSGTest.cc */; }; @@ -1946,6 +1949,8 @@ 729272F22238DB8500E7208E /* c4ExceptionUtils.hh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.h; path = c4ExceptionUtils.hh; sourceTree = ""; }; 72A3AF871F424EC0001E16D4 /* PrebuiltCopier.cc */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; path = PrebuiltCopier.cc; sourceTree = ""; }; 72A3AF881F424EC0001E16D4 /* PrebuiltCopier.hh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.h; path = PrebuiltCopier.hh; sourceTree = ""; }; + 99158D7C2FD35A610044D7E3 /* ParsedSequenceIDTest.cc */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = ParsedSequenceIDTest.cc; sourceTree = ""; }; + 99158D802FD35AA90044D7E3 /* ParsedSequenceID.hh */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = ParsedSequenceID.hh; sourceTree = ""; }; D624FC81282AF78900B423A8 /* WeakHolder.hh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.h; path = WeakHolder.hh; sourceTree = ""; }; D64D17BB2894777A008B68FD /* c4ReplicatorHelpers.hh */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = c4ReplicatorHelpers.hh; sourceTree = ""; }; D6F999FF28E4EFB200D2DC63 /* ReplicatorCollectionSGTest.cc */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; path = ReplicatorCollectionSGTest.cc; sourceTree = ""; }; @@ -2895,6 +2900,7 @@ 273613FB1F16976300ECB9DF /* ReplicatorAPITest.hh */, 277FEE5721ED10FA00B60E3C /* ReplicatorSGTest.cc */, 27A83D53269E3E69002B7EBA /* PropertyEncryptionTests.cc */, + 99158D7C2FD35A610044D7E3 /* ParsedSequenceIDTest.cc */, ); path = tests; sourceTree = ""; @@ -3247,6 +3253,7 @@ 27CCC7D71E52613C00CE1989 /* Replicator.hh */, 275E98FF238360B200EA516B /* Checkpointer.cc */, 275E9904238360B200EA516B /* Checkpointer.hh */, + 99158D802FD35AA90044D7E3 /* ParsedSequenceID.hh */, 275E4CD22241C701006C5B71 /* Pull */, 275E4CD32241C726006C5B71 /* Push */, 277B9567224597E1005B7E79 /* Support */, @@ -4700,6 +4707,7 @@ 274D17C22615445B0018D39C /* DBAccessTestWrapper.cc in Sources */, 27FA09A01D6FA380005888AA /* DataFileTest.cc in Sources */, 274D165D261250220018D39C /* c4CollectionTest.cc in Sources */, + 99158D7E2FD35A610044D7E3 /* ParsedSequenceIDTest.cc in Sources */, 27E0CAA01DBEB0BA0089A9C0 /* DocumentKeysTest.cc in Sources */, 27BA41642D680A5400FAA569 /* LogObserverTest.cc in Sources */, 27505DDD256335B000123115 /* VersionVectorTest.cc in Sources */, @@ -4867,6 +4875,7 @@ files = ( 2740A74E2B321073003387E9 /* TestsCommon.cc in Sources */, 27FE0CFB24BE7C2A00A36EC2 /* LiteCoreTest.cc in Sources */, + 99158D7D2FD35A610044D7E3 /* ParsedSequenceIDTest.cc in Sources */, 27FE0CF224BE7C2A00A36EC2 /* LogEncoderTest.cc in Sources */, 27FE0CEF24BE7C2A00A36EC2 /* DataFileTest.cc in Sources */, 27FE0CF024BE7C2A00A36EC2 /* DocumentKeysTest.cc in Sources */, @@ -5201,6 +5210,7 @@ buildActionMask = 2147483647; files = ( 27FD73512D834B7A00CC48BF /* ResultTest.cc in Sources */, + 99158D7F2FD35A610044D7E3 /* ParsedSequenceIDTest.cc in Sources */, 27FD73522D834B7A00CC48BF /* LiteCoreTest.cc in Sources */, 27FD73532D834B7A00CC48BF /* SequenceSetTest.cc in Sources */, 1BC685522E2EC10C00A5AEC1 /* MultipeerTest.cc in Sources */, diff --git a/tools b/tools index 93274f9fb..a71a5a9f2 160000 --- a/tools +++ b/tools @@ -1 +1 @@ -Subproject commit 93274f9fb0936af0b754a408154a2a24d2dd0041 +Subproject commit a71a5a9f2bb808cb2c45a82fe39fd5d4c43e5597