Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: CI

on:
push:
pull_request:

jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dev dependencies
run: pip install -e .[dev]
- name: Lint with ruff
run: ruff check .
- name: Check formatting with black
run: black --check .

test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dev dependencies
run: pip install -e .[dev]
- name: Run tests
run: pytest
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
__pycache__/
*.py[cod]
*.egg-info/
dist/
build/
.pytest_cache/
.ruff_cache/
*.egg
45 changes: 42 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,17 @@ Each package has a single responsibility. Do not let reconciliation logic bleed

## Tests

- Framework: `pytest`
**Every new module must ship with tests. A feature without a test file is not done.**

Full guide: [docs/testing.md](docs/testing.md).

- Framework: `pytest` — run with `pytest` from the repo root.
- Test files mirror the source tree: `src/sync_engine/store/base.py` → `tests/store/test_*.py`.
- One test per behavior, not per function.
- Webhook tests mock signature verification (do not depend on secrets in tests).
- Reconciliation tests mock the REST API (no network calls in CI).
- Naming: `test_<what_is_tested>_<condition>_<expected_behavior>`.
- Webhook tests mock signature verification (never depend on secrets).
- Reconciliation tests mock the REST API (no network calls in CI).
- Use `InMemoryStore` as the store fixture — never a real backend.

---

Expand All @@ -63,6 +69,29 @@ Each package has a single responsibility. Do not let reconciliation logic bleed
- Valid statuses: `Proposed` | `Accepted` | `Deprecated` | `Superseded by ADR-NNN`
- An ADR is not modified after acceptance — create a superseding ADR instead.

### When to write a new ADR

After every implementation session, ask: *did this introduce an architectural decision not covered by an existing ADR?*

Write a new ADR when the decision:
- Chooses between two or more structurally different approaches (e.g. abstract interface vs. concrete dependency, REST vs. event-driven).
- Has cross-cutting consequences — it constrains how multiple packages or future implementations must behave.
- Would be non-obvious to reverse without touching many files.

Do **not** write an ADR for:
- Code conventions already documented in this file (exception hierarchy, naming, formatting).
- Single-file implementation choices with no downstream constraints.
- Bug fixes or adjustments to existing decisions.

If a new implementation *contradicts* an existing ADR, the new ADR must reference it with status `Superseded by ADR-NNN`.

### Current ADR index

| ADR | Title | Status |
|---|---|---|
| [ADR-001](docs/adr/ADR-001-hybrid-rest-webhook.md) | Hybrid REST/webhook architecture | Accepted |
| [ADR-002](docs/adr/ADR-002-pluggable-store-interface.md) | Pluggable store interface via abstract base class | Accepted |

---

## Git
Expand All @@ -71,3 +100,13 @@ Each package has a single responsibility. Do not let reconciliation logic bleed
- Commit messages in English, imperative mood, no trailing period.
- Format: `<type>: <description>` — types: `feat`, `fix`, `docs`, `refactor`, `test`, `chore`
- Do not commit secrets, `.env` files, or tokens.

---

## PR review workflow

After pushing a PR branch:

1. Read all open review threads (`get_review_comments`).
2. For each thread: apply the fix, reply with the commit SHA and a one-line explanation, then resolve the thread.
3. Once all threads are resolved, post `@codex review` as a top-level PR comment to trigger a follow-up automated review.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# sync-engine

[![CI](https://github.com/koydas/sync-engine/actions/workflows/ci.yml/badge.svg)](https://github.com/koydas/sync-engine/actions/workflows/ci.yml)

Hybrid REST/webhook sync engine — push/pull coordination, idempotent processing, and failure recovery. Extracted from production integration patterns of `fullstack-pilot`.

---
Expand Down
69 changes: 69 additions & 0 deletions docs/adr/ADR-002-pluggable-store-interface.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# ADR-002 — Pluggable store interface via abstract base class

**Status**: Accepted
**Date**: 2026-06-09
**Deciders**: fullstack-pilot team

---

## Context

ADR-001 establishes that a persistent store is required for two things:

1. `last_successful_sync_at` — used by the reconciliation loop to compute the missed-event window after an outage.
2. The webhook queue — at-least-once delivery buffer that survives a crash between reception and processing.

ADR-001 does not specify the implementation technology or how the rest of the engine should depend on the store. Two options were considered:

1. **Concrete dependency** — engine components import and instantiate a specific backend directly (e.g. SQLite helper, Redis client).
2. **Abstract interface** — engine components depend on a Python ABC; the concrete backend is injected at startup.

---

## Decision

We define `SyncStore` as a Python abstract base class (`abc.ABC`) in `src/sync_engine/store/base.py`. All engine components — webhook handler, reconciliation loop, processor — interact exclusively with this interface. No engine code imports a concrete backend.

The interface surface is intentionally minimal (six methods):

| Method | Purpose |
|---|---|
| `get_last_sync_at()` | Read the recovery timestamp |
| `set_last_sync_at(dt)` | Write it after a confirmed commit |
| `is_event_processed(event_id)` | Idempotency check before processing |
| `mark_event_processed(event_id)` | Acknowledge after commit |
| `enqueue_webhook(event_id, payload)` | Persist before processing begins |
| `dequeue_unacknowledged()` | Replay buffer on recovery |

`dequeue_unacknowledged` returns `list[tuple[str, dict]]` — event_id is always returned alongside the payload so callers can acknowledge without the payload embedding its own ID.

---

## Why not a concrete dependency

- **Test coupling**: any concrete backend (SQLite, Redis) requires setup, teardown, and I/O in tests. An in-memory implementation behind the same interface eliminates all of that.
- **Migration cost**: coupling engine logic to a specific backend turns a backend swap into a cross-cutting refactor. With an interface, only a new implementation file is needed.
- **Invariant enforcement**: the interface is the right place to document the commit-before-acknowledge invariants (see CLAUDE.md §Invariants). Concrete classes are not readable enough for this.

---

## Consequences

**Positive:**
- `InMemoryStore` (`store/memory.py`) satisfies the interface for all tests — no I/O, no fixtures, no external process.
- Production backends (SQLite, Redis, Postgres) are drop-in replacements — they implement the ABC and are injected at startup.
- Engine invariants (acknowledge-after-commit, last_sync_at write-only-after-success) are enforced at the call site by convention documented on the interface, not scattered across backends.

**Negative / accepted costs:**
- Dependency injection must be wired at startup — the concrete backend is not self-instantiated.
- Adding a new persistence operation requires updating the ABC and every implementation, not just one file.

---

## Rejected alternatives

| Alternative | Reason for rejection |
|---|---|
| Hardcoded SQLite backend | Tight coupling; tests require a real file; migration is cross-cutting |
| Dataclass / TypedDict (no ABC) | No enforcement of the interface contract; silent drift between implementations |
| Repository pattern with separate read/write interfaces | Over-engineering for current scope; a single interface covers all six operations cleanly |
119 changes: 119 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# Testing guide

## Running tests

```bash
pip install -e ".[dev]"
pytest # all tests
pytest -v # verbose
pytest tests/store/ # one subdirectory
```

---

## Layout

Tests mirror the source tree under `tests/`:

```
tests/
├── test_exceptions.py
├── store/
│ └── test_memory_store.py
├── webhook/
│ └── test_handler.py # (upcoming)
├── reconciliation/
│ └── test_loop.py # (upcoming)
└── processor/
└── test_processor.py # (upcoming)
```

Every new module in `src/sync_engine/<package>/` gets a matching test file
in `tests/<package>/`. No test file = the feature is not done.

---

## Naming

```
test_<what_is_tested>_<condition>_<expected_behavior>
```

One test per behavior, not per function. Examples:

```python
def test_get_last_sync_at_before_any_write_returns_none(): ...
def test_dequeue_unacknowledged_excludes_processed_events(): ...
def test_webhook_signature_error_caught_as_sync_error(): ...
```

---

## Fixtures

Define fixtures at the test-file level unless shared across multiple files, in
which case add them to a `conftest.py` in the relevant directory.

`InMemoryStore` is the canonical fixture for any test that touches the store:

```python
import pytest
from sync_engine.store.memory import InMemoryStore

@pytest.fixture
def store() -> InMemoryStore:
return InMemoryStore()
```

---

## Mocking rules

### Webhook signature verification

Never depend on real secrets in tests. Patch the verification call:

```python
from unittest.mock import patch

def test_valid_webhook_is_queued(store):
with patch("sync_engine.webhook.handler.verify_signature", return_value=True):
...
```

### REST API (reconciliation)

No network calls in CI. Patch `httpx.Client` or inject a fake transport:

```python
import httpx

def test_reconciliation_fetches_since_last_sync(store):
def handler(request):
return httpx.Response(200, json={"items": []})

transport = httpx.MockTransport(handler)
client = httpx.Client(transport=transport)
...
```

---

## What to test

For each new module, cover at minimum:

| Scenario | Example |
|---|---|
| Happy path | event processed end-to-end |
| Idempotency | same `event_id` processed twice, second is a no-op |
| Pre-condition violated | invalid signature → `WebhookSignatureError` raised |
| State after failure | crash mid-processing → event still in `dequeue_unacknowledged` |

---

## What not to test

- Internal implementation details (private methods, internal dict structure).
- The abstract `SyncStore` interface directly — test it through `InMemoryStore`.
- Framework behavior (FastAPI routing, httpx serialization).
26 changes: 26 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "sync-engine"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
"fastapi",
"httpx",
]

[project.optional-dependencies]
dev = [
"pytest",
"ruff",
"black",
Comment thread
koydas marked this conversation as resolved.
]

[tool.hatch.build.targets.wheel]
packages = ["src/sync_engine"]

[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
Empty file added src/sync_engine/__init__.py
Empty file.
14 changes: 14 additions & 0 deletions src/sync_engine/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
class SyncError(Exception):
"""Base class for all sync-engine business exceptions."""


class WebhookSignatureError(SyncError):
"""Raised when a webhook payload fails HMAC signature verification."""


class DuplicateEventError(SyncError):
"""Raised when an event_id has already been processed (idempotency guard)."""


class ReconciliationError(SyncError):
"""Raised when the REST reconciliation loop encounters an unrecoverable error."""
Empty file.
32 changes: 32 additions & 0 deletions src/sync_engine/store/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from abc import ABC, abstractmethod
from datetime import datetime


class SyncStore(ABC):
@abstractmethod
def get_last_sync_at(self) -> datetime | None:
"""Return the timestamp of the last confirmed successful sync, or None."""

@abstractmethod
def set_last_sync_at(self, dt: datetime) -> None:
"""Persist dt as the last successful sync timestamp.

Must only be called after the sync has been fully committed — never
at the start of a cycle (see invariant #3 in CLAUDE.md).
"""

@abstractmethod
def is_event_processed(self, event_id: str) -> bool:
"""Return True if event_id has already been committed (idempotency check)."""

@abstractmethod
def mark_event_processed(self, event_id: str) -> None:
"""Record event_id as processed. Call only after the payload is committed."""

@abstractmethod
def enqueue_webhook(self, event_id: str, payload: dict) -> None:
"""Persist a webhook payload before processing begins (at-least-once delivery)."""

@abstractmethod
def dequeue_unacknowledged(self) -> list[tuple[str, dict]]:
"""Return (event_id, payload) pairs for every queued webhook not yet marked processed."""
Loading
Loading