Skip to content
This repository was archived by the owner on Sep 11, 2026. It is now read-only.
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
28 changes: 22 additions & 6 deletions .claude/skills/happysync/references/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,19 @@ public init(
)
```

Declared from a GRDB record type instead, so a rename is a build error rather than a table that
quietly stops syncing (#50). Same `SyncTable`, so a manifest can mix both forms — keep the string
form for a table with no Swift record type:

```swift
SyncTable.table(Recipe.self, jsonColumns: [Recipe.Columns.nutrition]) // name from databaseTableName
SyncTable.table(RecipeStep.self, dependsOn: [Recipe.self]) // dependency as a type
```

Columns are as checked as the record makes them: GRDB's conventional `enum Columns` ties each name
to one declaration; a bare `Column("nutrition")` is exactly as unchecked as the string was. Either
way `SyncEngine.init` still validates every name against the live schema.

Field gotchas:

- **`dependsOn`** is normally left alone: `nil` (the default) means the engine reads
Expand Down Expand Up @@ -228,22 +241,25 @@ Everything but the network is production code — same drain, pull, retry classi
ordering — so a test here asserts shipped behaviour.

- `InMemorySyncRemote` (actor): `upsertCalls` / `deleteCalls` / `fetchCalls` / `lastScope` record
what the client sent; `seed(_:rows:)` publishes a change "another device" made; `representation:`
supplies the server's response (server-normalized values, a `conflictColumns` merge);
`onUpsert`/`onFetch` hooks suspend the *n*-th call so a test can race a local write against an
in-flight upload.
what the client sent; `upsertRequests` / `deleteRequests` record how many round trips it took,
which batching makes fewer than the per-row call counts (#54); `seed(_:rows:)` publishes a change
"another device" made; `representation:` supplies the server's response (server-normalized values,
a `conflictColumns` merge); `onUpsert`/`onFetch` hooks suspend the *n*-th call so a test can race a
local write against an in-flight upload.
- Failure injection: `failUpserts` / `failFetches` counts with a `SimulatedFailure` —
`.transient` (retried), `.permanent` (parks on the first attempt), or `.error(anError)` which
applies the **production** classification to a real `HTTPError`/`PostgrestError`. Use `99` for
"always".
"always". `failUpserts` counts **requests**: a rejected batch spends one, and so does each row of
the per-row re-run that follows it.
- `ManualDoorbell`: `fire()` rings the current subscription; `ringScopes` and `liveSubscriptions`
prove an auth change re-subscribed and tore the old one down; `fire(subscription: 0)` rings a
torn-down one.
- `AsyncSignal` (`fire()`/`wait()`/`isFired`) and `eventually { … }` for background convergence —
never a fixed `Task.sleep` plus one assertion.
- A bespoke double is three methods on `SyncRemote`. Signal permanence by conforming the thrown
error to `ClassifiedSyncError`, or wrap a real one with `RemoteFailure(classifying:)`. Anything
unclassified is treated as transient.
unclassified is treated as transient. The batched `upsert(table:rows:)` / `delete(table:pks:)` the
drain prefers default to looping over the single-row calls, so implementing them is optional.

The three tests worth writing first: a cascade delete queues every child's tombstone; status reaches
`.degraded` when an upload parks; `stop()` has quiesced the engine before sign-out wipes the DB.
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,20 @@ jobs:
steps:
- uses: actions/checkout@v4
- run: swift test

docs:
# The DocC catalog is the documentation now, so a broken link or an unresolvable symbol
# reference should surface here rather than on Swift Package Index (issue #57).
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- run: swift package --allow-writing-to-directory ./docs-build generate-documentation --target HappySync --output-path ./docs-build

example:
# The example is a separate package that consumes this one by path, so it catches an API change
# the library's own tests can't see — and its tests assert the behaviours the demo puts on
# screen, so the demo can't quietly rot (issue #58).
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- run: swift test --package-path Examples
7 changes: 7 additions & 0 deletions .spi.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Swift Package Index build settings. `documentation_targets` is what makes SPI build and host the
# DocC catalog in Sources/HappySync/Documentation.docc, so the doc comments are reachable from a
# browser instead of only from Xcode Quick Help (issue #57).
version: 1
builder:
configs:
- documentation_targets: [HappySync]
47 changes: 47 additions & 0 deletions Examples/Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// swift-tools-version: 6.0
import PackageDescription

// A runnable HappySync demo (issue #58). Deliberately a *separate* package rooted here rather than
// extra targets in the library's manifest: an app that consumes HappySync should look like one, with
// its own dependency on the published package — and a consumer opening this folder builds only the
// demo, not the library's test suite.
let package = Package(
name: "HappySyncExamples",
platforms: [.iOS(.v16), .macOS(.v13)],
products: [
// The demo's model, sync wiring, and view state — everything but the SwiftUI. Split out so
// the sync behaviour the app shows can be asserted headlessly in CI (`DemoCoreTests`).
.library(name: "DemoCore", targets: ["DemoCore"]),
],
dependencies: [
.package(path: ".."),
.package(url: "https://github.com/groue/GRDB.swift.git", from: "7.11.0"),
.package(url: "https://github.com/supabase-community/supabase-swift.git", from: "2.0.0"),
],
targets: [
.target(
name: "DemoCore",
dependencies: [
.product(name: "HappySync", package: "HappySync"),
// The demo runs against the in-memory fake, so it needs no Supabase project at all —
// clone, `swift run`, watch two devices converge (issue #53 made this possible).
.product(name: "HappySyncTestSupport", package: "HappySync"),
.product(name: "GRDB", package: "GRDB.swift"),
.product(name: "Supabase", package: "supabase-swift"),
]
),
.executableTarget(name: "DemoApp", dependencies: ["DemoCore"]),
// The demo doubles as an integration test: the behaviours the app shows are asserted here,
// headlessly, against the same fake — so `swift test` in CI proves the example still works
// rather than merely still compiling.
.testTarget(
name: "DemoCoreTests",
dependencies: [
"DemoCore",
.product(name: "HappySync", package: "HappySync"),
.product(name: "HappySyncTestSupport", package: "HappySync"),
.product(name: "GRDB", package: "GRDB.swift"),
]
),
]
)
50 changes: 50 additions & 0 deletions Examples/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# HappySync example — a two-table app you can actually run

A SwiftUI demo of the whole engine, wired to the **in-memory fake** from `HappySyncTestSupport`. No
Supabase project, no schema to stand up, no credentials: clone and run.

```bash
open Examples/Package.swift # then run the DemoApp scheme (My Mac, or an iOS sim)
swift test --package-path Examples # the same behaviours, asserted headlessly
swift run --package-path Examples DemoApp # or straight from the terminal, on macOS
```

## What it shows

Two tables — `lists` → `items`, with a real foreign key — is enough for every behaviour that matters:

| In the app | What it demonstrates |
|---|---|
| Type a list or item name and hit Add | A **plain GRDB write**. No engine call: the table's capture trigger queues the upload in the same transaction. |
| Tick an item | An **expression update** (`SET done = NOT done`) — the kind of write a `save`-only sync API can't see. |
| **Delete list (cascades)** | One `DELETE`. SQLite removes the items too, and each removed item's own trigger queues its tombstone — children before parent, on the wire. |
| The status dot | `status.isHealthy`, **not** `phase == .idle`. The distinction is the point: a settled pass with writes still parked is `.degraded`. |
| **Change from device B** | Realtime as a doorbell: the ring triggers a pull, and the row arrives through the cursor fetch — never from the event payload. |
| **Make the server reject writes** | An RLS-shaped rejection: the write parks on its first attempt instead of retrying forever. |
| **Parked writes → Retry / Discard** | The dead-letter repair API. Fix the cause, then retry — or discard and take the server's version. |
| **Sign out → wipe → sign in** | The one order that's safe: `await stop()`, *then* replace the store. And replacing it, not `DELETE`-ing rows — a delete would fire the capture triggers and queue a tombstone for everything the user owns. |

Reads are plain `ValueObservation`. HappySync never sits between the app and its data: a pulled row
lands in SQLite and the observation fires, exactly as it does for a local write.

## Layout

```
Sources/DemoCore/ Schema, manifest, sync wiring, view state — no SwiftUI, so CI can drive it
Sources/DemoApp/ The SwiftUI app
Tests/DemoCoreTests/ The table above, as assertions
supabase/migrations/ The server half of the contract, for when you point it at a real project
```

`DemoCore` is where to look first: [`Schema.swift`](Sources/DemoCore/Schema.swift) is the entire
manifest, and [`DemoSync.swift`](Sources/DemoCore/DemoSync.swift) is the entire integration.

## Pointing it at a real Supabase project

One initializer changes — `SyncEngine(db:supabase:tables:auth:)` instead of
`SyncEngine.forTesting(db:remote:tables:)` (see the comment at the bottom of `DemoSync.swift`). The
server side is
[`supabase/migrations/`](supabase/migrations): `supabase start && supabase db reset` applies the
`updatedAt` trigger, the tombstone trigger, RLS, and the Realtime publication that the fake can't
check for you. The [Server Setup](https://github.com/happyface-studio/HappySync/wiki/Server-Setup)
wiki page explains why each of the four is non-negotiable.
167 changes: 167 additions & 0 deletions Examples/Sources/DemoApp/ContentView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import SwiftUI
import DemoCore

struct ContentView: View {
@EnvironmentObject private var store: DemoStore

var body: some View {
VStack(spacing: 0) {
SyncStatusBar()
Divider()
HStack(alignment: .top, spacing: 0) {
ListsPane()
Divider()
ItemsPane()
}
Divider()
DemoControls()
}
.frame(minWidth: 640, minHeight: 460)
}
}

// MARK: - Lists

private struct ListsPane: View {
@EnvironmentObject private var store: DemoStore
@State private var title = ""

var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Lists").font(.headline)
List(store.lists) { list in
Button {
store.selectedListID = list.id
} label: {
Text(list.title)
.foregroundStyle(list.id == store.selectedListID ? Color.accentColor : .primary)
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
}
HStack {
TextField("New list", text: $title)
Button("Add") { add() }.disabled(title.isEmpty)
}
// One plain DELETE. SQLite cascades to the list's items and each removed item's own
// capture trigger queues its tombstone — watch the delete count in the controls below.
Button("Delete list (cascades)") {
Task { await store.deleteSelectedList() }
}
.disabled(store.selectedListID == nil)
}
.padding()
.frame(minWidth: 240)
}

@MainActor private func add() {
let value = title
title = ""
Task { await store.addList(title: value) }
}
}

// MARK: - Items

private struct ItemsPane: View {
@EnvironmentObject private var store: DemoStore
@State private var title = ""

var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Items").font(.headline)
List(store.items(in: store.selectedListID)) { item in
Button {
Task { await store.toggle(item) }
} label: {
Label(item.title, systemImage: item.done ? "checkmark.circle.fill" : "circle")
}
.buttonStyle(.plain)
}
HStack {
TextField("New item", text: $title)
Button("Add") { add() }.disabled(title.isEmpty || store.selectedListID == nil)
}
}
.padding()
.frame(maxWidth: .infinity)
}

@MainActor private func add() {
let value = title
title = ""
Task { await store.addItem(title: value) }
}
}

// MARK: - The parts of sync a UI has to get right

private struct SyncStatusBar: View {
@EnvironmentObject private var store: DemoStore

var body: some View {
HStack {
// Ask `isHealthy`, never `phase == .idle` alone: a settled pass with writes still failing
// or parked is `.degraded`, and a green tick there is the bug this API exists to prevent.
Circle()
.fill(store.status.isHealthy ? Color.green : Color.orange)
.frame(width: 10, height: 10)
Text(store.healthLine)
Spacer()
if let lastSynced = store.status.lastSyncedAt {
Text("last sync \(lastSynced.formatted(date: .omitted, time: .standard))")
.foregroundStyle(.secondary)
}
}
.padding(.horizontal)
.padding(.vertical, 8)
}
}

private struct DemoControls: View {
@EnvironmentObject private var store: DemoStore

var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Button("Change from device B") { Task { await store.publishFromAnotherDevice() } }
Button("Make the server reject writes") { Task { await store.sync.rejectWrites() } }
Button("Let the server accept again") { Task { await store.sync.acceptWrites() } }
Button("Sign out → wipe → sign in") { Task { await signOutAndBackIn() } }
}
DeadLetterInspector()
}
.padding()
}

/// The order is the point: `stop()` awaits the in-flight pass, and only then is the store safe to
/// replace. The demo re-observes afterwards because the database object itself was swapped.
@MainActor private func signOutAndBackIn() async {
try? await store.sync.signOutAndBackIn()
store.observe()
}
}

/// Parked writes, and the two ways out of them. Repair is an API, not a support ticket: a write that
/// the server refused permanently stops retrying and waits here until the cause is fixed.
private struct DeadLetterInspector: View {
@EnvironmentObject private var store: DemoStore

var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text("Parked writes (\(store.deadLetters.count))").font(.headline)
Spacer()
Button("Retry") { Task { await store.retryDeadLetters() } }
Button("Discard") { Task { await store.discardDeadLetters() } }
}
.disabled(store.deadLetters.isEmpty)

ForEach(store.deadLetters, id: \.seq) { letter in
Text("\(letter.op.rawValue) \(letter.table)/\(letter.pk) — \(letter.failure.description)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
20 changes: 20 additions & 0 deletions Examples/Sources/DemoApp/DemoApp.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import SwiftUI
import DemoCore

/// A HappySync demo you can run with no backend at all: the engine is wired to the in-memory fake
/// from `HappySyncTestSupport`, so "another device" is a button (issue #58).
///
/// Everything the app does — writing, deleting with a cascade, reading through `ValueObservation`,
/// showing status, repairing parked writes — is the real engine. Only the transport is fake.
@main
struct HappySyncDemoApp: App {
@StateObject private var store = DemoStore.live()

var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(store)
.task { await store.start() }
}
}
}
Loading
Loading