Skip to content

feat: SyncedQueryPublisher/SyncedModelPublisher — reactive read + sync load-state - #673

Merged
3lvis merged 10 commits into
masterfrom
feature/synced-query
Jun 27, 2026
Merged

feat: SyncedQueryPublisher/SyncedModelPublisher — reactive read + sync load-state#673
3lvis merged 10 commits into
masterfrom
feature/synced-query

Conversation

@3lvis

@3lvis 3lvis commented Jun 26, 2026

Copy link
Copy Markdown
Owner

The idea

The @Syncable lens — 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.
  • Shared internal SyncLoadDriver so the load/phase logic lives in exactly one place.
  • All live under SwiftSync/UI/.

Proof — all query screens collapsed

ProjectsViewMachine, ProjectViewMachine, TaskViewMachine each lose their hand-wired ScreenLoadMachine + observeContinuously mirror + 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

  • Cut speculative surface. The SwiftUI wrappers I first added (@SyncedQuery/@SyncedModel/SyncedResults/SyncedView) had zero callers — the demo drives the publishers directly. Removed; only the consumed publishers remain.
  • Fixed a load-failure message regression (red-first). The shared driver surfaced an empty/whitespace errorDescription verbatim and had no per-screen fallback. Now it trims + falls back like presentError, with a screen-supplied fallbackMessage.

Bonus cleanup — redundant syncContainer param

DemoSyncEngine already owns the SyncContainer, 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.

3lvis added 4 commits June 26, 2026 23:04
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.
@3lvis 3lvis changed the title feat: @SyncedQuery — reactive read + sync load-state, derived feat: @SyncedQuery / @SyncedModel / SyncedView — reactive read + sync load-state, derived Jun 26, 2026
3lvis added 4 commits June 26, 2026 23:46
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.
@3lvis 3lvis changed the title feat: @SyncedQuery / @SyncedModel / SyncedView — reactive read + sync load-state, derived feat: SyncedQueryPublisher/SyncedModelPublisher — reactive read + sync load-state Jun 26, 2026
3lvis and others added 2 commits June 27, 2026 10:26
…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
@3lvis
3lvis marked this pull request as ready for review June 27, 2026 08:49
@3lvis
3lvis merged commit 3f66114 into master Jun 27, 2026
12 checks passed
@3lvis
3lvis deleted the feature/synced-query branch June 27, 2026 08:50
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