Skip to content

feat: pending/outbox + UI-layer primitives (drain, submission, pending observation, observe bridge) - #674

Merged
3lvis merged 3 commits into
feature/synced-queryfrom
feature/pending-outbox-primitives
Jun 27, 2026
Merged

feat: pending/outbox + UI-layer primitives (drain, submission, pending observation, observe bridge)#674
3lvis merged 3 commits into
feature/synced-queryfrom
feature/pending-outbox-primitives

Conversation

@3lvis

@3lvis 3lvis commented Jun 27, 2026

Copy link
Copy Markdown
Owner

Stacked on #673.

Four pieces of SwiftSync-shaped plumbing that the demo had hand-rolled, folded into the library (all under SwiftSync/UI/ except the pending logic):

1. SwiftSync.drainPendingChanges

SwiftSync already owned a single push pass (withPendingChanges: read-since-token, confirm-by-complement, advance the token only on a clean pass, the P1 mid-upload strand). The loop around it — re-read after each pass so a write landing mid-upload is caught by the next pass; stop on a failing pass so a pinned token doesn't spin — was hand-rolled. Folded in. The demo's drainToConvergence collapses into pushPendingChanges.

2. PendingChangesPublisher<Model>

Reactive counterpart of pendingChanges. Computed on read (a synchronous read right after a save is current) with an @Observable revision bumped by the save notification for reactivity. The demo's pendingChangeCount/failedChangeCount become computed off it — refreshPendingCount, markPulled, and their ~8 call sites are deleted.

3. SyncSubmissionDriver

The write-side counterpart of the synced-read drivers: submit { action } through idle/submitting/failed, with the double-submit guard and the same trim+fallback error message. Replaces the demo's SubmissionMachine (deleted); delete/save expose driver.phase.

4. SwiftSync.observeContinuously

The Observation-tracking bridge a UIKit controller / machine uses to mirror @Observable state — moved out of the demo and namespaced.

Verification

Red-first: drainPendingChanges converges across a mid-upload write and stops on failures; SyncSubmissionDriver success/failure/blank-fallback/dismiss; PendingChangesPublisher reflects a local insert after save. SwiftSync 189, DemoCore 43 green; demo app builds.

3lvis added 2 commits June 27, 2026 08:24
SwiftSync already owned the single pass (withPendingChanges: read-since-token,
confirm-by-complement, advance only on a clean pass, the P1 mid-upload strand).
The loop around it — re-read after each pass so a write landing mid-upload is
caught by the next pass; stop on failures so a pinned token doesn't spin — was
hand-rolled in the demo and easy to get wrong. Fold it into the library.

Red-first: drain converges across a write that lands during upload (2 passes),
and stops on a failing pass instead of spinning. Demo's drainToConvergence
collapses into pushPendingChanges (annotate + count are app bookkeeping, once
at the end).

SwiftSync 184, DemoCore 43, DemoBackend 32 green.
…eContinuously

Three UI-layer primitives, each replacing hand-rolled demo plumbing:

- SyncSubmissionDriver: the write-side counterpart of the synced-read drivers —
  submit { action } through idle/submitting/failed, with the double-submit guard.
  Replaces the demo's SubmissionMachine (deleted); delete/save read driver.phase.
- PendingChangesPublisher<Model>: reactive counterpart of pendingChanges, computed
  on read (so a synchronous read after a save is current) with an @observable
  revision for reactivity. The demo's pendingChangeCount/failedChangeCount become
  computed off it; refreshPendingCount + markPulled and their ~8 call sites are gone.
- SwiftSync.observeContinuously: the Observation-tracking bridge, moved out of the
  demo and namespaced.

Red-first: SyncSubmissionDriverTests (success/failure/blank-fallback/dismiss),
PendingChangesPublisher reflects a local insert after save. SwiftSync 189,
DemoCore 43 green; demo app builds.
@3lvis 3lvis changed the title feat: SwiftSync.drainPendingChanges — own the converging offline-push loop feat: pending/outbox + UI-layer primitives (drain, submission, pending observation, observe bridge) Jun 27, 2026
A row accepted by an earlier drain pass kept its stale syncFailureReason if a
later pass threw: drainPendingChanges propagates the throw, so the demo's
once-at-the-end annotateFailures never ran and the UI still showed an accepted
task as failed.

Add an afterPass hook to drainPendingChanges — run once per *completed* pass,
after its token advance, skipped for a pass whose process threw — and move the
demo's inbox annotation onto it.

Red-first: ConvergingDrainTests reproduces the stale-inbox case (pass 1 accepts a
corrected row, pass 2 throws); a SwiftSync test guards afterPass's per-pass /
not-on-throw contract. SwiftSync 190, DemoCore 44 green.
@3lvis
3lvis marked this pull request as ready for review June 27, 2026 08:26
@3lvis
3lvis merged commit 870f46e into feature/synced-query Jun 27, 2026
12 checks passed
3lvis added a commit that referenced this pull request Jun 27, 2026
…c load-state (#673)

* feat: SyncJSON — a Sendable structured-JSON payload carrier

sync(...) runs off the main actor, so a payload crossing into it must be Sendable
— which [String: Any] is not. Consumers were left to hand-roll a Sendable JSON
box (the demo had DemoSyncPayload + DemoSyncValue, ~120 lines). Ship it once in
the library instead:

- SyncJSON: a Sendable enum JSON value conforming to SyncPayloadConvertible, with
  init(dictionary:), toSyncPayloadDictionary(), and keyed accessors (string/objectArray).
  NSNumber matched first so JSON bools aren't read as 1. Preserves null (so a sync
  can clear) and value shapes.
- Demo drops DemoSyncPayload/DemoSyncValue entirely and uses SyncJSON.

red-first: SyncJSONTests (round-trip, accessors, syncs as a payload). All green:
SwiftSync 178, DemoCore 43, DemoBackend 32. README documents it.

* feat: @SyncedQuery — reactive read + sync load-state in one (core)

Applies the @SYNCable lens to screens: declare what to read + how to load it,
and the per-screen load-state machine + observation derive away. SyncedQueryPublisher
(@observable, UIKit-facing) pairs a SyncQueryPublisher with a load action and exposes
rows + phase (idle/loading/loaded/failed); @SyncedQuery is the SwiftUI wrapper that
auto-runs the load on first render. Mirrors the SyncQueryPublisher/@SyncQuery pair.

red-first: SyncedQueryTests (load success -> rows+loaded; failure -> failed phase).
Core only; next: collapse a demo screen to prove it deletes the ScreenMachine boilerplate.

* demo: collapse ProjectsViewMachine onto @SyncedQuery (proof)

Proves the abstraction earns its place: ProjectsViewMachine drops its
ScreenLoadMachine + SyncQueryPublisher + observeContinuously state-mirror +
run: wiring for a single SyncedQueryPublisher with the load declared inline
(~40 lines -> ~25). statusState now resolves from SyncLoadPhase. VC untouched
(it reads rows/statusState/send). DemoCore 43, SwiftSync 180 green.

* feat: collapse all query screens onto @SyncedQuery/@SyncedModel + SyncedView

Completes the abstraction across the demo:
- SyncedModelPublisher/@SyncedModel: single-row sibling (model + load), mirroring
  @SyncModel. Shared SyncLoadDriver so the load/phase logic lives once.
- SyncedView: SwiftUI content/loading/failure/empty container over SyncedResults.
- ProjectViewMachine + TaskViewMachine collapsed (tasks->SyncedQueryPublisher,
  task->SyncedModelPublisher; project/items ride along; delete stays). Their
  ScreenLoadMachines + observe-mirrors + run: wiring deleted. The editor
  (TaskFormSheetMachine) stays bespoke — the escape hatch.

Publishers proven by all 3 collapsed machines; the SwiftUI members (@SyncedQuery/
@SyncedModel/SyncedView) mirror the existing @SyncQuery family for SwiftUI consumers.
Green: SwiftSync 181, DemoCore 43, DemoBackend 32.

* refactor: gather the SwiftUI layer under SwiftSync/UI/

Move the reactive/UI helpers into a self-contained UI/ folder and split the
phantom-named ReactiveQuery.swift (held SyncQuery + SyncModel, no such type)
into type-named SyncQuery.swift / SyncModel.swift, matching the publisher pair.

UI/: SyncQueryPublisher, SyncModelPublisher, SyncQuery, SyncModel,
SyncedQuery (+SyncedModel/driver/phase/results), SyncedView. Pure move/rename;
same module, no API or import change. SwiftSync 181 green.

* docs: drop 'UIKit' from publisher doc comments — they import no UIKit

The reactive publishers depend only on Foundation/Observation/SwiftData; they're
plain-Swift (drivable from UIKit, but not UIKit-specific). Call them plain-Swift.

* refactor: drop the unconsumed SwiftUI wrapper layer; fix load-failure message

@SyncedQuery/@SyncedModel/SyncedResults/SyncedView had zero code callers — only
the publishers (SyncedQueryPublisher/SyncedModelPublisher) are used, by the demo
machines. Remove the speculative surface; keep + split the consumed types into
SyncLoadPhase.swift / SyncedQueryPublisher.swift / SyncedModelPublisher.swift.

Fix (red-first): the shared load driver surfaced an empty/whitespace
errorDescription verbatim and had no per-screen fallback, regressing the screens'
actionable text. Trim + empty-check like presentError, and thread a screen-supplied
fallbackMessage (projects/project/task) through the publishers.

SwiftSync 182, DemoCore 43 green.

* refactor: drop redundant syncContainer param — read it off the engine

DemoSyncEngine already holds the SyncContainer, so every machine + view taking
both (syncContainer, syncEngine) threaded the container twice. Expose the engine's
container and pass only the engine: the four screen machines and the whole view
tree (ProjectsView/ProjectView/TaskView/TaskFormSheet/FailuresSheet/ContentView)
shed the parameter. Container access (publishers, editContext) goes through
syncEngine.syncContainer.

Demo builds; DemoCore 43 green.

* feat: pending/outbox + UI-layer primitives (drain, submission, pending observation, observe bridge) (#674)

* feat: SwiftSync.drainPendingChanges — own the converging push loop

SwiftSync already owned the single pass (withPendingChanges: read-since-token,
confirm-by-complement, advance only on a clean pass, the P1 mid-upload strand).
The loop around it — re-read after each pass so a write landing mid-upload is
caught by the next pass; stop on failures so a pinned token doesn't spin — was
hand-rolled in the demo and easy to get wrong. Fold it into the library.

Red-first: drain converges across a write that lands during upload (2 passes),
and stops on a failing pass instead of spinning. Demo's drainToConvergence
collapses into pushPendingChanges (annotate + count are app bookkeeping, once
at the end).

SwiftSync 184, DemoCore 43, DemoBackend 32 green.

* feat: SyncSubmissionDriver, PendingChangesPublisher, SwiftSync.observeContinuously

Three UI-layer primitives, each replacing hand-rolled demo plumbing:

- SyncSubmissionDriver: the write-side counterpart of the synced-read drivers —
  submit { action } through idle/submitting/failed, with the double-submit guard.
  Replaces the demo's SubmissionMachine (deleted); delete/save read driver.phase.
- PendingChangesPublisher<Model>: reactive counterpart of pendingChanges, computed
  on read (so a synchronous read after a save is current) with an @observable
  revision for reactivity. The demo's pendingChangeCount/failedChangeCount become
  computed off it; refreshPendingCount + markPulled and their ~8 call sites are gone.
- SwiftSync.observeContinuously: the Observation-tracking bridge, moved out of the
  demo and namespaced.

Red-first: SyncSubmissionDriverTests (success/failure/blank-fallback/dismiss),
PendingChangesPublisher reflects a local insert after save. SwiftSync 189,
DemoCore 43 green; demo app builds.

* fix: annotate the failures inbox per drain pass, not once at the end

A row accepted by an earlier drain pass kept its stale syncFailureReason if a
later pass threw: drainPendingChanges propagates the throw, so the demo's
once-at-the-end annotateFailures never ran and the UI still showed an accepted
task as failed.

Add an afterPass hook to drainPendingChanges — run once per *completed* pass,
after its token advance, skipped for a pass whose process threw — and move the
demo's inbox annotation onto it.

Red-first: ConvergingDrainTests reproduces the stale-inbox case (pass 1 accepts a
corrected row, pass 2 throws); a SwiftSync test guards afterPass's per-pass /
not-on-throw contract. SwiftSync 190, DemoCore 44 green.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant