69 KiB
@sp/sync-core Extraction Plan
Status: In progress - PR 1, PR 2 guardrails/logger adapter work, PR 3a vector-clock ownership, full-state op classification config, PR 3b pure helper slices, PR 4a port contracts, PR 4b small orchestration/planning helpers, and PR 4c's narrow operation replay coordinator are present. PR 5 has shipped the
@sp/sync-providersscaffold, provider boundary lint, provider-neutral contracts, a credential-store port, the file-based sync envelope types, PKCE helpers, the native-HTTP retry helpers, the shared provider error classes, and the bundled Dropbox, WebDAV + Nextcloud, SuperSync, and LocalFile providers behind injected platform/credential/file ports. Next up: PR 6 final boundary hardening and documentation.
Goal: Carve the sync engine out of src/app/op-log/ into a reusable,
framework-agnostic, domain-agnostic @sp/sync-core package, plus a sibling
@sp/sync-providers package for bundled provider implementations.
Context
The sync frontend lives in src/app/op-log/ (the older src/app/pfapi/ is
legacy and out of scope). It already organizes itself by concern (core,
sync, apply, capture, persistence, encryption, validation, util,
model, sync-providers), but the boundary is convention-only: the engine
reaches into NgRx state, core/entity-registry.ts hardcodes imports from 15+
feature reducers, and providers and engine code intermix freely.
The eventual target is a three-concern split:
- Sync logic / engine - operation orchestration, vector clocks, conflict resolution, persistence interfaces. Framework-agnostic and domain-agnostic.
- Configuration - entity registry, model config, app-specific wiring, action-type enums, entity-type unions, repair payload shapes, provider lists. Lives in the app.
- Provider implementations - SuperSync, Dropbox, WebDAV, LocalFile. Pluggable, and talking to the engine through stable interfaces.
Domain Rule
Anything that names a Super Productivity domain object, enum value, or wire
convention belongs in the app, not in @sp/sync-core. The lib carries
actionType and entityType as plain string; the app narrows via
Omit-and-extend on top of the lib's generic Operation.
App-only forever:
ActionTypeenum - host-app action catalog, not lib content.ENTITY_TYPES/EntityTypeunion - TASK, PROJECT, TAG, METRIC, BOARD, etc. are SP's domain. Lib usesstring; app narrows.SyncImportReasonunion - SP's specific import flows.RepairSummary,RepairPayload- SP's repair-output shape.WrappedFullStatePayload+extractFullStateFromPayload+assertValidFullStatePayload- theappDataCompletewrapper and the['task','project','tag','globalConfig']key-presence check are SP wire format.SyncProviderId,OAUTH_SYNC_PROVIDERS,REMOTE_FILE_CONTENT_PREFIX,PRIVATE_CFG_PREFIX- SP's bundled providers and SP-flavored storage prefixes.@sp/shared-schema- that package is SP-coupled today, so@sp/sync-coremust not depend on it.
Where the lib needs host-specific enumerations, it exposes a factory or config object and the app supplies values at composition time. The current LWW helper factory is the model to follow.
Recommendations From PR #7546 Review
These adjustments should happen before the extraction proceeds beyond the thin first slice:
- Move boundary enforcement up. Add ESLint/package-boundary checks in the
next PR, not at the end. Once
packages/sync-core/exists, accidental imports from Angular, NgRx,src/app, or@sp/shared-schemashould fail immediately. - Single-source vector-clock algorithms. The client currently delegates
comparison/merge/prune behavior to
@sp/shared-schemafor client/server parity. Before moving vector-clock code, pick one owner for compare/merge/prune and have the other package/server import or re-export it. Do not duplicate the algorithms. - Treat full-state operation classification as configuration. PR 1 keeps
OpType.SyncImport,OpType.BackupImport, andOpType.Repairin the generic package for compatibility. Before the engine becomes reusable, make full-state operation classification configurable or explicitly document those op types as host-defined strings. - Do not move
OperationApplierServicewholesale. It currently coordinates NgRx bulk dispatch, hydration windows, archive side effects, and deferred local actions. Extract a small core replay contract/state machine first, leaving the Angular/SP choreography in the app until the port boundary has proven itself. - Make logger metadata privacy-safe.
CLAUDE.mdforbids logging user content into exportable logs. TheSyncLoggerport should make this explicit by accepting only safe, structured metadata and documenting that payloads/full entities must not be logged. - Add package tests before moving algorithms.
@sp/sync-corecan start with build-only checks, but PR 3a should first introduce the package test runner and then port algorithm specs. - Keep provider extraction separate. Do not let
@sp/sync-corelearn provider IDs, file prefixes, OAuth behavior, credential storage, or bundled provider lists.
Current Branch Snapshot
The branch already contains the first package boundary and part of the PR 2 groundwork:
packages/sync-core/exists and is exposed through the@sp/sync-corepath alias.npm run sync-core:buildruns the package build, andpreparebuildssync-core,shared-schema, thenplugin-api.eslint.config.jsappliesno-restricted-importsand a dynamic-import ban topackages/sync-core/**/*.ts.- The package currently exports operation primitives, apply types, LWW helper
factory, full-state op-type helper factory, entity-key helpers,
host-configured sync-file prefix helpers, generic error-message helpers,
SyncStateCorruptedError, entity-registry contracts, and the privacy-aware logger port. - The app registry now has
buildEntityRegistry()and anENTITY_REGISTRYinjection token. Existing helper functions still read the app-sideENTITY_CONFIGSsingleton for compatibility.
Current extraction state and remaining immediate debt:
- Full-state operation classification is now host-configured via
createFullStateOpTypeHelpers(). The SP-facingsrc/app/op-log/core/operation.types.tsshim instantiates its ownFULL_STATE_OP_TYPESandisFullStateOpType; the package root keeps deprecated SP compatibility exports for existing consumers.OpType.SyncImport,OpType.BackupImport, andOpType.Repairremain in@sp/sync-coreonly as host-defined compatibility strings. - Vector-clock compare/merge/prune now lives in
@sp/sync-core, with@sp/shared-schemare-exporting it for existing client/server imports. SyncLoggerexists, but movable app code still mostly callsOpLogdirectly.@sp/sync-corehas a Vitest package test runner and vector-clock tests.- Generic gzip/base64 compression helpers now live in
packages/sync-core/src/compression.ts. The app-facingsrc/app/op-log/encryption/compression-handler.tsshim preservesCompressError/DecompressErrorwrapping and the defaultOpLoglogger adapter. - PR 3b has generic conflict helpers in
packages/sync-core/src/conflict-resolution.ts: deep equality, identical conflict detection, conflict-resolution suggestion, entity frontier construction, clock-corruption comparison adjustment, pure LWW conflict resolution planning, and local-DELETE-loses-to-remote-UPDATE payload extraction/merge helpers. It also owns pure LWW resolution partitioning: local/remote winner counts, remote-winner ops after host processing, local-winner remote ops, rejected-op id buckets, local-win op collection, and remote-winner affected entity-key calculation. The AngularConflictResolutionServicedelegates to these helpers while keeping app orchestration, IndexedDB/apply flow, entity lookup, NgRx, dev-error wiring, app action-type ownership, fallback logging, and operation creation app-side. - PR 3b also has the pure full-state import vector-clock decision helper in
packages/sync-core/src/sync-import-filter.ts. The AngularSyncImportFilterServicestill owns full-state operation classification, latest import lookup from batch/store, IndexedDB access, conflict-dialog signaling, and logging. sync-errors.tsnow routes constructor diagnostics for additional-log errors, JSON parse failures, and validation failures through the privacy-awareSyncLoggeradapter with safe metadata only. The error classes still stay app-side because their recovery wording, provider diagnostics, andadditionalLogUI/reporting behavior are SP-specific.- PR 4a is present with
packages/sync-core/src/ports.ts. The package now exports minimal contracts for operation application, action dispatch, remote-apply windows, deferred local action flushing, archive side effects, operation-store persistence, conflict UI, and sync configuration. The existing Angular services satisfy these contracts app-side. Conflict UI and sync config adapters remain app-side and are not used by package orchestration yet. - PR 4b's current small helper set is present: remote-apply crash-safety ordering, upload last-server-sequence planning, full-state snapshot upload follow-up partitioning, download gap/full-state/encryption planning, and file-snapshot hydration skip planning. Provider calls, encryption/decryption, IndexedDB reads, UI, diagnostics, and result assembly remain app-side.
- PR 4c is present with
replayOperationBatch()in@sp/sync-core. It owns only the strict replay ordering around remote-apply windows, bulk dispatch, the required event-loop yield, archive side-effect processing, post-sync cooldown, and deferred local-action flushing. The AngularOperationApplierServicestill owns NgRx action construction, operation-to-action conversion, archive predicates,remoteArchiveDataApplied,Injectorusage, and diagnostics. - Pre-P5 readiness cleanup is complete for this branch: movable core code no
longer depends on
OpLog, generic prefix/error/compression helpers are package-side with app-owned diagnostics, sync-core source comments were rechecked for SP entity examples, and the core boundary grep was rerun with no forbidden source imports. - PR 5 has its package boundary:
packages/sync-providers/exists with tsup/Vitest scaffolding, root scripts, build-package wiring, the@sp/sync-providerspath alias, package-local generated-artifact ignores, and ESLint restrictions that reject Angular, NgRx, app imports,@sp/shared-schema, sync-core internals, and dynamic imports. - Provider-neutral contracts now live in
@sp/sync-providers: generic string-ID provider contracts, operation-sync response types, file provider response types, a credential-store port, and the local file-adapter port. The app-sideprovider.interface.tsand localfile-adapter.interface.tsremain compatibility shims that specialize those contracts withSyncProviderIdandPrivateCfgByProviderId. - File-based sync envelope contracts now live in
@sp/sync-providerswith generic host-owned state, compact-operation, and archive payload parameters. The app-sidefile-based-sync.types.tsshim binds those generics toCompactOperationandArchiveModel. - Dropbox PKCE code generation now lives in
@sp/sync-providers, including the existing WebCrypto-first andhash-wasmfallback behavior. The app-side Dropbox helper path remains a compatibility re-export. - Dropbox, WebDAV + Nextcloud, SuperSync, and LocalFile provider
implementations now live in
@sp/sync-providers. App-side shims keepSyncProviderId, OAuth routing, config UI, credential-store implementation, response validators that depend on@sp/shared-schema, and the Electron/SAF platform bridges app-side.
Suggested next order:
- Treat PR 2 documentation/verification and the targeted
SyncLoggerrouting needed before provider extraction as complete for this branch. Continue logger routing only when additional files actually move. - Treat the PR 3b pure conflict-resolution and sync-import slices as complete for this round.
- Treat the current PR 4a/4b/4c port, small-helper, and replay-coordinator
slices as complete for this round; keep the Angular
OperationApplierServiceshell app-side unless a later port proves another small extraction safe. - Treat PR 5 provider lift as complete for this branch. Continue with PR 6: final boundary hardening, manifest/public-export audits, and a short architecture note for package dependency direction.
PR 1 - Thin First Slice (#7546)
Stand up packages/sync-core/ with pieces that are framework-agnostic and
mostly domain-agnostic. No behavior change. Establishes the import boundary and
the @sp/sync-core alias so later PRs work against a real package boundary.
Goals
- Create
packages/sync-core/mirroring the existing package shape. - Move only generic primitives and helpers.
- Move only framework-agnostic code: no
@Injectable, noinject(), no NgRx, no Angular Material. - Keep existing
src/app/op-log/call sites working through stubs at the original paths. - Keep
ActionType, provider constants, full-state payload wrappers, repair payload shapes, and import reasons app-side. - Avoid behavior changes.
Current Contents
Source: packages/sync-core/src/. All exports come through index.ts.
Operation primitives (operation.types.ts):
OpTypeenum.OperationwithactionType: stringandentityType: string.OperationLogEntry,EntityConflict,ConflictResult,EntityChange,MultiEntityPayload.VectorClock = Record<string, number>.isMultiEntityPayload,extractActionPayload.
Full-state op-type helper factory (full-state-op-types.ts):
createFullStateOpTypeHelpers<TOpType>(fullStateOpTypes)returns the host-ownedFULL_STATE_OP_TYPESset andisFullStateOpTypepredicate.- The package keeps deprecated SP compatibility exports for
FULL_STATE_OP_TYPES/isFullStateOpType, but reusable hosts should instantiate their own helper instead of using those defaults.
LWW factory (lww-update-action-types.ts):
createLwwUpdateActionTypeHelpers<TEntityType>(entityTypes)returnsLWW_UPDATE_ACTION_TYPES,isLwwUpdateActionType,getLwwEntityType, andtoLwwUpdateActionType.- The app instantiates it once with
ENTITY_TYPES.
Apply types (apply.types.ts):
ApplyOperationsResult,ApplyOperationsOptionsover the lib's genericOperation.
Utilities:
toEntityKey,parseEntityKey.SyncStateCorruptedError.
App Stubs
Each previously-public symbol path keeps working via thin shims:
src/app/op-log/core/operation.types.tsre-exports generic symbols and redeclares SP-narrowedOperation,OperationLogEntry,EntityChange,EntityConflict,ConflictResult, andMultiEntityPayload. It also instantiatescreateFullStateOpTypeHelpers()with SP's full-state op strings.src/app/op-log/core/types/apply.types.tsredeclares app-narrowed apply result/options types.src/app/op-log/core/lww-update-action-types.tsinstantiates the LWW helper factory withENTITY_TYPES.src/app/op-log/core/sync-state-corrupted.error.tsre-exports from the package.src/app/op-log/util/entity-key.util.tsdelegates to the package while preserving the app'sEntityType-narrowed API.src/app/op-log/core/action-types.enum.tsstays full source in the app.src/app/op-log/sync-providers/provider.const.tsstays full source in the app.
PR 1 Follow-Ups Before Merge
- Update the PR description if it still says
action-types.enum.tsorprovider.const.tsmoved into@sp/sync-core; the code correctly keeps them app-side. - Fix comments that imply
sync-coredepends onshared-schema. The build may run aftershared-schema, but the package dependency direction must remain absent. - Resolved in follow-up:
FULL_STATE_OP_TYPESis now app-configured viacreateFullStateOpTypeHelpers().
Verification
-
cd packages/sync-core && npx tsup- package builds clean. -
npx tsc -p src/tsconfig.app.json --noEmit- app type-checks. -
npm run checkFileon every touched.tsfile. -
npm testor scoped op-log specs. -
App boot plus manual sync smoke: sync round-trip, conflict round-trip, encryption toggle.
-
SuperSync E2E when the branch is ready for merge.
-
Boundary check returns nothing:
grep -r "from '@angular\\|from '@ngrx\\|from '@sp/shared-schema\\|src/app" packages/sync-core/src/
PR 2 - Boundary Guardrails, Entity Registry Types, Logger Port
This replaces the original late ESLint PR. Boundary guardrails should land immediately after the package exists.
Goals
- Add package boundary enforcement. Lint
packages/sync-core/**and reject imports from Angular, NgRx,src/app, and@sp/shared-schema. - Entity registry as config. Move abstract registry types into
@sp/sync-core; keep SP feature imports and registry construction in the app. - Logger port. Define a privacy-aware
SyncLoggerinterface in@sp/sync-coreso moveable files can drop directOpLogimports.
Current State
Already present:
eslint.config.jshas apackages/sync-core/**/*.tsoverride that rejects Angular, NgRx,src/app,@sp/shared-schema, relativeshared-schemaimports, and dynamic imports.packages/sync-core/src/entity-registry.types.tsdefines structuralEntityConfig/EntityRegistrycontracts and helper predicates.src/app/op-log/core/entity-registry.tsbuilds the SP registry app-side, re-exports the core contracts, and providesENTITY_REGISTRY.SINGLETON_ENTITY_IDremains app-side, which is correct while singleton entity IDs are still an SP replay convention.ConflictResolutionServicenow uses the injectedENTITY_REGISTRY, proving the DI-based registry path while keeping compatibility helpers available for non-DI consumers.packages/sync-core/src/sync-logger.tsdefinesSyncLogger,NOOP_SYNC_LOGGER,SyncLogMeta,SyncLogError, andtoSyncLogError().packages/sync-core/src/sync-file-prefix.tsdefinescreateSyncFilePrefixHelpers(). The app shim suppliesREMOTE_FILE_CONTENT_PREFIXandInvalidFilePrefixError, keeping SP storage constants and diagnostics app-side while moving the generic parsing/formatting logic behind a config boundary.packages/sync-core/src/error.util.tsdefinesextractErrorMessage()for generic thrown-value message extraction. The app error module re-exports it for compatibility while keeping SP/provider-specific error classes app-side.src/app/op-log/core/errors/sync-errors.tsnow sends constructor diagnostics throughSyncLoggerinstead of direct rawOpLogcalls. Logs retain IDs, counts, paths, error names, and key summaries, but not validation payloads, raw provider responses, JSON samples, or wrapped error messages.src/app/op-log/core/sync-logger.adapter.tswiresSyncLoggertoOpLogvia the app-sideSYNC_LOGGERinjection token and theOP_LOG_SYNC_LOGGERdirect adapter.EncryptAndCompressHandlerServicenow accepts aSyncLoggerconstructor argument and uses the app adapter by default, proving the direct-constructor path for package-level classes without changing sync behavior.op-log/encryption/compression-handler.tsnow routes compression failures throughSyncLogger+toSyncLogError()and logs only safe length metadata.- A deliberate bad-import check was run with a temporary
packages/sync-core/src/__boundary-check__.tsimporting@angular/core;npm run lint:file -- packages/sync-core/src/__boundary-check__.tsfailed onno-restricted-imports, proving the boundary rule is active.
Remaining PR 2 follow-up:
- Keep the compatibility
ENTITY_CONFIGSsingleton until remaining non-DI consumers have been deliberately migrated. - Continue routing only files being moved or made movable through
SyncLogger; do not do a broadOpLogrefactor. - Keep external PR text aligned with this document if the branch is split for review.
Boundary Enforcement
eslint.config.jsalready lintspackages/sync-core/**.- The package override already has
no-restricted-importsfor:@angular/*@ngrx/*@sp/shared-schemasrc/app/*and relative app imports such as../../src/app/*
- Keep package exceptions explicit for packages that cannot yet be linted.
packages/sync-providers/**now has the same boundary shape, with an additional ban on sync-core internal import paths. It may import public@sp/sync-coreonly.- The rule was proved with a temporary
@angular/coreimport underpackages/sync-core/src/; scoped lint failed as expected withno-restricted-imports, and the file was removed.
Entity Registry Types
Define EntityConfig / EntityRegistry types in
@sp/sync-core/src/entity-registry.types.ts, but make the shape reflect the
current registry, not a simplified example.
Required storage patterns:
type EntityStoragePattern = 'adapter' | 'singleton' | 'map' | 'array' | 'virtual';
Guidelines:
- Registry keys are
string; the app narrows them toEntityType. - Selectors are structural function types; the package must not import NgRx selector types.
- Adapter support is structural, not
@ngrx/entity-typed. Include only the methods actually consumed by op-log code. - Include
payloadKey,featureName,mapKey, andarrayKeyif current consumers need them. - Keep
SINGLETON_ENTITY_IDgeneric if it remains engine-relevant; otherwise keep it in the app.
App-side state:
src/app/op-log/core/entity-registry.tsalready exposesbuildEntityRegistry().ENTITY_REGISTRYalready exists as an app injection token.ENTITY_CONFIGSand helper functions still read a singleton registry for compatibility. Keep that until services are deliberately ported to injected registry dependencies, or migrate one low-risk consumer in PR 2 to prove the token works.- Keep all feature reducer/selector imports in the app.
Logger Port
Define SyncLogger in the lib:
export type SyncLogMeta = Record<string, string | number | boolean | null | undefined>;
export interface SyncLogError {
name: string;
code?: string | number;
}
export interface SyncLogger {
log(message: string, meta?: SyncLogMeta): void;
error(message: string, error?: SyncLogError, meta?: SyncLogMeta): void;
err(message: string, error?: SyncLogError, meta?: SyncLogMeta): void;
normal(message: string, meta?: SyncLogMeta): void;
verbose(message: string, meta?: SyncLogMeta): void;
info(message: string, meta?: SyncLogMeta): void;
warn(message: string, meta?: SyncLogMeta): void;
critical(message: string, meta?: SyncLogMeta): void;
debug(message: string, meta?: SyncLogMeta): void;
}
Also provide NOOP_SYNC_LOGGER for tests and package defaults, plus
toSyncLogError(error: unknown) so adapters can preserve safe error identity
without passing arbitrary error objects into exportable logs.
Keep both error() and err() initially because current movable code uses both
OpLog spellings. If a follow-up PR normalizes calls to one spelling, do that
explicitly in the same PR instead of silently shrinking the port surface.
Privacy rule: logger metadata must not include full entities, operation payloads, task titles, note text, raw provider responses, credentials, or encryption material. IDs, counts, op IDs, action strings, entity types, and error names are acceptable.
App-side follow-up:
- The app adapter lives in
src/app/op-log/core/sync-logger.adapter.tsand satisfiesSyncLoggerby forwarding only the safe port arguments toOpLog. - Angular services should inject
SYNC_LOGGER; package-level pure functions and classes should receive aSyncLoggerconstructor/function argument. - Convert only files being moved or made movable; a broad
OpLogrefactor is unnecessary and risks changing log behavior.
Initial candidate-file audit:
op-log/encryption/encrypt-and-compress-handler.service.ts: safe prefix and flag metadata now goes throughSyncLogger.op-log/encryption/compression-handler.ts: routes failures throughSyncLoggerand preserves only safe counts such as input length. The generic stream/base64 implementation now lives in@sp/sync-core; the app file is a compatibility shim that keeps SP error classes and the defaultOpLogadapter app-side.op-log/core/errors/sync-errors.ts: constructor diagnostics now route throughSyncLoggerwith safe metadata only. GenericextractErrorMessage()lives in the package, but the error classes remain app-side because recovery messages, provider diagnostics, andadditionalLogUI/reporting behavior are still SP-specific.op-log/validation/validate-operation-payload.ts: validation warnings now route throughSyncLoggerwith sanitized operation metadata plus payload type/count summaries only; raw payload values and raw payload keys stay out of exportable logs.op-log/validation/auto-fix-typia-errors.ts: Typia repair attempts and applied fixes now route throughSyncLoggerwith path/type/count metadata only; raw invalid values, defaults, and full Typia error objects stay out of exportable logs.op-log/validation/repair-menu-tree.ts: menu-tree repair logs now useSyncLoggermetadata for removed references/invalid nodes; raw node objects and folder names stay out of exportable logs.op-log/validation/validation-fn.ts: schema validation failures now route throughSyncLoggerwith counts, paths, expected types, and data shape summaries only; raw validation result data and invalid values stay out of exportable logs.op-log/validation/is-related-model-data-valid.tsand the invalid-date repair branch indata-repair.ts: cross-model validation and date repair diagnostics now keep raw app state, titles, and corrupted date strings out of exportable logs.op-log/util/sync-file-prefix.ts: now delegates to the package helper with app-supplied prefix and error construction. The app-facing shim should remain until consumers are deliberately switched to injected/configured helpers.
What This Unlocks
After this PR, files blocked only by OpLog can move without creating a package
dependency on app logging:
op-log/encryption/op-log/core/errors/sync-errors.tsop-log/util/sync-file-prefix.ts
Verification
npm run lintproves package boundary rules are active.- Add and revert one deliberately-bad package import to prove the rule fails.
npm run sync-core:buildproves the new exported contracts build.npm testfor registry-related specs.- App boot + sync round-trip.
- Manual log export flow: sync/encryption events still appear and do not expose user content.
PR 3a - Vector-Clock Ownership and Package Test Harness
Do this before moving more algorithms. Vector-clock parity is load-bearing for sync correctness.
Status: implemented on this branch.
Goals
- Pick the single source of truth for vector-clock compare/merge/prune logic.
- Add a package test runner for
@sp/sync-core. - Port existing vector-clock tests before changing call sites.
Preferred Direction
Decide the dependency direction before PR 3a moves code. The preferred outcome
is that @sp/sync-core owns generic vector-clock algorithms:
compareVectorClocksmergeVectorClockslimitVectorClockSizeMAX_VECTOR_CLOCK_SIZE- validation/sanitization helpers if they are shared by client/server
This is acceptable only if current server/shared consumers can depend on
@sp/sync-core without creating a bad package direction or build cycle. In that
case, update build order so sync-core is available before those consumers, or
make the server consume @sp/sync-core directly.
If that dependency direction is awkward, create a tiny leaf package such as
@sp/vector-clock and have both @sp/sync-core and server/shared code consume
it. Do not make @sp/sync-core depend on @sp/shared-schema; the important
constraint is one implementation, not two copies.
Current Locations
- Generic compare/merge/prune and
MAX_VECTOR_CLOCK_SIZElive inpackages/sync-core/src/vector-clock.ts. packages/shared-schema/src/vector-clock.tsis a compatibility re-export from@sp/sync-core.- The client wrapper lives in
src/app/core/util/vector-clock.ts; it adds null/undefined handling, sanitization, logging, and pruning notifications. - Server sanitization and sync types live in
packages/super-sync-server/src/sync/sync.types.ts; server conflict detection and storage pruning consume the shared algorithms. - Existing vector-clock package tests live in
packages/sync-core/tests/vector-clock.spec.ts.shared-schemakeeps its existing compatibility coverage through the re-export.
PR 3a moved the algorithms and tests in one commit set to avoid client/server drift.
Test Harness
Implemented using the same Vitest shape as packages/shared-schema:
packages/sync-core/vitest.config.tswith Node environment andtests/**/*.spec.ts.testandtest:watchscripts inpackages/sync-core/package.json.vitestas apackages/sync-coredev dependency.- Root
sync-core:testnext tosync-core:build. packages/shared-schema/tests/vector-clock.spec.tsported topackages/sync-core/tests/vector-clock.spec.ts.
Server Build Fallout
Because @sp/shared-schema now depends on @sp/sync-core, all places that
currently copy, install, build, or pack only shared-schema must include
sync-core first:
packages/shared-schema/package.jsondepends on@sp/sync-core.package.jsonandpackages/build-packages.jsbuildsync-corebeforeshared-schema.packages/super-sync-server/Dockerfile.packages/super-sync-server/Dockerfile.test.- Any CI workflow that installs only
packages/shared-schemaandpackages/super-sync-server.
Keep @sp/shared-schema available to the server for schema/version/entity-type
contracts until those are separately decoupled.
Migration Notes
- Preserve client null/undefined wrapper behavior exactly.
- Preserve
MAX_VECTOR_CLOCK_SIZE = 20. - Preserve server ordering: conflict detection first, pruning before storage.
- Replace the RxJS prune
Subjectwith a callback/event hook at the package boundary. - Route logging through
SyncLogger.
Verification
npm run sync-core:build.npm run sync-core:testonce the root script exists.cd packages/shared-schema && npm testif shared-schema keeps re-exporting or wrapping the moved algorithms.cd packages/super-sync-server && npm testfor server parity.npm run test:file src/app/core/util/vector-clock.spec.tsfor client wrapper behavior.- Docker verification for the changed server image paths:
docker build -f packages/super-sync-server/Dockerfile.test .at minimum, anddocker build -f packages/super-sync-server/Dockerfile .before merge when image-build time is acceptable. - Keep app wrapper specs for null/undefined handling, logging, sanitization, and import compatibility.
- Boundary grep stays empty for
packages/sync-core/src/.
PR 3b - Pure Algorithmic Core
Move framework-agnostic, stateless sync algorithms. These should only need typed inputs and the logger port.
Current State
deepEqual,isIdenticalConflict,suggestConflictResolution,buildEntityFrontier,adjustForClockCorruption, andplanLwwConflictResolutionslive in@sp/sync-corewith package-level Vitest coverage.classifyOpAgainstSyncImportlives in@sp/sync-coreand owns only the vector-clock keep/invalidate decision for an op against the latest full-state import. It returns the raw comparison plus a reason so app logging stays unchanged.- Local DELETE losing to remote UPDATE conversion now delegates to
extractEntityFromPayload,extractUpdateChanges, andconvertLocalDeleteRemoteUpdatesToLwwin@sp/sync-core. The app supplies payload-key resolution, LWW action-type conversion, singleton-id handling, and fallback warning logging. ConflictResolutionServicekeeps compatibility wrappers/call sites and passes the appSyncLoggeradapter into package helpers. It also supplies the app-owned archive action predicate to LWW planning and creates archive/local-win operations app-side.- Pure remote/local operation partitioning now lives in
@sp/sync-core; NgRx state lookup and operation creation stay in the app. SyncImportFilterServicestill owns full-state op detection, latest import selection from current batch/local store, IndexedDB access, local unsynced import detection, and allOpLogmessages.- Generic gzip/base64 compression helpers live in
@sp/sync-corewith package-level Vitest coverage. The app shim keepsCompressError,DecompressError, truncated-file recovery wording, andOpLogadapter defaults app-side.
What Moves
- Conflict detection and LWW resolution algorithms from
op-log/sync/conflict-resolution.service.ts. - Filtering/partitioning helpers that operate on
OperationLogEntry[]. - Pure op merge helpers currently scattered across
remote-ops-processing.service.tsandoperation-log-sync.service.ts. - Pure operation payload validation from
op-log/validation/, as long as it does not import app schemas or NgRx selectors. - Remaining encryption utilities once their app diagnostics and runtime
dependencies are split. Generic compression is already package-side behind the
SyncLoggerport and host error factories. sync-errors.tsandsync-file-prefix.tsif they are generic after logger/config cleanup.
What Stays App-Side
- Anything that calls
Store.dispatch()orStore.select(). OperationLogStoreServiceand IndexedDB implementation details.- UI services: dialogs, snacks, Angular Material.
- Effects, meta-reducers, and
LOCAL_ACTIONSwiring. - App schema validation tied to SP model shape.
- Full-state payload wrappers and SP repair payloads.
Verification
- Package test suite for moved algorithms.
- Full app
npm testfor integration through stubs. - Boundary grep stays empty.
- Manual sync round-trip, encryption toggle, and conflict scenario.
PR 4a - Port Contracts Only
Introduce orchestration ports without moving the orchestrators yet. This reduces the risk of the later service moves.
Status: implemented for the current branch slice. @sp/sync-core exports the
first minimal replay/storage port contracts, and these app services now
explicitly satisfy them:
OperationApplierServiceimplementsOperationApplyPort<Operation>and usesActionDispatchPort<SyncActionLike>for its NgRx dispatch seam.HydrationStateServiceimplementsRemoteApplyWindowPort.OperationLogEffectsimplementsDeferredLocalActionsPort.ArchiveOperationHandlerimplementsArchiveSideEffectPort<PersistentAction>.OperationLogStoreServiceimplementsOperationStorePort<Operation, OperationLogEntry>.
ConflictUiPort and SyncConfigPort are also exported and satisfied by
app-side services:
SyncImportConflictDialogServiceimplementsConflictUiPort<SyncImportConflictResolution>for the sync-import conflict dialog while keeping its app-specificSyncImportConflictDataAPI.GlobalConfigServiceimplementsSyncConfigPortby exposing the currentselectSyncConfigsnapshot without leaking NgRx selectors into@sp/sync-core.
These adapters are intentionally not used by package orchestration yet.
This is contract-only: NgRx dispatch, hydration windows, archive IndexedDB handling, and deferred local action processing remain app-side.
App-side adapter specs now exercise the first port set through the sync-core types:
OperationApplyPortandActionDispatchPortcoverage inoperation-applier.service.spec.ts, including action/meta identity, bulk operation reference preservation, dispatch-yield-before-archive ordering, remote cooldown/end-window/deferred flush ordering, and local hydration close-window/deferred flush behavior.RemoteApplyWindowPortcoverage inhydration-state.service.spec.ts.ArchiveSideEffectPortcoverage inarchive-operation-handler.service.spec.ts.DeferredLocalActionsPortcoverage inoperation-log.effects.spec.ts.OperationStorePortcoverage inoperation-log-store.service.spec.ts.
Ports
OperationStorePort- abstract over op-log persistence. Method names useOperation/OperationLogEntryonly.ActionDispatchPort- abstract over dispatching replay actions. Takes generic action objects and must preservemetaexactly.RemoteApplyWindowPort- abstractsHydrationStateServicebehavior: start remote apply, end remote apply, post-sync cooldown.DeferredLocalActionsPort- abstractsOperationLogEffects.processDeferredActions().ArchiveSideEffectPort- abstracts archive-specific IndexedDB handling for remote operations.ConflictUiPort- app dialog/snack adapter. Reasons are strings at the package boundary.SyncConfigPort- app adapter around NgRx config selectors. Provider IDs are strings at the package boundary.RepairPortonly if truly needed, and with generic shapes.
Why Split This Out
OperationApplierService is not just replay logic. It currently coordinates:
- bulk NgRx dispatch,
- the required event-loop yield after dispatch,
- remote apply windows and cooldowns,
- archive side effects,
remoteArchiveDataApplied,- deferred local action processing.
Those behaviors should first be represented as ports and tested while the service remains app-side.
Verification
- Adapter specs prove app services satisfy the ports.
- Existing app sync specs still pass.
- Add contract tests for action
metapreservation and bulk-dispatch yield behavior.
PR 4b - Move Small Orchestration Units Behind Ports
Move only orchestration code whose dependencies are already represented by ports and whose behavior can be tested without Angular.
Status: implemented for the current branch slice. @sp/sync-core now exports
applyRemoteOperations() plus the narrow RemoteOperationApplyStorePort.
RemoteOpsProcessingService.applyNonConflictingOps() delegates the generic
remote-apply crash-safety ordering to that coordinator:
- append incoming remote ops as pending while atomically skipping duplicates;
- apply only newly appended ops through
OperationApplyPort; - mark applied seqs;
- merge applied remote vector clocks;
- clear older full-state ops after a newer applied full-state op lands;
- mark the failed op and remaining unapplied ops as failed on partial apply errors.
The Angular service still owns app diagnostics, validation/session latching, snack notifications, conflict detection, NgRx dispatch construction, and the IndexedDB implementation.
The package also owns small upload-planning helpers used by
OperationLogUploadService:
planRegularOpsAfterFullStateUpload()partitions regular ops into already-covered-by-snapshot vs still-needs-upload buckets after a full-state snapshot upload.planUploadLastServerSeqUpdate()keeps last-server-sequence persistence monotonic while preserving the "has more piggyback" follow-up download behavior.
Provider calls, encryption/decryption, snapshot upload, error handling, app logging, and persistence remain app-side.
Download-side planning is also limited to pure decisions:
planDownloadGapReset()allows one gap reset per download session.planDownloadFullStateUpload()decides when an empty remote needs a full-state upload and when the app should query synced-op history.planDownloadedDataEncryptionState()derives the "server has only unencrypted data" flag.planSnapshotHydration()decides when a file-based snapshot can be skipped because the local vector clock already equals or dominates the snapshot clock.
Provider pagination, snapshot handling, decryption, clock drift warnings, IndexedDB reads, and result assembly remain app-side.
Candidate Moves
- Upload batching/retry logic from
OperationLogUploadServiceif provider and store access are ported. - Remote op processing state machine if applying, marking, and validation are all ports.
- Pure parts of download/upload decision logic.
Keep App-Side Until Proven Safe
- The Angular
OperationApplierServiceshell. bulkApplyOperationsaction and meta-reducer wiring.HydrationStateServiceimplementation.ArchiveOperationHandlerimplementation.- Effects using
inject(LOCAL_ACTIONS). - UI-coupled conflict/import/download services.
Verification
- Package orchestration tests.
- App adapter tests.
- Full app unit tests.
- SuperSync scenarios focused on concurrency, fresh-client bootstrap, server migration, and import conflicts.
PR 4c - Revisit OperationApplierService
Only after 4a/4b are stable, decide whether any part of
OperationApplierService belongs in @sp/sync-core.
Status: implemented for the current branch slice. The extracted part is the
narrow replayOperationBatch() coordinator in packages/sync-core/src/replay-coordinator.ts.
It is intentionally generic and calls host-supplied ports/callbacks in a strict
order:
- open the remote-apply window;
- dispatch the host-created bulk replay action;
- yield after dispatch so host reducers finish before side effects;
- run remote archive side effects after dispatch when configured;
- yield around archive side effects to preserve UI responsiveness;
- start post-sync cooldown before ending the remote-apply window;
- end the remote-apply window and flush deferred local actions.
Package-level Vitest coverage now asserts dispatch-yield ordering, local hydration behavior, archive failure reporting, archive notification timing, cooldown failure handling, and empty-batch no-op behavior.
The Angular OperationApplierService delegates to this coordinator but keeps
all app-specific work app-side: bulkApplyOperations, convertOpToAction,
isArchiveAffectingAction, remoteArchiveDataApplied, Injector access to
OperationLogEffects, and OpLog diagnostics.
Acceptable extraction:
- a small generic replay coordinator that calls ports in a strict order;
- contract tests for yielding, failure reporting, archive side-effect ordering, and deferred-action flush timing.
Likely app-side permanently:
- NgRx action construction and
bulkApplyOperations, - Angular
Injectorusage, remoteArchiveDataApplied,- hydration-state implementation,
- archive handler implementation.
Hard requirements from CLAUDE.md:
- remote operations must not trigger normal effects;
- selector-based effects must remain guarded by the sync window;
- bulk dispatch must yield after the dispatch;
- remote archive side effects must still run;
- deferred local actions must be processed after remote apply finishes.
Pre-P5 Readiness Check
Status: complete for this branch.
- No remaining pre-P5
SyncLoggerrouting is needed in core: files already made movable either live in@sp/sync-corewithout app logging, accept aSyncLoggerport, or stay app-side because their diagnostics/recovery behavior is still SP-specific. sync-file-prefix, generic error-message extraction, and gzip/base64 compression helpers are package-side behind host-owned configuration/error factories.OperationApplierServicelogging remains app-side intentionally; the moved replay coordinator has no logging dependency.- Provider-specific logging and credential diagnostics remain in
src/app/op-log/sync-providers/and should be handled during PR 5 when those files move to@sp/sync-providers. - Boundary verification was rerun for
packages/sync-core/srcand found no forbidden Angular, NgRx,src/app, or@sp/shared-schemaimports.
PR 5 - Lift Providers Into @sp/sync-providers
Pull bundled providers out of src/app/op-log/sync-providers/ so engine,
providers, and app wiring each live in their own package.
What Moves
op-log/sync-providers/super-sync/op-log/sync-providers/file-based/dropbox/op-log/sync-providers/file-based/webdav/including Nextcloud-specific codeop-log/sync-providers/file-based/local-file/, with Electron APIs behind an app-provided port- provider registry/factory logic that does not read NgRx state directly
What Stays App-Side
SyncProviderIdand bundled provider lists.- Credential-store Angular service implementation.
- OAuth callback routing.
- Provider config UI/dialogs.
- Electron bridge implementation.
- Any code reading
selectSyncConfigdirectly.
Provider Package Rules
- Provider IDs inside the package are string constants, not the app's
SyncProviderIdenum. - Credential storage is an interface.
- HTTP should use
fetchor an injected HTTP port, not AngularHttpClient. - Provider package must not import
@sp/sync-coreinternals beyond public ports/types.
Current First Slice
packages/sync-providers/mirrors thesync-corepackage scaffolding:package.json, tsup build, Vitest config, strict packagetsconfig, and a package-local.gitignorefor generated artifacts.- Root wiring is in place:
sync-providers:build,sync-providers:test, thepackages:testaggregate used by rootnpm test,build-packages.js,prepare, the@sp/sync-providerspath alias, package-lock workspace metadata, and Angular lint coverage. - Boundary lint rejects Angular, NgRx, app source imports,
@sp/shared-schema, sync-core internals, and dynamic imports underpackages/sync-providers/**. - Provider-neutral type contracts moved first. App-owned
SyncProviderId, provider constants, OAuth routing, config UI, and the IndexedDB credential store implementation remain app-side. SyncCredentialStorenow implements the packageSyncCredentialStorePort, whilesrc/app/op-log/sync-providers/keeps shims so existing call sites keep their imports.
Current Second Slice
FileBasedSyncData,SyncFileCompactOp, andFILE_BASED_SYNC_CONSTANTSmoved into@sp/sync-providers.- The package contracts stay host-agnostic by accepting generic state, compact-operation, and archive payload types.
src/app/op-log/sync-providers/file-based/file-based-sync.types.tsremains the compatibility shim that binds the package envelope to app-ownedCompactOperationandArchiveModel.
Current Third Slice
- Provider-owned PKCE utilities moved into
@sp/sync-providers:generateCodeVerifier,generateCodeChallenge, andgeneratePKCECodes. - The implementation keeps the existing browser WebCrypto behavior and the
hash-wasmfallback needed whencrypto.subtleis unavailable. src/app/op-log/sync-providers/file-based/dropbox/generate-pkce-codes.tsremains as a compatibility re-export for existing Dropbox call sites.
Current Fourth Slice
- Provider-owned native HTTP retry helpers moved into
@sp/sync-providers:executeNativeRequestWithRetry,isTransientNetworkError, and theNativeHttpExecutor/NativeHttpRequestConfig/NativeHttpResponsecontracts. - The package version is platform-agnostic: callers inject a
NativeHttpExecutor(CapacitorHttp on Android, fetch on web/Electron, a test double in unit tests) and an optionalSyncLoggerfrom@sp/sync-core. Retry policy (2 attempts, 1s/2s backoff, transient network errors only) is preserved. - Retry log entries flow as safe
SyncLogMetaprimitives (url, attempt, errorName, errorCode) rather than raw error objects, aligning with the package's privacy-aware logger contract. src/app/op-log/sync-providers/native-http-retry.tsremains as the app-side adapter that wiresCapacitorHttpandOP_LOG_SYNC_LOGGERthrough to the package helper so existing Dropbox and SuperSync callers keep working unchanged.- The full Dropbox and WebDAV provider moves were deferred from this
slice because their dependency surface (provider error classes,
per-platform fetch hacks,
tryCatchInlineAsync, Capacitor plugin registration, OAuth glue) needs additional package ports that should be designed and reviewed in their own slice. Updated plan below.
Current Fifth Slice
Shipped as two commits behind a shared design doc
(docs/plans/2026-05-12-pr5-dropbox-slice.md) plus a post-review
cleanup pass:
- PR 5a — provider error classes. Twelve provider-shared error
classes (
AuthFailSPError,InvalidDataSPError,HttpNotOkAPIError,NoRevAPIError,RemoteFileNotFoundAPIError,MissingCredentialsSPError,MissingRefreshTokenAPIError,TooManyRequestsAPIError,UploadRevToMatchMismatchAPIError,PotentialCorsError,RemoteFileChangedUnexpectedly,EmptyRemoteBodySPError) plusAdditionalLogErrorBaseandextractErrorMessagemoved into@sp/sync-providers. App-sidesync-errors.tsis now a re-export shim so existing call sites andinstanceofcatches keep working; a co-located identity spec asserts constructor identity across import paths so future bundler or tsconfig drift can't silently break the catches. AdditionalLogErrorBaselost its constructor-timeOP_LOG_SYNC_LOGGER.logside effect (Option A from the design doc): privacy responsibility shifts entirely onto catch-site logging via the injectedSyncLoggerport.HttpNotOkAPIErrorsplit its parsed body excerpt off.messageonto a new opt-in.detailfield;getErrorTxtforwards.detailto UI surfaces, so user-visible toasts remain unchanged while privacy-aware logger paths see only "HTTP<status><statusText>".TooManyRequestsAPIError's constructor was narrowed to{ status, retryAfter?, path? }, closing a latent bearer-token leak where Dropbox's_handleErrorResponsehad passed rawAuthorizationheaders throughadditionalLog.- Package gained
"sideEffects": falseso consumers that only import error classes can tree-shake through the barrel. - PR 5b — Dropbox provider proper.
Dropbox,DropboxApi, andDropboxFileMetadatamoved intopackages/sync-providers/src/file-based/dropbox/behind three new injected ports:ProviderPlatformInfo— readonly booleans{ isNativePlatform, isAndroidWebView, isIosNative }replacing directCapacitor.isNativePlatform/IS_IOS_NATIVEreads inside the provider.WebFetchFactory— callable type() => fetch; lazy resolution preserves the iOS workaround where Capacitor patcheswindow.fetchasynchronously.NativeHttpExecutor(from slice 4) gained amaxRetriesoption sogetTokensFromAuthCodecan share the regular retry helper while still being one-shot for one-time auth-code exchanges.
- App-side
dropbox.tscollapsed to a 38-line factory functioncreateDropboxProvider(deps)that wiresOP_LOG_SYNC_LOGGER,APP_PROVIDER_PLATFORM_INFO,APP_WEB_FETCH,SyncCredentialStore, andCapacitorHttp.requestintoDropboxDepsand returns the packageDropboxclass directly.sync-providers.factory.tswas updated to call the factory. - Privacy work folded in alongside the move: malformed-download
raw
r.datano longer logged; everySyncLog.critical(..., e)catch-site replaced with structuredtoSyncLogError(e)+ curatedSyncLogMeta; URLs scrubbed to host + pathname; error constructors receive relativetargetPath, never the joinedbasePath + targetPath; andAuthFailSPErrorno longer carries rawresponseData. - Native-platform routing specs that were previously skipped under
Jasmine (
Capacitor.requestun-mockable) are now un-skipped under Vitest with the injectedNativeHttpExecutormock. Package spec count went from 70 to 103.tryCatchInlineAsyncwas deleted (the sole consumer inlined a defensiveresponse.json().catch(...)instead) andsrc/app/imex/sync/dropbox/dropbox.model.tswas deleted (no other consumers ofDropboxFileMetadata). - Post-review cleanups. Round-2 multi-review surfaced four
follow-ups: dropped a dead
export type { NativeHttpResponse }from the Dropbox module (the package barrel already re-exports it); replaced a hand-rolledencodeFormBodyhelper withURLSearchParams(fetch path passes it asBodyInit, native path uses.toString()); converted the runtime_idCheckconstant in the app shim into a pure-typeAssertDropboxIdconditional alias; inlined the redundant_executeNativeRequestWithRetryprivate wrapper onDropboxApi; and dropped the now-unnecessaryas unknown asstep on thecredentialStorecast in the factory shim.
Heads-up for the next slice: errorMeta(e, extra) and
urlPathOnly(url) (currently
packages/sync-providers/src/file-based/dropbox/dropbox-api.ts,
~lines 88-104) should be promoted into a shared
packages/sync-providers/src/log/ module before WebDAV duplicates
them.
Current Sixth Slice
Shipped as two commits (one helper-promotion PR plus the bulk move) behind
a shared design doc (docs/plans/2026-05-12-pr5-webdav-slice.md) with
multi-review consensus, followed by tightening commits that fold
post-review findings:
- PR 6a — shared log helpers. Promoted
errorMeta(e, extra)andurlPathOnly(url)fromdropbox-api.ts:88-104intopackages/sync-providers/src/log/error-meta.tsso WebDAV could adopt them without copy-paste. Exported from the package barrel. Dropbox imports updated. No behavior change. - PR 6b — WebDAV + Nextcloud provider proper.
webdav-base-provider.ts,webdav-api.ts,webdav-xml-parser.ts,webdav-http-adapter.ts,webdav.const.ts,webdav.model.ts,webdav.ts,nextcloud.ts, andnextcloud.model.ts(plus their co-located specs) moved intopackages/sync-providers/src/file-based/webdav/. Specs converted from Jasmine to Vitest.SyncProviderId.WebDAV/SyncProviderId.Nextcloudreplaced inside the package withPROVIDER_ID_WEBDAV/PROVIDER_ID_NEXTCLOUDconstants, with type-levelAssertWebdavId/AssertNextcloudIdbridges in the app-side shims (mirroring the Dropbox pattern). - Port reuse, not duplication. Multi-review consensus rejected the
initially-proposed
WebDavNativeHttpExecutorport. The existingNativeHttpExecutoralready supports arbitrary methods (PROPFIND,MKCOL,MOVE, …),responseType: 'text', andmaxRetries: 0. App-side wiresAPP_WEBDAV_NATIVE_HTTP: NativeHttpExecutoradapter pointing at the existingWebDavHttpCapacitor plugin registration incapacitor-webdav-http/. The inlineregisterPluginduplication inwebdav-http-adapter.ts:13-31was dropped — the subfolder registration withweb: () => import('./web')fallback is canonical. - Factory shape mirrors Dropbox. App-side
webdav.ts/nextcloud.tscollapsed tocreateWebdavProvider(extraPath?: string)/createNextcloudProvider(extraPath?: string)factory functions that composedepsinternally from app singletons (APP_PROVIDER_PLATFORM_INFO,APP_WEB_FETCH,OP_LOG_SYNC_LOGGER,SyncCredentialStore,APP_WEBDAV_NATIVE_HTTP). External callers pass app-level config (extraPath), not the internal deps bag. - Nextcloud generic widened.
WebdavBaseProvider's generic narrowed toT extends typeof PROVIDER_ID_WEBDAVwidened toT extends typeof PROVIDER_ID_WEBDAV | typeof PROVIDER_ID_NEXTCLOUD. Fouras unknown as SyncProviderId.WebDAVdouble-casts deleted. md5HashSyncmigrated tohash-wasmasync.WebdavApi._computeContentHashbecameasync; ripple touched ~5 spec call sites.spark-md5no longer appears in the package surface. The later LocalFile slice also migrated its rev hashing tohash-wasmand removed the app-sidespark-md5helper/dependency.- CORS heuristic tightened.
webdav-http-adapter.ts:180-219collapsed to a ~3-line check (error instanceof TypeError && error.message.includes('cors')). Ambiguous-error log path that leaked the raw URL viaerror.messagereplaced with structuredtoSyncLogError(error)+urlPathOnly(options.url)meta. ~40 lines deleted, one privacy leak closed. A follow-up commit (refactor(sync-providers): broaden WebDAV CORS heuristic for real browsers, W2) restored "Failed to fetch" / "NetworkError" / "Load failed" pattern coverage that browsers other than Firefox use for CORS rejections — still gated onTypeErrorand through the same structured-log surface. testWebdavConnectionhelper extraction. Test-connection path (webdav-api.ts:355-380) moved into a standalonepackages/sync-providers/src/file-based/webdav/test-connection.tshelper so the adapter and api shims could drop from the package barrel. App-sideWebDAVprovider, config UI, and connection-test command call the helper directly.- No-retry behavior preserved. Multi-review consensus rejected the
Gemini-only recommendation to add a 2-attempt / 1s+2s retry policy
with 423-Locked handling. WebDAV's stateful methods (LOCK/UNLOCK)
and conditional writes (412 Precondition Failed) have semantics that
differ from Dropbox's idempotent file API; preserving the existing
no-retry behavior keeps the slice scope a refactor. Trivially
addable later as a per-call-site
maxRetriesargument on the reusedNativeHttpExecutorport. - Spec count delta. Package spec count went from 103 (post PR 5b) to 177. PR 6b added 53 webdav specs (Jasmine → Vitest one-to-one move).
Two follow-up commits added 13 namespace/server-format specs (the
getElementsByTagNameNS('*', name)PROPFIND parsing path,:hrefvariants, server-format compatibility —restore WebDAV parser namespace + server-format specs, W4) and 7 CORS specs (broaden WebDAV CORS heuristic for real browsers, W2). Plus one refactor moving@xmldom/xmldomtodevDependenciesand adopting the globalDOMParserat runtime (@xmldom/xmldom to devDeps + global DOMParser, W3) so xmldom does not ship in the package bundle (grep -c xmldom dist/index.mjsreturns 0). - Privacy sweep. Applied the same A1/A3/B3.x audit as Dropbox plus
three sites the security reviewer surfaced.
webdav-api.ts:73, 111, 151, 261, 329, 372andwebdav-base-provider.ts:83, 109, 124, 130all moved fromSyncLog.critical(..., e)toerrorMeta(e)plus curatedSyncLogMeta. Full-URL_buildFullPathresults scrubbed viaurlPathOnlyat every error-construction and log site.testConnection's rawe.messagereturn narrowed viatoSyncLogError(e).message._buildFullPath's genericError('Invalid path: ${path}')replaced withInvalidDataSPErrorwith scrubbed path. PROPFIND multistatus bodies no longer fed intoHttpNotOkAPIError's second arg. Package boundary invariant documented: response headers are not logged or attached to errors. - Bundle size. Package CJS now 75.77 KB / ESM 73.23 KB / DTS
37.13 KB. Up from ~55 KB pre-slice. The single barrel is still fine;
tiered split (
@sp/sync-providers/dropbox,/webdav, …) deferred to post-provider-lift polish. - Architectural deferral.
getElementsByTagNameNS('*', name)subtree walk inwebdav-xml-parser.tsis O(n) per call; for typical PROPFIND sizes (10-100 files) it's <50 ms but the performance reviewer flagged a one-passchildNodesscan as cheaper. Tracked as a follow-up; not touched this slice.
Current Seventh Slice
Shipped behind the shared design doc
(docs/plans/2026-05-12-pr7-super-sync-slice.md) with multi-review
consensus, then tightened by follow-up commits from review findings:
- PR 7a - retryable upload helper. Promoted the broad-pattern
operation-upload retry predicate from
src/app/op-log/sync/sync-error-utils.tsintopackages/sync-providers/src/http/retryable-upload-error.tsasisRetryableUploadError. The app-sidesync-error-utils.tsnow re-exports it asisTransientNetworkErrorsooperation-log-upload.service.tskept its import surface unchanged. This helper remains distinct from the native-code-awareisTransientNetworkErrorinnative-http-retry.ts. - PR 7b - SuperSync provider proper.
super-sync.ts,super-sync.model.ts, and the SuperSync provider spec moved intopackages/sync-providers/src/super-sync/. The spec was converted from Jasmine to Vitest.SyncProviderId.SuperSyncwas replaced in the package byPROVIDER_ID_SUPER_SYNC, with the app-sideAssertSuperSyncIdbridge matching the Dropbox/WebDAV pattern. - App-side composition stayed thin.
src/app/op-log/sync-providers/super-sync/super-sync.tsnow exportscreateSuperSyncProvider()with noextraPathargument (SuperSync has no file base-path concept). The factory wiresOP_LOG_SYNC_LOGGER,APP_PROVIDER_PLATFORM_INFO,APP_WEB_FETCH,SyncCredentialStore,CapacitorHttp.request,SuperSyncStorage, and the app response validators intoSuperSyncDeps. - Response validators remain app-side.
response-validators.tsstill imports@sp/shared-schema, so it stays undersrc/appand is injected through the package'sSuperSyncResponseValidatorsport. The package owns the response types only. - Narrow SuperSync storage port.
localStorageaccess forlastServerSeqmoved behindSuperSyncStorage, while the package still owns thesuper_sync_last_server_seq_prefix and the per-server/per-token hash key._cachedServerSeqKeyis explicitly reset onsetPrivateCfgto preserve account/server isolation. - Transport and compression ports reused. SuperSync now uses the
existing
SyncLogger,ProviderPlatformInfo,WebFetchFactory,NativeHttpExecutor, andSyncCredentialStorePortports. Web uploads useWebFetchFactory; native uploads preserve the base64-gzipCapacitorHttppath for Android WebView/iOS binary-body safety. Compression imports directly from@sp/sync-core. - Privacy sweep. The move folded in the SuperSync-specific privacy
blockers from the design doc:
AuthFailSPErrorno longer retains raw response bodies inadditionalLog; transient native request errors throw a fixed user-facing message without interpolating raw native messages; web timeout messages no longer include the request path; servererrorreasons are capped at 80 chars; non-retryable foreign native errors are surfaced by error name only; logger catch paths use safe error name/code metadata rather than raw error objects. - Idempotency and retry hardening. Ops upload
requestIdgeneration was ported and hardened: it is deterministic over the logical ops batch, stable across JSON key ordering and encrypted payload IV changes, but changes when unencrypted payload content changes. A post-review fix broadenedisRetryableUploadErrorfor429, "too many requests", "rate limit", and "retry in ..." messages so full-state uploads (SYNC_IMPORT,BACKUP_IMPORT,REPAIR) are left pending instead of permanently rejected under SuperSync rate limiting. - Spec count delta. Package spec count is now 278 after the SuperSync move and rate-limit hardening. The SuperSync provider spec remains intentionally monolithic; splitting it into themed files is deferred to polish so the Jasmine -> Vitest conversion stays easy to review.
- Bundle size. Package build after the SuperSync slice is roughly
CJS 99.56 KB / ESM 96.76 KB / DTS 47.18 KB. The single barrel is
still acceptable; tiered exports (
@sp/sync-providers/dropbox,/webdav,/super-sync, …) remain deferred to post-provider-lift polish.
Current Eighth Slice
Shipped the LocalFile final slice:
LocalFileSyncBase,LocalFileSyncElectron,LocalFileSyncAndroid,LocalFileSyncPrivateCfg, andPROVIDER_ID_LOCAL_FILEnow live inpackages/sync-providers/src/file-based/local-file/.- LocalFile provider behavior is package-owned, but Electron and Android
platform bridges stay app-side:
- Electron file operations still go through the app's
ElectronFileAdapterandwindow.eabridge. - Android SAF operations still go through
SafService/SafFileAdapter. - App factory shims (
createLocalFileSyncElectron/createLocalFileSyncAndroid) inject those bridges plusSyncCredentialStoreandOP_LOG_SYNC_LOGGERinto the package classes. The dead app-side abstract base shim and duplicate spec were removed.
- Electron file operations still go through the app's
- LocalFile rev hashing now uses
hash-wasmdirectly in the package, matching the WebDAV move. The unused app-sidesrc/app/util/md5-hash.ts,spark-md5dependency, lockfile entry, and Angular CommonJS allowance were removed. FileHashCreationAPIErrormoved into@sp/sync-providerswith the other provider-shared errors. The app error module re-exports it so cross-importinstanceofidentity remains guarded.- Package LocalFile Vitest coverage was added for the base file provider, Electron folder-picker/path logic, and Android SAF permission/setup logic. The remaining app-side Jasmine spec continues to cover the Electron compatibility shim.
Remaining Slice Plan
PR 5 is complete for this branch. Continue with PR 6 final boundary hardening:
- Recheck package boundary rules and forbidden import greps for both
sync-coreandsync-providers. - Audit manifests/runtime deps now that all bundled providers moved.
- Audit the
@sp/sync-providerspublic barrel for app-owned concepts and consider whether tiered exports should remain polish-only. - Add the short package-boundary architecture note described in PR 6.
Verification
- Per-provider unit specs.
- E2E sync round-trip per provider: Dropbox, WebDAV, LocalFile, SuperSync.
- Fresh-client bootstrap for file-based providers.
- Electron-gated LocalFile path smoke test.
PR 6 - Final Boundary Hardening
This is now a final audit rather than the first boundary rule.
Goals
- Recheck the boundary rules for
packages/sync-core/**andpackages/sync-providers/**. - Audit package manifests for accidental runtime deps.
- Audit public exports for SP names and app-only concepts.
- Add a small architecture note that explains the package boundaries and allowed dependency direction.
Verification
npm run lint.- Boundary grep for both packages.
- Package builds from a clean install.
- Full app unit tests and selected sync E2E.
Optional Polish (Post-Provider Lift)
Non-blocking cleanups surfaced during the PR 5 provider lift. None of these change behaviour or boundaries — they remove duplication, tighten tests, and retire deprecated aliases once consumers have migrated.
Candidates
- Consolidate PKCE helpers.
packages/sync-providers/src/pkce.tscurrently duplicatessrc/app/util/pkce.util.ts. The package needs to stand alone, so the duplication is intentional during the scaffold, but the remaining consumers (src/app/plugins/oauth/plugin-oauth.service.ts,src/app/plugins/oauth/pkce.util.spec.ts,src/app/op-log/sync-providers/file-based/dropbox/dropbox-auth-helper.spec.ts) should migrate onto@sp/sync-providerssosrc/app/util/pkce.util.tscan be deleted. Drift between the two implementations is the risk this resolves. - Drop the dead
_lengtharg ingeneratePKCECodes. Pre-existing from the original helper; the parameter is unused. Either remove it (single call site) or document why it is kept for API compatibility. - Tighten the PKCE verifier-length assertion.
tests/pkce.spec.tsbounds the verifier withtoBeLessThanOrEqual(128), but a 32-byte random buffer always encodes to exactly 43 base64url chars. Replace with an exact length check so the test actually constrains the output. - Retire
SyncProviderServiceInterfacealias. Marked@deprecatedinsrc/app/op-log/sync-providers/provider.interface.ts. Sweep callers toSyncProviderBase/FileSyncProviderand remove the alias. - Trim duplicate ESLint pattern depths. The
packages/sync-providers/**block ineslint.config.jslists../sync-core/**,../../sync-core/**, and**/sync-core/**(plus shared-schema equivalents). The**/...form already covers the relative variants; collapse for readability.
Verification
npm run lint,npm test,npm run packages:test.- Grep for
pkce.utilandSyncProviderServiceInterfaceafter the cleanup — both should return zero hits outside ofpackages/sync-providers.
Summary Timeline
| PR | Scope | Risk | Notes |
|---|---|---|---|
| 1 | Stand up @sp/sync-core with generic primitives and stubs |
Low | Present on branch |
| 2 | Boundary lint, registry types, privacy-aware logger port | Medium | Groundwork present; finish follow-ups |
| 3a | Vector-clock ownership and package test harness | Medium | Present on branch |
| 3b | Pure algorithmic core | Medium | No Angular/NgRx/IndexedDB |
| 4a | Port contracts only | Medium | Current slice present |
| 4b | Move small orchestration units behind ports | High | Current slice present |
| 4c | Revisit OperationApplierService extraction |
High | Narrow replay coordinator present |
| 5 | Lift providers into @sp/sync-providers |
Medium-High | Provider deps stay out of core |
| 6 | Final boundary hardening and architecture note | Low | Audit and lock down |
| 7 | Optional polish: dedupe PKCE, retire deprecated aliases | Low | Non-blocking cleanup |
After the final PR, @sp/sync-core should be the domain-agnostic sync engine
and abstractions, @sp/sync-providers should contain bundled provider
implementations, and src/app/op-log/ should contain SP-specific wiring: NgRx
adapters, dialog ports, entity-registry composition, ActionType, EntityType,
SyncImportReason, SyncProviderId, repair shapes, and full-state wire format.