Skip to main content

max / synckit

Put exclude_where in the wire fingerprint The predicate is applied symmetrically on export (the trigger's WHEN) and import (the apply guard), so editing it changes which rows cross the wire. Device A narrows it and a class of rows starts syncing; device B, one build behind, pulls rows it was written to exclude and re-pushes one it does not project. Silent, which is the class the gate exists to stop. It renders last on the line, since SQL carries spaces and nothing may follow it, and a table without a predicate emits no exclude= term at all, so a schema that never had one keeps its digest. Minor bump: every schema carrying a predicate now fingerprints differently. The four local-only fields stay out, and the boundary test says which and why. Decided 2026-08-27 by Max, GO synckit-client 3e6f3dc9.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-28 16:59 UTC
Signed with PGP, not checked
Commit: 2ee43a2060ad6cc6f1ebf137075aef3373b4216b
Parent: 31eafd0
2 files changed, +76 insertions, -7 deletions
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "synckit-client"
3 - version = "0.9.1"
3 + version = "0.10.0"
4 4 edition = "2024"
5 5 license = "MIT"
6 6 description = "SyncKit client SDK with end-to-end encryption"
@@ -306,6 +306,11 @@
306 306
307 307 /// This table's line in [`SyncSchema::wire_manifest`]: the wire-visible
308 308 /// facts about it, and nothing else.
309 + ///
310 + /// `exclude_where` is last on the line because a SQL predicate carries
311 + /// spaces, so nothing may follow it. A table without a predicate emits no
312 + /// `exclude=` term at all rather than an empty one, which keeps the line a
313 + /// peer reads unchanged for the tables that never had one.
309 314 pub(crate) fn wire_line(&self) -> String {
310 315 let row_id = match self.row_id {
311 316 RowIdScheme::PrimaryKey => "pk",
@@ -323,8 +328,12 @@
323 328 DeleteMode::Ignore => "ignore".to_string(),
324 329 DeleteMode::Tombstone { column } => format!("tombstone:{column}"),
325 330 };
331 + let exclude = match self.exclude_where {
332 + Some(predicate) => format!(" exclude={predicate}"),
333 + None => String::new(),
334 + };
326 335 format!(
327 - "{} pk={} row_id={row_id} mode={mode} deletes={deletes} cols={}",
336 + "{} pk={} row_id={row_id} mode={mode} deletes={deletes} cols={}{exclude}",
328 337 self.name,
329 338 self.pk.join(","),
330 339 self.emitted_columns().join(","),
@@ -419,6 +428,14 @@
419 428 /// | new synced column | yes |
420 429 /// | changed `pk` or [`RowIdScheme`] | yes |
421 430 /// | changed [`SyncMode`] / [`DeleteMode`] | yes |
431 + /// | changed [`exclude_where`](SyncTable::exclude_where) | yes |
432 + ///
433 + /// The predicate is on the list because it is applied symmetrically on
434 + /// export and import, so editing it changes which rows cross the wire. A
435 + /// device that narrows the predicate starts syncing a class of rows; a
436 + /// device one build behind pulls rows it was written to exclude, and
437 + /// re-pushes ones it does not project. Silently, which is the class the gate
438 + /// exists to stop.
422 439 ///
423 440 /// Leaving it undeclared leaves the gate off, which is what an app that has
424 441 /// not adopted this yet gets. Declaring it is the opt-in, and
@@ -440,10 +457,22 @@
440 457 /// table, in declared order.
441 458 ///
442 459 /// Only what a peer can observe is in it, table names, [`emitted_columns`]
443 - /// (which *is* the wire projection), `pk`, [`RowIdScheme`], [`SyncMode`] and
444 - /// [`DeleteMode`]. Local-only policy is deliberately absent, so changing how
445 - /// this device resolves a conflict or which local column carries group
446 - /// provenance does not lock two devices apart.
460 + /// (which *is* the wire projection), `pk`, [`RowIdScheme`], [`SyncMode`],
461 + /// [`DeleteMode`] and [`exclude_where`]. Local-only policy is deliberately
462 + /// absent, so changing how this device resolves a conflict or which local
463 + /// column carries group provenance does not lock two devices apart.
464 + ///
465 + /// The four deliberate exclusions are `group_scope`, `field_merge` (with
466 + /// `counters` and `dependent`), `preserve_local`, and the schema-level
467 + /// [`ConflictStrategy`]. They make two devices *behave* differently rather
468 + /// than corrupt each other, which is a lesser severity: a device resolving a
469 + /// conflict its own way still applies a well-formed row. `preserve_local` is
470 + /// the sharpest of them, and the clearest case for staying out: it is a
471 + /// per-device list of columns the device keeps to itself, so two devices
472 + /// arguably *should* differ on it, and fingerprinting it would be wrong
473 + /// rather than merely strict.
474 + ///
475 + /// [`exclude_where`]: SyncTable::exclude_where
447 476 ///
448 477 /// **Declaration order is preserved rather than sorted**, because it is not
449 478 /// merely presentation: it is the foreign-key order the engine upserts in and
@@ -642,13 +671,29 @@
642 671 );
643 672 }
644 673
674 + /// The predicate is last on the line, and absent entirely when unset.
675 + #[test]
676 + fn a_predicate_renders_at_the_end_of_its_line() {
677 + let m = SyncSchema::new(vec![
678 + SyncTable::full("project", &["id", "name"]).exclude_where("{row}.name <> 'scratch'"),
679 + ])
680 + .wire_manifest();
681 + assert_eq!(
682 + m.lines().collect::<Vec<_>>(),
683 + vec![
684 + "project pk=id row_id=pk mode=full deletes=hard cols=id,name \
685 + exclude={row}.name <> 'scratch'"
686 + ]
687 + );
688 + }
689 +
645 690 #[test]
646 691 fn fingerprint_is_stable_across_calls() {
647 692 assert_eq!(base().fingerprint(), base().fingerprint());
648 693 assert_eq!(base().fingerprint().len(), 64);
649 694 }
650 695
651 - /// Each of the four rows the policy says fires the gate.
696 + /// Each of the rows the policy says fires the gate.
652 697 #[test]
653 698 fn every_ruled_wire_change_moves_the_fingerprint() {
654 699 let before = base().fingerprint();
@@ -710,6 +755,25 @@
710 755 ignore.fingerprint(),
711 756 "the two non-hard delete modes are distinguishable"
712 757 );
758 +
759 + // A predicate where there was none: rows that used to cross stop.
760 + let filtered = SyncSchema::new(vec![
761 + SyncTable::full("project", &["id", "name"]).exclude_where("{row}.name <> 'scratch'"),
762 + SyncTable::full("task", &["id", "project_id", "title", "done"]),
763 + ]);
764 + assert_ne!(filtered.fingerprint(), before, "new exclude_where");
765 +
766 + // An edited predicate. Narrowing one starts a class of rows syncing, so
767 + // it moves the number exactly as adding one does.
768 + let narrowed = SyncSchema::new(vec![
769 + SyncTable::full("project", &["id", "name"]).exclude_where("{row}.name <> 'tmp'"),
770 + SyncTable::full("task", &["id", "project_id", "title", "done"]),
771 + ]);
772 + assert_ne!(
773 + narrowed.fingerprint(),
774 + filtered.fingerprint(),
775 + "changed exclude_where predicate"
776 + );
713 777 }
714 778
715 779 /// Declaration order is the FK apply order, so it is wire-visible and the
@@ -726,6 +790,11 @@
726 790 /// The boundary the policy draws: local-only policy is not wire-visible, so
727 791 /// changing it must not lock two devices apart. If one of these ever should
728 792 /// fire the gate, this test is where the decision lands.
793 + ///
794 + /// These four are all that stay out, ruled 2026-08-27. Each makes two
795 + /// devices behave differently rather than corrupt each other;
796 + /// `exclude_where` was the one that failed that test and moved to
797 + /// `every_ruled_wire_change_moves_the_fingerprint`.
729 798 #[test]
730 799 fn local_only_policy_does_not_move_the_fingerprint() {
731 800 let before = base().fingerprint();