Skip to main content

cloudillo_types/
meta_adapter.rs

1// SPDX-FileCopyrightText: Szilárd Hajba
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4//! Adapter that manages metadata. Everything including tenants, profiles, actions, file metadata, etc.
5
6/// Special parent_id value for trashed files
7pub const TRASH_PARENT_ID: &str = "__trash__";
8
9use async_trait::async_trait;
10use serde::{Deserialize, Serialize};
11use serde_with::skip_serializing_none;
12use std::{cmp::Ordering, collections::HashMap, fmt::Debug};
13
14use crate::{
15	prelude::*,
16	types::{serialize_timestamp_iso, serialize_timestamp_iso_opt},
17};
18
19// Tenants, profiles
20//*******************
21#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
22pub enum ProfileType {
23	#[default]
24	#[serde(rename = "person")]
25	Person,
26	#[serde(rename = "community")]
27	Community,
28}
29
30#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
31pub enum ProfileStatus {
32	#[serde(rename = "A")]
33	Active,
34	#[serde(rename = "T")]
35	Trusted,
36	#[serde(rename = "B")]
37	Blocked,
38	#[serde(rename = "M")]
39	Muted,
40	#[serde(rename = "S")]
41	Suspended,
42	#[serde(rename = "X")]
43	Banned,
44}
45
46impl ProfileStatus {
47	/// Lowercase string form for JSON DTO exposure to the frontend.
48	pub fn as_str(&self) -> &'static str {
49		match self {
50			ProfileStatus::Active => "active",
51			ProfileStatus::Trusted => "trusted",
52			ProfileStatus::Blocked => "blocked",
53			ProfileStatus::Muted => "muted",
54			ProfileStatus::Suspended => "suspended",
55			ProfileStatus::Banned => "banned",
56		}
57	}
58}
59
60/// Per-profile proxy-token preference for passive reads of a remote profile's content.
61/// Absent (NULL) means ask the user at the time of access.
62#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
63#[serde(rename_all = "lowercase")]
64pub enum ProfileTrust {
65	/// Always authenticate via proxy token when accessing this profile.
66	Always,
67	/// Never authenticate; always access anonymously.
68	Never,
69}
70
71#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
72pub enum ProfileConnectionStatus {
73	#[default]
74	Disconnected,
75	RequestPending,
76	Connected,
77}
78
79impl ProfileConnectionStatus {
80	pub fn is_connected(&self) -> bool {
81		matches!(self, ProfileConnectionStatus::Connected)
82	}
83}
84
85impl std::fmt::Display for ProfileConnectionStatus {
86	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87		match self {
88			ProfileConnectionStatus::Disconnected => write!(f, "disconnected"),
89			ProfileConnectionStatus::RequestPending => write!(f, "pending"),
90			ProfileConnectionStatus::Connected => write!(f, "connected"),
91		}
92	}
93}
94
95// Reference / Bookmark types
96//*****************************
97
98#[skip_serializing_none]
99#[derive(Debug, Clone, Serialize)]
100#[serde(rename_all = "camelCase")]
101pub struct RefData {
102	pub ref_id: Box<str>,
103	pub r#type: Box<str>,
104	pub description: Option<Box<str>>,
105	#[serde(serialize_with = "serialize_timestamp_iso")]
106	pub created_at: Timestamp,
107	#[serde(serialize_with = "serialize_timestamp_iso_opt")]
108	pub expires_at: Option<Timestamp>,
109	/// Usage count: None = unlimited, Some(n) = n uses remaining
110	pub count: Option<u32>,
111	/// Resource ID for share links (e.g., file_id for share.file type)
112	pub resource_id: Option<Box<str>>,
113	/// Access level for share links ('R'=Read, 'W'=Write)
114	pub access_level: Option<char>,
115	/// Launch params as serialized query string (e.g., "mode=present")
116	pub params: Option<Box<str>>,
117}
118
119pub struct ListRefsOptions {
120	pub typ: Option<String>,
121	pub filter: Option<String>, // 'active', 'used', 'expired', 'all'
122	/// Filter by resource_id (for listing share links for a specific resource)
123	pub resource_id: Option<String>,
124}
125
126#[derive(Default)]
127pub struct CreateRefOptions {
128	pub typ: String,
129	pub description: Option<String>,
130	pub expires_at: Option<Timestamp>,
131	pub count: Option<u32>,
132	/// Resource ID for share links (e.g., file_id for share.file type)
133	pub resource_id: Option<String>,
134	/// Access level for share links ('R'=Read, 'W'=Write)
135	pub access_level: Option<char>,
136	/// Launch params as serialized query string (e.g., "mode=present")
137	pub params: Option<String>,
138}
139
140#[skip_serializing_none]
141#[derive(Debug, Serialize)]
142#[serde(rename_all = "camelCase")]
143pub struct Tenant<S: AsRef<str>> {
144	#[serde(rename = "id")]
145	pub tn_id: TnId,
146	pub id_tag: S,
147	pub name: S,
148	#[serde(rename = "type")]
149	pub typ: ProfileType,
150	pub profile_pic: Option<S>,
151	pub cover_pic: Option<S>,
152	#[serde(serialize_with = "serialize_timestamp_iso")]
153	pub created_at: Timestamp,
154	pub x: HashMap<S, S>,
155}
156
157/// Options for listing tenants in meta adapter
158#[derive(Debug, Default)]
159pub struct ListTenantsMetaOptions {
160	pub limit: Option<u32>,
161	pub offset: Option<u32>,
162}
163
164/// Tenant list item from meta adapter (without cover_pic and x fields)
165#[skip_serializing_none]
166#[derive(Debug, Clone, Serialize)]
167#[serde(rename_all = "camelCase")]
168pub struct TenantListMeta {
169	pub tn_id: TnId,
170	pub id_tag: Box<str>,
171	pub name: Box<str>,
172	#[serde(rename = "type")]
173	pub typ: ProfileType,
174	pub profile_pic: Option<Box<str>>,
175	#[serde(serialize_with = "serialize_timestamp_iso")]
176	pub created_at: Timestamp,
177}
178
179#[derive(Debug, Default, Deserialize)]
180pub struct UpdateTenantData {
181	#[serde(rename = "idTag", default)]
182	pub id_tag: Patch<String>,
183	#[serde(default)]
184	pub name: Patch<String>,
185	#[serde(rename = "type", default)]
186	pub typ: Patch<ProfileType>,
187	#[serde(rename = "profilePic", default)]
188	pub profile_pic: Patch<String>,
189	#[serde(rename = "coverPic", default)]
190	pub cover_pic: Patch<String>,
191	/// Partial merge for x JSON field: Some(value) = upsert, None = delete key
192	#[serde(default)]
193	pub x: Option<std::collections::HashMap<String, Option<String>>>,
194}
195
196#[derive(Debug)]
197pub struct Profile<S: AsRef<str>> {
198	pub id_tag: S,
199	pub name: S,
200	pub typ: ProfileType,
201	pub profile_pic: Option<S>,
202	pub status: Option<ProfileStatus>,
203	pub synced_at: Option<Timestamp>,
204	pub following: bool,
205	pub connected: ProfileConnectionStatus,
206	pub roles: Option<Box<[Box<str>]>>,
207	pub trust: Option<ProfileTrust>,
208}
209
210#[derive(Debug, Default, Deserialize)]
211pub struct ListProfileOptions {
212	#[serde(rename = "type")]
213	pub typ: Option<ProfileType>,
214	pub status: Option<Box<[ProfileStatus]>>,
215	pub connected: Option<ProfileConnectionStatus>,
216	pub following: Option<bool>,
217	pub q: Option<String>,
218	pub id_tag: Option<String>,
219	/// Filter profiles by whether a trust preference is set.
220	/// `Some(true)` returns only profiles with a non-null trust value;
221	/// `Some(false)` returns only profiles with NULL trust; `None` does not filter.
222	pub trust_set: Option<bool>,
223}
224
225/// Profile data returned from adapter queries
226#[derive(Debug, Clone, Serialize, Deserialize)]
227#[serde(rename_all = "camelCase")]
228pub struct ProfileData {
229	pub id_tag: Box<str>,
230	pub name: Box<str>,
231	#[serde(rename = "type")]
232	pub r#type: Box<str>, // "person" or "community"
233	pub profile_pic: Option<Box<str>>,
234	/// Federation lifecycle: "active" | "trusted" | "suspended" | "blocked" | "muted" | "banned"
235	#[serde(default, skip_serializing_if = "Option::is_none")]
236	pub status: Option<Box<str>>,
237	#[serde(serialize_with = "serialize_timestamp_iso")]
238	pub created_at: Timestamp,
239}
240
241/// List of profiles response
242#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct ProfileList {
244	pub profiles: Vec<ProfileData>,
245	pub total: usize,
246	pub limit: usize,
247	pub offset: usize,
248}
249
250#[derive(Debug, Default, Deserialize)]
251pub struct UpdateProfileData {
252	// Profile content fields
253	#[serde(default)]
254	pub name: Patch<Box<str>>,
255	#[serde(default, rename = "profilePic")]
256	pub profile_pic: Patch<Option<Box<str>>>,
257	#[serde(default)]
258	pub roles: Patch<Option<Vec<Box<str>>>>,
259
260	// Status and moderation
261	#[serde(default)]
262	pub status: Patch<ProfileStatus>,
263
264	// Relationship fields
265	#[serde(default)]
266	pub synced: Patch<bool>,
267	#[serde(default)]
268	pub following: Patch<bool>,
269	#[serde(default)]
270	pub connected: Patch<ProfileConnectionStatus>,
271	#[serde(default)]
272	pub trust: Patch<ProfileTrust>,
273
274	// Sync metadata
275	#[serde(default)]
276	pub etag: Patch<Box<str>>,
277}
278
279/// Outcome of an `upsert_profile` call.
280#[derive(Debug, Clone, Copy, PartialEq, Eq)]
281pub enum UpsertResult {
282	/// The profile row did not exist and was inserted.
283	Created,
284	/// The profile row existed and was updated.
285	Updated,
286}
287
288/// Fields for `MetaAdapter::upsert_profile`.
289///
290/// All fields are `Patch` and apply to both INSERT and UPDATE:
291/// * `Patch::Value(v)` / `Patch::Null` → set the column on both branches.
292/// * `Patch::Undefined` → leave the column at its current value on UPDATE,
293///   and use the column default (NULL or `""` for `name`) on INSERT.
294///
295/// **Note on the INSERT branch:** `Patch::Null` and `Patch::Undefined`
296/// collapse to the same column default for most fields — the INSERT can't
297/// distinguish "user explicitly set to NULL" from "user didn't touch this
298/// field." This is fine semantically (both mean "no value here"), but
299/// differs from UPDATE, which preserves the existing value on `Undefined`.
300///
301/// **Stub-row idiom:** `upsert_profile` creates a row with `type = NULL`
302/// when `typ` is `Patch::Undefined`. These stub rows are filtered out of
303/// `list_profiles` (which requires `type IS NOT NULL`), but `read_profile` /
304/// `get_info` will return `Error::NotFound` for them. This is intentional:
305/// relationship hooks (FOLLOW, FSHR) create stubs first and federation sync
306/// populates `type` later. Callers performing read-then-write should not
307/// rely on `read_profile` finding a freshly-inserted stub.
308#[derive(Default)]
309pub struct UpsertProfileFields {
310	pub name: Patch<Box<str>>,
311	pub typ: Patch<ProfileType>,
312	pub profile_pic: Patch<Option<Box<str>>>,
313	pub roles: Patch<Option<Vec<Box<str>>>>,
314	pub status: Patch<ProfileStatus>,
315	pub synced: Patch<bool>,
316	pub following: Patch<bool>,
317	pub connected: Patch<ProfileConnectionStatus>,
318	pub trust: Patch<ProfileTrust>,
319	pub etag: Patch<Box<str>>,
320}
321
322impl UpsertProfileFields {
323	/// Build an `UpsertProfileFields` from an existing `UpdateProfileData`.
324	///
325	/// `typ` is left `Undefined` — callers that know the profile type should
326	/// set it explicitly.
327	pub fn from_update(update: UpdateProfileData) -> Self {
328		Self {
329			name: update.name,
330			typ: Patch::Undefined,
331			profile_pic: update.profile_pic,
332			roles: update.roles,
333			status: update.status,
334			synced: update.synced,
335			following: update.following,
336			connected: update.connected,
337			trust: update.trust,
338			etag: update.etag,
339		}
340	}
341}
342
343// Actions
344//*********
345
346/// Additional action data (cached counts/stats)
347#[derive(Debug, Clone)]
348pub struct ActionData {
349	pub subject: Option<Box<str>>,
350	pub reactions: Option<Box<str>>,
351	pub comments: Option<u32>,
352}
353
354/// Options for updating action metadata
355#[derive(Debug, Clone, Default)]
356pub struct UpdateActionDataOptions {
357	pub subject: Patch<String>,
358	pub reactions: Patch<String>,
359	pub comments: Patch<u32>,
360	pub comments_read: Patch<u32>,
361	pub status: Patch<char>,
362	pub visibility: Patch<char>,
363	pub x: Patch<serde_json::Value>, // Extensible metadata (x.role for SUBS, etc.)
364	pub content: Patch<String>,
365	pub attachments: Patch<String>, // Comma-separated list of attachment IDs
366	pub flags: Patch<String>,
367	pub sub_typ: Patch<String>,
368	/// Dual-purpose for actions in status `R` (draft) or `S` (scheduled): the
369	/// `actions.created_at` column holds the target publish instant, not the
370	/// row's actual creation time. PATCH /actions, `publish_draft`, and
371	/// `task::handle_create_action` all rely on this overload. For any other
372	/// status, leave this `Patch::Undefined` — overwriting `created_at` on a
373	/// finalized (`A`) action would corrupt the timeline.
374	pub created_at: Patch<Timestamp>,
375}
376
377/// Options for finalizing an action (resolved fields from ActionCreatorTask)
378#[derive(Debug, Clone, Default)]
379pub struct FinalizeActionOptions<'a> {
380	pub attachments: Option<&'a [&'a str]>,
381	pub subject: Option<&'a str>,
382	pub audience_tag: Option<&'a str>,
383	pub key: Option<&'a str>,
384}
385
386fn deserialize_split<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
387where
388	D: serde::Deserializer<'de>,
389{
390	let s = String::deserialize(deserializer)?;
391	let values: Vec<String> =
392		s.split(',').map(|v| v.trim().to_string()).filter(|v| !v.is_empty()).collect();
393	if values.is_empty() { Ok(None) } else { Ok(Some(values)) }
394}
395
396/// Audience filter axis: classify actions by the **type of the effective wall
397/// owner** (`coalesce(audience, issuer_tag)` joined to `profiles.type`).
398/// `Personal` matches `pa.type='P'` (with NULL→Personal fallback for unknown
399/// remote profiles). `Community` matches `pa.type='C'`.
400/// Combines with `audience` (specific community) as AND.
401#[derive(Debug, Clone, Copy, Deserialize)]
402#[serde(rename_all = "lowercase")]
403pub enum AudienceType {
404	Personal,
405	Community,
406}
407
408/// Options for listing actions
409#[derive(Debug, Default, Deserialize)]
410#[serde(deny_unknown_fields)]
411pub struct ListActionOptions {
412	/// Maximum number of items to return (default: 20)
413	pub limit: Option<u32>,
414	/// Cursor for pagination (opaque base64-encoded string)
415	pub cursor: Option<String>,
416	/// Sort order: 'created' (default, created_at DESC)
417	pub sort: Option<String>,
418	/// Sort direction: 'asc' or 'desc' (default: desc)
419	#[serde(rename = "sortDir")]
420	pub sort_dir: Option<String>,
421	#[serde(default, rename = "type", deserialize_with = "deserialize_split")]
422	pub typ: Option<Vec<String>>,
423	#[serde(default, deserialize_with = "deserialize_split")]
424	pub status: Option<Vec<String>>,
425	pub tag: Option<String>,
426	pub search: Option<String>,
427	#[serde(default, deserialize_with = "deserialize_split")]
428	pub visibility: Option<Vec<String>>,
429	pub issuer: Option<String>,
430	pub audience: Option<String>,
431	#[serde(rename = "audienceType")]
432	pub audience_type: Option<AudienceType>,
433	pub involved: Option<String>,
434	/// The authenticated user's id_tag (set by handler, not from query params)
435	#[serde(skip)]
436	pub viewer_id_tag: Option<String>,
437	#[serde(rename = "actionId")]
438	pub action_id: Option<String>,
439	#[serde(rename = "parentId")]
440	pub parent_id: Option<String>,
441	#[serde(rename = "rootId")]
442	pub root_id: Option<String>,
443	pub subject: Option<String>,
444	#[serde(rename = "createdAfter")]
445	pub created_after: Option<Timestamp>,
446}
447
448#[skip_serializing_none]
449#[derive(Debug, Clone, Serialize)]
450pub struct ProfileInfo {
451	#[serde(rename = "idTag")]
452	pub id_tag: Box<str>,
453	pub name: Box<str>,
454	#[serde(rename = "type")]
455	pub typ: ProfileType,
456	#[serde(rename = "profilePic")]
457	pub profile_pic: Option<Box<str>>,
458}
459
460pub struct Action<S: AsRef<str>> {
461	pub action_id: S,
462	pub typ: S,
463	pub sub_typ: Option<S>,
464	pub issuer_tag: S,
465	pub parent_id: Option<S>,
466	pub root_id: Option<S>,
467	pub audience_tag: Option<S>,
468	pub content: Option<S>,
469	pub attachments: Option<Vec<S>>,
470	pub subject: Option<S>,
471	pub created_at: Timestamp,
472	pub expires_at: Option<Timestamp>,
473	pub visibility: Option<char>, // None: Direct, P: Public, V: Verified, 2: 2nd degree, F: Follower, C: Connected
474	pub flags: Option<S>,         // Action flags: R/r (reactions), C/c (comments), O/o (open)
475	pub x: Option<serde_json::Value>, // Extensible metadata (x.role for SUBS, etc.)
476}
477
478#[skip_serializing_none]
479#[derive(Debug, Clone, Serialize)]
480pub struct AttachmentView {
481	#[serde(rename = "fileId")]
482	pub file_id: Box<str>,
483	pub dim: Option<(u32, u32)>,
484	#[serde(rename = "localVariants")]
485	pub local_variants: Option<Vec<Box<str>>>,
486}
487
488#[skip_serializing_none]
489#[derive(Debug, Clone, Serialize)]
490#[serde(rename_all = "camelCase")]
491pub struct ActionView {
492	pub action_id: Box<str>,
493	#[serde(rename = "type")]
494	pub typ: Box<str>,
495	#[serde(rename = "subType")]
496	pub sub_typ: Option<Box<str>>,
497	pub parent_id: Option<Box<str>>,
498	pub root_id: Option<Box<str>>,
499	pub issuer: ProfileInfo,
500	pub audience: Option<ProfileInfo>,
501	pub content: Option<serde_json::Value>,
502	pub attachments: Option<Vec<AttachmentView>>,
503	pub subject: Option<Box<str>>,
504	pub subject_profile: Option<ProfileInfo>,
505	#[serde(serialize_with = "serialize_timestamp_iso")]
506	pub created_at: Timestamp,
507	#[serde(serialize_with = "serialize_timestamp_iso_opt")]
508	pub expires_at: Option<Timestamp>,
509	pub status: Option<Box<str>>,
510	pub stat: Option<serde_json::Value>,
511	pub visibility: Option<char>,
512	pub flags: Option<Box<str>>, // Action flags: R/r (reactions), C/c (comments), O/o (open)
513	pub x: Option<serde_json::Value>, // Extensible metadata (x.role for SUBS, etc.)
514}
515
516// Files
517//*******
518#[derive(Debug)]
519pub enum FileId<S: AsRef<str>> {
520	FileId(S),
521	FId(u64),
522}
523
524pub enum ActionId<S: AsRef<str>> {
525	ActionId(S),
526	AId(u64),
527}
528
529/// File status enum
530/// Note: Mutability is determined by fileTp (BLOB=immutable, CRDT/RTDB=mutable)
531#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
532pub enum FileStatus {
533	#[serde(rename = "A")]
534	Active,
535	#[serde(rename = "P")]
536	Pending,
537	#[serde(rename = "D")]
538	Deleted,
539}
540
541/// User-specific file metadata (access tracking, pinned/starred status)
542#[skip_serializing_none]
543#[derive(Debug, Clone, Default, Serialize)]
544#[serde(rename_all = "camelCase")]
545pub struct FileUserData {
546	#[serde(serialize_with = "serialize_timestamp_iso_opt")]
547	pub accessed_at: Option<Timestamp>,
548	#[serde(serialize_with = "serialize_timestamp_iso_opt")]
549	pub modified_at: Option<Timestamp>,
550	pub pinned: bool,
551	pub starred: bool,
552}
553
554#[skip_serializing_none]
555#[derive(Debug, Clone, Serialize)]
556#[serde(rename_all = "camelCase")]
557pub struct FileView {
558	pub file_id: Box<str>,
559	pub parent_id: Option<Box<str>>, // Parent folder file_id (None = root)
560	pub root_id: Option<Box<str>>,   // Document tree root file_id (None = standalone)
561	pub owner: Option<ProfileInfo>,
562	pub creator: Option<ProfileInfo>,
563	pub preset: Option<Box<str>>,
564	pub content_type: Option<Box<str>>,
565	pub file_name: Box<str>,
566	pub file_tp: Option<Box<str>>, // 'BLOB', 'CRDT', 'RTDB', 'FLDR'
567	#[serde(serialize_with = "serialize_timestamp_iso")]
568	pub created_at: Timestamp,
569	#[serde(serialize_with = "crate::types::serialize_timestamp_iso_opt")]
570	pub accessed_at: Option<Timestamp>, // Global: when anyone last accessed
571	#[serde(serialize_with = "crate::types::serialize_timestamp_iso_opt")]
572	pub modified_at: Option<Timestamp>, // Global: when anyone last modified
573	pub status: FileStatus,
574	pub tags: Option<Vec<Box<str>>>,
575	pub visibility: Option<char>, // None: Direct, P: Public, V: Verified, 2: 2nd degree, F: Follower, C: Connected
576	pub hidden: bool,
577	pub access_level: Option<crate::types::AccessLevel>, // User's access level to this file (R/W)
578	pub user_data: Option<FileUserData>, // User-specific data (only when authenticated)
579	pub x: Option<serde_json::Value>, // Extensible metadata (e.g., {"dim": [width, height]} for images)
580}
581
582#[skip_serializing_none]
583#[derive(Debug, Clone, Serialize)]
584pub struct FileVariant<S: AsRef<str> + Debug> {
585	#[serde(rename = "variantId")]
586	pub variant_id: S,
587	pub variant: S,
588	pub format: S,
589	pub size: u64,
590	pub resolution: (u32, u32),
591	pub available: bool,
592	/// Duration in seconds (for video/audio)
593	pub duration: Option<f64>,
594	/// Bitrate in kbps (for video/audio)
595	pub bitrate: Option<u32>,
596	/// Page count (for documents like PDF)
597	#[serde(rename = "pageCount")]
598	pub page_count: Option<u32>,
599}
600
601impl<S: AsRef<str> + Debug> PartialEq for FileVariant<S> {
602	fn eq(&self, other: &Self) -> bool {
603		self.variant_id.as_ref() == other.variant_id.as_ref()
604			&& self.variant.as_ref() == other.variant.as_ref()
605			&& self.format.as_ref() == other.format.as_ref()
606			&& self.size == other.size
607			&& self.resolution == other.resolution
608			&& self.available == other.available
609			&& self.duration == other.duration
610			&& self.bitrate == other.bitrate
611			&& self.page_count == other.page_count
612	}
613}
614
615impl<S: AsRef<str> + Debug> Eq for FileVariant<S> {}
616
617impl<S: AsRef<str> + Debug + Ord> PartialOrd for FileVariant<S> {
618	fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
619		Some(self.cmp(other))
620	}
621}
622
623impl<S: AsRef<str> + Debug + Ord> Ord for FileVariant<S> {
624	fn cmp(&self, other: &Self) -> Ordering {
625		self.size
626			.cmp(&other.size)
627			.then_with(|| self.resolution.0.cmp(&other.resolution.0))
628			.then_with(|| self.resolution.1.cmp(&other.resolution.1))
629			.then_with(|| self.variant.as_ref().cmp(other.variant.as_ref()))
630	}
631}
632
633/// Options for listing files
634///
635/// By default (when `status` is `None`), deleted files (status 'D') are excluded.
636/// To include deleted files, explicitly set `status` to `FileStatus::Deleted`.
637#[derive(Debug, Default, Deserialize)]
638#[serde(deny_unknown_fields)]
639pub struct ListFileOptions {
640	/// Maximum number of items to return (default: 30)
641	pub limit: Option<u32>,
642	/// Cursor for pagination (opaque base64-encoded string)
643	pub cursor: Option<String>,
644	#[serde(default, rename = "fileId", deserialize_with = "deserialize_split")]
645	pub file_id: Option<Vec<String>>,
646	#[serde(rename = "parentId")]
647	pub parent_id: Option<String>, // Filter by parent folder (None = root, "__trash__" = trash)
648	#[serde(rename = "rootId")]
649	pub root_id: Option<String>, // Filter by document tree root
650	pub tag: Option<String>,
651	pub preset: Option<String>,
652	pub variant: Option<String>,
653	/// File status filter. If None, excludes deleted files by default.
654	pub status: Option<FileStatus>,
655	#[serde(default, rename = "fileTp", deserialize_with = "deserialize_split")]
656	pub file_type: Option<Vec<String>>,
657	/// Filter by content type pattern (e.g., "image/*", "video/*")
658	#[serde(default, rename = "contentType", deserialize_with = "deserialize_split")]
659	pub content_type: Option<Vec<String>>,
660	/// Substring search in file name
661	#[serde(rename = "fileName")]
662	pub file_name: Option<String>,
663	/// Filter by owner id_tag
664	#[serde(rename = "ownerIdTag")]
665	pub owner_id_tag: Option<String>,
666	/// Exclude files by this owner id_tag
667	#[serde(rename = "notOwnerIdTag")]
668	pub not_owner_id_tag: Option<String>,
669	/// Filter by pinned status (user-specific)
670	pub pinned: Option<bool>,
671	/// Filter by starred status (user-specific)
672	pub starred: Option<bool>,
673	/// Hidden file filter. None = exclude hidden (default). Some(true) = only hidden.
674	pub hidden: Option<bool>,
675	/// Sort order: 'recent' (accessed_at), 'modified' (modified_at), 'name', 'created'
676	pub sort: Option<String>,
677	/// Sort direction: 'asc' or 'desc' (default: desc for dates, asc for name)
678	#[serde(rename = "sortDir")]
679	pub sort_dir: Option<String>,
680	/// User id_tag for user-specific data (set by handler, not from query)
681	#[serde(skip)]
682	pub user_id_tag: Option<String>,
683	/// Scope file_id filter: returns files matching this file_id OR having this root_id.
684	/// Overrides the normal root_id IS NULL constraint. Set by handler for scoped tokens.
685	#[serde(skip)]
686	pub scope_file_id: Option<String>,
687	/// Allowed visibility levels for SQL-level filtering (correct pagination).
688	/// None = no filter (owner sees all including NULL/Direct).
689	/// Set by handler based on subject's access level via `SubjectAccessLevel::visible_levels()`.
690	#[serde(skip)]
691	pub visible_levels: Option<Vec<char>>,
692}
693
694#[derive(Debug, Clone, Default)]
695pub struct CreateFile {
696	pub orig_variant_id: Option<Box<str>>,
697	pub file_id: Option<Box<str>>,
698	pub parent_id: Option<Box<str>>, // Parent folder file_id (None = root)
699	pub root_id: Option<Box<str>>,   // Document tree root file_id (None = standalone)
700	pub owner_tag: Option<Box<str>>, // Set only for files owned by someone OTHER than the tenant (e.g., shared files)
701	pub creator_tag: Option<Box<str>>, // The user who actually created the file
702	pub preset: Option<Box<str>>,
703	pub content_type: Box<str>,
704	pub file_name: Box<str>,
705	pub file_tp: Option<Box<str>>, // 'BLOB', 'CRDT', 'RTDB', 'FLDR' - defaults to 'BLOB'
706	pub created_at: Option<Timestamp>,
707	pub tags: Option<Vec<Box<str>>>,
708	pub x: Option<serde_json::Value>,
709	pub visibility: Option<char>, // None: Direct (default), P: Public, V: Verified, 2: 2nd degree, F: Follower, C: Connected
710	pub hidden: bool,
711	pub status: Option<FileStatus>, // None defaults to Pending, can set to Active for shared files
712}
713
714#[derive(Debug, Clone, Deserialize)]
715pub struct CreateFileVariant {
716	pub variant: Box<str>,
717	pub format: Box<str>,
718	pub resolution: (u32, u32),
719	pub size: u64,
720	pub available: bool,
721}
722
723/// Options for updating file metadata
724#[derive(Debug, Clone, Default, Deserialize)]
725pub struct UpdateFileOptions {
726	#[serde(default, rename = "fileName")]
727	pub file_name: Patch<String>,
728	#[serde(default, rename = "parentId")]
729	pub parent_id: Patch<String>, // Move file to different folder (null = root)
730	#[serde(default)]
731	pub visibility: Patch<char>,
732	#[serde(default)]
733	pub status: Patch<char>,
734	#[serde(default)]
735	pub hidden: Patch<bool>,
736}
737
738// Share Entries
739//**************
740
741#[skip_serializing_none]
742#[derive(Debug, Clone, Serialize)]
743#[serde(rename_all = "camelCase")]
744pub struct ShareEntry {
745	pub id: i64,
746	pub resource_type: char,
747	pub resource_id: Box<str>,
748	pub subject_type: char,
749	pub subject_id: Box<str>,
750	pub permission: char,
751	#[serde(serialize_with = "serialize_timestamp_iso_opt")]
752	pub expires_at: Option<Timestamp>,
753	pub created_by: Box<str>,
754	#[serde(serialize_with = "serialize_timestamp_iso")]
755	pub created_at: Timestamp,
756	// Enrichment fields (populated by JOINs in list_by_resource)
757	pub subject_file_name: Option<Box<str>>,
758	pub subject_content_type: Option<Box<str>>,
759	pub subject_file_tp: Option<Box<str>>,
760}
761
762#[derive(Debug, Deserialize)]
763#[serde(rename_all = "camelCase")]
764pub struct CreateShareEntry {
765	pub subject_type: char,
766	pub subject_id: String,
767	pub permission: char,
768	pub expires_at: Option<Timestamp>,
769}
770
771// Push Subscriptions
772//********************
773
774/// Web Push subscription data (RFC 8030)
775#[skip_serializing_none]
776#[derive(Debug, Clone, Serialize, Deserialize)]
777pub struct PushSubscriptionData {
778	/// Push endpoint URL
779	pub endpoint: String,
780	/// Expiration time (Unix timestamp, if provided by browser)
781	#[serde(rename = "expirationTime")]
782	pub expiration_time: Option<i64>,
783	/// Subscription keys (p256dh and auth)
784	pub keys: PushSubscriptionKeys,
785}
786
787/// Subscription keys for Web Push encryption
788#[derive(Debug, Clone, Serialize, Deserialize)]
789pub struct PushSubscriptionKeys {
790	/// P-256 public key for encryption (base64url encoded)
791	pub p256dh: String,
792	/// Authentication secret (base64url encoded)
793	pub auth: String,
794}
795
796/// Full push subscription record stored in database
797#[derive(Debug, Clone, Serialize)]
798#[serde(rename_all = "camelCase")]
799pub struct PushSubscription {
800	/// Unique subscription ID
801	pub id: u64,
802	/// The subscription data (endpoint, keys, etc.)
803	pub subscription: PushSubscriptionData,
804	/// When this subscription was created
805	#[serde(serialize_with = "serialize_timestamp_iso")]
806	pub created_at: Timestamp,
807}
808
809// Tasks
810//*******
811pub struct Task {
812	pub task_id: u64,
813	pub tn_id: TnId,
814	pub kind: Box<str>,
815	pub status: char,
816	pub created_at: Timestamp,
817	pub next_at: Option<Timestamp>,
818	pub input: Box<str>,
819	pub output: Box<str>,
820	pub deps: Box<[u64]>,
821	pub retry: Option<Box<str>>,
822	pub cron: Option<Box<str>>,
823}
824
825#[derive(Debug, Default)]
826pub struct TaskPatch {
827	pub input: Patch<String>,
828	pub next_at: Patch<Timestamp>,
829	pub deps: Patch<Vec<u64>>,
830	pub retry: Patch<String>,
831	pub cron: Patch<String>,
832}
833
834#[derive(Debug, Default)]
835pub struct ListTaskOptions {}
836
837// Installed Apps
838//***************
839
840/// Data for installing an app
841#[derive(Debug)]
842pub struct InstallApp {
843	pub app_name: Box<str>,
844	pub publisher_tag: Box<str>,
845	pub version: Box<str>,
846	pub action_id: Box<str>,
847	pub file_id: Box<str>,
848	pub blob_id: Box<str>,
849	pub capabilities: Option<Vec<Box<str>>>,
850}
851
852/// Installed app record
853#[derive(Debug, Serialize)]
854#[serde(rename_all = "camelCase")]
855pub struct InstalledApp {
856	pub app_name: Box<str>,
857	pub publisher_tag: Box<str>,
858	pub version: Box<str>,
859	pub action_id: Box<str>,
860	pub file_id: Box<str>,
861	pub blob_id: Box<str>,
862	pub status: Box<str>,
863	pub capabilities: Option<Vec<Box<str>>>,
864	pub auto_update: bool,
865	#[serde(serialize_with = "serialize_timestamp_iso")]
866	pub installed_at: Timestamp,
867}
868
869// Contacts / Address Books (CardDAV + JSON REST)
870//*************************************************
871
872/// Address book collection metadata
873#[derive(Debug, Clone, Serialize)]
874#[serde(rename_all = "camelCase")]
875pub struct AddressBook {
876	pub ab_id: u64,
877	pub name: Box<str>,
878	pub description: Option<Box<str>>,
879	/// Collection tag — changes on any contact mutation within this book (used by CardDAV sync)
880	pub ctag: Box<str>,
881	#[serde(serialize_with = "serialize_timestamp_iso")]
882	pub created_at: Timestamp,
883	#[serde(serialize_with = "serialize_timestamp_iso")]
884	pub updated_at: Timestamp,
885}
886
887#[derive(Debug, Default)]
888pub struct UpdateAddressBookData {
889	pub name: Patch<String>,
890	pub description: Patch<String>,
891}
892
893/// Indexed projection of a contact — lives in DB columns, parallel to the stored vCard blob.
894/// Used both for REST API responses (via the handler layer's JSON conversion) and for
895/// CardDAV `addressbook-query` REPORT text-match filtering.
896#[derive(Debug, Clone, Default)]
897pub struct ContactExtracted {
898	pub fn_name: Option<Box<str>>,
899	pub given_name: Option<Box<str>>,
900	pub family_name: Option<Box<str>>,
901	pub email: Option<Box<str>>,
902	pub emails: Option<Box<str>>,
903	pub tel: Option<Box<str>>,
904	pub tels: Option<Box<str>>,
905	pub org: Option<Box<str>>,
906	pub title: Option<Box<str>>,
907	pub note: Option<Box<str>>,
908	pub photo_uri: Option<Box<str>>,
909	pub profile_id_tag: Option<Box<str>>,
910}
911
912/// Full contact row including the authoritative stored vCard blob.
913#[derive(Debug, Clone)]
914pub struct Contact {
915	pub c_id: u64,
916	pub ab_id: u64,
917	pub uid: Box<str>,
918	pub etag: Box<str>,
919	pub vcard: Box<str>,
920	pub extracted: ContactExtracted,
921	pub created_at: Timestamp,
922	pub updated_at: Timestamp,
923}
924
925/// Contact summary without the vCard blob — for list endpoints (REST + CardDAV REPORTs that
926/// don't need the full body).
927#[derive(Debug, Clone)]
928pub struct ContactView {
929	pub c_id: u64,
930	pub ab_id: u64,
931	pub uid: Box<str>,
932	pub etag: Box<str>,
933	pub extracted: ContactExtracted,
934	pub created_at: Timestamp,
935	pub updated_at: Timestamp,
936}
937
938/// One entry in a CardDAV `sync-collection` REPORT response. Tombstones (`deleted: true`)
939/// let clients drop stale cards.
940#[derive(Debug, Clone)]
941pub struct ContactSyncEntry {
942	pub uid: Box<str>,
943	pub etag: Box<str>,
944	pub deleted: bool,
945	pub updated_at: Timestamp,
946}
947
948#[derive(Debug, Default)]
949pub struct ListContactOptions {
950	/// Free-text query — matches against fn_name, emails, tels (SQL LIKE).
951	pub q: Option<String>,
952	/// Opaque cursor for pagination.
953	pub cursor: Option<String>,
954	/// Page size.
955	pub limit: Option<u32>,
956}
957
958// Calendars / Calendar Objects (CalDAV + JSON REST)
959//***************************************************
960
961/// Calendar collection metadata. Parallels `AddressBook`.
962#[derive(Debug, Clone, Serialize)]
963#[serde(rename_all = "camelCase")]
964pub struct Calendar {
965	pub cal_id: u64,
966	pub name: Box<str>,
967	pub description: Option<Box<str>>,
968	/// CSS `#RRGGBB` hex for client colouring (CalendarServer `calendar-color` ext).
969	pub color: Option<Box<str>>,
970	/// Default VTIMEZONE blob, surfaced via CalDAV `calendar-timezone`.
971	pub timezone: Option<Box<str>>,
972	/// Comma-separated component set (`VEVENT,VTODO`) — powers `supported-calendar-component-set`.
973	pub components: Box<str>,
974	/// Collection tag — bumps on any calendar-object mutation (used by CalDAV sync).
975	pub ctag: Box<str>,
976	#[serde(serialize_with = "serialize_timestamp_iso")]
977	pub created_at: Timestamp,
978	#[serde(serialize_with = "serialize_timestamp_iso")]
979	pub updated_at: Timestamp,
980}
981
982#[derive(Debug, Default)]
983pub struct CreateCalendarData {
984	pub name: String,
985	pub description: Option<String>,
986	pub color: Option<String>,
987	pub timezone: Option<String>,
988	/// If `None`, defaults to `VEVENT,VTODO`.
989	pub components: Option<String>,
990}
991
992#[derive(Debug, Default)]
993pub struct UpdateCalendarData {
994	pub name: Patch<String>,
995	pub description: Patch<String>,
996	pub color: Patch<String>,
997	pub timezone: Patch<String>,
998	pub components: Patch<String>,
999}
1000
1001/// Indexed projection of a calendar object — lives in DB columns alongside the authoritative
1002/// iCalendar blob. Enables `calendar-query` time-range filtering and REST search.
1003#[derive(Debug, Clone, Default)]
1004pub struct CalendarObjectExtracted {
1005	/// `VEVENT` | `VTODO` (first primary component in the VCALENDAR; overrides share it).
1006	pub component: Box<str>,
1007	pub summary: Option<Box<str>>,
1008	pub location: Option<Box<str>>,
1009	pub description: Option<Box<str>>,
1010	/// Master DTSTART as unix seconds (UTC). `None` for floating/undated VTODO.
1011	pub dtstart: Option<Timestamp>,
1012	/// DTEND for VEVENT, DUE for VTODO, as unix seconds (UTC). `None` for open-ended.
1013	pub dtend: Option<Timestamp>,
1014	/// True when DTSTART is `VALUE=DATE`.
1015	pub all_day: bool,
1016	/// `STATUS` value (CONFIRMED / TENTATIVE / CANCELLED / NEEDS-ACTION / COMPLETED / IN-PROCESS).
1017	pub status: Option<Box<str>>,
1018	/// `PRIORITY` 0..9 (primarily VTODO).
1019	pub priority: Option<u8>,
1020	pub organizer: Option<Box<str>>,
1021	/// Raw RRULE string — presence signals recurrence; expansion is client-side.
1022	pub rrule: Option<Box<str>>,
1023	/// `EXDATE` exclusions on the master as unix seconds; empty for override rows.
1024	pub exdate: Vec<Timestamp>,
1025	/// `RECURRENCE-ID` as unix seconds for override instances; `None` for the master row.
1026	pub recurrence_id: Option<Timestamp>,
1027	pub sequence: i64,
1028}
1029
1030/// Borrowed write payload for calendar-object upserts. Groups the four fields that always
1031/// travel together (authoritative blob + its derived etag + indexed projection) so trait
1032/// methods writing multiple objects in one tx don't accumulate parallel-scalar parameter
1033/// lists.
1034#[derive(Debug, Clone, Copy)]
1035pub struct CalendarObjectWrite<'a> {
1036	pub uid: &'a str,
1037	pub ical: &'a str,
1038	pub etag: &'a str,
1039	pub extracted: &'a CalendarObjectExtracted,
1040}
1041
1042/// Full calendar object row including the authoritative stored VCALENDAR blob.
1043#[derive(Debug, Clone)]
1044pub struct CalendarObject {
1045	pub co_id: u64,
1046	pub cal_id: u64,
1047	pub uid: Box<str>,
1048	pub etag: Box<str>,
1049	pub ical: Box<str>,
1050	pub extracted: CalendarObjectExtracted,
1051	pub created_at: Timestamp,
1052	pub updated_at: Timestamp,
1053}
1054
1055/// Calendar object summary without the iCalendar blob — for list endpoints.
1056#[derive(Debug, Clone)]
1057pub struct CalendarObjectView {
1058	pub co_id: u64,
1059	pub cal_id: u64,
1060	pub uid: Box<str>,
1061	pub etag: Box<str>,
1062	pub extracted: CalendarObjectExtracted,
1063	pub created_at: Timestamp,
1064	pub updated_at: Timestamp,
1065}
1066
1067/// One entry in a CalDAV `sync-collection` REPORT response. Tombstones (`deleted: true`) let
1068/// clients drop stale objects.
1069#[derive(Debug, Clone)]
1070pub struct CalendarObjectSyncEntry {
1071	pub uid: Box<str>,
1072	pub etag: Box<str>,
1073	pub deleted: bool,
1074	pub updated_at: Timestamp,
1075}
1076
1077#[derive(Debug, Default)]
1078pub struct ListCalendarObjectOptions {
1079	/// Restrict to a component (`VEVENT` or `VTODO`); `None` lists both.
1080	pub component: Option<String>,
1081	/// Free-text query matched against summary / location / description.
1082	pub q: Option<String>,
1083	/// Time-range start (inclusive, unix seconds).
1084	pub start: Option<Timestamp>,
1085	/// Time-range end (exclusive, unix seconds).
1086	pub end: Option<Timestamp>,
1087	pub cursor: Option<String>,
1088	pub limit: Option<u32>,
1089	/// Include recurrence-exception rows (`RECURRENCE-ID IS NOT NULL`) in the result set.
1090	/// Default `false` preserves CalDAV/legacy semantics where list endpoints return masters only.
1091	pub include_exceptions: bool,
1092}
1093
1094#[async_trait]
1095pub trait MetaAdapter: Debug + Send + Sync {
1096	// Tenant management
1097	//*******************
1098
1099	/// Reads a tenant profile
1100	async fn read_tenant(&self, tn_id: TnId) -> ClResult<Tenant<Box<str>>>;
1101
1102	/// Creates a new tenant
1103	async fn create_tenant(&self, tn_id: TnId, id_tag: &str) -> ClResult<TnId>;
1104
1105	/// Updates a tenant
1106	async fn update_tenant(&self, tn_id: TnId, tenant: &UpdateTenantData) -> ClResult<()>;
1107
1108	/// Deletes a tenant
1109	async fn delete_tenant(&self, tn_id: TnId) -> ClResult<()>;
1110
1111	/// Lists all tenants (for admin use)
1112	async fn list_tenants(&self, opts: &ListTenantsMetaOptions) -> ClResult<Vec<TenantListMeta>>;
1113
1114	/// Lists all profiles matching a set of options
1115	async fn list_profiles(
1116		&self,
1117		tn_id: TnId,
1118		opts: &ListProfileOptions,
1119	) -> ClResult<Vec<Profile<Box<str>>>>;
1120
1121	/// Get relationships between the current user and multiple target profiles
1122	///
1123	/// Efficiently queries relationship status (following, connected) for multiple profiles
1124	/// in a single database call, avoiding N+1 query patterns.
1125	///
1126	/// Returns: HashMap<target_id_tag, (following: bool, connected: bool)>
1127	async fn get_relationships(
1128		&self,
1129		tn_id: TnId,
1130		target_id_tags: &[&str],
1131	) -> ClResult<HashMap<String, (bool, bool)>>;
1132
1133	/// Reads a profile
1134	///
1135	/// Returns an `(etag, Profile)` tuple.
1136	async fn read_profile(
1137		&self,
1138		tn_id: TnId,
1139		id_tag: &str,
1140	) -> ClResult<(Box<str>, Profile<Box<str>>)>;
1141
1142	/// Read profile roles for access token generation
1143	async fn read_profile_roles(
1144		&self,
1145		tn_id: TnId,
1146		id_tag: &str,
1147	) -> ClResult<Option<Box<[Box<str>]>>>;
1148
1149	/// Insert a profile row if missing, otherwise update it.
1150	///
1151	/// Returns `UpsertResult::Created` if the row was inserted, or
1152	/// `UpsertResult::Updated` if an existing row was updated. Never returns
1153	/// `Error::Conflict` or `Error::NotFound` — the operation is idempotent
1154	/// with respect to row existence.
1155	async fn upsert_profile(
1156		&self,
1157		tn_id: TnId,
1158		id_tag: &str,
1159		fields: &UpsertProfileFields,
1160	) -> ClResult<UpsertResult>;
1161
1162	/// Reads the public key of a profile
1163	///
1164	/// Returns a `(public key, expiration)` tuple.
1165	async fn read_profile_public_key(
1166		&self,
1167		id_tag: &str,
1168		key_id: &str,
1169	) -> ClResult<(Box<str>, Timestamp)>;
1170	/// Cache a federated profile public key.
1171	///
1172	/// `expires_at` is the owner-declared key expiration from the remote profile.
1173	/// `None` means the owner did not declare an expiration; the implementation
1174	/// may store it as NULL (treated as "never expires" by `read_profile_public_key`).
1175	async fn add_profile_public_key(
1176		&self,
1177		id_tag: &str,
1178		key_id: &str,
1179		public_key: &str,
1180		expires_at: Option<Timestamp>,
1181	) -> ClResult<()>;
1182	/// List stale profiles that need refreshing
1183	///
1184	/// Returns profiles where:
1185	/// - `synced_at IS NULL` (never synced — always eligible), OR
1186	/// - `synced_at < now - max_age_secs` AND `synced_at >= now - disable_after_secs`
1187	///   (stale but not yet abandoned).
1188	///
1189	/// Profiles with `synced_at < now - disable_after_secs` are excluded so the
1190	/// refresh batch stops attempting persistently failing remotes.
1191	/// Returns `Vec<(tn_id, id_tag, etag)>` tuples for conditional refresh requests.
1192	async fn list_stale_profiles(
1193		&self,
1194		max_age_secs: i64,
1195		disable_after_secs: i64,
1196		limit: u32,
1197	) -> ClResult<Vec<(TnId, Box<str>, Option<Box<str>>)>>;
1198
1199	// Action management
1200	//*******************
1201	async fn get_action_id(&self, tn_id: TnId, a_id: u64) -> ClResult<Box<str>>;
1202	async fn list_actions(
1203		&self,
1204		tn_id: TnId,
1205		opts: &ListActionOptions,
1206	) -> ClResult<Vec<ActionView>>;
1207	async fn list_action_tokens(
1208		&self,
1209		tn_id: TnId,
1210		opts: &ListActionOptions,
1211	) -> ClResult<Box<[Box<str>]>>;
1212
1213	async fn create_action(
1214		&self,
1215		tn_id: TnId,
1216		action: &Action<&str>,
1217		key: Option<&str>,
1218	) -> ClResult<ActionId<Box<str>>>;
1219
1220	async fn finalize_action(
1221		&self,
1222		tn_id: TnId,
1223		a_id: u64,
1224		action_id: &str,
1225		options: FinalizeActionOptions<'_>,
1226	) -> ClResult<()>;
1227
1228	async fn create_inbound_action(
1229		&self,
1230		tn_id: TnId,
1231		action_id: &str,
1232		token: &str,
1233		ack_token: Option<&str>,
1234	) -> ClResult<()>;
1235
1236	/// Get the root_id of an action
1237	async fn get_action_root_id(&self, tn_id: TnId, action_id: &str) -> ClResult<Box<str>>;
1238
1239	/// Get action data (subject, reaction count, comment count)
1240	async fn get_action_data(&self, tn_id: TnId, action_id: &str) -> ClResult<Option<ActionData>>;
1241
1242	/// Get action by key
1243	async fn get_action_by_key(
1244		&self,
1245		tn_id: TnId,
1246		action_key: &str,
1247	) -> ClResult<Option<Action<Box<str>>>>;
1248
1249	/// Store action token for federation (called when action is created)
1250	async fn store_action_token(&self, tn_id: TnId, action_id: &str, token: &str) -> ClResult<()>;
1251
1252	/// Get action token for federation
1253	async fn get_action_token(&self, tn_id: TnId, action_id: &str) -> ClResult<Option<Box<str>>>;
1254
1255	/// Update action data (subject, reactions, comments, status)
1256	async fn update_action_data(
1257		&self,
1258		tn_id: TnId,
1259		action_id: &str,
1260		opts: &UpdateActionDataOptions,
1261	) -> ClResult<()>;
1262
1263	/// Update inbound action status
1264	async fn update_inbound_action(
1265		&self,
1266		tn_id: TnId,
1267		action_id: &str,
1268		status: Option<char>,
1269	) -> ClResult<()>;
1270
1271	/// Get related action tokens by APRV action_id
1272	/// Returns list of (action_id, token) pairs for actions that have ack = aprv_action_id
1273	async fn get_related_action_tokens(
1274		&self,
1275		tn_id: TnId,
1276		aprv_action_id: &str,
1277	) -> ClResult<Vec<(Box<str>, Box<str>)>>;
1278
1279	// File management
1280	//*****************
1281	async fn get_file_id(&self, tn_id: TnId, f_id: u64) -> ClResult<Box<str>>;
1282	async fn list_files(&self, tn_id: TnId, opts: &ListFileOptions) -> ClResult<Vec<FileView>>;
1283	async fn list_file_variants(
1284		&self,
1285		tn_id: TnId,
1286		file_id: FileId<&str>,
1287	) -> ClResult<Vec<FileVariant<Box<str>>>>;
1288	/// List locally available variant names for a file (only those marked available)
1289	async fn list_available_variants(&self, tn_id: TnId, file_id: &str) -> ClResult<Vec<Box<str>>>;
1290	async fn read_file_variant(
1291		&self,
1292		tn_id: TnId,
1293		variant_id: &str,
1294	) -> ClResult<FileVariant<Box<str>>>;
1295	/// Look up the file_id for a given variant_id
1296	async fn read_file_id_by_variant(&self, tn_id: TnId, variant_id: &str) -> ClResult<Box<str>>;
1297	/// Look up the internal f_id for a given file_id (for adding variants to existing files)
1298	async fn read_f_id_by_file_id(&self, tn_id: TnId, file_id: &str) -> ClResult<u64>;
1299	async fn create_file(&self, tn_id: TnId, opts: CreateFile) -> ClResult<FileId<Box<str>>>;
1300	async fn create_file_variant<'a>(
1301		&'a self,
1302		tn_id: TnId,
1303		f_id: u64,
1304		opts: FileVariant<&'a str>,
1305	) -> ClResult<&'a str>;
1306	async fn update_file_id(&self, tn_id: TnId, f_id: u64, file_id: &str) -> ClResult<()>;
1307
1308	/// Finalize a pending file - sets file_id and transitions status from 'P' to 'A' atomically
1309	async fn finalize_file(&self, tn_id: TnId, f_id: u64, file_id: &str) -> ClResult<()>;
1310
1311	// Task scheduler
1312	//****************
1313	async fn list_tasks(&self, opts: ListTaskOptions) -> ClResult<Vec<Task>>;
1314	async fn list_task_ids(&self, kind: &str, keys: &[Box<str>]) -> ClResult<Vec<u64>>;
1315	async fn create_task(
1316		&self,
1317		kind: &'static str,
1318		key: Option<&str>,
1319		input: &str,
1320		deps: &[u64],
1321	) -> ClResult<u64>;
1322	async fn update_task_finished(&self, task_id: u64, output: &str) -> ClResult<()>;
1323	async fn update_task_error(
1324		&self,
1325		task_id: u64,
1326		output: &str,
1327		next_at: Option<Timestamp>,
1328	) -> ClResult<()>;
1329
1330	/// Find a pending task by its key
1331	async fn find_task_by_key(&self, key: &str) -> ClResult<Option<Task>>;
1332
1333	/// Update task fields with partial updates
1334	async fn update_task(&self, task_id: u64, patch: &TaskPatch) -> ClResult<()>;
1335
1336	/// Find deps that have completed (status != 'P')
1337	async fn find_completed_deps(&self, deps: &[u64]) -> ClResult<Vec<u64>>;
1338
1339	// Phase 1: Profile Management
1340	//****************************
1341	/// Get a single profile by id_tag
1342	async fn get_profile_info(&self, tn_id: TnId, id_tag: &str) -> ClResult<ProfileData>;
1343
1344	// Phase 2: Action Management
1345	//***************************
1346	/// Get a single action by action_id
1347	async fn get_action(&self, tn_id: TnId, action_id: &str) -> ClResult<Option<ActionView>>;
1348
1349	/// Update action content and attachments (if not yet federated)
1350	async fn update_action(
1351		&self,
1352		tn_id: TnId,
1353		action_id: &str,
1354		content: Option<&str>,
1355		attachments: Option<&[&str]>,
1356	) -> ClResult<()>;
1357
1358	/// Delete an action (soft delete with cleanup)
1359	async fn delete_action(&self, tn_id: TnId, action_id: &str) -> ClResult<()>;
1360
1361	/// Count active (non-DEL, non-deleted) REACT actions for a given subject, grouped by type
1362	/// Returns colon-separated format: "L5:V3:W1" (Like=5, Love=3, Wow=1)
1363	async fn count_reactions(&self, tn_id: TnId, subject_id: &str) -> ClResult<String>;
1364
1365	// Phase 2: File Management Enhancements
1366	//**************************************
1367	/// Delete a file (set status to 'D')
1368	async fn delete_file(&self, tn_id: TnId, file_id: &str) -> ClResult<()>;
1369
1370	/// List all child files in a document tree (files with the given root_id)
1371	async fn list_children_by_root(&self, tn_id: TnId, root_id: &str) -> ClResult<Vec<Box<str>>>;
1372
1373	// Settings Management
1374	//*********************
1375	/// List all settings for a tenant, optionally filtered by prefix
1376	async fn list_settings(
1377		&self,
1378		tn_id: TnId,
1379		prefix: Option<&[String]>,
1380	) -> ClResult<std::collections::HashMap<String, serde_json::Value>>;
1381
1382	/// Read a single setting by name
1383	async fn read_setting(&self, tn_id: TnId, name: &str) -> ClResult<Option<serde_json::Value>>;
1384
1385	/// Update or delete a setting (None = delete)
1386	async fn update_setting(
1387		&self,
1388		tn_id: TnId,
1389		name: &str,
1390		value: Option<serde_json::Value>,
1391	) -> ClResult<()>;
1392
1393	// Reference / Bookmark Management
1394	//********************************
1395	/// List all references for a tenant
1396	async fn list_refs(&self, tn_id: TnId, opts: &ListRefsOptions) -> ClResult<Vec<RefData>>;
1397
1398	/// Get a specific reference by ID
1399	async fn get_ref(&self, tn_id: TnId, ref_id: &str) -> ClResult<Option<(Box<str>, Box<str>)>>;
1400
1401	/// Create a new reference
1402	async fn create_ref(
1403		&self,
1404		tn_id: TnId,
1405		ref_id: &str,
1406		opts: &CreateRefOptions,
1407	) -> ClResult<RefData>;
1408
1409	/// Delete a reference
1410	async fn delete_ref(&self, tn_id: TnId, ref_id: &str) -> ClResult<()>;
1411
1412	/// Use/consume a reference - validates type, expiration, counter, decrements counter
1413	/// Returns (TnId, id_tag, RefData) of the tenant that owns this ref
1414	async fn use_ref(
1415		&self,
1416		ref_id: &str,
1417		expected_types: &[&str],
1418	) -> ClResult<(TnId, Box<str>, RefData)>;
1419
1420	/// Validate a reference without consuming it - checks type, expiration, counter
1421	/// Returns (TnId, id_tag, RefData) of the tenant that owns this ref if valid
1422	async fn validate_ref(
1423		&self,
1424		ref_id: &str,
1425		expected_types: &[&str],
1426	) -> ClResult<(TnId, Box<str>, RefData)>;
1427
1428	// Tag Management
1429	//***************
1430	/// List all tags for a tenant
1431	///
1432	/// # Arguments
1433	/// * `tn_id` - Tenant ID
1434	/// * `prefix` - Optional prefix filter
1435	/// * `with_counts` - If true, include file counts per tag
1436	/// * `limit` - Optional limit on number of tags returned
1437	async fn list_tags(
1438		&self,
1439		tn_id: TnId,
1440		prefix: Option<&str>,
1441		with_counts: bool,
1442		limit: Option<u32>,
1443	) -> ClResult<Vec<TagInfo>>;
1444
1445	/// Add a tag to a file
1446	async fn add_tag(&self, tn_id: TnId, file_id: &str, tag: &str) -> ClResult<Vec<String>>;
1447
1448	/// Remove a tag from a file
1449	async fn remove_tag(&self, tn_id: TnId, file_id: &str, tag: &str) -> ClResult<Vec<String>>;
1450
1451	// File Management Enhancements
1452	//****************************
1453	/// Update file metadata (name, visibility, status)
1454	async fn update_file_data(
1455		&self,
1456		tn_id: TnId,
1457		file_id: &str,
1458		opts: &UpdateFileOptions,
1459	) -> ClResult<()>;
1460
1461	/// Read file metadata
1462	async fn read_file(&self, tn_id: TnId, file_id: &str) -> ClResult<Option<FileView>>;
1463
1464	// File User Data (per-user file activity tracking)
1465	//**************************************************
1466
1467	/// Record file access for a user (upserts record, updates accessed_at timestamp)
1468	async fn record_file_access(&self, tn_id: TnId, id_tag: &str, file_id: &str) -> ClResult<()>;
1469
1470	/// Record file modification for a user (upserts record, updates modified_at timestamp)
1471	async fn record_file_modification(
1472		&self,
1473		tn_id: TnId,
1474		id_tag: &str,
1475		file_id: &str,
1476	) -> ClResult<()>;
1477
1478	/// Update file user data (pinned/starred status)
1479	async fn update_file_user_data(
1480		&self,
1481		tn_id: TnId,
1482		id_tag: &str,
1483		file_id: &str,
1484		pinned: Option<bool>,
1485		starred: Option<bool>,
1486	) -> ClResult<FileUserData>;
1487
1488	/// Get file user data for a specific file
1489	async fn get_file_user_data(
1490		&self,
1491		tn_id: TnId,
1492		id_tag: &str,
1493		file_id: &str,
1494	) -> ClResult<Option<FileUserData>>;
1495
1496	// Push Subscription Management
1497	//*****************************
1498
1499	/// List all push subscriptions for a tenant (user)
1500	///
1501	/// Returns all active push subscriptions for this tenant.
1502	/// Each tenant represents a user, so this returns all their device subscriptions.
1503	async fn list_push_subscriptions(&self, tn_id: TnId) -> ClResult<Vec<PushSubscription>>;
1504
1505	/// Create a new push subscription
1506	///
1507	/// Stores a Web Push subscription for a tenant. The subscription contains
1508	/// the endpoint URL and encryption keys needed to send push notifications.
1509	/// Returns the generated subscription ID.
1510	async fn create_push_subscription(
1511		&self,
1512		tn_id: TnId,
1513		subscription: &PushSubscriptionData,
1514	) -> ClResult<u64>;
1515
1516	/// Delete a push subscription by ID
1517	///
1518	/// Removes a push subscription. Called when a subscription becomes invalid
1519	/// (e.g., 410 Gone response from push service) or when user unsubscribes.
1520	async fn delete_push_subscription(&self, tn_id: TnId, subscription_id: u64) -> ClResult<()>;
1521
1522	// Share Entry Management
1523	//***********************
1524
1525	/// Create a share entry (idempotent on unique constraint)
1526	async fn create_share_entry(
1527		&self,
1528		tn_id: TnId,
1529		resource_type: char,
1530		resource_id: &str,
1531		created_by: &str,
1532		entry: &CreateShareEntry,
1533	) -> ClResult<ShareEntry>;
1534
1535	/// Delete a share entry by ID
1536	async fn delete_share_entry(&self, tn_id: TnId, id: i64) -> ClResult<()>;
1537
1538	/// List share entries for a resource
1539	async fn list_share_entries(
1540		&self,
1541		tn_id: TnId,
1542		resource_type: char,
1543		resource_id: &str,
1544	) -> ClResult<Vec<ShareEntry>>;
1545
1546	/// List share entries by subject (reverse lookup).
1547	/// If `subject_type` is None, matches all subject types.
1548	async fn list_share_entries_by_subject(
1549		&self,
1550		tn_id: TnId,
1551		subject_type: Option<char>,
1552		subject_id: &str,
1553	) -> ClResult<Vec<ShareEntry>>;
1554
1555	/// Check if a subject has share access to a resource
1556	/// Returns the permission char if access exists, None otherwise
1557	async fn check_share_access(
1558		&self,
1559		tn_id: TnId,
1560		resource_type: char,
1561		resource_id: &str,
1562		subject_type: char,
1563		subject_id: &str,
1564	) -> ClResult<Option<char>>;
1565
1566	/// Read a single share entry by ID (for delete validation)
1567	async fn read_share_entry(&self, tn_id: TnId, id: i64) -> ClResult<Option<ShareEntry>>;
1568
1569	// Installed App Management
1570	//*************************
1571
1572	/// Install an app package
1573	async fn install_app(&self, tn_id: TnId, install: &InstallApp) -> ClResult<()>;
1574
1575	/// Uninstall an app by name and publisher
1576	async fn uninstall_app(&self, tn_id: TnId, app_name: &str, publisher_tag: &str)
1577	-> ClResult<()>;
1578
1579	/// List installed apps, optionally filtered by search term
1580	async fn list_installed_apps(
1581		&self,
1582		tn_id: TnId,
1583		search: Option<&str>,
1584	) -> ClResult<Vec<InstalledApp>>;
1585
1586	/// Get a specific installed app
1587	async fn get_installed_app(
1588		&self,
1589		tn_id: TnId,
1590		app_name: &str,
1591		publisher_tag: &str,
1592	) -> ClResult<Option<InstalledApp>>;
1593
1594	// Address book / contact management
1595	//***********************************
1596
1597	/// Create a new address book collection.
1598	async fn create_address_book(
1599		&self,
1600		tn_id: TnId,
1601		name: &str,
1602		description: Option<&str>,
1603	) -> ClResult<AddressBook>;
1604
1605	/// List all address books for a tenant.
1606	async fn list_address_books(&self, tn_id: TnId) -> ClResult<Vec<AddressBook>>;
1607
1608	/// Read a single address book by id.
1609	async fn get_address_book(&self, tn_id: TnId, ab_id: u64) -> ClResult<Option<AddressBook>>;
1610
1611	/// Look up an address book by its name (for CardDAV path routing).
1612	async fn get_address_book_by_name(
1613		&self,
1614		tn_id: TnId,
1615		name: &str,
1616	) -> ClResult<Option<AddressBook>>;
1617
1618	/// Patch an address book's metadata.
1619	async fn update_address_book(
1620		&self,
1621		tn_id: TnId,
1622		ab_id: u64,
1623		patch: &UpdateAddressBookData,
1624	) -> ClResult<()>;
1625
1626	/// Delete an address book (and all its contacts).
1627	async fn delete_address_book(&self, tn_id: TnId, ab_id: u64) -> ClResult<()>;
1628
1629	/// List + search contacts. When `ab_id` is `Some`, scopes to that book (cursor
1630	/// is c_id-ordered). When `None`, queries across all books sorted by name.
1631	async fn list_contacts(
1632		&self,
1633		tn_id: TnId,
1634		ab_id: Option<u64>,
1635		opts: &ListContactOptions,
1636	) -> ClResult<Vec<ContactView>>;
1637
1638	/// Read a single contact (including vCard blob) by UID.
1639	async fn get_contact(&self, tn_id: TnId, ab_id: u64, uid: &str) -> ClResult<Option<Contact>>;
1640
1641	/// Insert or update a contact (keyed by UID). Also bumps the address book's ctag.
1642	/// Returns the new etag.
1643	async fn upsert_contact(
1644		&self,
1645		tn_id: TnId,
1646		ab_id: u64,
1647		uid: &str,
1648		vcard: &str,
1649		etag: &str,
1650		extracted: &ContactExtracted,
1651	) -> ClResult<Box<str>>;
1652
1653	/// Soft-delete a contact (sets `deleted_at`), leaving a tombstone row for CardDAV sync.
1654	/// Also bumps the address book's ctag.
1655	async fn delete_contact(&self, tn_id: TnId, ab_id: u64, uid: &str) -> ClResult<()>;
1656
1657	/// Fetch multiple contacts by UID — for CardDAV `addressbook-multiget` REPORT.
1658	async fn get_contacts_by_uids(
1659		&self,
1660		tn_id: TnId,
1661		ab_id: u64,
1662		uids: &[&str],
1663	) -> ClResult<Vec<Contact>>;
1664
1665	/// Return live + tombstone entries for CardDAV `sync-collection` REPORT.
1666	/// `since` is the sync token's timestamp; `None` means full sync.
1667	/// `limit` caps the number of rows returned; callers supply their own hard ceiling
1668	/// to keep responses bounded. `None` means no client-supplied limit — callers should
1669	/// still pass their server-side ceiling.
1670	async fn list_contacts_since(
1671		&self,
1672		tn_id: TnId,
1673		ab_id: u64,
1674		since: Option<Timestamp>,
1675		limit: Option<u32>,
1676	) -> ClResult<Vec<ContactSyncEntry>>;
1677
1678	/// List all contacts linked to a given profile id_tag (for bulk snapshot refresh).
1679	async fn list_contacts_by_profile(
1680		&self,
1681		tn_id: TnId,
1682		profile_id_tag: &str,
1683	) -> ClResult<Vec<Contact>>;
1684
1685	// Calendar / calendar-object management (CalDAV + JSON REST)
1686	//************************************************************
1687
1688	/// Create a new calendar collection.
1689	async fn create_calendar(&self, tn_id: TnId, input: &CreateCalendarData) -> ClResult<Calendar>;
1690
1691	/// List all calendars for a tenant.
1692	async fn list_calendars(&self, tn_id: TnId) -> ClResult<Vec<Calendar>>;
1693
1694	/// Read a single calendar by id.
1695	async fn get_calendar(&self, tn_id: TnId, cal_id: u64) -> ClResult<Option<Calendar>>;
1696
1697	/// Look up a calendar by its name (for CalDAV path routing).
1698	async fn get_calendar_by_name(&self, tn_id: TnId, name: &str) -> ClResult<Option<Calendar>>;
1699
1700	/// Patch a calendar's metadata.
1701	async fn update_calendar(
1702		&self,
1703		tn_id: TnId,
1704		cal_id: u64,
1705		patch: &UpdateCalendarData,
1706	) -> ClResult<()>;
1707
1708	/// Delete a calendar (and all its objects).
1709	async fn delete_calendar(&self, tn_id: TnId, cal_id: u64) -> ClResult<()>;
1710
1711	/// List + search calendar objects within a calendar. Excludes soft-deleted rows.
1712	async fn list_calendar_objects(
1713		&self,
1714		tn_id: TnId,
1715		cal_id: u64,
1716		opts: &ListCalendarObjectOptions,
1717	) -> ClResult<Vec<CalendarObjectView>>;
1718
1719	/// Read a single calendar object (including iCalendar blob) by UID.
1720	/// Returns the master row; recurrence-override rows live under the same UID but distinct
1721	/// `recurrence_id` and are not merged here.
1722	async fn get_calendar_object(
1723		&self,
1724		tn_id: TnId,
1725		cal_id: u64,
1726		uid: &str,
1727	) -> ClResult<Option<CalendarObject>>;
1728
1729	/// Read a single recurrence-override row keyed by `(uid, recurrence_id)`.
1730	async fn get_calendar_object_override(
1731		&self,
1732		tn_id: TnId,
1733		cal_id: u64,
1734		uid: &str,
1735		recurrence_id: Timestamp,
1736	) -> ClResult<Option<CalendarObject>>;
1737
1738	/// List all non-deleted recurrence-override rows for a given master UID.
1739	async fn list_calendar_object_overrides(
1740		&self,
1741		tn_id: TnId,
1742		cal_id: u64,
1743		uid: &str,
1744	) -> ClResult<Vec<CalendarObject>>;
1745
1746	/// Soft-delete a single recurrence-override row (leaves the master untouched).
1747	async fn delete_calendar_object_override(
1748		&self,
1749		tn_id: TnId,
1750		cal_id: u64,
1751		uid: &str,
1752		recurrence_id: Timestamp,
1753	) -> ClResult<()>;
1754
1755	/// Insert or update a calendar object (keyed by UID). Also bumps the calendar's ctag.
1756	/// Returns the new etag. The `extracted.recurrence_id` selects which row is written — the
1757	/// master row has `None`, recurrence overrides carry their own timestamp.
1758	async fn upsert_calendar_object(
1759		&self,
1760		tn_id: TnId,
1761		cal_id: u64,
1762		uid: &str,
1763		ical: &str,
1764		etag: &str,
1765		extracted: &CalendarObjectExtracted,
1766	) -> ClResult<Box<str>>;
1767
1768	/// Soft-delete a calendar object by UID (sets `deleted_at` on all rows sharing that UID),
1769	/// leaving tombstones for CalDAV sync. Also bumps the calendar's ctag.
1770	async fn delete_calendar_object(&self, tn_id: TnId, cal_id: u64, uid: &str) -> ClResult<()>;
1771
1772	/// Atomically split a recurring series at `split_at`:
1773	///   1. Upsert the existing master (typically with a truncated RRULE) using the
1774	///      caller-supplied ical / etag / extracted projection.
1775	///   2. Soft-delete every override row whose `recurrence_id >= split_at`.
1776	///   3. Insert the tail as a new master under its own UID.
1777	///   4. Bump the calendar's ctag once for the whole fork.
1778	///
1779	/// The whole operation runs in a single transaction; on any error the caller sees the
1780	/// original series unchanged. Returns the stored etags of the master and the tail,
1781	/// in that order.
1782	async fn split_calendar_object_series(
1783		&self,
1784		tn_id: TnId,
1785		cal_id: u64,
1786		master: CalendarObjectWrite<'_>,
1787		tail: CalendarObjectWrite<'_>,
1788		split_at: Timestamp,
1789	) -> ClResult<(Box<str>, Box<str>)>;
1790
1791	/// Fetch multiple calendar objects by UID — for CalDAV `calendar-multiget` REPORT.
1792	async fn get_calendar_objects_by_uids(
1793		&self,
1794		tn_id: TnId,
1795		cal_id: u64,
1796		uids: &[&str],
1797	) -> ClResult<Vec<CalendarObject>>;
1798
1799	/// Return live + tombstone entries for CalDAV `sync-collection` REPORT.
1800	/// `since` is the sync token's timestamp; `None` means full sync.
1801	async fn list_calendar_objects_since(
1802		&self,
1803		tn_id: TnId,
1804		cal_id: u64,
1805		since: Option<Timestamp>,
1806		limit: Option<u32>,
1807	) -> ClResult<Vec<CalendarObjectSyncEntry>>;
1808
1809	/// Return calendar objects overlapping a time range — for CalDAV `calendar-query` REPORT.
1810	/// Semantics are deliberately loose (superset): any object whose master `dtstart` is ≤ `end`
1811	/// AND (`rrule` is set OR `dtend` is ≥ `start` OR `dtend IS NULL`) is returned. Clients
1812	/// expand recurrence locally. A `None` component lists both VEVENT and VTODO.
1813	async fn query_calendar_objects_in_range(
1814		&self,
1815		tn_id: TnId,
1816		cal_id: u64,
1817		component: Option<&str>,
1818		start: Option<Timestamp>,
1819		end: Option<Timestamp>,
1820	) -> ClResult<Vec<CalendarObject>>;
1821}
1822
1823#[cfg(test)]
1824mod tests {
1825	use super::*;
1826	#[test]
1827	fn test_deserialize_list_action_options_with_multiple_statuses() {
1828		let query = "status=C,N&type=POST,REPLY";
1829		let opts: ListActionOptions =
1830			serde_urlencoded::from_str(query).expect("should deserialize");
1831
1832		assert!(opts.status.is_some());
1833		let statuses = opts.status.expect("status should be Some");
1834		assert_eq!(statuses.len(), 2);
1835		assert_eq!(statuses[0].as_str(), "C");
1836		assert_eq!(statuses[1].as_str(), "N");
1837
1838		assert!(opts.typ.is_some());
1839		let types = opts.typ.expect("type should be Some");
1840		assert_eq!(types.len(), 2);
1841		assert_eq!(types[0].as_str(), "POST");
1842		assert_eq!(types[1].as_str(), "REPLY");
1843	}
1844
1845	#[test]
1846	fn test_deserialize_list_action_options_without_status() {
1847		let query = "issuer=alice";
1848		let opts: ListActionOptions =
1849			serde_urlencoded::from_str(query).expect("should deserialize");
1850
1851		assert!(opts.status.is_none());
1852		assert!(opts.typ.is_none());
1853		assert_eq!(opts.issuer.as_deref(), Some("alice"));
1854	}
1855
1856	#[test]
1857	fn test_deserialize_list_action_options_single_status() {
1858		let query = "status=C";
1859		let opts: ListActionOptions =
1860			serde_urlencoded::from_str(query).expect("should deserialize");
1861
1862		assert!(opts.status.is_some());
1863		let statuses = opts.status.expect("status should be Some");
1864		assert_eq!(statuses.len(), 1);
1865		assert_eq!(statuses[0].as_str(), "C");
1866	}
1867
1868	#[test]
1869	fn test_deserialize_list_action_options_audience_type() {
1870		let opts: ListActionOptions = serde_urlencoded::from_str("audienceType=personal")
1871			.expect("should deserialize personal");
1872		assert!(matches!(opts.audience_type, Some(AudienceType::Personal)));
1873
1874		let opts: ListActionOptions = serde_urlencoded::from_str("audienceType=community")
1875			.expect("should deserialize community");
1876		assert!(matches!(opts.audience_type, Some(AudienceType::Community)));
1877
1878		let opts: ListActionOptions =
1879			serde_urlencoded::from_str("issuer=alice").expect("should deserialize");
1880		assert!(opts.audience_type.is_none());
1881
1882		let res: Result<ListActionOptions, _> = serde_urlencoded::from_str("audienceType=garbage");
1883		assert!(res.is_err(), "garbage audienceType should error");
1884	}
1885
1886	#[test]
1887	fn test_deserialize_list_action_options_multi_visibility() {
1888		let opts: ListActionOptions =
1889			serde_urlencoded::from_str("visibility=F,C").expect("should deserialize");
1890		let v = opts.visibility.expect("visibility should be Some");
1891		assert_eq!(v.len(), 2);
1892		assert_eq!(v[0].as_str(), "F");
1893		assert_eq!(v[1].as_str(), "C");
1894
1895		let opts: ListActionOptions =
1896			serde_urlencoded::from_str("visibility=P").expect("should deserialize");
1897		let v = opts.visibility.expect("visibility should be Some");
1898		assert_eq!(v.len(), 1);
1899		assert_eq!(v[0].as_str(), "P");
1900
1901		let opts: ListActionOptions =
1902			serde_urlencoded::from_str("issuer=alice").expect("should deserialize");
1903		assert!(opts.visibility.is_none());
1904	}
1905
1906	#[test]
1907	fn test_deserialize_list_action_options_visibility_with_direct() {
1908		let opts: ListActionOptions =
1909			serde_urlencoded::from_str("visibility=D,F").expect("should deserialize");
1910		let v = opts.visibility.expect("visibility should be Some");
1911		assert_eq!(v.len(), 2);
1912		assert_eq!(v[0].as_str(), "D");
1913		assert_eq!(v[1].as_str(), "F");
1914	}
1915}
1916
1917// vim: ts=4