Skip to content

Deletion Log Sync

This page explains the design behind Conflux's deletion log consumption (CFX-211, part of epic CFX-208 "Tracking deleted records") — why it exists, and the trade-offs in the chosen approach. It is the sole mechanism by which Conflux detects records deleted on the source platform; an earlier, full-sync-only reconciliation mechanism (comparing what a full sync touched against what already existed) was removed once every registered integration implemented a deletion log.

For the exact contract method and DTO, see Integration Contract — fetchDeletionLog(). For the endpoints it consumes, see WooCommerce Integration — Deletion Log and PrestaShop Integration — Deletion Log.

Why Not Infer Deletions From Absence

A delta sync only fetches records modified since the cursor, so a record absent from a delta response is not necessarily deleted — it may simply be unchanged. Detecting deletions by comparing a sync's result set against what already exists in Conflux therefore only works after a full sync, and even then only catches deletions between two full syncs: a product removed from a store shortly after a full sync would stay visible in Conflux until the next one caught up — and full syncs are only triggered on demand (see Scheduled Sync), not on their own recurring schedule.

The deletion log avoids inferring deletions from absence entirely. The source platform (via CFX-209/CFX-210 plugin work, in separate repos) actively logs every deletion to a GET .../deletion-log?since=&entity_type= endpoint. Conflux polls that log after every bulk sync batch — delta or full — and soft-deletes the matching local records directly, closing the gap a snapshot-comparison approach would leave between full syncs.

Where It Runs: FetchDeletionLogJob

BulkSyncJob resolves the integration once per capability and checks whether it implements HasDeletionLog before deciding what happens next to the batch — for both delta and full syncs:

if ($supportsDeletionLog) {
    FetchDeletionLogJob::dispatch($syncBatch, $modifiedAfter);
} else {
    $syncBatch->markCompleted();
}

This also happens when the probe found nothing to fetch (totalPages === 0) — BulkSyncJob still creates a SyncBatch and takes the same branch directly, without a Bus::batch() around it, since there are no bulk jobs to wait for. A run that inserts or updates nothing can still be the run in which records were deleted on the source platform, and that must not go unchecked.

FetchDeletionLogJob owns the batch's Completed transition only for the connections it's actually dispatched for — see Sync Batches — Status Lifecycle. An integration without a deletion log has nothing to wait for, so BulkSyncJob closes the batch itself rather than dispatching a job whose handle() would do nothing. Where it is dispatched, the batch stays InProgress until deletions have actually been processed, and moves to Failed if the job's queue retries are exhausted first — see FetchDeletionLogJob::failed() — so a permanently stuck sweep is visible rather than sitting at InProgress forever with no signal it stopped running.

Reusing the Delta Cursor — No New Storage

FetchDeletionLogJob receives the same $modifiedAfter value BulkSyncJob already computed for the bulk export itself (see Delta Sync for how that cursor is derived from the previous completed SyncBatch.started_at). No new cursor field or table was introduced for the deletion log.

The original epic refinement proposed a dedicated deletion_log_fetched_at field to track deletion-log progress independently of the export cursor. This was deliberately dropped in favour of piggybacking on the existing delta cursor:

  • SystemConnection.configuration — the obvious place to store a per-connection timestamp — is wholesale-replaced by the connector-widget settings endpoint on every save. Writing a deletion_log_fetched_at key into it would be silently lost the next time a merchant updates their widget settings.
  • A separate sync_cursors-style table would duplicate the cursor-tracking already done for delta sync, for no real benefit: the deletion log endpoint is queried at exactly the same cadence as the bulk export it follows, so the two cursors would always move in lockstep anyway.

The trade-off: on the very first sync for a connection ($modifiedAfter === null), the deletion log is queried with since = null too. An integration must treat that as "return the full deletion log" (or simply nothing, if the platform has no history yet) rather than assuming a non-null cursor.

Matching Entries to Local Records

FetchDeletionLogJob resolves the target model class from the SyncBatch's capability via SystemCapability::modelClass(), then chunks the DeletionLogRecords returned by the integration (500 at a time) and soft-deletes matching local records by source_id in bulk, rather than one lookup query per entry:

$modelClass::query()
    ->where('system_connection_id', $connection->id)
    ->whereIn('source_id', $sourceIds)
    ->deleteWithReason(DeletionReason::SourceEvent);

deleteWithReason() is App\Models\Builders\MirroredDomainRecordBuilder's, and it records the cause and moves content_changed_at in the same statement as the soft delete. The cause is what lets the delivery policy tell this deletion apart from a teardown of Conflux's own mirror, and the moved change signal is what makes the resulting tombstone reachable by the delivery cursor. In the job itself the query is assigned to a variable first, because $modelClass is a class-string and the custom builder is not visible through it to static analysis.

entityId is compared against source_id as a string — the same dedup key every transformer uses to match platform records to Conflux rows (see Integration Contract — the sourceId / id split). entityType on the DTO is not separately validated against the batch's capability: the job trusts that the integration only returns entries relevant to the capability it was asked for, since fetchDeletionLog() is itself called with that capability as a parameter.

Idempotency

Soft-deleting is safe to run more than once against the same deletion log window without extra bookkeeping: Eloquent's default soft-delete scope already excludes trashed rows from every query above, so a record already soft-deleted (by this job or by any other path) simply isn't matched again on a re-run, and deleted_items is not incremented twice. This matters because a deletion log entry is not "consumed" or acknowledged anywhere — the same since window could, in principle, be re-queried without double-counting.

Cascading to Line Items

For the Orders capability, soft-deleting the matched Orders also soft-deletes their LineItem rows in the same pass, via one whereIn('order_id', $orderIds)->delete() per chunk. Line items carry no source_id of their own to match against a deletion log entry; their staleness is derived entirely from the parent order's.

Cascading to Dependent Records

A customer deleted through the deletion log takes its consents with it. For each dependent domain of the capability, the job soft-deletes the connection's rows whose parent_source_id is in the chunk, with the same cause (source_event). Matching is by source id within the same connection, so another connection's consents that share a parent_source_id are not touched. Dependent rows are not added to deleted_items: a batch counts its own domain's records.

Optional Capability via HasDeletionLog, Not the Base Contract

fetchDeletionLog() is not part of IntegrationContract, which declares no methods at all: every capability is opt-in, and a platform that exposes a deletion log declares App\Contracts\HasDeletionLog and takes the App\Services\Concerns\FetchesDeletionLog trait that backs it — the same interface-plus-trait shape every other capability uses, recorded in ADR-0023. The trait supplies a final public function fetchDeletionLog() wrapping an abstract protected performFetchDeletionLog() hook in the same circuit-breaker and exception-translation strategy used for probeBulkExport(). An integration that never takes the trait has no deletion-log methods at all, rather than inherited plumbing waiting behind an interface declaration.

This was a deliberate departure from the original design, which put fetchDeletionLog()/supportsDeletionLog(): bool directly on IntegrationContract (mirroring how processWebhook()/handleWebhook() works for inbound webhooks — see Integration Contract Design — Inbound Webhooks). That shape forced every integration, including ones with no deletion log endpoint at all, to either implement performFetchDeletionLog() or inherit a supportsDeletionLog(): false default that exists solely to guard a method the class doesn't otherwise support — a capability check bolted onto a contract every integration must satisfy, for a capability not every integration has. HasDeletionLog moves that distinction to the type system: an integration either declares the interface and takes the trait — in which case PHP requires performFetchDeletionLog() — or it does neither, and has nothing to override.

BulkSyncJob checks $integration instanceof HasDeletionLog once, right after resolving the integration for the batch, and that single check gates whether FetchDeletionLogJob is ever dispatched — unlike processWebhook(), which only runs in reaction to an inbound request. An integration without a deletion log is never handed to FetchDeletionLogJob in the first place, so there's no default performFetchDeletionLog() throw to guard against there: FetchDeletionLogJob itself only ever runs for a HasDeletionLog integration, a static guarantee from its sole caller that it asserts rather than re-checks.

WooCommerce

WooCommerceIntegration implements HasDeletionLog, takes FetchesDeletionLog, and implements performFetchDeletionLog(): it calls GET {storeBase}/wp-json/wc-conflux/v1/deletion-log with an entity_type query parameter (singular: product/order/customer) and an optional since parameter, authenticated the same way as the existing bulk-export calls. See WooCommerce Integration — Deletion Log for the full endpoint reference.

PrestaShop

PrestaShopIntegration implements HasDeletionLog, takes FetchesDeletionLog, and implements performFetchDeletionLog(): it calls GET {base_url}/api/conflux/deletion-log, a custom webservice resource added by the conflux module (CFX-210, a separate plugin repo), with the same entity_type/since query parameters as WooCommerce, authenticated via the same HTTP Basic Auth (credentials.api_key) used by the bulk-export endpoints. See PrestaShop Integration — Deletion Log for the full endpoint reference, including the module's deletion-log table and retention policy.

See also