Repository navigation
feat: Track personal recents on the server - #5135
Draft
taratatach wants to merge 11 commits into
Draft
taratatach wants to merge 11 commits into
taratatach wants to merge 11 commits into
Conversation
taratatach
force-pushed
the
feat/personal-recents
branch
5 times, most recently
from
November 2, 2026 12:13
1f72d14 to
7022c71
Compare
Personal recents need a per-user store no client may touch directly. Declare the doctype, block it from `/data`, keep it out of the `model/move` export and import, and reject realtime subscriptions and `@event` triggers on it, as for the public link events, so only the dedicated endpoint will serve it later. The instance lifecycle now creates the database and its sort index on `(last_activity_at, _id)`. New package `model/recents` declares the row, `FileDirActivity`, keyed by `ResourceKey` (source instance ID + file ID), and `PublicID`, the hash of that key given to the clients so that the internal ID of the source instance never leaves the stack. It also holds the `drive.recents.enabled` feature flag (off by default, set per instance or context) that gates the whole feature. `Enabled` reads the flags of the instance first, without any I/O, and otherwise asks `feature.GetFlags`, like every other caller of the stack. Co-Authored-By: AI
The request actor resolved by the permission middleware only knows a kind, a display name and a domain. Recording personal activity needs to act for the person behind a request: without more, it would have to redo the token inspection and the instance lookup, and would lose the original token on the shared drive proxy, which swaps it for the drive token. `Actor` gains `Instance`, the actor's instance on this stack, and `Person`, computed once from the original token: a session-bound app token, the flagship app or `cozy-desktop` OAuth client, an OAuth token with the `*` scope (only the flagship app asks for it, and the stack grants it without certification when the context skips it), or a share-interact code. `Person` survives the drive proxy swap because the share-interact branch keeps an actor that is already set. `Instance` is the request instance for the owner's tokens, and the member's instance for a share-interact code, but only once the member has accepted the sharing: before that, the member's Cozy URL comes from the sharer's contacts and cannot be trusted. `Domain` keeps its current value for `trashed_by`. `MemberInstance` loads the instance of a member by its domain. A member on another stack (not found or illegal domain) is silent, any other error is logged as a warning. Co-Authored-By: AI
Personal recents must reflect my own edits and opens, not the file's latest change. `RecordActivity` upserts the row of the actor on an item with a monotone max per component (`last_edited_at`, `last_viewed_at`, `last_activity_at`), stamped server-side, idempotent under replay. Two activities of the same kind less than a minute apart rewrite nothing, and a new activity refreshes the `source_locator` after a domain change. Each process also remembers for a minute the activities it stored, so that the hot write paths skip the read of the row. A row deleted by other means cannot be recreated by that process during that minute, which no legitimate activity needs. An activity on a moved source instance records nothing, as its rows would point to an instance that is going away. The files handlers record an edit right after a successful create, overwrite, restore, version revert or folder creation; renames, trash, copies, downloads and permission changes record nothing. The automatic writes of the flagship app record nothing either: the photo backup (files tagged with `metadata.backupDeviceIds`), any folder it creates (backup tree, konnector folders, logs) and its files under `/Settings/Logs` and `/Settings/AALogs`. Uploads from its share sheet still count. Identity comes from the actor middleware: the actor must be a person, and its instance on this stack is the one that gets the row. On the shared drive proxy, the person is checked on the original token of the recipient; a proxied route without authentication pins an actor that is not a person before the swap to the drive token. The capture is gated by the `drive.recents.enabled` feature flag of the actor's instance. A failed row write is logged, counted in `recents_activity_failures_total` and swallowed; a member who has not accepted the sharing yet, or whose instance is not on this stack, silently loses the activity. `lifecycle.Reset` tolerates a recents database recreated by an activity of another instance while the databases are deleted: it resets it, or keeps it if it is recreated again. The ADR documents the identities, the exceptions to the edits and the one-minute memory. Co-Authored-By: AI
The memory store of the OnlyOffice sessions kept the session it was given, and `GetDoc` returned a pointer to the stored one: a caller changing a session it had read changed it in the store, outside the lock, and the `MD5Sum` slice stayed shared with the caller that added the session. The redis store has no such sharing, as it reads and writes JSON. `ConflictDetector.Clone` makes a deep copy of a session. The memory store clones a session when it stores it (`AddDoc`, `UpdateDoc`) and when it returns it (`GetDoc`), so that it behaves like the redis store. No caller changed a session it had read, so the behavior is unchanged today. Co-Authored-By: AI
Opening an OnlyOffice document records a view for the person who opens it. The document server does not report who saved, so each save callback credits an edit to every person who opened the session, kept in a `Participants` list on the session key. A participant who only viewed is credited with the edit too, the closest exact attribution available. Adding a participant, and moving the session to the revision written by a save, are atomic updates of the session (the lock of the memory store, a `WATCH`/`MULTI` transaction in redis): concurrent openings and saves never drop a participant. When a conflict copies the file, `MoveToRevision` also moves the file index to the copy, which `UpdateSecret` did in a separate call that callers had to order. An update never revives an expired session of the memory store. Known limit, left as is: in redis, the session and the index of its file live under two keys that a cluster may place on different slots, where a transaction cannot span both. The index is moved after the session, and until then the old file still points to the session. Making it atomic would need both keys on the same slot, through a hash tag in their names: a change of the stored key format that would lose the sessions open at deploy time, with their pending saves. The window is short, and the separate `UpdateSecret` call already had it. As for files, the capture is gated by the `drive.recents.enabled` feature flag of each participant's instance, and a participant whose instance is not on this stack silently loses the activity: any other failure to load it is logged with a warning. A session store failure that loses an opening, when reading the session or adding the participant, is logged and counted in `recents_activity_failures_total`. Co-Authored-By: AI
Notes are written through `model/note` instead of the hooked files handlers: creating a note and applying its steps (`PATCH /notes/:id`) now record an edit, and loading it in the editor (`GET /notes/:id`) records an open, as the notes editor never downloads a note. Drive shows a drawing in its excalidraw editor and a PDF file in its viewer after opening it with `GET /editor/:file-id/open`, which records the open. A content read is never an open: it is the same request as a download, a preview or a sync. The ADR is updated accordingly. Sharing members are credited on their own instance, read-only openings included. `ParseJWT` keeps the member to whom a share code was given in the request context (`GetShareMember`), and `ActorInstance` resolves a share-preview code to that member once they have accepted the sharing: until then, their Cozy URL comes from the sharer's contacts. The capture is gated by the `drive.recents.enabled` feature flag, like the others. The ADR also covers the opens of the mobile app, made in webviews with session-bound app tokens, and records a known limit: whoever holds the code of a member counts as that member. Co-Authored-By: AI
`cozy-client` and the flagship app look for the Photos magic folder by its `io.cozy.apps/photos` reference, but the stack creates /Photos at instance creation without it. The reference is only added later, when a konnector or the photo backup asks for the magic folder. Until then, the folder looks like one the person created, and the seed of the personal recents would list it among their folders. Add the reference when the folder is created, as done for the Administrative folder. Existing instances keep their folder as is. Co-Authored-By: AI
Switching from the browser DataProxy to server-side recents must not show an empty page. `Seed` fills up to 20 rows from the local `io.cozy.files`: files carry their modification date, folders their creation date, never the seed time. A `_local` marker in the recents database makes the seed run once per instance; the upsert by resource key absorbs concurrent seeds, and a concurrent marker write is not a failure. `move.Import` removes the marker with `ForgetSeed` once the parts are imported: a seed that ran while the import was starting saw the replaced files, and the first read seeds the imported ones. The items the user did not create or edit are left out by the selectors, so that they never take one of the 20 places: the system folders (fixed `io.cozy.files.` ids), the trashed folders (by path, as folders have no trashed field), the magic folders referenced by an app or a konnector (Administrative, Photos, Notes, photo backup...), the folders created by the flagship app, and the files of the photo backup (`metadata.backupDeviceIds`). A Mango negation does not match a missing field, hence the explicit `$exists` branches. The dates come from the clients. A date up to 14 hours in the future, at most a local time written as UTC (UTC+14), is kept with a warning; a later one is skipped, as its row would stay on top until then. The selectors bound the dates at now plus 14 hours, as a date read as a string is at most that far ahead, so that the items dated far in the future do not take the places of the others; the parsed dates are checked again, with a warning for an item skipped there. Files and folders are queried through two indexed requests (new indexes, hence `IndexViewsVersion` 43) and merged by seeded date, capped at 20. A failed seed is logged and counted in `recents_seed_failures_total`: it leaves an empty page, never an error. The list endpoint will trigger it on the first read. Co-Authored-By: AI
The list of personal recents will check, for each item owned by another instance of the stack, whether the reader can still read it. `ResolveForMember` works on one target at a time: it loads the doc again, then makes one request for its parent, one for the shared roots on its path and one for the sharings, so a page of items would cost a few requests each. `ResolveDocsForInstance` takes docs already loaded by the caller and makes at most three requests for all of them: one `_all_docs` for the parents of the files, one `dir-by-path` find on the deduplicated union of their ancestor paths, and one load of the sharings. A missing parent gives an error for its target only. The reader is matched on every sharing as the ready member whose Cozy URL has one of the instance domains, aliases included. The email is never compared, as the settings email of an instance is not a proof of identity. The find and the collection of shared roots are extracted from `applicableSharingsForDoc` with no change of behavior for its callers. `vfs.DirOrFileDocsByIDs` loads files and folders in one `_all_docs`, and `vfs.NewFilePatherWithDirs` computes paths from parents loaded beforehand, without fetching again a parent known to be missing. Co-Authored-By: AI
taratatach
force-pushed
the
feat/personal-recents
branch
3 times, most recently
from
November 3, 2026 17:04
18193a1 to
202a76f
Compare
Add `GET /files/recents` so Drive can read its recents from the backend instead of the browser DataProxy. JSON:API page of up to 50 items (`page[limit]` capped at 100), cursor over the `(last_activity_at, _id)` index, and `meta.incomplete` when a source cannot be checked or the work limit is hit. An instance whose recents database does not exist yet gets an empty page. Entries and cursors carry the public ID of a row, a hash of its key: the internal ID of the source instance, possibly the owner of a shared drive, never leaves the stack. A cursor is resolved among the rows of its date; when its row changed meanwhile, the traversal resumes before that date and sets `meta.incomplete`. Every candidate is re-checked at read time: fresh metadata is read from the database of the instance that holds the item (same stack), trashed, deleted or unauthorized items are skipped and the page refilled within a bounded work limit, and losing access only hides a row — regaining it shows the row again with the user's own date. The access of the user on the items of another instance is checked by `ResolveDocsForInstance`, which matches the user's domains only. The source of a row is resolved from the instance ID of its key, as the domain of an instance never changes: a change of domain is a move, which creates a new instance and keeps the file IDs. The rows of a moved source are migrated under the key of its successor on the stack, found from `moved_to` or from the old domain of the new instance, with the dates of both rows merged; the user's own rows are never migrated. The rows of a source gone with no successor, of a destroyed item or with a malformed key are deleted, a stale `source_locator` is refreshed, and a source that cannot be checked hides its rows. Rows are read by batches, the size of the page plus one first, then twice the previous one. Each batch costs one find for the rows, one request for the unknown source instances, one `_all_docs` per source and at most three for the access; a moved or deleted source adds the lookups of its successor, and the rows it migrates one `_all_docs`. The repairs are written in bulk at the end with `couchdb.BulkWriteDocs`, which reports each document. A cursor always points to a row that is still there: the next cursor is the last examined row that is not deleted, at the end of the page or where the work limit is hit. The item of a migrated row ending a page may then come back on the next page. The first page of a traversal triggers the lazy seed. The endpoint is gated by the `drive.recents.enabled` feature flag of the instance, and answers 404 while it is off. Co-Authored-By: AI
A moved or destroyed instance leaves rows keyed by its ID in the recents of the members of its sharings. The reads migrate or delete them, but only when they happen: once a moved instance is deleted, a month after the move, its successor can no longer be found. `move.Finalize` pushes a `recents-source` job to each member of the sharings of the moved instance that lives on this stack and has the recents enabled. The job migrates the rows on its items to the successor at the end of the chain of moves, with the dates merged at their maximum, or deletes them when the target is on another stack. It is given the successor by its ID, as its domain may be given to another instance before the job runs. `lifecycle.Destroy` calls `recents.PrepareDestroy` before deleting the databases, as it reads the members and the successor of a moved instance: the rows on a moved instance, destroyed a month after its move, are migrated to its successor, which catches the ones the jobs of the move missed, and the other rows are deleted. When the successor cannot be checked, no job is pushed, and the reads handle the rows. Both look for the successor only when the instance has such members, loaded in one request. `MigrateSource` and `DropSource` read the rows of a source by batches from the range of its key prefix in `_all_docs`, and write them in bulk. They never touch the user's own rows, and running them again is a no-op. A former member removed from the sharings is not found, and a member whose recents are disabled is skipped: the reads remain the safety net. Co-Authored-By: AI
taratatach
force-pushed
the
feat/personal-recents
branch
from
November 4, 2026 12:47
202a76f to
d69f239
Compare
This was referenced Nov 4, 2026
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #4951
Drive's recents view lists the files most recently modified by anyone,
computed in the browser from the DataProxy. It should show my own
activity: what I edited or opened, across my files and the ones shared
with me. This branch moves the recents into the stack, as described in
ADR 007, behind the
drive.recents.enabledfeature flag (off bydefault).
A protected
io.cozy.files.recentsdoctype stores oneFileDirActivityrow per user and item, in the user's own database:blocked from
/data, excluded from the instance export, refused torealtime subscriptions. A row is keyed by the source instance ID and
the file ID, and keeps separate edit and open dates, stamped by the
server. Activities of the same kind less than a minute apart rewrite
nothing, which keeps note typing and sync uploads cheap.
Activity is captured where the stack sees it: file and folder
creation, overwrite, restore and version revert; OnlyOffice openings
and saves, where every participant of a session is credited through
atomic updates of the session store; note creation, steps and loading;
and Drive's editors, the excalidraw editor and the PDF viewer, through
GET /editor/:file-id/open. A download is never an open. Sharingmembers are credited on their own instance, read-only openings
included; a member on another stack loses the activity, with a
warning.
GET /files/recentsreturns a JSON:API page of these items. Eachentry is re-checked at read time against the owner's instance, so
trashed, deleted or no longer accessible items are hidden. Entries and
cursors carry a hash of the row key, never the internal ID of the
source instance. On the first read, a seed fills up to 20 rows from
the latest files and folders, so that switching to the server-side
recents never shows an empty page; it needs two new
io.cozy.filesindexes (
IndexViewsVersion42).