| 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 |
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 |
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 |
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 |
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 |
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 |
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();
|