Skip to content

Commit 02a5991

Browse files
committed
feat: Skip dependants of skipped changes
A skipped change left its dependants unprotected: on the next cycle they would be attempted and either fail spuriously (e.g. `MISSING_PARENT`) or, worse, succeed partially and create an orphan. The multi-error loop makes this more visible since dependants are no longer shielded by the first-failure break. When a change is skipped (automatically via `handleSyncError` returning `'skipped'`, or by the user), the `syncBatch` loop now adds it to `blockedIds`. Dependents detected via `directPrerequisites` whose prereq has `doc.skipped === true` are themselves skipped (`skipChange` persists the flag) and emit a synthetic `SKIPPED_DEPENDENCY` alert. `blockedIds` propagates the skip transitively across the topologically ordered batch. The synthetic `SyncError` carries `prereqPath` as a dedicated field, transported by `makeAlert` to the GUI. `viewByCode` interpolates it into a localized message with the path wrapped in backticks. The restored `Util.DecorationParser` parses those backticks in `alertContent`, and `decoratedName` renders the path as a clickable chip (blue background, `title` with full path, `ShowInParent` on click).
1 parent 81b1bcc commit 02a5991

13 files changed

Lines changed: 571 additions & 20 deletions

File tree

core/metadata.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ export type Metadata = {
166166
167167
trashed?: true,
168168
errors?: number,
169-
skipped?: boolean,
169+
skipped?: string,
170170
overwrite?: SavedMetadata,
171171
childMove?: boolean,
172172
incompatibilities?: *,

core/sync/errors.js

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,10 @@ const EXCLUDED_DIR_CODE = 'ExcludedDir'
2525
const INCOMPATIBLE_DOC_CODE = 'IncompatibleDoc'
2626
const MISSING_PERMISSIONS_CODE = 'MissingPermissions'
2727
const NO_DISK_SPACE_CODE = 'NoDiskSpace'
28+
const SKIPPED_DEPENDENCY_CODE = 'SkippedDependency'
2829
const UNSYNCED_PARENT_MOVE_CODE = 'UnsyncedParentMove'
2930
const UNKNOWN_SYNC_ERROR_CODE = 'UnknownSyncError'
31+
const USER_SKIPPED_CODE = 'UserSkipped'
3032

3133
class UnsyncedParentMoveError extends Error {
3234
/*::
@@ -52,7 +54,7 @@ class SyncError extends Error {
5254
5355
code: string
5456
message: string
55-
sideName: SideName
57+
sideName: ?SideName
5658
originalErr: Error
5759
doc: SavedMetadata
5860
*/
@@ -63,7 +65,7 @@ class SyncError extends Error {
6365
sideName,
6466
err,
6567
doc
66-
} /*: { code?: string, sideName: SideName, err: Error, doc: SavedMetadata } */
68+
} /*: { code?: string, sideName: ?SideName, err: Error, doc: SavedMetadata } */
6769
) {
6870
super(err.message)
6971

@@ -157,6 +159,27 @@ const minRetryDelay = (
157159
return Math.min(...causes.map(c => retryDelay(c.err)))
158160
}
159161

162+
// Synthetic error emitted when a change is skipped because one of its
163+
// prerequisite changes was skipped. `prereqPath` is carried to the GUI so
164+
// `viewByCode` can build a localized message with the path wrapped in
165+
// backticks, rendered as a clickable chip by `Util.DecorationParser`.
166+
const skippedDependencyErr = (
167+
change /*: Change */,
168+
prereqPath /*: string */
169+
) /*: SyncError */ => {
170+
const err = new Error(
171+
`Change skipped: a prerequisite change on ${prereqPath} was skipped`
172+
)
173+
// $FlowFixMe: Error has no prereqPath by default, we add it on purpose.
174+
err.prereqPath = prereqPath
175+
return new SyncError({
176+
code: SKIPPED_DEPENDENCY_CODE,
177+
sideName: null,
178+
err,
179+
doc: change.doc
180+
})
181+
}
182+
160183
const retryAll = async (
161184
causes /*: Array<{| err: RemoteError |} | {| err: SyncError, change: Change |}> */,
162185
sync /*: Sync */
@@ -219,7 +242,16 @@ const skip = async (
219242
clearInterval(sync.retryInterval)
220243

221244
if (cause.change) {
222-
await sync.skipChange(cause.change, cause.err)
245+
// Wrap the original error so the skip is recorded as user-initiated
246+
// (fatal), regardless of the original error's code (which may be
247+
// non-fatal like MISSING_DOCUMENT).
248+
const wrappedErr = new SyncError({
249+
code: USER_SKIPPED_CODE,
250+
sideName: cause.err.sideName,
251+
err: cause.err,
252+
doc: cause.change.doc
253+
})
254+
await sync.skipChange(cause.change, wrappedErr)
223255
}
224256

225257
// Fire-and-forget: awaiting watcher.start() would deadlock with the
@@ -353,12 +385,15 @@ module.exports = {
353385
INCOMPATIBLE_DOC_CODE,
354386
MISSING_PERMISSIONS_CODE,
355387
NO_DISK_SPACE_CODE,
388+
SKIPPED_DEPENDENCY_CODE,
356389
UNKNOWN_SYNC_ERROR_CODE,
357390
UNSYNCED_PARENT_MOVE_CODE,
391+
USER_SKIPPED_CODE,
358392
UnsyncedParentMoveError,
359393
SyncError,
360394
retryDelay,
361395
minRetryDelay,
396+
skippedDependencyErr,
362397
retry,
363398
retryAll,
364399
skip,

core/sync/index.js

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,19 @@ const wasSkipped = (change /*: PouchDBFeedData */) => {
7575
return change.doc.skipped
7676
}
7777

78+
// Skip codes whose dependants should be cascaded (fatal skips).
79+
// Non-fatal skips (e.g. CONFLICTING_NAME, INVALID_METADATA,
80+
// MISSING_DOCUMENT when marked for deletion) are auto-resolved by a
81+
// future merge/re-scan: cascading them would leave dependants stuck.
82+
const FATAL_SKIP_CODES = new Set([
83+
remoteErrors.MISSING_PARENT_CODE,
84+
syncErrors.SKIPPED_DEPENDENCY_CODE,
85+
syncErrors.USER_SKIPPED_CODE
86+
])
87+
88+
const isFatalSkip = (skipped /*: ?string */) =>
89+
typeof skipped === 'string' && FATAL_SKIP_CODES.has(skipped)
90+
7891
// Returns the given side metadata of the given PouchDB record.
7992
// It is meant to get the outdated side metadata of the record to compare it
8093
// against the new metadata and decide which actions to take.
@@ -548,12 +561,29 @@ class Sync {
548561
if (blockedIds.size === 0) {
549562
await this.pouch.setLocalSeq(change.seq)
550563
}
564+
blockedIds.add(change.id)
551565
this.resolveBlockingCause(change.id)
552566
continue
553567
}
554568

555-
if (graph.directPrerequisites(change).some(d => blockedIds.has(d.id))) {
569+
const prereqs = graph.directPrerequisites(change)
570+
if (prereqs.some(c => blockedIds.has(c.id))) {
556571
blockedIds.add(change.id)
572+
573+
const skippedPrereq = prereqs.find(c => isFatalSkip(c.doc.skipped))
574+
if (skippedPrereq) {
575+
const err = syncErrors.skippedDependencyErr(
576+
change,
577+
skippedPrereq.doc.path
578+
)
579+
await this.skipChange(change, err)
580+
this.events.emit(
581+
'user-alert',
582+
err,
583+
change.seq,
584+
change.operation.side != null && change.operation.side
585+
)
586+
}
557587
continue
558588
}
559589

@@ -569,7 +599,8 @@ class Sync {
569599

570600
const result = await this.handleSyncError(err, change)
571601
if (result === 'fatal') return
572-
if (result === 'blocked') blockedIds.add(change.id)
602+
if (result === 'blocked' || result === 'skipped')
603+
blockedIds.add(change.id)
573604
if (
574605
(result === 'skipped' || result === 'recovered') &&
575606
blockedIds.size === 0
@@ -594,8 +625,8 @@ class Sync {
594625
err /*: SyncError */,
595626
change /*: Change */
596627
) /*: Promise<'blocked' | 'skipped' | 'recovered' | 'fatal'> */ {
628+
const sideName = err.sideName || 'local'
597629
const {
598-
sideName,
599630
doc: { path }
600631
} = err
601632

@@ -1337,7 +1368,7 @@ class Sync {
13371368
doc.errors = (doc.errors || 0) + 1
13381369

13391370
// Make sure isUpToDate(sourceSideName, doc) is still true
1340-
const sourceSideName = otherSide(err.sideName)
1371+
const sourceSideName = err.sideName ? otherSide(err.sideName) : 'local'
13411372
metadata.markSide(sourceSideName, doc, doc)
13421373

13431374
change.doc = await this.pouch.put(doc, { checkInvariants: false })
@@ -1377,7 +1408,7 @@ class Sync {
13771408

13781409
this._blockedCauses.delete(change.id)
13791410

1380-
doc.skipped = true
1411+
doc.skipped = err.code
13811412
await this.pouch.put(doc, { checkInvariants: false })
13821413
}
13831414

core/syncstate.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ const makeAlert = (
7575
side: side || null,
7676
doc: doc ? { id: doc._id, docType: doc.docType, path: doc.path } : null,
7777
links: links || null,
78+
prereqPath: err.prereqPath || null,
7879
lastSeenAt: Date.now()
7980
}
8081
}

gui/elm/Data/UserAlert.elm

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import I18n exposing (Helpers)
2020
import Icons
2121
import Time
2222
import Util.Conditional exposing (ShowInWeb, inWeb, onOS)
23+
import Util.DecorationParser exposing (DecorationResult(..), findDecorations)
2324

2425

2526
type alias UserAlertCode =
@@ -44,7 +45,7 @@ type Side
4445

4546

4647
type alias SynchronizationErrorInfo =
47-
{ status : UserActionStatus, seq : Int, id : String, docType : String, path : String, side : Maybe Side, lastSeenAt : Time.Posix }
48+
{ status : UserActionStatus, seq : Int, id : String, docType : String, path : String, side : Maybe Side, prereqPath : Maybe String, lastSeenAt : Time.Posix }
4849

4950

5051
type alias RemoteWarningInfo =
@@ -119,6 +120,7 @@ type alias EncodedUserAlert =
119120
Maybe
120121
{ self : String
121122
}
123+
, prereqPath : Maybe String
122124
, lastSeenAt : Maybe Int
123125
}
124126

@@ -128,7 +130,7 @@ type alias EncodedCommand =
128130

129131

130132
decode : EncodedUserAlert -> Maybe UserAlert
131-
decode { seq, status, code, side, doc, links, lastSeenAt } =
133+
decode { seq, status, code, side, doc, links, prereqPath, lastSeenAt } =
132134
let
133135
decodedStatus =
134136
decodeUserActionStatus status
@@ -149,6 +151,7 @@ decode { seq, status, code, side, doc, links, lastSeenAt } =
149151
, docType = docType
150152
, path = path
151153
, side = decodedSide side
154+
, prereqPath = prereqPath
152155
, lastSeenAt = decodedLastSeenAt
153156
}
154157
)
@@ -167,6 +170,7 @@ encode alert =
167170
, side = encodedSide a.side
168171
, doc = Just { id = a.id, docType = a.docType, path = a.path }
169172
, links = Nothing
173+
, prereqPath = a.prereqPath
170174
, lastSeenAt = Just (Time.posixToMillis a.lastSeenAt)
171175
}
172176

@@ -177,6 +181,7 @@ encode alert =
177181
, side = Nothing
178182
, doc = Nothing
179183
, links = Nothing
184+
, prereqPath = Nothing
180185
, lastSeenAt = Nothing
181186
}
182187

@@ -187,6 +192,7 @@ encode alert =
187192
, side = Nothing
188193
, links = Just { self = a.link }
189194
, doc = Nothing
195+
, prereqPath = Nothing
190196
, lastSeenAt = Nothing
191197
}
192198

@@ -350,7 +356,7 @@ viewSyncError helpers platform now alert info =
350356
[ text (Path.toString dirPath) ]
351357
]
352358
, span [ class "file-line-content u-spacenormal u-errorColorDark u-mt-half" ]
353-
(alertContent helpers content)
359+
(alertContent helpers platform content)
354360
, div [ class "u-flex u-mt-half u-pb-1" ] buttons
355361
]
356362

@@ -373,7 +379,7 @@ viewRemoteError helpers platform now alert =
373379
[ span [ class "file-parent-folder" ] [ text (helpers.t "UserAlert System") ]
374380
]
375381
, span [ class "file-line-content u-spacenormal u-errorColorDark u-mt-half" ]
376-
(alertContent helpers content)
382+
(alertContent helpers platform content)
377383
, div [ class "u-flex u-mt-half u-pb-1" ] buttons
378384
]
379385

@@ -588,6 +594,15 @@ viewByCode helpers alert =
588594
[ actionButton helpers (SendCommand Retry alert) "UserAlert Retry" Primary ]
589595
}
590596

597+
SynchronizationError "SkippedDependency" { prereqPath } ->
598+
{ title = "Error Skipped dependency"
599+
, content =
600+
[ Maybe.withDefault "" prereqPath
601+
|> (\p -> helpers.interpolate [ p ] "Error Change skipped: a prerequisite change on `{0}` was skipped.")
602+
]
603+
, buttons = []
604+
}
605+
591606
SynchronizationError "UnknownRemoteError" { docType } ->
592607
let
593608
localDocType =
@@ -617,14 +632,43 @@ localDocTypeLabel docType =
617632
"Helpers file"
618633

619634

620-
alertContent : Helpers -> List String -> List (Html Msg)
621-
alertContent helpers details =
635+
alertContent : Helpers -> Platform -> List String -> List (Html Msg)
636+
alertContent helpers platform details =
622637
details
623638
|> List.map helpers.capitalize
624-
|> List.map text
639+
|> List.map (viewActionContentLine platform)
625640
|> List.intersperse (br [] [])
626641

627642

643+
viewActionContentLine : Platform -> String -> Html Msg
644+
viewActionContentLine platform line =
645+
let
646+
toHTML =
647+
\decoration ->
648+
case decoration of
649+
Decorated path ->
650+
Path.fromString platform path
651+
|> decoratedName
652+
653+
Normal str ->
654+
text str
655+
in
656+
span []
657+
(findDecorations line
658+
|> List.map toHTML
659+
)
660+
661+
662+
decoratedName : Path -> Html Msg
663+
decoratedName path =
664+
span
665+
[ class "u-bg-frenchPass u-bdrs-4 u-ph-half u-pv-0 u-c-pointer"
666+
, title (Path.toString path)
667+
, onClick (ShowInParent path onOS)
668+
]
669+
[ text (Path.name path) ]
670+
671+
628672
classList : List String -> List ( Maybe Bool, String ) -> String
629673
classList baseList optionalClasses =
630674
let

0 commit comments

Comments
 (0)