docs/plans/2026-07-03-sync-engine-extraction-plan.md
Status: Plan (no code changed)
Date: 2026-07-03 (v2 — revised after two-agent adversarial review; v1's step 5 "near drop-in" claim was wrong)
Decisions baked in: the extracted library has no rxjs and no @angular/core signals (both verified below)
Builds on: docs/sync-and-op-log/package-boundaries.md (the extraction is an established, lint-enforced direction; @sp/sync-core + @sp/sync-providers + @sp/shared-schema already exist)
createValidate / 11 rxjs files), all cited line numbers, port inventory, ESLint boundary rules, stale build-packages.js comment. Zero factual errors; two wording fixes applied in this v2 (OpLogDbAdapter is still app-side; "Phase B" citation tightened).MatDialog/TranslateService-bound services — invalidated v1's "swap 3 things" framing for step 5; (2) SyncSessionValidationService is a shared-singleton correctness latch (issue #7330) injected by 10 files straddling the cut, with silent-corruption risk if duplicated; (3) Angular signals in three engine-slated services (the v1 no-rxjs analysis never checked for them). Plus: reactive provider lifecycle vs pull-based getConfig(), the unaddressed boot hydrator, an engine↔engine lazyInject cycle, dynamic per-entity selector reads, i18n-key leakage, and Karma/TestBed test-gate circularity.| Claim | Status | Evidence |
|---|---|---|
Core orchestrator src/app/op-log/sync/operation-log-sync.service.ts (1204 LOC) has zero rxjs; fully sequential async/await (54 awaits) with DownloadOutcome/UploadOutcome unions | ✅ twice-verified | grep + two independent reads |
Upload/download services (operation-log-upload/download.service.ts): zero rxjs | ✅ | grep |
All other engine-side rxjs is one-shot firstValueFrom reads; genuinely-streaming observables (syncInterval$, afterCurrentSyncDoneOrSyncDisabled$, UI signals) are shell concerns | ✅ | per-file import audit |
sync-wrapper.service.ts (1558 LOC): _syncBody try block (444–642) is pure engine; catch (643–902) = 19-branch error chain, classification=engine / reaction=shell | ✅ | full read, branch-by-branch |
BUT: Angular signals exist in the engine set — super-sync-status.service.ts:23-41 (signal/computed, written by orchestrator at operation-log-sync.service.ts:363,834 and operation-log-download.service.ts:415), apply/hydration-state.service.ts:65-84 (signals + toObservable, implements RemoteApplyWindowPort, drives the capture meta-reducer via module-level setIsApplyingRemoteOps), super-sync-websocket.service.ts:43 (isConnected = signal(false)) | ⚠ new in v2 | adversarial review, spot-verified |
SyncSessionValidationService = one mutable-boolean latch whose docstring requires a single shared instance; injected by 10 files on both sides of the cut (sync-wrapper, ws-triggered-download, conflict-resolution, remote-ops-processing, rejected-ops-handler, immediate-upload, sync-hydration, sync-cycle-guard, + types) | ⚠ new in v2 | grep, spot-verified |
Orchestrator injects 16 services; among them ServerMigrationService (opens MatDialog at server-migration.service.ts:309; called at operation-log-sync.service.ts:211,414,648), SyncLocalStateService (injects TranslateService, translateService.instant at :45-46 — welds engine predicates hasMeaningfulStoreData/isWhollyFreshClient to dialog-string building), BackupService (injects Store), SuperSyncStatusService (signals) | ⚠ new in v2 | spot-verified |
Engine↔engine cycle: sync-wrapper ↔ ws-triggered-download (broken today via lazyInject(Injector, SyncWrapperService) at ws-triggered-download.service.ts:42); immediate-upload.service.ts:53-55 injects both OperationLogSyncService and SyncWrapperService | ⚠ new in v2 | spot-verified |
conflict-resolution.service.ts reads dynamic per-entity selectors (store.select(config.selectById(entityId, null)) at :952, registry-driven) — a flat state-snapshot port cannot express this | ⚠ new in v2 | spot-verified |
Provider lifecycle is reactive: provider-manager.service.ts:127-149 subscribes selectSyncConfig and rebuilds the active provider on config change; encryption toggles fire setProviderConfig mid-session; wrapped-provider.service.ts:50-56 invalidates adapter cache via providerConfigChanged$ | ⚠ new in v2 | adversarial review |
Boot flow: data-init.service.ts → operation-log-hydrator.service.ts interleaves engine work (snapshot load, schema migration, replay) with host-only work (legacy PFAPI migration, store.dispatch(loadAllData) ×4, snacks/alerts, signals) | ⚠ new in v2 | adversarial review |
Multi-tab: no cross-tab op broadcast exists (only single-instance detection, startup.service.ts:262); navigator.locks + SyncCycleGuard → a LockPort suffices | ✅ | grep |
typia: type-only leakage (ValidationResult<T> = IValidation<T>); all 20 createValidate codegen sites confined to host-side validation/validation-fn.ts → lib defines its own shape, no typia dep | ✅ | grep |
Capture effect is a thin shell: createEffect at operation-log.effects.ts:101 → async writeOperation* methods → maps to engine.captureAction() | ✅ | read |
Test coverage: sync-wrapper.service.spec.ts 130 tests but these error branches have zero coverage: EmptyRemoteBodySPError, LegacySyncFormatDetectedError, HTTP-423, DecryptNoPasswordError→dialog, DecryptError→dialog, WebCryptoNotAvailableError, flatpak/snap variants. operation-log-sync.service.spec.ts: 81 tests, well-covered | ✅ | spec grep |
Test-gate integrity: orchestrator/integration specs are Karma/TestBed with real Chrome IndexedDB; packages test with vitest. TZ-sensitive sync logic is covered by the test:tz:* Karma matrix; moved code drops out unless re-wired | ⚠ new in v2 | adversarial review |
E2EE: all crypto already framework-free in packages/sync-core/src/encryption* (Argon2id + AES-256-GCM, @noble/ciphers fallback, session caches, legacy PBKDF2); encrypt-and-compress-handler.service.ts already framework-free; isEncryptionMandatory fail-closed guards at operation-log-upload.service.ts:134 + snapshot-upload.service.ts:251; setLegacyKdfWarningHandler host hook at src/main.ts:115 | ✅ twice-verified | reads |
Host-domain leaks inside persistence: archive-store.service.ts imports ArchiveModel from features/time-tracking; operation-log-store.service.ts:1900-1932 implements SP profile-switching (profile_data store) | ⚠ new in v2 | adversarial review |
sync-core is consumed by super-sync-server (package.json:45, vector-clock imports) → engine goes in a new package, not into sync-core | ✅ | verified |
host app (SP / any TS host)
└─► @sp/sync-engine (NEW — orchestration; NO rxjs, NO Angular incl. signals, NO NgRx)
├─► @sp/sync-providers (existing — Dropbox/OneDrive/WebDAV/Nextcloud/LocalFile/SuperSync, OAuth/PKCE)
└─► @sp/sync-core (existing — ops model, vector clocks, LWW planners, E2EE crypto, ports)
super-sync-server ─► @sp/sync-core, @sp/shared-schema (unchanged)
shared-schema stays SP-specific, never a library dependency; hosts supply entity types (plain strings) and migrations via ports (createFullStateOpTypeHelpers / createLwwUpdateActionTypeHelpers already anticipate this).async/await + typed result unions + plain callback subscriptions (onX(cb): () => void). Debounce via setTimeout. Signals become engine-owned plain state + change callbacks; the Angular shell wraps them back into signals for UI.engine.run(). The engine constructs its internal subsystems itself and late-binds the two known cycles (wrapper↔ws-download, immediate-upload→wrapper) via setters at build time — replacing today's Angular lazyInject.const engine = createSyncEngine({
// host semantics
entityRegistry: EntityRegistry, // sync-core structural contract
actionSchema: { isPersistentAction /* op ↔ host-action mapping */ },
validation?: ValidationPort, // engine-defined ValidationResult (typia-free)
migration?: SchemaMigrationPort,
// host infrastructure
db: OpLogDbAdapter, // today app-side (op-log/persistence); moves into the lib in step 3
credentialStore: SyncCredentialStorePort, // exists in sync-providers
platform: ProviderPlatformInfo, webFetch, nativeHttpExecutor, // exist
lock: LockPort, clientId: ClientIdPort,
logger: SyncLogger, // exists
// config & provider lifecycle — PUSH, not pull (v2)
config: {
get(): Promise<SyncEngineConfig>;
onChange(cb: (cfg: SyncEngineConfig) => void): Unsubscribe; // provider-manager rebuild + adapter-cache invalidation
},
providerFactories: Record<ProviderId, ProviderFactory>, // host composes platform deps; engine owns the ACTIVE-provider lifecycle (today's SyncProviderManager + WrappedProviderService logic moves in)
// host state access — registry-driven, not a flat snapshot (v2)
state: {
readEntityById(entityType: string, id: string): Promise<unknown | null>; // conflict-resolution dynamic reads
readFeatureState(entityType: string): Promise<unknown>;
getFullSnapshot(): Promise<AppStateSnapshot>; // backup/state-snapshot stays host-implemented
resetToDefault(): Promise<void>; // today: store.dispatch(loadAllData(default))
},
// host UI/decisions (all Promise-returning)
ui: {
resolveLocalDataConflict(d: ConflictData): Promise<'USE_LOCAL'|'USE_REMOTE'|'CANCEL'>;
resolveSyncImportConflict: ConflictUiPort<SyncImportConflictResolution>; // exists, pattern proven
confirmFreshClientSync(info: FreshClientInfo): Promise<boolean>; // engine passes data; host builds strings
confirmServerMigration(info: ServerMigrationInfo): Promise<boolean>; // v2: ServerMigrationService's MatDialog
requestEncryptionPassword(ctx): Promise<PasswordResult>;
},
// engine → host event sinks (v2: three distinct sinks, not one)
onStatus: (s: SyncStatus) => void, // ~20 setSyncStatus call sites
onPendingOpsStatus: (s: PendingOpsStatus) => void, // v2: today's SuperSyncStatusService signals
onRemoteApplyWindow: (w: RemoteApplyWindowState) => void, // v2: today's HydrationStateService signals; host feeds its capture meta-reducer
onNotification: (n: SyncNotification) => void, // typed SEMANTIC codes (v2), host maps code → i18n key → snack/alert
onRemoteOpsAvailable: (n: NewOpsNotification) => void, // WS push (Subject → callback)
});
engine.sync({ isUserTriggered }): Promise<SyncOutcome>
engine.captureAction(action): Promise<void>
engine.hydrate(applyPort): Promise<void> // engine replay half of today's hydrator (see step 8)
engine.forceUploadLocal(src) / engine.forceDownloadRemote()
engine.runWithSyncBlocked(op) // promise mutex (today a BehaviorSubject latch)
engine.session: SyncSessionValidation // v2: the #7330 latch — engine-owned, SINGLE instance, exposed as a handle so shell code reads the same latch
engine.encryption.{enable, disable, changePassword, setPassword}
engine.restore.{getRestorePoints, restoreToPoint}
engine.ws.{connect, disconnect}
SyncNotification (v2): the engine emits { code: SyncNotificationCode, status?, severity, offerForceUpload?, silentIfAutomatic?, data } where code is a semantic enum ('REMOTE_FILE_EMPTY' | 'AUTH_REJECTED' | 'LOCK_TIMEOUT' | …). The host owns the code→T.* mapping; SP translation keys never enter the library (v1 leaked msgKey). Notification actions (e.g. "force upload") are expressed as code-level affordances the host wires back to engine.forceUploadLocal(...) — the shell holds the engine handle.
Trigger policy (sync-trigger.service.ts, sync.effects.ts, immediate-upload cadence config), OAuth redirect capture (oauth-callback-handler.service.ts), all dialogs/snacks/banners/config forms and the code→i18n mapping, entity-registry.ts / model-config.ts / backup/state-snapshot.service.ts (host implementations of registry/state ports), typia-generated validators, legacy PFAPI migration + archive migration (boot-time, hydrator's host half), profile-switching (profile_data) and SP-typed archive models, _promptSuperSyncEncryptionIfNeeded (MatDialog-stack polling), flatpak/snap message selection (behind getPermissionMessage()), signal/toObservable wrappers for UI.
Nothing to redesign. Primitives already extracted and pure (packages/sync-core/src/encryption*; deps: @noble/ciphers, hash-wasm only). Moves into sync-engine: operation-encryption.service.ts (per-op/batch, SuperSync), encrypt-and-compress-handler.service.ts (gzip+encrypt+prefix, file-based — already framework-free), the isEncryptionMandatory fail-closed guards (GHSA-9v8x) with their upload paths, and the delete+reupload sequencing from toggle/password-change services (UI confirmation stays host-side). Key storage stays behind SyncCredentialStorePort; setLegacyKdfWarningHandler is already the right host-hook shape.
Every step keeps the app green: npm test, npm run lint, npm run packages:test, scheduled SuperSync/WebDAV E2E after risky steps. Test-gate rule (v2): while a facade exists, the old Karma/TestBed spec keeps running against the moved code through the facade — a moved unit is only "done" when its spec is also ported to vitest (with fake-indexeddb where needed) and TZ-sensitive specs are re-wired into a package-level TZ matrix (vitest TZ= env runs). Never delete a Karma spec before its vitest replacement is green.
Step 0 — Characterization tests first.
Cover the untested error-matrix branches before touching them: EmptyRemoteBodySPError + LegacySyncFormatDetectedError (force-upload snack actions), HTTP-423, DecryptNoPasswordError/DecryptError dialog flows, WebCryptoNotAvailableError, flatpak/snap variants, payload-too-large alertDialog. These are exactly the branches the seam cuts through.
Verify: new tests green on unmodified code.
Step 1 — Scaffold + port design (bigger than v1).
Create packages/sync-engine (tsup, workspace, tsconfig alias, vitest; ESLint boundary rule banning @angular/* — which covers signals — @ngrx/*, rxjs, src/app, @sp/shared-schema). Fix the stale build-packages.js comment. Design and land the v2 port set as types first (state reader, config push, three event sinks, semantic notification codes, session-latch handle, UI ports incl. confirmServerMigration) — steps 4–8 all consume them, so they must exist before code moves, not be invented mid-move.
Verify: lint + package builds; port types reviewed against the call-site inventory in §1.
Step 2 — Move the already-pure files (low risk).
op-log/encryption/*, op-log/util/* (minus the client-id.provider.ts DI token), op-log/core types/consts/errors (lib defines its own ValidationResult; EntityType generic over strings), persistence/ adapters + compact/ codec + db-upgrade.ts/op-log-db-schema.ts, pure utils in apply/ and sync/. v2 carve-outs: archive-store.service.ts keeps its SP ArchiveModel typing host-side (generic blob-store interface in the lib); profile_data/profile-switching stays host-side (or becomes a generic namespaced-blob API).
Verify: full suite + boundary grep from package-boundaries.md.
Step 3 — Finish persistence connection-ownership inversion (medium risk).
The remaining tail of docs/sync-and-op-log/sqlite-migration.md Phase B (followup B3): operation-log-store.service.ts (69 KB) stops calling openDB directly / lending connections via adoptConnection(), goes fully behind OpLogDbAdapter; then becomes a lib class constructed with the adapter. (Data-access routing — the doc's Phase A — is already done; connection ownership is what's left.)
Verify: unit suite + IndexedDB integration specs + manual upgrade test from a v7 SUP_OPS DB.
Step 4 — Split the shared singletons + status/window state (v2, new step — prerequisite for everything after). These cross-cutting objects must move as single instances before any consumer moves, or the cut duplicates them:
SyncSessionValidationService → engine-owned SyncSessionValidation latch, constructed once, exposed via engine.session; the Angular facade delegates to that same instance. Never two instances — a duplicated latch silently re-enables the #7330 corruption class it exists to prevent. Move it and re-point all 10 injection sites in one PR.SuperSyncStatusService → plain engine state + onPendingOpsStatus callback; SP shell wraps it back into signals for the header UI.HydrationStateService → engine RemoteApplyWindow state + onRemoteApplyWindow callback; the SP shell keeps feeding the capture meta-reducer (setIsApplyingRemoteOps) from it. The RemoteApplyWindowPort contract already exists in sync-core.SyncCycleGuardService (same latch family) moves alongside.
Verify: characterization tests for #7330 latch semantics (set/read across upload/download/conflict paths); full suite.Step 5 — De-rxjs/de-Angular the mid-tier services (v2: NOT purely mechanical).
Move: vector-clock, lock (LockPort), sync-import-filter, upload, download, conflict-resolution, remote-ops-processing, rejected-ops/superseded-op handlers, compaction, snapshot, operation-encryption, imex/sync/snapshot-upload.service.ts. The rxjs part is cheap (firstValueFrom → port reads; debounce → setTimeout; Subject → callback). The real work (v1 undercounted): remote-ops-processing has 7 snack sites + selectSyncConfig reads; conflict-resolution has snacks, optional TranslateService, and the dynamic per-entity selector reads (:947-978) that require the registry-driven state.readEntityById port; download has 2 snack sites. All of these consume the step-1 ports — notifications become semantic codes here, not in step 6.
Verify: per-service vitest port + facade-routed Karma specs; scheduled SuperSync E2E.
Step 6 — Move operation-log-sync.service.ts (v2: real work, not "near drop-in").
The file itself is rxjs-free and sequential, but it injects 16 services. Beyond the mechanical swaps (6 snack sites → onNotification, 1 store.dispatch(loadAllData) → state.resetToDefault()), this step requires:
SyncLocalStateService: engine predicates (hasMeaningfulStoreData, isWhollyFreshClient) move; TranslateService.instant dialog-string building becomes host-side via ui.confirmFreshClientSync(info) receiving structured data.ServerMigrationService: its decision logic moves; its MatDialog (:309) becomes ui.confirmServerMigration(info). Called from deep inside the state machine (:211,414,648), so it must land with this step.BackupService (injects Store) stays host-side behind the snapshot/import port; SuperSyncStatusService writes go through the step-4 engine state; gate/coordinator services are already port-shaped and move.
Verify: the 81-test spec ported to vitest against the same outcome unions; scheduled SuperSync E2E.Step 7 — ⚠ Riskiest: split sync-wrapper.service.ts + resolve the engine↔engine cycle.
Into lib SyncRunner: the _syncBody try block (state machine), the 19-branch error classifier emitting semantic SyncNotifications, runWithSyncBlocked as a promise mutex, sync() entry guards, pure predicates (_isPermissionError, _isTimeoutError), the SuperSync 3-strike auth-tolerance counter, _syncVectorClockToPfapi, WS connect/disconnect orchestration + ws-triggered-download + immediate-upload cores. v2: the sync-wrapper ↔ ws-triggered-download cycle (today lazyInject) and immediate-upload's dual injection are resolved by the engine constructing these subsystems itself and late-binding via setters — design this in step 1's port work. Unify Path-A conflict onto ui.resolveLocalDataConflict.
Stays in shell: reaction rendering (code→i18n→snack/alert), all dialog methods, _promptSuperSyncEncryptionIfNeeded, streaming observables (syncInterval$, afterCurrentSyncDoneOrSyncDisabled$), signal wrappers. Provider lifecycle: SyncProviderManager's rebuild-on-config-change logic moves into the engine driven by config.onChange; the host keeps only the NgRx selectSyncConfig subscription that feeds it.
Verify: step-0 characterization tests + the 130-test spec split (lib classifier vs shell rendering) + full scheduled E2E matrix incl. multi-tab and encryption suites.
Step 8 — ⚠ Invert capture/apply + split the boot hydrator (v2: hydrator now explicit).
ALL_ACTIONS effect body → engine.captureAction(); host keeps a ~10-line effect. Sync-correctness rules 1–3/6 live here (one intent = one op; replayed ops must not re-trigger effects).operation-log-hydrator.service.ts — engine half = snapshot load + schema migration + tail replay planning (engine.hydrate(applyPort)); host half = legacy PFAPI/archive migration, the four store.dispatch(loadAllData) sites, snacks/alerts, window.ea.reloadMainWin(). The bulk-dispatch meta-reducer, setTimeout(0) flush, and hydration windows stay host-side by design (NgRx-timing-specific).
Verify: op-log integration specs (ported per the test-gate rule), replay-determinism benchmarks, SuperSync multi-client E2E, TZ matrix runs.Step 9 — Validation port split.
Generic machinery (repair-op lifecycle, typia-error auto-fix loop shape, entity-id checks) → lib behind ValidationPort; SP-typed validators + typia codegen stay app-side as the port implementation.
Verify: validation + data-repair specs.
Step 10 — Reference host + publish.
Final SP shape: src/app/op-log = port implementations + provider factories + dialogs; imex/sync = UI. Minimal Node/CLI reference host (doubles as the framework-agnosticism integration test — it would have caught the signals gap). Publish under real names (e.g. @super-productivity/sync-*), semver the port interfaces; separate-repo extraction optional and orthogonal.
Granularity: steps 2–3 = several small PRs; step 4 = one PR per singleton (latch PR is atomic across its 10 consumers); steps 5–8 = one focused PR per service, characterization/port tests first.
SyncSessionValidationService, #7330): duplicated instances = silent cross-device corruption → step 4 moves it atomically with all 10 consumers; engine exposes the single instance.config.onChange push port; provider-manager logic moves into the engine.lazyInject) → engine-internal construction + setter late-binding, designed up front in step 1.state.readEntityById port.T.* mapping.setSyncStatus mutations and UI↔engine re-entry make step 7 a careful diff (mitigation: onStatus callback + engine handles + step-0 tests).Confidence: ~85% on the boundary design and step ordering (v2 incorporates a full fact-check — zero errors found — plus an adversarial gap review whose blockers are now modeled). Verified: six agent sweeps + direct reads of both orchestrators and their specs; rxjs/signals/typia/broadcast greps; all blocker claims spot-checked by hand. Unsure about: vitest/fake-indexeddb parity for persistence specs; exact effort of steps 5–7 (the dependency web is mapped but not yet sized per-PR); whether the WS-triggered download path has E2E coverage. Risks: behavioral parity in steps 7–8 (sync-correctness invariants) and the session-latch move in step 4 — both gated by characterization-first testing and the scheduled E2E matrix.