feat(drive): plug trash to drives

This commit is contained in:
Edouard Vanbelle
2026-06-23 21:08:55 +02:00
parent 062bcb701b
commit 184520c17a
10 changed files with 324 additions and 69 deletions
+11
View File
@@ -61,6 +61,11 @@ pub struct TrashResourceRow {
pub resource_created_at: DateTime<Utc>,
pub modified_at: DateTime<Utc>,
pub owner_id: Uuid,
/// Drive the trashed item belongs to. Surfaced verbatim on the wire
/// (`TrashResourceItemDto.drive_id`) so the `/trash` UI can group by
/// drive without an extra lookup per row. D2b: filtering by drive is
/// done in SQL via `WHERE drive_id = ANY($accessible_drive_ids)`.
pub drive_id: Uuid,
/// Raw BLAKE3 content hash. `Some(_)` for file rows, `None` for
/// folder rows. Feeds `File::compute_etag` so the trash listing's
/// `etag` matches what GET/HEAD/PROPFIND would return for the
@@ -162,6 +167,12 @@ pub struct TrashResourceItemDto {
pub trashed_at: DateTime<Utc>,
/// When the item will be permanently deleted by the retention sweeper.
pub deletion_date: DateTime<Utc>,
/// The drive the trashed item belongs to. Enables client-side
/// group-by-drive in the `/trash` UI (D2b spec — see
/// `project_trash_groupbys_d2b` memory). The drive's display name
/// resolves through the `/api/drives` listing the client already
/// holds in `drives.svelte` — no extra round-trip needed.
pub drive_id: Uuid,
/// Full resource details — shape determined by `resource_type`.
pub resource: ResourceContentDto,
}
+106 -20
View File
@@ -19,6 +19,7 @@ use crate::application::ports::trash_ports::TrashUseCase;
use crate::common::errors::{DomainError, ErrorKind, Result};
use crate::domain::entities::file::File;
use crate::domain::entities::trashed_item::{TrashedItem, TrashedItemType};
use crate::domain::repositories::drive_repository::DriveRepository;
use crate::domain::repositories::folder_repository::FolderRepository;
use crate::domain::repositories::trash_repository::TrashRepository;
use crate::domain::services::authorization::ResourceKind;
@@ -70,6 +71,11 @@ pub struct TrashService {
/// Authz engine
authz: Arc<PgAclEngine>,
/// Drive repository — D2b uses it to resolve "drives the caller can read"
/// so trash listings filter by drive membership instead of the legacy
/// per-user scope.
drive_repo: Arc<crate::infrastructure::repositories::pg::DrivePgRepository>,
/// Number of days items should be kept in trash before automatic cleanup
retention_days: u32,
}
@@ -85,6 +91,7 @@ impl TrashService {
dedup_service: Arc<DedupService>,
content_cache: Option<Arc<FileContentCache>>,
authz: Arc<PgAclEngine>,
drive_repo: Arc<crate::infrastructure::repositories::pg::DrivePgRepository>,
) -> Self {
Self {
trash_repository,
@@ -95,6 +102,7 @@ impl TrashService {
file_deleted_hook: None,
content_cache,
authz,
drive_repo,
retention_days,
}
}
@@ -366,10 +374,7 @@ impl TrashUseCase for TrashService {
// Get the trash item
info!("Retrieving trash item from repository: ID={}", trash_id);
let item_result = self
.trash_repository
.get_trash_item(&trash_uuid, &user_uuid)
.await;
let item_result = self.trash_repository.get_trash_item(&trash_uuid).await;
match item_result {
Ok(Some(item)) => {
@@ -380,6 +385,21 @@ impl TrashUseCase for TrashService {
item.original_id()
);
// D2b stage 3: gate the restore on Delete permission against
// the item's original resource. The drive precheck in
// `pg_acl_engine` resolves Owner-on-drive → Delete-permission,
// so a drive Owner can restore items they didn't originally
// trash (per `drive.md §12`). 404-on-deny via `authz.require`
// matches the lookup-NotFound shape.
let original = item.original_id();
let resource = match item.item_type() {
TrashedItemType::File => Resource::File(original),
TrashedItemType::Folder => Resource::Folder(original),
};
self.authz
.require(Subject::User(user_id), Permission::Delete, resource)
.await?;
// Restore based on type
match item.item_type() {
TrashedItemType::File => {
@@ -540,10 +560,7 @@ impl TrashUseCase for TrashService {
// Get the trash item
info!("Retrieving trash item from repository: ID={}", trash_id);
let item_result = self
.trash_repository
.get_trash_item(&trash_uuid, &user_uuid)
.await;
let item_result = self.trash_repository.get_trash_item(&trash_uuid).await;
match item_result {
Ok(Some(item)) => {
@@ -554,6 +571,19 @@ impl TrashUseCase for TrashService {
item.original_id()
);
// D2b stage 3: gate the permanent-delete on Delete permission
// against the item's original resource (mirrors `restore_item`
// above). Drive Owners can hard-delete shared-drive items
// they didn't trash.
let original = item.original_id();
let resource = match item.item_type() {
TrashedItemType::File => Resource::File(original),
TrashedItemType::Folder => Resource::Folder(original),
};
self.authz
.require(Subject::User(user_id), Permission::Delete, resource)
.await?;
// Permanently delete based on type
match item.item_type() {
TrashedItemType::File => {
@@ -688,6 +718,41 @@ impl TrashUseCase for TrashService {
async fn empty_trash(&self, user_id: Uuid) -> Result<()> {
info!("Emptying trash for user {}", user_id);
// D2b stage 3: resolve the set of drives the caller can permanently
// delete content in. "Empty trash" means "drop every trashed item
// I have Delete on" — Owner role's bundle includes Delete; Editor /
// Viewer / Contributor / Commenter do not. So this filter picks out
// the drives where the caller is effectively Owner (direct or via a
// group). Single-drive users: this resolves to just their personal
// drive, identical to the legacy `WHERE user_id = $1` scope.
let (subject_types, subject_ids) = self
.authz
.expand_subject_for_listing(Subject::User(user_id))
.await?;
let drives = self
.drive_repo
.list_for_subjects(&subject_types, &subject_ids)
.await
.map_err(|e| {
DomainError::internal_error(
"Trash",
format!("Failed to resolve accessible drives: {e:?}"),
)
})?;
let drive_ids: Vec<Uuid> = drives
.iter()
.filter(|d| {
d.caller_role
.is_some_and(|r| r.expand().contains(&Permission::Delete))
})
.map(|d| d.drive.id)
.collect();
if drive_ids.is_empty() {
info!("empty_trash: caller has Delete on no drive — nothing to do");
return Ok(());
}
// Collect ALL trashed file IDs BEFORE bulk-deleting so hooks (thumbnail
// cleanup, etc.) can run afterward. We use get_all_trashed_file_ids (not
// get_trash_items) because the trash_items view excludes files inside a
@@ -696,7 +761,7 @@ impl TrashUseCase for TrashService {
let trashed_file_ids: Vec<String> = if self.file_deleted_hook.is_some() {
match self
.trash_repository
.get_all_trashed_file_ids(&user_id)
.get_all_trashed_file_ids(&drive_ids)
.await
{
Ok(ids) => ids,
@@ -709,16 +774,14 @@ impl TrashUseCase for TrashService {
Vec::new()
};
// clear_trash() performs bulk SQL DELETEs in 2 queries:
// 1. DELETE FROM storage.files WHERE user_id = $1 AND is_trashed = TRUE
// 2. DELETE FROM storage.folders WHERE user_id = $1 AND is_trashed = TRUE
// clear_trash() performs bulk SQL DELETEs in 2 queries (post D2b stage 3):
// 1. DELETE FROM storage.files WHERE drive_id = ANY($1) AND is_trashed = TRUE
// 2. DELETE FROM storage.folders WHERE drive_id = ANY($1) AND is_trashed = TRUE
//
// Folder deletion cascades (FK ON DELETE CASCADE) to child folders and
// their files. The PG trigger `trg_files_decrement_blob_ref` automatically
// decrements blob ref_counts for every deleted file row.
//
// Finally it clears the trash_items index for the user.
self.trash_repository.clear_trash(&user_id).await?;
self.trash_repository.clear_trash(&drive_ids).await?;
// The PG trigger decremented ref_counts but cannot delete disk files or
// thumbnails. Run garbage_collect() to remove any blobs whose ref_count
@@ -766,11 +829,32 @@ impl TrashService {
kinds: Option<&[ResourceKind]>,
reverse: bool,
) -> Result<(Vec<TrashResourceItemDto>, Option<String>)> {
// D2b: scope by drives the caller can read (resolved through
// role_grants on resource_type='drive', including group-mediated
// grants). Empty set → empty page without a SQL round-trip.
let (subject_types, subject_ids) = self
.authz
.expand_subject_for_listing(Subject::User(user_id))
.await?;
let drive_ids: Vec<Uuid> = match self
.drive_repo
.list_for_subjects(&subject_types, &subject_ids)
.await
{
Ok(drives) => drives.into_iter().map(|d| d.drive.id).collect(),
Err(e) => {
return Err(DomainError::internal_error(
"Trash",
format!("Failed to resolve accessible drives: {e:?}"),
));
}
};
// Fetch one extra row to detect whether a next page exists.
let mut rows = self
.trash_repository
.list_resources_paged(
user_id,
&drive_ids,
limit + 1,
cursor.as_ref(),
order_by,
@@ -823,10 +907,10 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto {
path,
parent_id: row.parent_id.map(|u| u.to_string()),
owner_id: Some(row.owner_id.to_string()),
// Trash listing — drive_id is informational and the trash
// row doesn't currently SELECT it. Path-based lookups
// never enter this code path.
drive_id: uuid::Uuid::nil(),
// D2b: the trash listing query now SELECTs `drive_id` (the
// unified view exposes it). Surfaced so per-drive grouping
// in the `/trash` UI doesn't need an extra lookup per row.
drive_id: row.drive_id,
created_at: row.resource_created_at.timestamp() as u64,
modified_at: row.modified_at.timestamp() as u64,
is_root: false,
@@ -841,6 +925,7 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto {
resource_type: ResourceTypeDto::Folder,
trashed_at: row.trashed_at,
deletion_date: row.deletion_date,
drive_id: row.drive_id,
resource: ResourceContentDto::Folder(dto),
}
} else {
@@ -884,6 +969,7 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto {
resource_type: ResourceTypeDto::File,
trashed_at: row.trashed_at,
deletion_date: row.deletion_date,
drive_id: row.drive_id,
resource: ResourceContentDto::File(dto),
}
}
+17 -18
View File
@@ -203,10 +203,7 @@ where
let trash_uuid = Uuid::parse_str(trash_id)
.map_err(|e| DomainError::validation_error(format!("Invalid trash ID: {}", e)))?;
let item = self
.trash_repository
.get_trash_item(&trash_uuid, &user_id)
.await?;
let item = self.trash_repository.get_trash_item(&trash_uuid).await?;
match item {
Some(item) => {
match item.item_type() {
@@ -265,10 +262,7 @@ where
let trash_uuid = Uuid::parse_str(trash_id)
.map_err(|e| DomainError::validation_error(format!("Invalid trash ID: {}", e)))?;
let item = self
.trash_repository
.get_trash_item(&trash_uuid, &user_id)
.await?;
let item = self.trash_repository.get_trash_item(&trash_uuid).await?;
match item {
Some(item) => {
match item.item_type() {
@@ -319,7 +313,11 @@ where
}
async fn empty_trash(&self, user_id: Uuid) -> Result<()> {
self.trash_repository.clear_trash(&user_id).await
// Test mock — treats `user_id` as a stand-in for the single accessible
// drive (the mock storage is single-user / single-drive). The
// production `TrashService::empty_trash` resolves the real drive set
// via `drive_repo.list_for_subjects` + role-bundle filter.
self.trash_repository.clear_trash(&[user_id]).await
}
}
@@ -364,13 +362,11 @@ impl TrashRepository for MockTrashRepository {
Ok(user_items)
}
async fn get_trash_item(&self, id: &Uuid, user_id: &Uuid) -> Result<Option<TrashedItem>> {
async fn get_trash_item(&self, id: &Uuid) -> Result<Option<TrashedItem>> {
// Mock mirrors the production repo's no-filter lookup — the
// user-scoped check has moved into the service's `authz.require`.
let items = self.trash_items.lock().unwrap();
let item = items
.get(id)
.filter(|item| item.user_id() == *user_id)
.cloned();
Ok(item)
Ok(items.get(id).cloned())
}
async fn restore_from_trash(&self, id: &Uuid, user_id: &Uuid) -> Result<()> {
@@ -393,16 +389,19 @@ impl TrashRepository for MockTrashRepository {
Ok(())
}
async fn clear_trash(&self, user_id: &Uuid) -> Result<()> {
async fn clear_trash(&self, drive_ids: &[Uuid]) -> Result<()> {
// Test mock: the single-user/single-drive mock uses `user_id` as
// the drive proxy (see `empty_trash` in the service mock). Filter
// trash items whose owner is in the passed-in set.
let mut items = self.trash_items.lock().unwrap();
items.retain(|_, item| item.user_id() != *user_id);
items.retain(|_, item| !drive_ids.contains(&item.user_id()));
// Simulate PG CASCADE: clear trashed file/folder storage too
self.trashed_files.lock().unwrap().clear();
self.trashed_folders.lock().unwrap().clear();
Ok(())
}
async fn get_all_trashed_file_ids(&self, _user_id: &Uuid) -> Result<Vec<String>> {
async fn get_all_trashed_file_ids(&self, _drive_ids: &[Uuid]) -> Result<Vec<String>> {
let files = self.trashed_files.lock().unwrap();
Ok(files.keys().cloned().collect())
}
+3 -1
View File
@@ -768,6 +768,7 @@ impl AppServiceFactory {
repos: &RepositoryServices,
core: &CoreServices,
authz: &Arc<PgAclEngine>,
drive_repo: &Arc<crate::infrastructure::repositories::pg::DrivePgRepository>,
) -> Option<Arc<TrashService>> {
if !self.config.features.enable_trash {
tracing::info!("Trash service is disabled in configuration");
@@ -787,6 +788,7 @@ impl AppServiceFactory {
core.dedup_service.clone(),
Some(core.file_content_cache.clone()),
authz.clone(),
drive_repo.clone(),
)
.with_file_deleted_hook(core.file_lifecycle.clone()),
);
@@ -1137,7 +1139,7 @@ impl AppServiceFactory {
// 3b. Trash service (needed before application services)
let trash_service = self
.create_trash_service(&repos, &core, &authorization)
.create_trash_service(&repos, &core, &authorization, &drive_repo)
.await;
// 3c. Storage usage / quota service (needed by the instant-upload
+28 -6
View File
@@ -6,15 +6,37 @@ use crate::domain::entities::trashed_item::TrashedItem;
pub trait TrashRepository: Send + Sync {
async fn add_to_trash(&self, item: &TrashedItem) -> Result<()>;
async fn get_trash_items(&self, user_id: &Uuid) -> Result<Vec<TrashedItem>>;
async fn get_trash_item(&self, id: &Uuid, user_id: &Uuid) -> Result<Option<TrashedItem>>;
/// Fetch a trashed item by its trash-row id.
///
/// **Caller contract**: this method does NO authorization check. Callers
/// MUST follow with
/// `authz.require(Permission::Delete, Resource::File|Folder(item.original_id()))`
/// before acting on the result. The trash service's `restore_item` and
/// `delete_permanently` are the canonical examples.
///
/// **Implementor contract**: do NOT re-introduce a `user_id` (or
/// `drive_id`) scope filter at the SQL layer. Authorization is the
/// service's job, not the repository's — adding a scope here would
/// silently break drive-Owner-restores-another-user's-trashed-item
/// (the canonical D2 use case). A direct-by-id lookup is the
/// intended shape.
async fn get_trash_item(&self, id: &Uuid) -> Result<Option<TrashedItem>>;
async fn restore_from_trash(&self, id: &Uuid, user_id: &Uuid) -> Result<()>;
async fn delete_permanently(&self, id: &Uuid, user_id: &Uuid) -> Result<()>;
async fn clear_trash(&self, user_id: &Uuid) -> Result<()>;
/// Bulk-delete all trashed files and folders in the given drives.
///
/// **Caller contract**: pass only drive UUIDs the caller has
/// `Permission::Delete` on (resolved by the service via
/// `DriveRepository::list_for_subjects` + role-bundle filter). This
/// repository performs no authorization — see
/// `TrashService::empty_trash` for the canonical call site.
async fn clear_trash(&self, drive_ids: &[Uuid]) -> Result<()>;
/// All trashed file IDs for this user, regardless of parent folder trash status.
/// Used by empty_trash for thumbnail cleanup — the view used by get_trash_items
/// excludes files inside trashed folders, which would miss their ext thumbnails.
async fn get_all_trashed_file_ids(&self, user_id: &Uuid) -> Result<Vec<String>>;
/// All trashed file IDs across the given drives, regardless of parent
/// folder trash status. Used by `empty_trash` for thumbnail cleanup —
/// the trash_items view excludes files inside trashed folders, which
/// would miss their thumbnails. Same caller contract as `clear_trash`.
async fn get_all_trashed_file_ids(&self, drive_ids: &[Uuid]) -> Result<Vec<String>>;
/// Bulk-delete all expired trash items (files + folders) in a single
/// transaction. Returns `(files_deleted, folders_deleted)`.
@@ -147,18 +147,22 @@ impl TrashRepository for TrashDbRepository {
.collect())
}
async fn get_trash_item(&self, id: &Uuid, user_id: &Uuid) -> Result<Option<TrashedItem>> {
async fn get_trash_item(&self, id: &Uuid) -> Result<Option<TrashedItem>> {
// D2b stage 3: lookup by id only — the `user_id` filter that used to
// gate this query is replaced by an explicit `authz.require(Delete,
// …)` in the service callers (`restore_item`, `delete_permanently`).
// The drive precheck in `pg_acl_engine` then resolves Owner-on-drive
// → Delete-permission for items in shared drives.
let row = sqlx::query_as::<_, (Uuid, String, String, Uuid, Option<DateTime<Utc>>, String)>(
r#"
SELECT t.id, t.name, t.item_type, t.user_id, t.trashed_at,
COALESCE(p.path || '/' || t.name, t.name) AS original_path
FROM storage.trash_items t
LEFT JOIN storage.folders p ON p.id = t.original_parent_id
WHERE t.id = $1 AND t.user_id = $2
WHERE t.id = $1
"#,
)
.bind(id)
.bind(user_id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("TrashDb", format!("get: {e}")))?;
@@ -182,17 +186,22 @@ impl TrashRepository for TrashDbRepository {
Ok(())
}
async fn clear_trash(&self, user_id: &Uuid) -> Result<()> {
// Delete all trashed files for this user
sqlx::query("DELETE FROM storage.files WHERE user_id = $1 AND is_trashed = TRUE")
.bind(user_id)
async fn clear_trash(&self, drive_ids: &[Uuid]) -> Result<()> {
if drive_ids.is_empty() {
return Ok(());
}
// Delete all trashed files in the given drives.
sqlx::query("DELETE FROM storage.files WHERE drive_id = ANY($1) AND is_trashed = TRUE")
.bind(drive_ids)
.execute(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("TrashDb", format!("clear files: {e}")))?;
// Delete all trashed folders for this user
sqlx::query("DELETE FROM storage.folders WHERE user_id = $1 AND is_trashed = TRUE")
.bind(user_id)
// Delete all trashed folders in the given drives. FK ON DELETE CASCADE
// sweeps descendant rows; the `trg_files_decrement_blob_ref` trigger
// handles blob refcount drops automatically.
sqlx::query("DELETE FROM storage.folders WHERE drive_id = ANY($1) AND is_trashed = TRUE")
.bind(drive_ids)
.execute(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("TrashDb", format!("clear folders: {e}")))?;
@@ -200,11 +209,14 @@ impl TrashRepository for TrashDbRepository {
Ok(())
}
async fn get_all_trashed_file_ids(&self, user_id: &Uuid) -> Result<Vec<String>> {
async fn get_all_trashed_file_ids(&self, drive_ids: &[Uuid]) -> Result<Vec<String>> {
if drive_ids.is_empty() {
return Ok(Vec::new());
}
let rows = sqlx::query_scalar::<_, String>(
"SELECT id::text FROM storage.files WHERE user_id = $1 AND is_trashed = TRUE",
"SELECT id::text FROM storage.files WHERE drive_id = ANY($1) AND is_trashed = TRUE",
)
.bind(user_id)
.bind(drive_ids)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("TrashDb", format!("all_trashed_files: {e}")))?;
@@ -253,23 +265,34 @@ impl TrashRepository for TrashDbRepository {
// Cursor-paginated trash listing (used by GET /api/trash/resources)
// ════════════════════════════════════════════════════════════════════════════
impl TrashDbRepository {
/// Cursor-paginated list of the user's trashed resources.
/// Cursor-paginated list of trashed resources the caller can read.
///
/// D2b: scope is drive-membership-based — pass the set of drive UUIDs
/// the caller can read (resolved upstream by `DriveRepository::list_for_subjects`
/// or equivalent). Items in drives outside this set drop out at the
/// WHERE clause. The legacy `WHERE user_id = $1::uuid` filter is gone;
/// for single-drive users this returns exactly the same items as before
/// because the caller's own personal drive is always in `drive_ids`.
///
/// Mirrors the favorites/grants pattern: a UNION-ALL CTE over folder and
/// file branches (each pre-computing sort columns), then a per-dimension
/// keyset WHERE + ORDER BY.
///
/// Returns rows in caller-requested sort order. The caller is expected to
/// fetch `limit + 1` to detect end-of-results.
/// fetch `limit + 1` to detect end-of-results. Empty `drive_ids` returns
/// an empty page without hitting PG.
pub async fn list_resources_paged(
&self,
user_id: Uuid,
drive_ids: &[Uuid],
limit: usize,
cursor: Option<&TrashCursor>,
order_by: &str,
kinds: Option<&[ResourceKind]>,
reverse: bool,
) -> Result<Vec<TrashResourceRow>> {
if drive_ids.is_empty() {
return Ok(Vec::new());
}
let include_folders =
kinds.is_none_or(|k| k.iter().any(|r| matches!(r, ResourceKind::Folder)));
let include_files = kinds.is_none_or(|k| k.iter().any(|r| matches!(r, ResourceKind::File)));
@@ -291,6 +314,7 @@ impl TrashDbRepository {
fld.created_at AS resource_created_at,
fld.updated_at AS modified_at,
fld.user_id AS owner_id,
fld.drive_id AS drive_id,
NULL::text AS blob_hash,
fld.trashed_at AS trashed_at,
(fld.trashed_at + ($7::int * INTERVAL '1 day')) AS deletion_date,
@@ -299,7 +323,7 @@ impl TrashDbRepository {
0::bigint AS type_order,
0::int AS folder_first
FROM storage.folders fld
WHERE fld.user_id = $1::uuid
WHERE fld.drive_id = ANY($1)
AND fld.is_trashed = TRUE
AND (fld.parent_id IS NULL
OR NOT EXISTS (
@@ -317,6 +341,7 @@ impl TrashDbRepository {
f.created_at AS resource_created_at,
f.updated_at AS modified_at,
f.user_id AS owner_id,
f.drive_id AS drive_id,
f.blob_hash,
f.trashed_at AS trashed_at,
(f.trashed_at + ($7::int * INTERVAL '1 day')) AS deletion_date,
@@ -327,7 +352,7 @@ impl TrashDbRepository {
FROM storage.files f
LEFT JOIN storage.folders pfld
ON pfld.id = f.folder_id
WHERE f.user_id = $1::uuid
WHERE f.drive_id = ANY($1)
AND f.is_trashed = TRUE
AND (f.folder_id IS NULL
OR NOT EXISTS (
@@ -447,7 +472,7 @@ impl TrashDbRepository {
SELECT
r.resource_type, r.resource_id, r.name, r.parent_id,
r.mime_type, r.size, r.resource_created_at, r.modified_at,
r.owner_id, r.trashed_at, r.deletion_date, r.resource_path,
r.owner_id, r.drive_id, r.trashed_at, r.deletion_date, r.resource_path,
r.sort_str, r.type_order, r.folder_first
FROM resources r
{keyset}
@@ -456,7 +481,7 @@ LIMIT $6"
);
let rows = sqlx::query(&sql)
.bind(user_id) // $1
.bind(drive_ids) // $1 (was user_id pre-D2b)
.bind(cur_str) // $2
.bind(cur_int) // $3
.bind(cur_ts) // $4
@@ -505,6 +530,7 @@ LIMIT $6"
resource_created_at: row.get("resource_created_at"),
modified_at: row.get("modified_at"),
owner_id: row.get("owner_id"),
drive_id: row.get("drive_id"),
blob_hash: row.try_get("blob_hash").ok(),
trashed_at,
deletion_date,