feat: SyncedQueryPublisher/SyncedModelPublisher — reactive read + sync load-state - #673
Merged
Conversation
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.
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.
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.
…cedView 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.
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.
The reactive publishers depend only on Foundation/Observation/SwiftData; they're plain-Swift (drivable from UIKit, but not UIKit-specific). Call them plain-Swift.
… 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.
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.
…g 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.
# Conflicts: # DemoCore/Sources/DemoCore/Features/ScreenMachines.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The idea
The
@Syncablelens — declare the surface, derive the plumbing — applied to screens. Every screen hand-wired the same thing: a load-state machine (idle→loading→loaded/error) + a reactive publisher + observation to mirror it. Declare what to read + how to load it; the rest derives away.What
SyncedQueryPublisher— collection + load →rows+phase.SyncedModelPublisher— single row + load →row+phase.SyncLoadDriverso the load/phase logic lives in exactly one place.SwiftSync/UI/.Proof — all query screens collapsed
ProjectsViewMachine,ProjectViewMachine,TaskViewMachineeach lose their hand-wiredScreenLoadMachine+observeContinuouslymirror +run:wiring, replaced by one publisher with the load declared inline. The editor (TaskFormSheetMachine) stays bespoke — the escape hatch.Two corrections found along the way
@SyncedQuery/@SyncedModel/SyncedResults/SyncedView) had zero callers — the demo drives the publishers directly. Removed; only the consumed publishers remain.errorDescriptionverbatim and had no per-screen fallback. Now it trims + falls back likepresentError, with a screen-suppliedfallbackMessage.Bonus cleanup — redundant
syncContainerparamDemoSyncEnginealready owns theSyncContainer, yet every machine + view took both. Exposed the engine's container and dropped the separate param across the four machines and the whole view tree.Verification
SwiftSync 182, DemoCore 43, DemoBackend 32 green; demo app builds.