Skip to content
This repository was archived by the owner on Sep 11, 2026. It is now read-only.

Move documentation to DocC and add runnable example app - #67

Merged
simonweniger merged 5 commits into
mainfrom
claude/remaining-project-issues-pubkf5
Jul 31, 2026
Merged

simonweniger merged 5 commits into
mainfrom
claude/remaining-project-issues-pubkf5

Conversation

@simonweniger

Copy link
Copy Markdown
Contributor

Summary

This PR consolidates HappySync's documentation into the source code via DocC, making it discoverable on Swift Package Index and eliminating duplication between wiki pages and API docs. It also adds a complete, runnable example app demonstrating the engine with the in-memory fake backend.

Key Changes

Documentation Migration

  • Created DocC catalog in Sources/HappySync/Documentation.docc/ with pages for Getting Started, Server Setup, Testing, Operations, and Architecture
  • Updated wiki pages to redirect to the generated documentation on Swift Package Index
  • Added .spi.yml configuration to enable SPI documentation builds
  • Updated CI workflow to validate DocC builds

Example Application

  • Added Examples/ package with a complete two-table SwiftUI demo (lists and items with cascade delete)
  • DemoSync wires the engine to InMemorySyncRemote instead of Supabase, requiring no backend setup
  • DemoStore provides view state with plain GRDB ValueObservation for reads
  • DemoApp and ContentView demonstrate the UI patterns
  • DemoSyncTests assert the same behaviors the app exhibits (writes uploading, cascading deletes, remote changes via doorbell, dead-letter repair, sign-out quiescing)
  • Includes server schema migrations for reference (in supabase/migrations/)

API Enhancements

Test Coverage

  • Added BatchUploadTests.swift covering chunking logic and healthy/unhealthy upload paths
  • Added TypedManifestTests.swift verifying typed manifest equivalence with string-based declarations

Documentation in Code

  • Expanded doc comments on SyncEngine, SyncTable, SyncRemote, and SyncOp
  • Added comprehensive parameter documentation for testing configuration

Notable Details

  • The example app runs entirely in-process with no network or Supabase project required, making it ideal for CI and local development
  • Documentation is now single-sourced in the code, eliminating drift between wiki and actual APIs
  • Batch upload chunking respects table and operation boundaries while capping request size, with per-row fallback on rejection
  • Typed manifest declarations are fully equivalent to string-based ones, allowing mixed usage in a single manifest

https://claude.ai/code/session_015y9Y9kec3hj8S35oLQXxvQ

claude added 5 commits July 31, 2026 17:23
`SupabaseRemote.upsert` sent exactly one row and `drainOutbox` looped entry by
entry, so a 500-row backlog was 500 sequential round trips: ~50s of pure latency
at 100ms RTT, with the engine reporting `.syncing` the whole time. That is the
common case — first sync after an import, a device back from a long offline
stretch, `retryDeadLetters()` with no argument, any bulk operation.

Consecutive entries sharing a table and an op now go out as one request: an
array body for the upsert, `in.(…)` for the tombstone update, capped at 256
rows. Chunking is adjacency-only — entries are never reordered to make a bigger
batch — so the FK-safe order `orderForUpload` produced survives intact.

Per-entry failure isolation is the part worth keeping, and it is kept: a
rejected batch names the request, not the row, so the chunk is re-run one entry
at a time and exactly the offending entries are classified, retried and parked
as before. The failed batch itself charges no attempts, or one poison row would
spend the retry budget of every healthy row beside it.

`SyncRemote` grows batched forms with default implementations that loop over the
single-row calls, so an existing conformance keeps working and only misses the
round-trip saving. Batch representations are matched back to their uploads by
primary key rather than arrival order, so a reordered response can't write one
row's server state onto another; the write-back for a whole batch now lands in
one transaction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015y9Y9kec3hj8S35oLQXxvQ
There was no way to see HappySync working without wiring a Supabase project,
writing a schema with the right triggers and RLS, and building an app. Every
integration started from prose, and the only proof the API works was a private
consumer nobody evaluating the library can read.

`Examples/` is a separate SPM package — an app that consumes HappySync should
look like one — with a two-table schema (`lists` → `items`, real FK) that is
enough to show every behaviour that matters: a plain GRDB write uploading with
no engine call, an expression update, a cascade delete tombstoning children
before their parent, reads staying plain `ValueObservation`, `isHealthy` vs a
green tick on `.idle`, a doorbell ring pulling a change from "device B", a
refused write parking, and the dead-letter repair that unparks it — plus
sign-out → stop → wipe → sign-in in the one order that's safe.

It runs against `InMemorySyncRemote`, so there is no backend to stand up: clone
and run. `supabase/migrations/` carries the server half of the contract for
pointing it at a real project, since that's the half a fake can't check.

The demo is split so the sync wiring lives in a SwiftUI-free `DemoCore`, and
`Tests/DemoCoreTests` asserts the same behaviours headlessly — CI runs them, so
the example can't rot into something that merely compiles.

Also makes `SyncSchema.migrator()` / `register(into:)` public. Both have been
documented as the way to share a migrator since the wiki was written, and
neither was actually reachable from outside the module.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015y9Y9kec3hj8S35oLQXxvQ
The source carries unusually good documentation — `SyncTable`'s field comments
explain not just what each option does but when to reach for it and what breaks
if you get it wrong — and none of it was reachable from a browser. There was no
catalog, so Swift Package Index rendered nothing and Xcode Quick Help was the
only way in. Meanwhile the wiki duplicated a subset of the same content by hand
and had already started to drift.

`Sources/HappySync/Documentation.docc/` now carries a landing page (the model in
five sentences, plus curation so SyncEngine/SyncTable/SyncStatus/DeadLetter lead
the symbol list) and the five articles, ported from the wiki so there is one
copy: Getting Started, Server Setup, Operations & Troubleshooting, Testing, and
Architecture. `.spi.yml` turns it into hosted documentation; the README links it.

The wiki pages that held that content are now one-paragraph pointers to it. The
sync contract stays in `docs/SYNC-CONTRACT.md` and is linked, not duplicated —
it is language-neutral and belongs outside the Swift docs.

Also adds the doc comments the two public symbols were missing (`SyncOp`'s cases
and `SyncCursor`'s fields), and a CI job that builds the catalog, so an
unresolvable symbol link surfaces on the PR rather than on Swift Package Index.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015y9Y9kec3hj8S35oLQXxvQ
`SyncTable` was eight stringly-typed parameters, so a rename in a Swift record
or a GRDB migration left the manifest stale with no build error. #49 catches
that at launch; this catches it at compile time, which is the difference between
a crash on a tester's device and a red squiggle.

`SyncTable.table(Recipe.self, …)` reads the table name off the record's
`databaseTableName`, and `dependsOn:` takes record types rather than names. It
returns the same `SyncTable` the string initializer does — validation, trigger
install and the drain are one code path, so this is a spelling choice rather
than a mode, and a manifest can mix both forms. The string form stays for tables
with no Swift record type.

Columns stay as checked as the record makes them: GRDB's conventional
`enum Columns` ties each name to a single declaration, while a bare
`Column("nutrition")` is exactly as unchecked as the string was. Key paths can't
give column names without a `CodingKeys` bridge the app would have to write, so
how much checking to buy stays the consumer's choice — and either way the launch
check against the live schema still runs, since a record can be ahead of the
migration that creates its table.

Deliberately not included: `engine.save(_:)` / `engine.delete(_:)`. Since #48
made writes plain GRDB, a typed write API would re-introduce the parallel write
path the trigger capture removed — and #56 dropped `delete(table:pk:)` for the
same reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015y9Y9kec3hj8S35oLQXxvQ
The engine subscribes during its first sync pass, so a ring fired immediately
after start() can land before anything is listening — the periodic poll would
still converge, but not inside the assertion's window.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015y9Y9kec3hj8S35oLQXxvQ
@simonweniger
simonweniger merged commit 7302473 into main Jul 31, 2026
2 of 3 checks passed
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants