Skip to content
Merged
459 changes: 338 additions & 121 deletions app/application.ml

Large diffs are not rendered by default.

20 changes: 10 additions & 10 deletions app/journal_header.ml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,15 @@ let feedback ~key ~top ~visible ~compact ~expanded body =
feedback_with_visibility ~key ~top ~visible ~compact ~expanded body
;;

let graph_picker ~leading ~controls body =
Ui.Native_widget.widget chrome
~key:(Ui.Key.string "graph-picker-chrome")
~props:(props ~mode:"graph-picker" ~connecting:false ~controls:true ())
~on_event:(fun _ -> ())
~children:[V.Body.Private.to_widget body; controls; V.column []; leading] ()
|> V.Body.static
;;

let date_header ?relative_label ~title () =
Ui.Native_widget.widget
chrome
Expand Down Expand Up @@ -241,7 +250,7 @@ let view_impl
parent)
in
let cluster_for presentation =
let local_deletion_available = presentation.local_deletion_available in
let _ = presentation.local_deletion_available in
let on_error_info = if presentation.error_available then on_error_info else None in
let account_action =
Option.map
Expand All @@ -259,15 +268,6 @@ let view_impl
, Switch_graph
, V.Button_role.Normal )
]
@ (if local_deletion_available
then
[ ( 3L
, "Delete local graph copy"
, "trash"
, Request_local_cache_reset
, V.Button_role.Destructive )
]
else [])
@ [ ( 4L
, "Sign out"
, "rectangle.portrait.and.arrow.right"
Expand Down
7 changes: 7 additions & 0 deletions app/journal_header.mli
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,10 @@ val detail
(** Stable native startup surface; action remains reachable during waiting. *)
val startup : loading:bool -> initial:bool -> active:bool -> ?value:float
-> message:string -> Journal_view.View.t -> Journal_view.View.Body.t

(** Full-canvas native list with floating close and catalog controls. *)
val graph_picker
: leading:Journal_view.View.t
-> controls:Journal_view.View.t
-> Journal_view.View.Body.t
-> Journal_view.View.Body.t
4 changes: 2 additions & 2 deletions app/journal_startup.ml
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ let derive ~snapshot ~(graph : Logseq_db_worker.graph_state) =
&& graph.phase = Logseq_db_worker.Graph_open
&& not snapshot.timeline_presentation_pending
in
if Option.is_some snapshot.local_deletion
if Option.is_some snapshot.local_deletion && not ready
then (
match snapshot.local_deletion with
| Some (Graph_service.Deletion_in_progress _) ->
Expand Down Expand Up @@ -236,7 +236,7 @@ module Presentation = struct
| Some o ->
(match o.manager with
| Some manager when o.graph_ready && o.feed_loaded && not o.graph_error
&& manager.local_deletion = None && manager.startup.authenticated
&& manager.startup.authenticated
&& o.graph.phase = Logseq_db_worker.Graph_open && graph_matches manager o.graph -> Timeline
| Some manager ->
let startup = derive ~snapshot:manager ~graph:o.graph in
Expand Down
31 changes: 22 additions & 9 deletions app/journal_view.ml
Original file line number Diff line number Diff line change
Expand Up @@ -1758,25 +1758,31 @@ module View = struct
?key
~activation_id:_
?(enabled = true)
?enabled_signal
?(disclosure = true)
~on_activate
~label
()
=
element
?key
(Lui_elements.list_item
~disabled:(not enabled)
(fun context parent ->
let disabled_signal = Option.map (fun source ->
let mapped = Signal.map not source in
Signal.on_dispose context.Lui_ui.ui_scope (fun () -> Signal.dispose_signal mapped);
mapped) enabled_signal in
Lui_elements.list_item
~disabled:(not enabled) ?disabled_signal
?on_press:
(if enabled
(if enabled || Option.is_some enabled_signal
then Some (fun _ -> invoke on_activate Event.Payload.Unit)
else None)
(* Callers choose whether the native list item carries a disclosure icon. *)
?icon:(if disclosure then Some (journal_icon "chevron.right") else None)
~icon_placement:`trailing
(* A list-item must carry text or children; mount the label as the
item content so composite labels render too. *)
[ label.mount ])
[ label.mount ] context parent)
;;
end

Expand Down Expand Up @@ -1965,24 +1971,31 @@ module View = struct
; title : string
; icon : string option
; enabled : bool
; enabled_signal : bool Signal.signal option
; role : Button_role.t
; test_id : Test_id.t option
}

let action ~id ~title ?icon ?(enabled = true) ?(role = Button_role.Normal) () =
{ id; title; icon; enabled; role }
let action ~id ~title ?icon ?(enabled = true) ?enabled_signal ?(role = Button_role.Normal) ?test_id () =
{ id; title; icon; enabled; enabled_signal; role; test_id }
;;

let to_elements ~on_select entries =
List.map
(fun entry ->
(fun entry context parent ->
let disabled_signal = Option.map (fun source ->
let mapped = Signal.map not source in
Signal.on_dispose context.Lui_ui.ui_scope (fun () -> Signal.dispose_signal mapped);
mapped) entry.enabled_signal in
Lui_elements.menu_item
~key:(Int64.to_string entry.id)
?accessibility_identifier:entry.test_id
~text:(if String.length entry.title = 0 then " " else entry.title)
?icon:(Option.map journal_icon entry.icon)
~disabled:(not entry.enabled)
~disabled:(not entry.enabled) ?disabled_signal
?variant:(Button_role.lui_variant entry.role)
~on_press:(fun _ -> invoke on_select (Event.Payload.Int64 entry.id))
[])
[] context parent)
entries
;;
end
Expand Down
3 changes: 3 additions & 0 deletions app/journal_view.mli
Original file line number Diff line number Diff line change
Expand Up @@ -711,6 +711,7 @@ module View : sig
: ?key:Key.t
-> activation_id:string
-> ?enabled:bool
-> ?enabled_signal:bool Signal.signal
-> ?disclosure:bool
-> on_activate:Event.handler
-> label:t
Expand Down Expand Up @@ -781,7 +782,9 @@ module View : sig
-> title:string
-> ?icon:string
-> ?enabled:bool
-> ?enabled_signal:bool Signal.signal
-> ?role:Button_role.t
-> ?test_id:Test_id.t
-> unit
-> entry
end
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Switch Graph Native Picker

## Problem

The graph picker used uneven capsule buttons and lost the active graph when opened. It had no per-row local-copy management or durable presence observation. Deleting several local resources in sequence could lose attachment sources before mirror ownership was checked, or strand partially collected retired bytes after a failed purge.

## Decision

Use the selected concept A with native equal-width list rows and no header, following Timeline's full-canvas List and floating controls. Retain close, refresh and existing account controls. Keep catalog order, mark the current graph in place, activate names to switch, and expose Delete local copy only through the separate row menu. Do not add search, graph creation or remote deletion. OCaml owns presentation and request state; Swift provides native-specific list controls.

The user explicitly permits discarding unsynchronized content after confirmation (Sentinel_3c2ff0ee96f88191ab349fca7f7befe9). The confirmation explains that unsynchronized changes, drafts and attachments are discarded on this device, while the cloud copy remains downloadable. The active graph must be switched away from first. Unbound legacy mirrors require opening once and switching away before removal.

The user authorized the two protected deletion interface changes (Sentinel_098717acd4dc8191baeaae8a9cdbee4b), migration without compatibility paths (Sentinel_17788ec75690819198347944f824cd8c and Sentinel_8265ed5fca0c8191852d23c144887053), and retaining Delete_account_assets. Remove Delete_graph_assets, Database.delete_mirror and the obsolete graph-wide upload-checkpoint delete. Do not add wrappers or fallbacks. Dune, lockfiles and shared opam/pins remain unchanged.

Use an independent Retirement_id value type, distinct from a graph UUID. Share that storage-independent type through the existing value library, avoiding a runtime dependency from the pure reducer into the database I/O implementation.

Keep a single worker business deletion flight. Its internal resource protocol is:

- Acquire the inspected mirror's existing exclusive ownership primitive before retiring any asset source. Revalidate the physical generation and persisted account owner immediately before the namespace commit.
- Cancel and drain target uploads, then freeze staging/checkpoint writers while capturing exact operation/payload pairs. Persist a coordinator journal before preparing assets.
- The asset owner blocks new work for the target origin/account/graph across generations, cancels existing operations and asynchronously waits for native leases. Release and cancellation controls remain available; destructive staging cleanup waits. Move the captured cache into a unique retirement namespace without unlinking its bytes.
- Mirror commit atomically renames that one canonical mirror namespace. This is not an atomic transaction over all directories. Synchronize the directory chain and persist this retirement's committed receipt before destructive cleanup in another producer. An unrelated missing or changed canonical mirror is never commit evidence.
- After durable commit, conditionally remove only captured upload rows and collect only the captured retired asset namespace. Preserve new same-graph operations and replacement canonical files. Physical purge failures stay in producer-owned pending queues; committed collection failure must not prevent redownload.
- Before commit, cancel restores only the captured asset directory with an atomic no-replace rename. A conflict preserves bytes, journal and the barrier for recovery. Aborted/account-deleted tombstones reject late preparation or restoration. Before a mirror is inspected or attached, replay its journal using exact durable commit evidence; unknown outcomes fail closed.

The producer APIs expose prepare/commit/cancel because the worker coordinates independently owned resources between those steps. Durable receipts and cleanup queues remain private. Completed receipts are looked up by ID; ordinary collection visits pending namespaces rather than all historical receipts.

## Alternatives considered

### Reuse the original picker transition

It detached the current graph and cleared its identity. Keep an application-owned picker presentation, using the existing graph selection lifecycle only for an actual switch.

### Delete attachments and then delete the mirror

A busy or stale mirror could reject deletion after its attachment sources were already gone. Merely reversing the calls would still leave post-commit cleanup failures and retries unsafe. Explicitly capture each resource and retain durable recovery evidence.

### Retain the old single-graph deletion APIs

Their production callers belong to this workflow. Migrate those callers and their contract tests, then remove the obsolete entry points rather than maintain parallel destructive paths. Account cleanup retains its separate existing purpose.

## Validation

- Public mounted Application tests reproduce catalog-refresh error visibility through the existing service and mounted property subscriptions; the reducer cannot execute the missing presentation dependency. Refresh loading/errors remain visible in the list. They also cover current marking, close/session retention, name switching, menu confirmation, action usability while another graph is deleting, and cancellation of superseded presence checks.
- A public worker actor regression faults the coordinator-journal directory with a real file, proving that mkdir/write/fsync exceptions return a retryable deletion error rather than escaping into the shared session switch. The mirror and current graph session remain available. Pure reducer events cannot execute filesystem exceptions.
- Public worker actor tests use real SQLite ownership contention to prove that busy admission precedes asset retirement. They also hold an asset preparation completion while catalog reconciliation retires another graph, proving only the exact deletion ticket/request can resolve its waiter.
- Public Database tests cover stale generations, rebound account ownership, preparation ownership, cancellation, durable commit replay, partial-purge retry and replacement-mirror isolation. A pure reducer cannot execute filesystem permission failures or ownership acquisition; injecting a failed completion there would not reproduce either bug.
- Public asset reducer events cover token scope validation and replay during pending work, after commit and after account cleanup. Public runner requests exercise native leases, staged-file restoration, atomic restore conflicts and replacement-cache isolation.
- Upload checkpoint tests cover durable capture roundtrips, changed-row refusal with no partial deletion, restart/replay and preservation of new same-graph rows.
- Run full repository compilation and regression/source-boundary checks before building the native probe. Simulator acceptance uses only an isolated iPhone 13 / iOS 26.1 device and rebuildable synthetic graphs, with actual list/menu taps, long names and accessibility Dynamic Type, cancellation, current-graph protection, busy failure/retry, retained Not downloaded rows and real-worker redownload through a controlled loopback TLS fixture. Also exercise intentional discard of an unsent text capture and photo attachment. The fixture is not a live remote sync service.
- Keep build manifests, exact source hashes, test results, screenshots and independent review reports outside Git. Review the final PR head with journal-code-review and wait for that head's CI terminal result.

## Risks

Unknown presence or an unresolved recovery conflict must expose an actionable error rather than assume a safe downloaded state. Restoring a prepared directory can fail if another namespace has appeared; retain the exclusive captured bytes for retry instead of overwriting the new copy.

Host ARM64 OCaml restamping is an experimental Simulator compatibility route. Runtime acceptance does not establish full iOS cross-compilation or ABI validity.

## Consequences

Presence observations are cached by account and invalidated for graph lifecycle changes. Native row controls stay mounted while property signals update text and enabled state: real Simulator testing showed that replacing interactive subtrees could retain obsolete callbacks. The active graph remains usable when the picker closes while another graph's copy is deleting.

Application owns the manager snapshot and action gate; Root_navigation does not, so its events cannot reproduce those UI guards. The worker runtime owns exact promise completion; a reducer supplied with an already incorrect completion cannot reproduce its wrapper inference bug. Resource producers own filesystem transitions, durable receipts and native leases. Each regression lives at the narrowest public owner that executes its defect.
83 changes: 74 additions & 9 deletions logseq_db_storage/lib/asset_upload_store.ml
Original file line number Diff line number Diff line change
Expand Up @@ -241,15 +241,6 @@ let list db ~origin ~account ~graph ~after ~limit =
rows [])
;;

let delete_graph db ~origin ~account ~graph =
statement
db
"DELETE FROM asset_upload_intents WHERE origin=? AND account=? AND graph=?"
(fun stmt ->
bind stmt [ TEXT origin; TEXT account; TEXT (Uuid.to_string graph) ];
check db (Sqlite3.step stmt))
;;

let delete_account db ~origin ~account =
statement
db
Expand All @@ -258,3 +249,77 @@ let delete_account db ~origin ~account =
bind stmt [ TEXT origin; TEXT account ];
check db (Sqlite3.step stmt))
;;

module Captured_rows = Map.Make (struct type t = Uuid.t let compare = Uuid.compare end)

type captured_graph =
{ captured_origin : string; captured_account : string; captured_graph : Uuid.t
; captured_rows : string Captured_rows.t }

let captured_graph_matches capture ~origin ~account ~graph =
capture.captured_origin = origin && capture.captured_account = account && Uuid.equal capture.captured_graph graph
;;

let capture_graph db ~origin ~account ~graph =
statement db "SELECT operation,payload FROM asset_upload_intents WHERE origin=? AND account=? AND graph=? ORDER BY operation"
(fun stmt ->
bind stmt [TEXT origin; TEXT account; TEXT (Uuid.to_string graph)];
let rec rows acc = match Sqlite3.step stmt with
| DONE -> Ok {captured_origin=origin; captured_account=account; captured_graph=graph; captured_rows=acc}
| ROW ->
let payload = Sqlite3.column_text stmt 1 in
let* intent = decode payload in
if intent.origin <> origin || intent.account <> account || not (Uuid.equal intent.graph graph)
|| Uuid.to_string intent.operation_id <> Sqlite3.column_text stmt 0
then Error "Retirement checkpoint scope mismatch"
else rows (Captured_rows.add intent.operation_id payload acc)
| rc -> let* () = check db rc in Error "Unexpected retirement capture result" in
rows Captured_rows.empty)
;;

let captured_graph_to_yojson capture =
`Assoc ["version", `Int 1; "origin", `String capture.captured_origin
; "account", `String capture.captured_account; "graph", Uuid.to_yojson capture.captured_graph
; "rows", `List (Captured_rows.bindings capture.captured_rows |> List.map (fun (id,payload) ->
`List [Uuid.to_yojson id; `String payload]))]
;;

let captured_graph_of_yojson json =
try
let open Yojson.Safe.Util in
if json |> member "version" |> to_int <> 1 then failwith "Unsupported retirement capture";
let origin = json |> member "origin" |> to_string in
let account = json |> member "account" |> to_string in
let* graph = json |> member "graph" |> Uuid.of_yojson in
let rec decode_rows acc = function
| [] -> Ok acc
| `List [id; `String payload] :: rest ->
let* id = Uuid.of_yojson id in let* intent = decode payload in
if intent.origin <> origin || intent.account <> account || not (Uuid.equal graph intent.graph)
|| not (Uuid.equal id intent.operation_id) then Error "Retirement capture scope mismatch"
else if Captured_rows.mem id acc then Error "Duplicate retirement checkpoint"
else decode_rows (Captured_rows.add id payload acc) rest
| _ -> Error "Invalid retirement capture" in
let* rows = decode_rows Captured_rows.empty (json |> member "rows" |> to_list) in
Ok {captured_origin=origin;captured_account=account;captured_graph=graph;captured_rows=rows}
with _ -> Error "Invalid retirement capture"
;;

let delete_captured_graph db capture =
let* () = check db (Sqlite3.exec db "BEGIN IMMEDIATE") in
let result =
let* () = Captured_rows.fold (fun id payload result ->
let* () = result in
statement db "SELECT payload FROM asset_upload_intents WHERE operation=?" (fun stmt ->
bind stmt [TEXT (Uuid.to_string id)]; match Sqlite3.step stmt with
| DONE -> Ok () | ROW when Sqlite3.column_text stmt 0 = payload -> Ok ()
| ROW -> Error "A captured upload changed; retirement cleanup remains pending."
| rc -> check db rc)) capture.captured_rows (Ok ()) in
Captured_rows.fold (fun id payload result -> let* () = result in
statement db "DELETE FROM asset_upload_intents WHERE operation=? AND payload=?" (fun stmt ->
bind stmt [TEXT (Uuid.to_string id); TEXT payload]; check db (Sqlite3.step stmt)))
capture.captured_rows (Ok ()) in
match result with
| Ok () -> check db (Sqlite3.exec db "COMMIT")
| Error _ as error -> ignore (Sqlite3.exec db "ROLLBACK"); error
;;
Loading
Loading