enqueue method

SdkResult<void> enqueue({
  1. required String idempotencyKey,
  2. required Map<String, Object?> payload,
  3. int priority = 0,
  4. int? expiresAtMs,
})

Queues payload under idempotencyKey — replaces any existing item with the same key rather than duplicating it. Bounded at capacity: once full, the lowest-priority item is evicted to make room.

Implementation

SdkResult<void> enqueue({
  required String idempotencyKey,
  required Map<String, Object?> payload,
  int priority = 0,
  int? expiresAtMs,
}) {
  if (idempotencyKey.isEmpty) {
    return const SdkFailure(
      kind: SdkErrorKind.validation,
      message: 'idempotencyKey must not be empty',
    );
  }
  final next = [...items]
    ..removeWhere((i) => i.idempotencyKey == idempotencyKey);
  if (next.length >= capacity) {
    // BUG-44: eviction candidates exclude any item already parked in
    // manualReview (never auto-discard something only a human decision
    // can resolve) and must have STRICTLY LOWER priority than the
    // incoming item (a low/equal-priority newcomer can't bump an
    // existing higher-or-equal-priority item). If nothing qualifies,
    // the outbox is genuinely full for this item — reject it loudly
    // instead of silently evicting something it has no right to.
    final evictable = [
      for (var i = 0; i < next.length; i++)
        if (!next[i].manualReview && next[i].priority < priority) i,
    ];
    if (evictable.isEmpty) {
      return const SdkFailure(
        kind: SdkErrorKind.validation,
        message:
            'Outbox is full and no lower-priority, non-manualReview item '
            'can be evicted for this one',
      );
    }
    var lowestIndex = evictable.first;
    for (final i in evictable) {
      if (next[i].priority < next[lowestIndex].priority) lowestIndex = i;
    }
    next.removeAt(lowestIndex);
  }
  next.add(
    OutboxItem(
      idempotencyKey: idempotencyKey,
      payload: payload,
      priority: priority,
      expiresAtMs: expiresAtMs,
    ),
  );
  items.assignAll(next);
  // BUG-89: a drain() pass already in flight took its `ordered` snapshot
  // before this item existed, so it won't see it — flag it so drain()'s
  // own loop runs one more pass after this one finishes instead of
  // leaving this item stuck until some later, not-guaranteed-to-happen
  // drain() call.
  if (_draining) _dirtyDuringDrain = true;
  unawaited(_persist());
  // Auto-drain only when a ConnectivityCoordinator confirms we're online
  // right now — without one, the caller drives drain() explicitly (e.g.
  // this SDK's own tests, or an app not wiring FEAT-62 in). Auto-draining
  // unconditionally here would race a caller enqueuing several items
  // back-to-back against each one's own fire-and-forget drain attempt,
  // breaking the priority ordering a single drain() call guarantees.
  if (connectivity?.state == ConnectivityState.online) {
    unawaited(drain());
  }
  return const SdkSuccess(null);
}