WP_REST_Server::READABLE, 'callback' => array( $this, 'pull_orders' ), 'permission_callback' => array( $this, 'permissions_check' ), 'args' => array( 'limit' => array( 'default' => 100, 'sanitize_callback' => 'absint', ), 'updated_at_gmt' => array( 'default' => '1970-01-01T00:00:00.000Z', 'sanitize_callback' => 'sanitize_text_field', ), 'order_id' => array( 'default' => 0, 'sanitize_callback' => 'absint', ), 'sequence' => array( 'default' => 0, 'sanitize_callback' => 'absint', ), 'include_deletes' => array( 'default' => false, 'type' => 'boolean', 'sanitize_callback' => 'rest_sanitize_boolean', ), ), ) ); // Out-of-band admin lane (ADR 0021): populates the gated journal that // feeds /orders/pull. Zero client callers by design — an operator repair // job — and it stays health-gated because its work lives in the gated // table (it cannot cure a broken install, only fail against it). register_rest_route( Api::ROUTE_NAMESPACE, '/orders/index/backfill', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'index_backfill' ), 'permission_callback' => array( $this, 'admin_permissions_check' ), 'args' => array( 'limit' => array( 'default' => 50, 'sanitize_callback' => 'absint', ), 'reset' => array( 'default' => '', 'sanitize_callback' => 'sanitize_text_field', ), ), ) ); } /** * The checkpointed greedy order pull. Renders the planner's typed decisions * onto the batch response shape; the checkpoint hold-back, coalescing, * tombstone-split and uuid-stop invariants live in the planner. */ public function pull_orders( WP_REST_Request $request ) { $limit = max( 1, min( 250, (int) $request->get_param( 'limit' ) ) ); $updated_at_gmt = (string) $request->get_param( 'updated_at_gmt' ); $order_id = (int) $request->get_param( 'order_id' ); $sequence = (int) $request->get_param( 'sequence' ); $include_deletes = rest_sanitize_boolean( $request->get_param( 'include_deletes' ) ); // strict: 'false'/'0' ⇒ false $query = new Order_Query(); $serializer = new Order_Serializer(); $change_rows = $query->changes_after_checkpoint( $updated_at_gmt, $order_id, $sequence, $limit + 1 ); $has_more = count( $change_rows ) > $limit; $planner = new Order_Pull_Planner( array( 'updatedAtGmt' => $updated_at_gmt, 'orderId' => $order_id, 'revision' => '', 'sequence' => $sequence, ), $include_deletes ); $plan = $planner->plan( $change_rows, $has_more, function ( int $id ) use ( $serializer, $request ): array { return $serializer->serialize_order( $id, $request ); }, static function ( array $full_payload ): string { return Order_Serializer::canonical_revision( $full_payload ); } ); $documents = array(); $deletes = array(); // wooOrderIds of deleted orders (F6) — a SEPARATE channel from documents $response_checkpoint = array( 'updatedAtGmt' => $updated_at_gmt, 'orderId' => $order_id, 'revision' => '', 'sequence' => $sequence, ); foreach ( $plan as $decision ) { if ( 'tombstone' === $decision['type'] ) { $deletes[] = $decision['wooOrderId']; continue; } if ( 'complete' === $decision['type'] ) { $response_checkpoint = $decision['checkpoint']; $has_more = $decision['hasMore']; continue; } $full_payload = $decision['payload']; $documents[] = Order_Document::build( $full_payload, $full_payload, $decision['orderId'], $decision['revision'], $decision['checkpoint'], false, 'custom-pull' ); } $payloads = (array) apply_filters( 'woocommerce_pos_sync_order_pull_payloads', array_column( $documents, 'payload' ), 'orders', $request ); foreach ( $payloads as $index => $payload ) { $documents[ $index ]['payload'] = $payload; } // Journal epoch + head (F8): the client resyncs from zero when the epoch it stored differs // (a new sequence generation) or when its checkpoint sequence exceeds the head (the // AUTO_INCREMENT space reset beneath it). Cheap: an autoloaded option + a MAX(sequence). $journal = new Sync_Journal(); return rest_ensure_response( array( 'documents' => $documents, 'deletes' => $deletes, // wooOrderIds the client resolves + removes (F6); empty unless include_deletes 'checkpoint' => $response_checkpoint, 'hasMore' => $has_more, 'epoch' => $journal->ensure_epoch(), // F8 journal epoch — client resyncs on mismatch 'head' => $journal->head_sequence( array( 'order' ) ), // F8 order-lane head — client resyncs if its cursor exceeds it; stream-scoped so catalogue writes don't move it 'horizon' => $journal->prune_watermark( array( 'order' ) ), ) ); } /** * Run one bounded chunk of the append-only journal backfill. `reset=1` * clears the persisted backfill cursor so a COMPLETED store can re-run the * append-only backfill; the F8 epoch is untouched. */ public function index_backfill( WP_REST_Request $request ) { $raw_limit = $request->get_param( 'limit' ); $limit = max( 1, min( 250, (int) ( null === $raw_limit ? 50 : $raw_limit ) ) ); $journal = new Sync_Journal(); if ( in_array( (string) $request->get_param( 'reset' ), array( '1', 'true' ), true ) ) { $journal->reset_backfill_state(); } return rest_ensure_response( $journal->run_backfill_chunk( $limit ) ); } }