Skip to content

Commit d9c3161

Browse files
pmaxhoganclaude
andauthored
feat(drive): support Google Shared Drive destinations end-to-end (#142)
Backlog item #7 (epic #34): thread Google Shared Drive destinations through every Drive request path, the folder picker, and per-source persistence, so a source can back up into a Shared Drive as well as My Drive. ## Request-path checklist (every path + the param it gained) `supportsAllDrives=true` is now sent UNCONDITIONALLY on every `files.*` request (harmless for My Drive; const `SUPPORTS_ALL_DRIVES` in `google/mod.rs`). The LIST/search paths additionally scope the corpus by `DriveContext`. | Trait method / path | Endpoint | Param(s) gained | |---|---|---| | `ensure_folder` search | `GET /files` (via `list_query`) | `supportsAllDrives`; Shared Drive: `corpora=drive`+`driveId`+`includeItemsFromAllDrives` | | `ensure_folder` create | `POST /files` | `supportsAllDrives` | | `list_folder` | `GET /files` (paginated) | `supportsAllDrives`; Shared Drive: `corpora=drive`+`driveId`+`includeItemsFromAllDrives` | | `find_by_op_uuid` | `GET /files` (appProperties query) | same as list | | `create` (small) | `POST /upload/files?uploadType=multipart` | `supportsAllDrives` | | `update` (small) | `PATCH /upload/files/{id}?uploadType=multipart` | `supportsAllDrives` | | `resumable_session` init | `POST/PATCH /upload/files[/{id}]?uploadType=resumable` | `supportsAllDrives` | | `resume_chunk` PUT | `PUT <session-url>` | none (session URL already encodes the drive) | | `trash` | `PATCH /files/{id}` `{trashed:true}` | `supportsAllDrives` | | `delete_permanent` | `DELETE /files/{id}` | `supportsAllDrives` | | `metadata` | `GET /files/{id}` | `supportsAllDrives` | | `download` | `GET /files/{id}?alt=media` | `supportsAllDrives` | | appProperties patch | `PATCH /files/{id}` | `supportsAllDrives` | | `about` | `GET /about` | n/a (not a `files.*` call) | | `list_shared_drives` (new) | `GET /drives` | picker enumeration of Shared Drive roots | The My-Drive-vs-Shared-Drive list params are built by the pure fn `pagination::list_query_params(q, page_token, &DriveContext)` and the resumable init params by `resumable::open_session_query_params()` - both unit-tested for both contexts (this is the "assert the actual query params built" coverage). ## Drive-context persistence design The `GoogleDriveStore` is built PER ACCOUNT and shared across all of that account's sources, and an account can hold a My-Drive source AND a Shared-Drive source at once. So drive context is threaded PER CALL (a new `DriveContext { MyDrive | SharedDrive{drive_id} }` argument on the three list methods), exactly the way `parent_id`/`drive_folder_id` is already threaded - NOT held as a store field. - New nullable column `drive_id` on `backup_sources` (migration `0009_shared_drive_id.sql`; additive, NULL / "my-drive" decode to My Drive). - `SourceRow.drive_id: Option<String>` + `SourceRow.drive_context()`; executor list call sites pass `&source.drive_context()`. - `add_source` normalises + persists the picker's `driveId` (validated: bounded, no control/whitespace). - `.sqlx` offline cache regenerated. ## Picker UX `pick_drive_folder` gained a `driveId` param and a `drives.list` call. At the My Drive root it lists the account's Shared Drive roots (badged "Shared drive") above the My Drive folders; descending into one carries its `driveId` back in so the listing switches to `corpora=drive` scope, and selecting a folder persists the correct `drive_id`. `DriveFolderEntry`/`DriveFolderListing` carry the `driveId` through descent. UI: `DriveFolderPicker.vue` gains a `drive-id` v-model wired through both the first-run `SetupWizard` and the `AddSourceWizard`; localized (`drivePicker.sharedDriveBadge`); vitest mount coverage in `drive-folder-picker.test.ts` (badge render + Shared Drive descent scoping + driveId propagation). ## Trash semantics (item 4) The existing `trash` path (`files.update trashed=true`) now carries `supportsAllDrives=true`, so trashing an object inside a Shared Drive works with no new behavior. Caveat (Drive-side, not code): trashing in a Shared Drive requires the account to have at least the Content Manager role on that drive; a Viewer/Commenter/Contributor cannot trash and Drive returns 403 (surfaced as the existing classified permission error). No new permission handling was added. ## Live e2e activation `google_e2e.rs` runs the portable contract scenarios against a real Shared Drive when, IN ADDITION to the existing `DRIVEN_E2E_REFRESH_TOKEN` + `DRIVEN_E2E_DEST_FOLDER_ID` + `DRIVEN_OAUTH_CLIENT_SECRET` gate, `DRIVEN_E2E_SHARED_DRIVE_ID` is set to a Shared Drive id the token's account can manage. Each test creates a UUID-named child directly under the Shared Drive root (its id doubles as the root folder id) and trashes it on success and failure. Unset -> the `google_shared_drive_*` tests print a skip line and pass (honest env gate, not `#[ignore]`). Run once creds exist: ``` DRIVEN_E2E_REFRESH_TOKEN=... DRIVEN_E2E_DEST_FOLDER_ID=... \ DRIVEN_OAUTH_CLIENT_SECRET=... DRIVEN_E2E_SHARED_DRIVE_ID=0A... \ cargo test -p driven-drive --test google_e2e ``` ## Tests - Pure query-param builders unit-tested for My Drive vs Shared Drive (`pagination.rs`, `resumable.rs`). - Fake contract: drive-context plumb-through (`drive_contexts_seen`), My-Drive-vs-Shared distinctness, `list_shared_drives` config (`fake_contract.rs`); portable scenarios parameterized on `DriveContext`. - Real e2e: `google_shared_drive_*` scenarios gated on `DRIVEN_E2E_SHARED_DRIVE_ID`. - UI: `drive-folder-picker.test.ts` mount coverage. Refs #34 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 929e93d commit d9c3161

51 files changed

Lines changed: 1502 additions & 288 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.sqlx/query-3195ca7d060e43ce8be8651e66bd65239aaede46a6d237390abeda1a119a2d75.json

Lines changed: 0 additions & 12 deletions
This file was deleted.

.sqlx/query-68f85a137d75f3bb06d7c864a2b05aa0c6225d41c931595997629946b1d51d28.json

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.sqlx/query-519c2f6c9e040c67a14f44d92553d4aea61375d50d79cf95105980d318db5ff9.json renamed to .sqlx/query-7ab8e9edaf83ed2697ef04c2249b7ff94afa9ecbcc80860482a5e6369f3a86df.json

Lines changed: 26 additions & 14 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.sqlx/query-95c1eb7488ee60abedbc08d67ca4c78c3be90e8863d8eaa89733fd6f20cc5ce8.json renamed to .sqlx/query-ea24f36f62c84328655dbf258eb7422a1ac0bac4ea492559ec6229af2e94fe32.json

Lines changed: 26 additions & 14 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/driven-chaos/src/scenarios/concurrency.rs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ fn source_in(account: AccountId, root: &std::path::Path, folder_id: &str) -> Sou
135135
enabled: true,
136136
local_path: root.to_string_lossy().into_owned(),
137137
drive_folder_id: folder_id.to_string(),
138+
drive_id: None,
138139
drive_folder_path: "/concurrency".into(),
139140
encryption_enabled: false,
140141
wrapped_source_key: None,
@@ -184,7 +185,10 @@ fn noop_progress(_p: driven_core::types::ExecProgress) {}
184185
/// Count non-trashed objects under `folder_id`.
185186
async fn live_object_count(remote: &InMemoryRemoteStore, folder_id: &str) -> anyhow::Result<usize> {
186187
Ok(remote
187-
.list_folder(folder_id)
188+
.list_folder(
189+
folder_id,
190+
&driven_drive::remote_store::DriveContext::MyDrive,
191+
)
188192
.await?
189193
.iter()
190194
.filter(|e| !e.trashed)
@@ -355,7 +359,9 @@ impl Scenario for PauseMidResumable5m {
355359

356360
// The upload completed via byte-level resume: exactly one object, full
357361
// size, the SAME session consumed (not a from-zero re-do).
358-
let children = remote.list_folder(&folder).await?;
362+
let children = remote
363+
.list_folder(&folder, &driven_drive::remote_store::DriveContext::MyDrive)
364+
.await?;
359365
anyhow::ensure!(
360366
children.len() == 1,
361367
"resume must finalise exactly one object; got {}",
@@ -511,7 +517,9 @@ impl Scenario for PauseMidResumable7d {
511517
handle.run_one_cycle().await?;
512518

513519
// Exactly one object, full size, no duplicate.
514-
let children = remote.list_folder(&folder).await?;
520+
let children = remote
521+
.list_folder(&folder, &driven_drive::remote_store::DriveContext::MyDrive)
522+
.await?;
515523
anyhow::ensure!(
516524
children.len() == 1,
517525
"restart must produce exactly one object; got {}",
@@ -718,7 +726,9 @@ impl Scenario for Kill9MidPipeline {
718726

719727
// --- invariants -----------------------------------------------------
720728
// Every file backed up exactly once: 16 live objects, names unique.
721-
let children = remote.list_folder(&folder).await?;
729+
let children = remote
730+
.list_folder(&folder, &driven_drive::remote_store::DriveContext::MyDrive)
731+
.await?;
722732
let live: Vec<_> = children.iter().filter(|e| !e.trashed).collect();
723733
anyhow::ensure!(
724734
live.len() == expected.len(),

0 commit comments

Comments
 (0)