LiveSync: Two-Way Object Synchronization¶
LiveSync keeps the same object alive on both tiers. It has two directions, each opt-in:
- Down (server → clients): annotate the class
@LiveSync; after mutating it on the server, callsyncEngine.notifyChanged(obj)— every client's instance updates in place. - Up (client → server): additionally annotate
@ClientWritable; setter calls on the client's instance propagate to the server automatically — no service method, no explicit save:
@DataModel @LiveSync @ClientWritable("editor")
public class TeamProfile {
@NotBlank private String mission;
@Min(1) private int headcount;
...
}
How the up direction works¶
Deserialization on the client instantiates an APT-generated <Model>_Live subclass whose setters report changes. A burst of setter calls coalesces into one whole-object mutation frame. The server stays authoritative — every mutation passes three gates before it touches the canonical instance:
- the class is
@ClientWritable(without it, mutations are ignored — deny by default); - the session holds a declared write role, if any (
@ClientWritable("editor")); - the proposed state passes the model's validation annotations, checked against a throwaway copy so a rejected mutation never touches server state.
Accepted mutations are applied in place, announced to LiveMutationListener beans, and re-broadcast to all sessions. Rejected mutations answer the writer with a corrective sync that reverts its optimistic local change.
Persistence and business logic¶
The framework does not know your storage root — implement LiveMutationListener to persist and audit:
@ApplicationScoped
public class ProfilePersistence implements LiveMutationListener {
@Inject ZeroZDbNode db;
@Override
public void onMutated(Object model, Principal principal) {
// A write-block, so the change commits atomically and is on disk when this returns.
db.localDb().write(ctx -> ctx.store(model));
}
}
For writes that are operations rather than edits — "checkout", "approve", "close ticket" — keep using RMI service methods: an operation deserves a name, its own security annotations, and a validation point. The doctrine: state edits sync, operations call.
Rules and limits (stated plainly)¶
- Setters are the tracking boundary. Mutations must go through setters. In-place collection edits (
obj.getTags().add(...)) are invisible — reassign via the setter or callLiveMutationTracker.touch(obj)afterward. Tracked collections are planned. - Whole-object, last-write-wins. Mutations replace the object's state; two unlocked concurrent editors race and the later write wins. Serialize editors with
LiveMutex(see the collab-editor example pattern) where that matters. Field-level merging and version-conflict rejection (MUTATE/ACK/REJECTversions) are reserved in the protocol but not yet implemented. - Re-rendering is automatic. A
@LiveSyncobject is a reactive dependency: read one of its getters inside anEffectorComputedand an inbound sync re-runs it. Notification is per object, not per field. - Only objects the server has previously synced to the client can be mutated (the canonical instance must exist in the server's object mapper).
Choosing a propagation feature¶
| Shape | Client writes | |
|---|---|---|
| Shared signal | one value, latest-wins, retained | sharedWritable opt-in |
| Server event | discrete occurrence, no replay | n/a (events come from the server) |
| LiveSync | object graph, in-place | @ClientWritable opt-in |