Client state
Client state
Views\MultiViewClient applies bootstrap pages and deltas. Everything it knows
lives behind Client\Contracts\ClientState, so a device that is killed
mid-page comes back knowing what it knew.
use Cbox\Sync\Client\Pdo\PdoClientState;
use Cbox\Sync\Views\MultiViewClient;
$state = new PdoClientState(new PDO('sqlite:'.$path));
$state->migrate();
$client = new MultiViewClient($state);
Client\InMemoryClientState is the default and needs no arguments; it is for
tests and for a process that can afford to bootstrap again.
Each page is applied inside one state transaction. Records, version watermarks, tombstone watermarks, view memberships and the cursor move together, because a cursor that advanced without its records would claim progress the local data does not have.
What survives a reset, and why
resetView() drops one view's cursor, bootstrap progress and ownership. It
deliberately keeps the canonical knowledge: the highest version seen for
each entity and the delete watermark.
That watermark is the barrier that stops a slow or stale page from resurrecting something the client already saw deleted. Losing it on a reset would turn every epoch rotation into a chance for deleted records to come back.
The outbox
Client\Outbox owns the ordering and retry semantics for one device, over
Client\Contracts\OutboxStore — in memory, or Client\Pdo\PdoOutboxStore.
// The queue and the replica share one connection, so what the device knows and
// what it still owes commit against the same database.
$queue = new PdoOutboxStore($pdo);
$queue->migrate();
$outbox = Outbox::for($queue, new Replica($deviceId));
$outbox->queue($entity, MutationKind::Update, [FieldOperation::set('title', $title)], $baseVersion);
A sequence is assigned when a mutation is sent, not when it is queued.
Numbering at queue time looks harmless and is not: a write the transport refuses
has already taken a number the server never receives, so the server waits for it
forever and every later write comes back as a gap for a number that will never
arrive. The device is wedged permanently. Numbering at send time makes that hole
impossible, and it is why head() returns a mutation numbered for this attempt
rather than the one that was stored.
Once handed out, a write keeps its number for every resend, and while one on a
stream is waiting for its answer, that stream sends it again before numbering
anything else. A write may also be sent out of queue order with take() - a
parent's create ahead of the child that points at it - and the same rule keeps
it from claiming a number another write already used.
A transport sends head() and reports back exactly one of these outcomes:
| Outcome | Call | Why |
|---|---|---|
| processed — applied, no-op, conflicted | acknowledged($mutation, $named) |
answers; the mutation is done. $named is the record's name when the server named a create |
processed and refused — rejected, validation_failed, precondition_failed |
refused($mutation, $status) |
the write will never land; kept as abandoned, and a refused create holds back what depends on it |
pull_required (sent with OnConflict::Pull) |
rebase($mutation, $version, $operations), then send it again |
nothing was stored; the same identity goes again, rethought |
| server is busy | nothing; send the same mutation again | the engine returns the stored result for a repeated id, so only an unchanged id is safe |
| sign in again | answered($mutation), and nothing else |
this sending did not land; the write waits |
| server has not seen everything before this | resumeAfter($mutation, $acknowledgedSequence) |
drops only what it already has; the rest go again in order |
| terminal for this identity | abandon($mutation, $reason) - answered: false if you gave up without an answer |
it can never be sent again, so it leaves the queue instead of blocking everything behind it |
receipt_pruned |
settledUnknown($mutation, $acknowledgedSequence) |
it may have been applied and nobody can say; it takes its own position so the replays behind it keep theirs. When the server is further along the stream than the device - restored from an old backup, or an id reused by a new install - every write still queued on the stream is settled the same way at once, and new writes go on after the server |
Getting any of those wrong is silent data loss or a permanently wedged queue, which is why they are implemented once here rather than in each application. Abandoned mutations are kept with their reason and must be surfaced: nothing else will tell the user that a write is never going to land. A handle names one record on the device: queueing a create whose handle is already used for a record in another space is refused, since a reference to it carries no space. It must never equal a server id either - a reference to that server record would be rewritten to the handle's name. Use UUIDs.
A create that may have landed and is dismissed takes the writes that need it
along as parent_unknown. Find the record on the server, tell the outbox what it
is called with found($handle, $name), then requeue them; requeue() refuses
until a name has been applied.
Do not offer to requeue a receipt_pruned write without asking: it may already
be on the server, and sending it again under a new identity applies it twice.