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
9/// Special parent_id value for system-managed files (action attachments, profile/cover
10/// images, cached remote profile images). Files in this hidden per-tenant folder are
11/// reaped by the file GC when no canonical column still references them.
12pub const MANAGED_PARENT_ID: &str = "__managed__";
13
14/// Sentinel parent_id value representing the root (files with no parent folder).
15/// API input/filter only — never appears in DB rows; root rows have
16/// `parent_id = NULL` in the `files` table. Use this constant only on the API
17/// surface when the request needs to disambiguate root from "no filter".
18pub const ROOT_PARENT_ID: &str = "__root__";
19
20use async_trait::async_trait;
21use serde::{Deserialize, Serialize};
22use serde_with::skip_serializing_none;
23use std::{
24	cmp::Ordering,
25	collections::{HashMap, HashSet},
26	fmt::Debug,
27};
28
29use crate::{
30	prelude::*,
31	types::{serialize_timestamp_iso, serialize_timestamp_iso_opt},
32};
33
34// Tenants, profiles
35//*******************
36#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
37pub enum ProfileType {
38	#[default]
39	#[serde(rename = "person")]
40	Person,
41	#[serde(rename = "community")]
42	Community,
43}
44
45#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
46pub enum ProfileStatus {
47	#[serde(rename = "A")]
48	Active,
49	#[serde(rename = "B")]
50	Blocked,
51	#[serde(rename = "M")]
52	Muted,
53	#[serde(rename = "S")]
54	Suspended,
55	#[serde(rename = "X")]
56	Banned,
57}
58
59impl ProfileStatus {
60	/// Lowercase string form for JSON DTO exposure to the frontend.
61	pub fn as_str(&self) -> &'static str {
62		match self {
63			ProfileStatus::Active => "active",
64			ProfileStatus::Blocked => "blocked",
65			ProfileStatus::Muted => "muted",
66			ProfileStatus::Suspended => "suspended",
67			ProfileStatus::Banned => "banned",
68		}
69	}
70}
71
72/// Per-profile proxy-token preference for passive reads of a remote profile's content.
73/// Absent (NULL) means ask the user at the time of access.
74#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
75#[serde(rename_all = "lowercase")]
76pub enum ProfileTrust {
77	/// Always authenticate via proxy token when accessing this profile.
78	Always,
79	/// Never authenticate; always access anonymously.
80	Never,
81}
82
83#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
84pub enum ProfileConnectionStatus {
85	#[default]
86	Disconnected,
87	RequestPending,
88	Connected,
89}
90
91impl ProfileConnectionStatus {
92	pub fn is_connected(&self) -> bool {
93		matches!(self, ProfileConnectionStatus::Connected)
94	}
95}
96
97impl std::fmt::Display for ProfileConnectionStatus {
98	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99		match self {
100			ProfileConnectionStatus::Disconnected => write!(f, "disconnected"),
101			ProfileConnectionStatus::RequestPending => write!(f, "pending"),
102			ProfileConnectionStatus::Connected => write!(f, "connected"),
103		}
104	}
105}
106
107// Reference / Bookmark types
108//*****************************
109
110#[skip_serializing_none]
111#[derive(Debug, Clone, Serialize)]
112#[serde(rename_all = "camelCase")]
113pub struct RefData {
114	pub ref_id: Box<str>,
115	pub r#type: Box<str>,
116	pub description: Option<Box<str>>,
117	#[serde(serialize_with = "serialize_timestamp_iso")]
118	pub created_at: Timestamp,
119	#[serde(serialize_with = "serialize_timestamp_iso_opt")]
120	pub expires_at: Option<Timestamp>,
121	/// Usage count: None = unlimited, Some(n) = n uses remaining
122	pub count: Option<u32>,
123	/// Resource ID for share links (e.g., file_id for share.file type)
124	pub resource_id: Option<Box<str>>,
125	/// Access level for share links ('R'=Read, 'W'=Write)
126	pub access_level: Option<char>,
127	/// Launch params as serialized query string (e.g., "mode=present")
128	pub params: Option<Box<str>>,
129}
130
131pub struct ListRefsOptions {
132	pub typ: Option<String>,
133	pub filter: Option<String>, // 'active', 'used', 'expired', 'all'
134	/// Filter by resource_id (for listing share links for a specific resource)
135	pub resource_id: Option<String>,
136}
137
138#[derive(Default)]
139pub struct CreateRefOptions {
140	pub typ: String,
141	pub description: Option<String>,
142	pub expires_at: Option<Timestamp>,
143	pub count: Option<u32>,
144	/// Resource ID for share links (e.g., file_id for share.file type)
145	pub resource_id: Option<String>,
146	/// Access level for share links ('R'=Read, 'W'=Write)
147	pub access_level: Option<char>,
148	/// Launch params as serialized query string (e.g., "mode=present")
149	pub params: Option<String>,
150}
151
152/// Options for updating an existing reference via PATCH semantics.
153///
154/// Each field uses `Patch<T>`: `Undefined` leaves the column unchanged,
155/// `Null` clears it, `Value(v)` sets it. `type`, `resource_id`, and
156/// `params` are intentionally immutable post-create.
157#[derive(Debug, Default)]
158pub struct UpdateRefOptions {
159	pub description: Patch<String>,
160	/// Expiration timestamp. `Null` clears expiration (link never expires).
161	pub expires_at: Patch<Timestamp>,
162	/// `Null` clears the counter (unlimited uses).
163	pub count: Patch<u32>,
164	/// `Value('R'|'C'|'W')`.
165	pub access_level: Patch<char>,
166}
167
168#[skip_serializing_none]
169#[derive(Debug, Serialize)]
170#[serde(rename_all = "camelCase")]
171pub struct Tenant<S: AsRef<str>> {
172	#[serde(rename = "id")]
173	pub tn_id: TnId,
174	pub id_tag: S,
175	pub name: S,
176	#[serde(rename = "type")]
177	pub typ: ProfileType,
178	pub profile_pic: Option<S>,
179	pub cover_pic: Option<S>,
180	#[serde(serialize_with = "serialize_timestamp_iso")]
181	pub created_at: Timestamp,
182	/// Presence: stamped when the tenant's last ws-bus connection closes.
183	#[serde(skip_serializing_if = "Option::is_none")]
184	pub last_seen_at: Option<Timestamp>,
185	/// Offline-throttle watermark for the 'direct' group (MSG/CONN/FSHR).
186	#[serde(skip_serializing_if = "Option::is_none")]
187	pub notify_email_direct_at: Option<Timestamp>,
188	/// Offline-throttle watermark for the 'engagement' group (CMNT/REACT).
189	#[serde(skip_serializing_if = "Option::is_none")]
190	pub notify_email_engagement_at: Option<Timestamp>,
191	/// Offline-throttle watermark for the 'social' group (FLLW/POST).
192	#[serde(skip_serializing_if = "Option::is_none")]
193	pub notify_email_social_at: Option<Timestamp>,
194	pub x: HashMap<S, S>,
195}
196
197/// Options for listing tenants in meta adapter
198#[derive(Debug, Default)]
199pub struct ListTenantsMetaOptions {
200	pub limit: Option<u32>,
201	pub offset: Option<u32>,
202}
203
204/// Tenant list item from meta adapter (without cover_pic and x fields)
205#[skip_serializing_none]
206#[derive(Debug, Clone, Serialize)]
207#[serde(rename_all = "camelCase")]
208pub struct TenantListMeta {
209	pub tn_id: TnId,
210	pub id_tag: Box<str>,
211	pub name: Box<str>,
212	#[serde(rename = "type")]
213	pub typ: ProfileType,
214	pub profile_pic: Option<Box<str>>,
215	#[serde(serialize_with = "serialize_timestamp_iso")]
216	pub created_at: Timestamp,
217}
218
219#[derive(Debug, Default, Deserialize)]
220pub struct UpdateTenantData {
221	#[serde(rename = "idTag", default)]
222	pub id_tag: Patch<String>,
223	#[serde(default)]
224	pub name: Patch<String>,
225	#[serde(rename = "type", default)]
226	pub typ: Patch<ProfileType>,
227	#[serde(rename = "profilePic", default)]
228	pub profile_pic: Patch<String>,
229	#[serde(rename = "coverPic", default)]
230	pub cover_pic: Patch<String>,
231	/// Partial merge for x JSON field: Some(value) = upsert, None = delete key
232	#[serde(default)]
233	pub x: Option<std::collections::HashMap<String, Option<String>>>,
234	/// Presence watermark, server-set only (not deserialized from API requests).
235	/// Stamped when the tenant's last ws-bus connection closes.
236	#[serde(skip)]
237	pub last_seen_at: Patch<Timestamp>,
238	/// Offline-throttle watermarks, server-set only (stamped after an offline
239	/// notification email is scheduled for the group).
240	#[serde(skip)]
241	pub notify_email_direct_at: Patch<Timestamp>,
242	#[serde(skip)]
243	pub notify_email_engagement_at: Patch<Timestamp>,
244	#[serde(skip)]
245	pub notify_email_social_at: Patch<Timestamp>,
246}
247
248#[derive(Debug)]
249pub struct Profile<S: AsRef<str>> {
250	pub id_tag: S,
251	pub name: S,
252	pub typ: ProfileType,
253	pub profile_pic: Option<S>,
254	pub status: Option<ProfileStatus>,
255	pub synced_at: Option<Timestamp>,
256	pub following: bool,
257	pub follower: bool,
258	pub connected: ProfileConnectionStatus,
259	pub roles: Option<Box<[Box<str>]>>,
260	pub trust: Option<ProfileTrust>,
261	/// Reader's feed read-watermark for this context (own/community profile).
262	pub feed_read_at: Option<Timestamp>,
263	/// Reader's DM read-watermark for this peer profile.
264	pub msg_read_at: Option<Timestamp>,
265	/// Composition control for the home feed: `Some(true)` = this community is
266	/// hidden from the merged home feed (shown only in its own feed); `None` =
267	/// shown (the default). Only meaningful for community profiles.
268	pub hidden_in_home: Option<bool>,
269}
270
271#[derive(Debug, Default, Deserialize)]
272pub struct ListProfileOptions {
273	#[serde(rename = "type")]
274	pub typ: Option<ProfileType>,
275	pub status: Option<Box<[ProfileStatus]>>,
276	pub connected: Option<ProfileConnectionStatus>,
277	pub following: Option<bool>,
278	pub follower: Option<bool>,
279	pub q: Option<String>,
280	pub id_tag: Option<String>,
281	/// Filter profiles by whether a trust preference is set.
282	/// `Some(true)` returns only profiles with a non-null trust value;
283	/// `Some(false)` returns only profiles with NULL trust; `None` does not filter.
284	pub trust_set: Option<bool>,
285	/// Filter by home-feed composition flag. Some(true) → only communities hidden
286	/// from the home feed (hidden_in_home = 1); Some(false) → only shown; None → no filter.
287	pub hidden_in_home: Option<bool>,
288}
289
290/// Profile data returned from adapter queries
291#[derive(Debug, Clone, Serialize, Deserialize)]
292#[serde(rename_all = "camelCase")]
293pub struct ProfileData {
294	pub id_tag: Box<str>,
295	pub name: Box<str>,
296	#[serde(rename = "type")]
297	pub r#type: Box<str>, // "person" or "community"
298	pub profile_pic: Option<Box<str>>,
299	/// Federation lifecycle: "active" | "trusted" | "suspended" | "blocked" | "muted" | "banned"
300	#[serde(default, skip_serializing_if = "Option::is_none")]
301	pub status: Option<Box<str>>,
302	#[serde(serialize_with = "serialize_timestamp_iso")]
303	pub created_at: Timestamp,
304}
305
306/// List of profiles response
307#[derive(Debug, Clone, Serialize, Deserialize)]
308pub struct ProfileList {
309	pub profiles: Vec<ProfileData>,
310	pub total: usize,
311	pub limit: usize,
312	pub offset: usize,
313}
314
315#[derive(Debug, Default, Deserialize)]
316pub struct UpdateProfileData {
317	// Profile content fields
318	#[serde(default)]
319	pub name: Patch<Box<str>>,
320	#[serde(default, rename = "profilePic")]
321	pub profile_pic: Patch<Option<Box<str>>>,
322	#[serde(default)]
323	pub roles: Patch<Option<Vec<Box<str>>>>,
324
325	// Status and moderation
326	#[serde(default)]
327	pub status: Patch<ProfileStatus>,
328
329	// Relationship fields
330	#[serde(default)]
331	pub synced: Patch<bool>,
332	#[serde(default)]
333	pub trust: Patch<ProfileTrust>,
334	/// Composition control: `Value(true)` hides this community from the home
335	/// feed (column → 1), `Null`/`Value(false)` clears it (column → NULL = shown).
336	#[serde(default)]
337	pub hidden_in_home: Patch<bool>,
338
339	// Sync metadata
340	#[serde(default)]
341	pub etag: Patch<Box<str>>,
342}
343
344/// Outcome of an `upsert_profile` call.
345#[derive(Debug, Clone, Copy, PartialEq, Eq)]
346pub enum UpsertResult {
347	/// The profile row did not exist and was inserted.
348	Created,
349	/// The profile row existed and was updated.
350	Updated,
351}
352
353/// Fields for `MetaAdapter::upsert_profile`.
354///
355/// All fields are `Patch` and apply to both INSERT and UPDATE:
356/// * `Patch::Value(v)` / `Patch::Null` → set the column on both branches.
357/// * `Patch::Undefined` → leave the column at its current value on UPDATE,
358///   and use the column default (NULL or `""` for `name`) on INSERT.
359///
360/// **Note on the INSERT branch:** `Patch::Null` and `Patch::Undefined`
361/// collapse to the same column default for most fields — the INSERT can't
362/// distinguish "user explicitly set to NULL" from "user didn't touch this
363/// field." This is fine semantically (both mean "no value here"), but
364/// differs from UPDATE, which preserves the existing value on `Undefined`.
365///
366/// **Stub-row idiom:** `upsert_profile` creates a row with `type = NULL`
367/// when `typ` is `Patch::Undefined`. These stub rows are filtered out of
368/// `list_profiles` (which requires `type IS NOT NULL`), but `read_profile` /
369/// `get_info` will return `Error::NotFound` for them. This is intentional:
370/// relationship hooks (FOLLOW, FSHR) create stubs first and federation sync
371/// populates `type` later. Callers performing read-then-write should not
372/// rely on `read_profile` finding a freshly-inserted stub.
373#[derive(Default)]
374pub struct UpsertProfileFields {
375	pub name: Patch<Box<str>>,
376	pub typ: Patch<ProfileType>,
377	pub profile_pic: Patch<Option<Box<str>>>,
378	pub roles: Patch<Option<Vec<Box<str>>>>,
379	pub status: Patch<ProfileStatus>,
380	pub synced: Patch<bool>,
381	pub following: Patch<bool>,
382	pub follower: Patch<bool>,
383	pub connected: Patch<ProfileConnectionStatus>,
384	pub trust: Patch<ProfileTrust>,
385	/// Composition: `Value(true)` → column 1 (hidden from home); `Null` → column
386	/// NULL (shown). Callers normalize a `false` request to `Null` so the column
387	/// stays in the NULL/1 encoding.
388	pub hidden_in_home: Patch<bool>,
389	pub etag: Patch<Box<str>>,
390}
391
392impl UpsertProfileFields {
393	/// Build an `UpsertProfileFields` from an existing `UpdateProfileData`.
394	///
395	/// `typ` is left `Undefined` — callers that know the profile type should
396	/// set it explicitly.
397	pub fn from_update(update: UpdateProfileData) -> Self {
398		Self {
399			name: update.name,
400			typ: Patch::Undefined,
401			profile_pic: update.profile_pic,
402			roles: update.roles,
403			status: update.status,
404			synced: update.synced,
405			// `following`, `follower`, and `connected` are set only by the
406			// FLLW/CONN native hooks, never via the client-facing update DTO;
407			// leave them untouched here.
408			following: Patch::Undefined,
409			follower: Patch::Undefined,
410			connected: Patch::Undefined,
411			trust: update.trust,
412			hidden_in_home: update.hidden_in_home,
413			etag: update.etag,
414		}
415	}
416}
417
418// Actions
419//*********
420
421/// Additional action data (cached counts/stats)
422#[derive(Debug, Clone)]
423pub struct ActionData {
424	pub subject: Option<Box<str>>,
425	pub reactions: Option<Box<str>>,
426	/// Total comment count (active child CMNT rows). Federated as STAT `c`.
427	pub comments: Option<i64>,
428	/// Last-comment timestamp (epoch seconds = created_at of the newest active
429	/// child comment). Federated as STAT `ct`; drives the unread comment dot.
430	pub comments_ts: Option<Timestamp>,
431	/// Highest `created_at` of any STAT mirror update applied to this row
432	/// on the non-authoritative side. Used to reject reordered inbound
433	/// STATs. Always `None` on the authoritative node (REACT/CMNT write
434	/// the counters there; STAT `on_receive` never touches the row — see
435	/// the counter-update exclusivity invariant in
436	/// `cloudillo_action::native_hooks::ownership`).
437	pub stat_at: Option<Timestamp>,
438}
439
440/// Options for updating action metadata
441#[derive(Debug, Clone, Default)]
442pub struct UpdateActionDataOptions {
443	pub subject: Patch<String>,
444	pub reactions: Patch<String>,
445	/// Total comment count, federated as STAT `c`.
446	pub comments: Patch<u32>,
447	/// Last-comment timestamp (epoch seconds), federated as STAT `ct`.
448	pub comments_ts: Patch<Timestamp>,
449	pub reposts: Patch<u32>,
450	/// Watermark for inbound STAT mirror updates — see [`ActionData::stat_at`].
451	pub stat_at: Patch<Timestamp>,
452	pub status: Patch<char>,
453	pub visibility: Patch<char>,
454	pub x: Patch<serde_json::Value>, // Extensible metadata (x.role for SUBS, etc.)
455	pub content: Patch<String>,
456	pub attachments: Patch<String>, // Comma-separated list of attachment IDs
457	pub flags: Patch<String>,
458	/// Reader's W/T/M thread subscription level. `Patch::Null` clears it.
459	pub sub_level: Patch<char>,
460	pub sub_typ: Patch<String>,
461	/// Dual-purpose for actions in status `R` (draft) or `S` (scheduled): the
462	/// `actions.created_at` column holds the target publish instant, not the
463	/// row's actual creation time. PATCH /actions, `publish_draft`, and
464	/// `task::handle_create_action` all rely on this overload. For any other
465	/// status, leave this `Patch::Undefined` — overwriting `created_at` on a
466	/// finalized (`A`) action would corrupt the timeline.
467	pub created_at: Patch<Timestamp>,
468}
469
470/// Options for finalizing an action (resolved fields from ActionCreatorTask)
471#[derive(Debug, Clone, Default)]
472pub struct FinalizeActionOptions<'a> {
473	pub attachments: Option<&'a [&'a str]>,
474	pub subject: Option<&'a str>,
475	pub audience_tag: Option<&'a str>,
476	pub key: Option<&'a str>,
477}
478
479fn deserialize_split<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
480where
481	D: serde::Deserializer<'de>,
482{
483	let s = String::deserialize(deserializer)?;
484	let values: Vec<String> =
485		s.split(',').map(|v| v.trim().to_string()).filter(|v| !v.is_empty()).collect();
486	if values.is_empty() { Ok(None) } else { Ok(Some(values)) }
487}
488
489/// Audience filter axis: classify actions by the **type of the effective wall
490/// owner** (`coalesce(audience, issuer_tag)` joined to `profiles.type`).
491/// `Personal` matches `pa.type='P'` (with NULL→Personal fallback for unknown
492/// remote profiles). `Community` matches `pa.type='C'`.
493/// Combines with `audience` (specific community) as AND.
494#[derive(Debug, Clone, Copy, Deserialize)]
495#[serde(rename_all = "lowercase")]
496pub enum AudienceType {
497	Personal,
498	Community,
499}
500
501/// Field to group an action count by. Mapped to a fixed column server-side
502/// (never interpolated from caller input) to keep the query injection-safe.
503#[derive(Debug, Clone, Copy)]
504pub enum ActionCountGroupBy {
505	SubType,
506}
507
508/// Options for listing actions
509#[derive(Debug, Default, Deserialize)]
510#[serde(deny_unknown_fields)]
511pub struct ListActionOptions {
512	/// Maximum number of items to return (default: 20)
513	pub limit: Option<u32>,
514	/// Cursor for pagination (opaque base64-encoded string)
515	pub cursor: Option<String>,
516	/// Sort order: 'created' (default, created_at) or 'received' (received_at,
517	/// the home feed's ingestion-order sort). Also selects the column used by the
518	/// keyset cursor and the created_after/created_before range filters.
519	pub sort: Option<String>,
520	/// Sort direction: 'asc' or 'desc' (default: desc)
521	#[serde(rename = "sortDir")]
522	pub sort_dir: Option<String>,
523	#[serde(default, rename = "type", deserialize_with = "deserialize_split")]
524	pub typ: Option<Vec<String>>,
525	#[serde(default, deserialize_with = "deserialize_split")]
526	pub status: Option<Vec<String>>,
527	pub tag: Option<String>,
528	pub search: Option<String>,
529	#[serde(default, deserialize_with = "deserialize_split")]
530	pub visibility: Option<Vec<String>>,
531	pub issuer: Option<String>,
532	pub audience: Option<String>,
533	#[serde(rename = "audienceType")]
534	pub audience_type: Option<AudienceType>,
535	pub involved: Option<String>,
536	/// The authenticated user's id_tag (set by handler, not from query params)
537	#[serde(skip)]
538	pub viewer_id_tag: Option<String>,
539	#[serde(rename = "actionId")]
540	pub action_id: Option<String>,
541	#[serde(rename = "parentId")]
542	pub parent_id: Option<String>,
543	#[serde(rename = "rootId")]
544	pub root_id: Option<String>,
545	#[serde(default, deserialize_with = "deserialize_split")]
546	pub subject: Option<Vec<String>>,
547	#[serde(rename = "createdAfter")]
548	pub created_after: Option<Timestamp>,
549	#[serde(rename = "createdBefore")]
550	pub created_before: Option<Timestamp>,
551	/// HTTP boolean flag: when true, return only rows the viewer is subscribed
552	/// to (`sub_level` set to a followed level). Uses `idx_actions_sub_level`.
553	pub subscribed: Option<bool>,
554	/// When true, the list path populates each `ActionView.token` with the raw
555	/// signed JWS from `action_tokens`. Opt-in so normal feed payloads stay lean.
556	#[serde(rename = "includeTokens")]
557	pub include_tokens: Option<bool>,
558	/// When true, hydrate each row's `subject_action` (the referenced action with
559	/// its full `stat`) for any row whose `subject` is a real action id (not an
560	/// `@`-prefixed placeholder). Opt-in — unread-dot count probes omit it to stay
561	/// lean; feed/banner/conversation-list paths set it to get the subject's
562	/// commentCount/lastCommentAt/commentsReadAt in one round-trip.
563	#[serde(rename = "includeSubject")]
564	pub include_subject: Option<bool>,
565	/// Exclude actions whose issuer's profile has any of these statuses.
566	/// LEFT JOIN profiles ON (tn_id, id_tag=issuer.id_tag) — missing-profile
567	/// rows are NOT excluded (open-federation default).
568	#[serde(skip)]
569	pub exclude_issuer_profile_status: Option<Box<[ProfileStatus]>>,
570	/// Exclude action rows whose `sub_type` is in this set. Used by relationship
571	/// fan-out queries to drop tombstone rows (e.g. FLLW:DEL / SUBS:DEL), which
572	/// rest at status 'A' but represent a severed relationship. NULL sub_type
573	/// (the active join/follow row) is always kept.
574	#[serde(skip)]
575	pub exclude_sub_typ: Option<Box<[Box<str>]>>,
576	/// Exclude actions whose *effective audience* (coalesce(audience, issuer_tag))
577	/// is in this set. Server-set, not from query params. Used by the home feed
578	/// to drop posts addressed to communities the reader opted out of home
579	/// (`profiles.hidden_in_home = 1`).
580	#[serde(skip)]
581	pub exclude_audiences: Option<Box<[String]>>,
582	/// When true, exclude actions issued by the requesting tenant (issuer == viewer).
583	/// Requires an authenticated request (viewer_id_tag set by the handler).
584	#[serde(rename = "excludeOwnIssuer")]
585	pub exclude_own_issuer: Option<bool>,
586	/// When true, `GET /actions` returns only a `COUNT(*)` of matching rows (under
587	/// `cursorPagination.count`) instead of the row list. The count applies
588	/// `visibility_guard` below, so it's a post-visibility count.
589	pub count: Option<bool>,
590	/// Visibility guard for the aggregate count path (H1). NEVER deserialized from
591	/// the client — set only by the `/actions` handler. Reuses `Patch<String>`:
592	/// `Undefined` → no guard (tenant see-all, internal callers, list path);
593	/// `Null` → guest, only Public ('P') rows; `Value(id_tag)` → viewer, full ABAC
594	/// translation for `id_tag`.
595	///
596	/// `#[serde(skip)]` is load-bearing: it keeps the field out of client
597	/// deserialization so a client cannot forge a see-all (`Undefined`) count.
598	/// `Patch` defaults to `Undefined`, so existing `..Default::default()` sites
599	/// keep the "no guard" behavior.
600	#[serde(skip)]
601	pub visibility_guard: Patch<String>,
602}
603
604#[skip_serializing_none]
605#[derive(Debug, Clone, Serialize, serde::Deserialize)]
606pub struct ProfileInfo {
607	#[serde(rename = "idTag")]
608	pub id_tag: Box<str>,
609	pub name: Box<str>,
610	#[serde(rename = "type")]
611	pub typ: ProfileType,
612	#[serde(rename = "profilePic")]
613	pub profile_pic: Option<Box<str>>,
614}
615
616#[derive(Default)]
617pub struct Action<S: AsRef<str>> {
618	pub action_id: S,
619	pub typ: S,
620	pub sub_typ: Option<S>,
621	pub issuer_tag: S,
622	pub parent_id: Option<S>,
623	pub root_id: Option<S>,
624	pub audience_tag: Option<S>,
625	pub content: Option<S>,
626	pub attachments: Option<Vec<S>>,
627	pub subject: Option<S>,
628	pub created_at: Timestamp,
629	pub expires_at: Option<Timestamp>,
630	pub visibility: Option<char>, // None: Direct, P: Public, V: Verified, 2: 2nd degree, F: Follower, C: Connected
631	pub flags: Option<S>,         // Action flags: R/r (reactions), C/c (comments), O/o (open)
632	pub x: Option<serde_json::Value>, // Extensible metadata (x.role for SUBS, etc.)
633}
634
635#[skip_serializing_none]
636#[derive(Debug, Clone, Serialize)]
637pub struct AttachmentView {
638	#[serde(rename = "fileId")]
639	pub file_id: Box<str>,
640	pub dim: Option<(u32, u32)>,
641	#[serde(rename = "localVariants")]
642	pub local_variants: Option<Vec<Box<str>>>,
643}
644
645#[skip_serializing_none]
646#[derive(Debug, Clone, Serialize)]
647#[serde(rename_all = "camelCase")]
648pub struct ActionView {
649	pub action_id: Box<str>,
650	#[serde(rename = "type")]
651	pub typ: Box<str>,
652	#[serde(rename = "subType")]
653	pub sub_typ: Option<Box<str>>,
654	pub parent_id: Option<Box<str>>,
655	pub root_id: Option<Box<str>>,
656	pub issuer: ProfileInfo,
657	pub audience: Option<ProfileInfo>,
658	pub content: Option<serde_json::Value>,
659	pub attachments: Option<Vec<AttachmentView>>,
660	pub subject: Option<Box<str>>,
661	pub subject_profile: Option<ProfileInfo>,
662	/// Hydrated original action referenced by `subject` (e.g. the post a REPOST
663	/// shares). Populated by the listing path for REPOST rows so the client can
664	/// render the embedded original card without a second fetch. Boxed to keep
665	/// the recursive type sized.
666	#[serde(default, skip_serializing_if = "Option::is_none")]
667	pub subject_action: Option<Box<ActionView>>,
668	#[serde(serialize_with = "serialize_timestamp_iso")]
669	pub created_at: Timestamp,
670	/// LOCAL ingestion time (when this action was inserted on this node), emitted
671	/// as `receivedAt`. Drives the home feed's arrival-order sort and its unread
672	/// watermark so late-federated posts (old `created_at`, recent arrival)
673	/// surface correctly. Optional: NULL on relationship/system rows inserted via
674	/// paths that don't stamp it. See `meta-adapter-sqlite` migration 36.
675	#[serde(
676		serialize_with = "serialize_timestamp_iso_opt",
677		skip_serializing_if = "Option::is_none"
678	)]
679	pub received_at: Option<Timestamp>,
680	#[serde(serialize_with = "serialize_timestamp_iso_opt")]
681	pub expires_at: Option<Timestamp>,
682	pub status: Option<Box<str>>,
683	pub stat: Option<serde_json::Value>,
684	pub visibility: Option<char>,
685	pub flags: Option<Box<str>>, // Action flags: R/r (reactions), C/c (comments), O/o (open)
686	/// Reader's W/T/M thread subscription level on this (cached) action row.
687	#[serde(rename = "subLevel", skip_serializing_if = "Option::is_none")]
688	pub sub_level: Option<Box<str>>,
689	pub x: Option<serde_json::Value>, // Extensible metadata (x.role for SUBS, etc.)
690	/// Raw signed JWS for this action, populated only when the list query sets
691	/// `includeTokens=true`. Lets clients verify action signatures locally.
692	#[serde(default, skip_serializing_if = "Option::is_none")]
693	pub token: Option<Box<str>>,
694}
695
696// Files
697//*******
698#[derive(Debug)]
699pub enum FileId<S: AsRef<str>> {
700	FileId(S),
701	FId(u64),
702}
703
704pub enum ActionId<S: AsRef<str>> {
705	ActionId(S),
706	AId(u64),
707}
708
709/// File status enum
710/// Note: Mutability is determined by fileTp (BLOB=immutable, CRDT/RTDB=mutable)
711#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
712pub enum FileStatus {
713	#[serde(rename = "A")]
714	Active,
715	#[serde(rename = "P")]
716	Pending,
717	#[serde(rename = "D")]
718	Deleted,
719}
720
721/// User-specific file metadata (access tracking, pinned/starred status)
722#[skip_serializing_none]
723#[derive(Debug, Clone, Default, Serialize, serde::Deserialize)]
724#[serde(rename_all = "camelCase")]
725pub struct FileUserData {
726	#[serde(default, serialize_with = "serialize_timestamp_iso_opt")]
727	pub accessed_at: Option<Timestamp>,
728	#[serde(default, serialize_with = "serialize_timestamp_iso_opt")]
729	pub modified_at: Option<Timestamp>,
730	#[serde(default)]
731	pub pinned: bool,
732	#[serde(default)]
733	pub starred: bool,
734	/// Cached source-reported access level for cross-context (hand-pinned)
735	/// rows. Written by `POST /files/{id}/refresh` and FSHR on_accept on the
736	/// receiver side. Cross-context list responses prefer this over the
737	/// FSHR-fallback path in `get_access_level`. `None` means the row has
738	/// never been refreshed (frontend renders no badge).
739	#[serde(default)]
740	pub access_level: Option<crate::types::AccessLevel>,
741}
742
743#[skip_serializing_none]
744#[derive(Debug, Clone, Serialize, serde::Deserialize)]
745#[serde(rename_all = "camelCase")]
746pub struct FileView {
747	pub file_id: Box<str>,
748	#[serde(default)]
749	pub parent_id: Option<Box<str>>, // Parent folder file_id (None = root)
750	#[serde(default)]
751	pub root_id: Option<Box<str>>, // Document tree root file_id (None = standalone)
752	#[serde(default)]
753	pub owner: Option<ProfileInfo>,
754	#[serde(default)]
755	pub creator: Option<ProfileInfo>,
756	#[serde(default)]
757	pub preset: Option<Box<str>>,
758	#[serde(default)]
759	pub content_type: Option<Box<str>>,
760	pub file_name: Box<str>,
761	#[serde(default)]
762	pub file_tp: Option<Box<str>>, // 'BLOB', 'CRDT', 'RTDB', 'FLDR'
763	#[serde(serialize_with = "serialize_timestamp_iso")]
764	pub created_at: Timestamp,
765	#[serde(default, serialize_with = "crate::types::serialize_timestamp_iso_opt")]
766	pub accessed_at: Option<Timestamp>, // Global: when anyone last accessed
767	#[serde(default, serialize_with = "crate::types::serialize_timestamp_iso_opt")]
768	pub modified_at: Option<Timestamp>, // Global: when anyone last modified
769	pub status: FileStatus,
770	#[serde(default)]
771	pub tags: Option<Vec<Box<str>>>,
772	#[serde(default)]
773	pub visibility: Option<char>, // None: Direct, P: Public, V: Verified, 2: 2nd degree, F: Follower, C: Connected
774	/// LEGACY: read-only flag from pre-managed-folder schema. New writes route
775	/// system-managed files into `parent_id = MANAGED_PARENT_ID` instead; the
776	/// `hidden` column is preserved only so existing rows from earlier DB
777	/// versions still list-filter correctly until they are migrated.
778	#[serde(default)]
779	pub hidden: bool,
780	#[serde(default)]
781	pub access_level: Option<crate::types::AccessLevel>, // User's access level to this file (R/W)
782	#[serde(default)]
783	pub user_data: Option<FileUserData>, // User-specific data (only when authenticated)
784	#[serde(default)]
785	pub x: Option<serde_json::Value>, // Extensible metadata (e.g., {"dim": [width, height]} for images)
786	/// Immediate parent folder name. Populated only when listing requests
787	/// `withParent=true`; `None` for root, trash, managed-parent, or when not
788	/// requested. Serialized as `parentName` and omitted when `None`.
789	#[serde(default)]
790	pub parent_name: Option<Box<str>>,
791	/// Full path from root → immediate parent (not including the file itself).
792	/// Populated only when listing requests `withPath=true` (typically a
793	/// single-file fetch). Serialized as `path` and omitted when `None`.
794	#[serde(default)]
795	pub path: Option<Vec<PathSegment>>,
796	/// Tombstone: when set, the source of this cross-context row has issued
797	/// an authoritative permanent signal (deleted or revoked). Written by
798	/// `POST /api/files/{file_id}/refresh`; the frontend calls that endpoint
799	/// when it detects an inconsistency (broken thumbnail, 404 on blob,
800	/// stale access). Transient network failures do NOT set this — they
801	/// surface via the response wrapper's `refreshStatus` field instead.
802	#[serde(default, serialize_with = "crate::types::serialize_timestamp_iso_opt")]
803	pub broken_at: Option<Timestamp>,
804	/// Tombstone reason, set together with `broken_at`. See
805	/// [`BrokenReason`] for the closed set of values.
806	#[serde(default)]
807	pub broken_reason: Option<BrokenReason>,
808}
809
810/// Reason a cross-context file row is tombstoned. Written by the refresh
811/// endpoint based on the source's response. Tombstones are sticky, so this
812/// is reserved for permanent / authoritative source signals — transient
813/// network failures DO NOT mutate the row (the handler surfaces them
814/// out-of-band via `refreshStatus` in the response wrapper).
815#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
816#[serde(rename_all = "lowercase")]
817pub enum BrokenReason {
818	/// Source returned 404 / 410: the row is gone upstream.
819	Deleted,
820	/// Source returned 403: the caller's grant on the source has been revoked.
821	Revoked,
822}
823
824impl BrokenReason {
825	pub fn as_str(&self) -> &'static str {
826		match self {
827			Self::Deleted => "deleted",
828			Self::Revoked => "revoked",
829		}
830	}
831}
832
833/// Single hop in a file's folder ancestry chain.
834#[derive(Debug, Clone, Serialize, serde::Deserialize)]
835#[serde(rename_all = "camelCase")]
836pub struct PathSegment {
837	pub id: Box<str>,
838	pub name: Box<str>,
839}
840
841#[skip_serializing_none]
842#[derive(Debug, Clone, Serialize)]
843pub struct FileVariant<S: AsRef<str> + Debug> {
844	#[serde(rename = "variantId")]
845	pub variant_id: S,
846	pub variant: S,
847	pub format: S,
848	pub size: u64,
849	pub resolution: (u32, u32),
850	pub available: bool,
851	/// Blob stored in the shared `TnId(0)` store instead of this tenant's store.
852	#[serde(skip_serializing_if = "std::ops::Not::not")]
853	pub global: bool,
854	/// Duration in seconds (for video/audio)
855	pub duration: Option<f64>,
856	/// Bitrate in kbps (for video/audio)
857	pub bitrate: Option<u32>,
858	/// Page count (for documents like PDF)
859	#[serde(rename = "pageCount")]
860	pub page_count: Option<u32>,
861}
862
863// `global` is a storage location, not part of content identity, so it is
864// deliberately excluded from PartialEq/Ord.
865impl<S: AsRef<str> + Debug> PartialEq for FileVariant<S> {
866	fn eq(&self, other: &Self) -> bool {
867		self.variant_id.as_ref() == other.variant_id.as_ref()
868			&& self.variant.as_ref() == other.variant.as_ref()
869			&& self.format.as_ref() == other.format.as_ref()
870			&& self.size == other.size
871			&& self.resolution == other.resolution
872			&& self.available == other.available
873			&& self.duration == other.duration
874			&& self.bitrate == other.bitrate
875			&& self.page_count == other.page_count
876	}
877}
878
879impl<S: AsRef<str> + Debug> Eq for FileVariant<S> {}
880
881impl<S: AsRef<str> + Debug + Ord> PartialOrd for FileVariant<S> {
882	fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
883		Some(self.cmp(other))
884	}
885}
886
887impl<S: AsRef<str> + Debug + Ord> Ord for FileVariant<S> {
888	fn cmp(&self, other: &Self) -> Ordering {
889		self.size
890			.cmp(&other.size)
891			.then_with(|| self.resolution.0.cmp(&other.resolution.0))
892			.then_with(|| self.resolution.1.cmp(&other.resolution.1))
893			.then_with(|| self.variant.as_ref().cmp(other.variant.as_ref()))
894	}
895}
896
897/// Options for listing files
898///
899/// By default (when `status` is `None`), deleted files (status 'D') are excluded.
900/// To include deleted files, explicitly set `status` to `FileStatus::Deleted`.
901#[derive(Debug, Default, Deserialize)]
902#[serde(deny_unknown_fields)]
903#[allow(clippy::struct_excessive_bools)]
904pub struct ListFileOptions {
905	/// Maximum number of items to return (default: 30)
906	pub limit: Option<u32>,
907	/// Cursor for pagination (opaque base64-encoded string)
908	pub cursor: Option<String>,
909	#[serde(default, rename = "fileId", deserialize_with = "deserialize_split")]
910	pub file_id: Option<Vec<String>>,
911	#[serde(rename = "parentId")]
912	pub parent_id: Option<String>, // Filter by parent folder (None = root, "__trash__" = trash)
913	/// Exclude files whose immediate parent is this folder. Used by the
914	/// frontend "more matches exist outside this folder" probe so it can ask
915	/// a single global question without re-finding the in-folder matches.
916	#[serde(rename = "notParentId")]
917	pub not_parent_id: Option<String>,
918	#[serde(rename = "rootId")]
919	pub root_id: Option<String>, // Filter by document tree root
920	pub tag: Option<String>,
921	pub preset: Option<String>,
922	pub variant: Option<String>,
923	/// File status filter. If None, excludes deleted files by default.
924	pub status: Option<FileStatus>,
925	#[serde(default, rename = "fileTp", deserialize_with = "deserialize_split")]
926	pub file_type: Option<Vec<String>>,
927	/// Filter by content type pattern (e.g., "image/*", "video/*")
928	#[serde(default, rename = "contentType", deserialize_with = "deserialize_split")]
929	pub content_type: Option<Vec<String>>,
930	/// Include folders (file_tp='FLDR') even when a content_type/file_type filter
931	/// is set, so folder navigation keeps working in type-filtered pickers.
932	#[serde(default, rename = "includeFolders")]
933	pub include_folders: bool,
934	/// Substring search in file name
935	#[serde(rename = "fileName")]
936	pub file_name: Option<String>,
937	/// Filter by owner id_tag
938	#[serde(rename = "ownerIdTag")]
939	pub owner_id_tag: Option<String>,
940	/// Exclude files by this owner id_tag
941	#[serde(rename = "notOwnerIdTag")]
942	pub not_owner_id_tag: Option<String>,
943	/// Restrict to files owned by the active tenant (owner_tag IS NULL), excluding
944	/// remote/federated cached copies. Unlike `owner_id_tag` (which keys off
945	/// COALESCE(creator_tag, owner_tag, tenant) and so matches the *creator*),
946	/// this keys purely off ownership — the right test for "can be embedded".
947	#[serde(default, rename = "localOnly")]
948	pub local_only: bool,
949	/// Filter by pinned status (user-specific)
950	pub pinned: Option<bool>,
951	/// Filter by starred status (user-specific)
952	pub starred: Option<bool>,
953	/// LEGACY hidden filter. None = exclude hidden (default). Some(true) = only hidden.
954	/// Kept so pre-migration `hidden=1` rows still drop out of user-library
955	/// listings; new system-managed files use `parent_id = MANAGED_PARENT_ID`
956	/// instead and are filtered by the managed-folder rule above.
957	pub hidden: Option<bool>,
958	/// Sort order: 'recent' (accessed_at), 'modified' (modified_at), 'name', 'created'
959	pub sort: Option<String>,
960	/// Sort direction: 'asc' or 'desc' (default: desc for dates, asc for name)
961	#[serde(rename = "sortDir")]
962	pub sort_dir: Option<String>,
963	/// User id_tag for user-specific data (set by handler, not from query)
964	#[serde(skip)]
965	pub user_id_tag: Option<String>,
966	/// Scope file_id filter: returns files matching this file_id OR having this root_id.
967	/// Overrides the normal root_id IS NULL constraint. Set by handler for scoped tokens.
968	#[serde(skip)]
969	pub scope_file_id: Option<String>,
970	/// Allowed visibility levels for SQL-level filtering (correct pagination).
971	/// None = no filter (owner sees all including NULL/Direct).
972	/// Set by handler based on subject's access level via `SubjectAccessLevel::visible_levels()`.
973	#[serde(skip)]
974	pub visible_levels: Option<Vec<char>>,
975	/// When true, populate `FileView.parent_name` with the immediate parent
976	/// folder's name (one level). Resolved via a shared LRU cache; on cache
977	/// misses, one SQL round-trip per distinct missing parent on the page.
978	#[serde(default, rename = "withParent")]
979	pub with_parent: bool,
980	/// When true, populate `FileView.path` with the full root→parent chain.
981	/// Typically used together with `file_id` to fetch a single file's location.
982	#[serde(default, rename = "withPath")]
983	pub with_path: bool,
984}
985
986#[derive(Debug, Clone, Default)]
987pub struct CreateFile {
988	pub orig_variant_id: Option<Box<str>>,
989	pub file_id: Option<Box<str>>,
990	pub parent_id: Option<Box<str>>, // Parent folder file_id (None = root)
991	pub root_id: Option<Box<str>>,   // Document tree root file_id (None = standalone)
992	pub owner_tag: Option<Box<str>>, // Set only for files owned by someone OTHER than the tenant (e.g., shared files)
993	pub creator_tag: Option<Box<str>>, // The user who actually created the file
994	pub preset: Option<Box<str>>,
995	pub content_type: Box<str>,
996	pub file_name: Box<str>,
997	pub file_tp: Option<Box<str>>, // 'BLOB', 'CRDT', 'RTDB', 'FLDR' - defaults to 'BLOB'
998	pub created_at: Option<Timestamp>,
999	pub tags: Option<Vec<Box<str>>>,
1000	pub x: Option<serde_json::Value>,
1001	pub visibility: Option<char>, // None: Direct (default), P: Public, V: Verified, 2: 2nd degree, F: Follower, C: Connected
1002	/// LEGACY: do not set on new rows. System-managed files should be created
1003	/// with `parent_id = MANAGED_PARENT_ID` so the file GC can reap them.
1004	pub hidden: bool,
1005	pub status: Option<FileStatus>, // None defaults to Pending, can set to Active for shared files
1006}
1007
1008#[derive(Debug, Clone, Deserialize)]
1009pub struct CreateFileVariant {
1010	pub variant: Box<str>,
1011	pub format: Box<str>,
1012	pub resolution: (u32, u32),
1013	pub size: u64,
1014	pub available: bool,
1015}
1016
1017/// Options for updating file metadata
1018#[derive(Debug, Clone, Default, Deserialize)]
1019pub struct UpdateFileOptions {
1020	#[serde(default, rename = "fileName")]
1021	pub file_name: Patch<String>,
1022	#[serde(default, rename = "parentId")]
1023	pub parent_id: Patch<String>, // Move file to different folder (null = root)
1024	#[serde(default)]
1025	pub visibility: Patch<char>,
1026	#[serde(default)]
1027	pub status: Patch<char>,
1028	/// LEGACY: writes to the `hidden` column. Prefer moving files into the
1029	/// managed folder via `parent_id = MANAGED_PARENT_ID`.
1030	#[serde(default)]
1031	pub hidden: Patch<bool>,
1032	// Fields below (content_type, file_tp, tags, preset, x, broken) are set
1033	// only by the cross-context refresh handler; not exposed as PATCH fields.
1034	#[serde(default, rename = "contentType", skip_deserializing)]
1035	pub content_type: Patch<String>,
1036	#[serde(default, rename = "fileTp", skip_deserializing)]
1037	pub file_tp: Patch<String>,
1038	#[serde(default, skip_deserializing)]
1039	pub tags: Patch<Vec<String>>,
1040	#[serde(default, skip_deserializing)]
1041	pub preset: Patch<String>,
1042	#[serde(default, skip_deserializing)]
1043	pub x: Patch<serde_json::Value>,
1044	/// Paired tombstone field. `Patch::Value(reason)` sets `broken_reason` and
1045	/// stamps `broken_at = unixepoch()`. `Patch::Null` clears both. `Undefined`
1046	/// touches neither.
1047	#[serde(default, skip_deserializing)]
1048	pub broken: Patch<BrokenReason>,
1049}
1050
1051// Share Entries
1052//**************
1053
1054#[skip_serializing_none]
1055#[derive(Debug, Clone, Serialize)]
1056#[serde(rename_all = "camelCase")]
1057pub struct ShareEntry {
1058	pub id: i64,
1059	pub resource_type: char,
1060	pub resource_id: Box<str>,
1061	pub subject_type: char,
1062	pub subject_id: Box<str>,
1063	pub permission: char,
1064	#[serde(serialize_with = "serialize_timestamp_iso_opt")]
1065	pub expires_at: Option<Timestamp>,
1066	pub created_by: Box<str>,
1067	#[serde(serialize_with = "serialize_timestamp_iso")]
1068	pub created_at: Timestamp,
1069	// Enrichment fields (populated by JOINs in list_by_resource)
1070	pub subject_file_name: Option<Box<str>>,
1071	pub subject_content_type: Option<Box<str>>,
1072	pub subject_file_tp: Option<Box<str>>,
1073}
1074
1075#[derive(Debug, Deserialize)]
1076#[serde(rename_all = "camelCase")]
1077pub struct CreateShareEntry {
1078	pub subject_type: char,
1079	pub subject_id: String,
1080	pub permission: char,
1081	pub expires_at: Option<Timestamp>,
1082}
1083
1084/// Options for updating an existing share entry via PATCH semantics.
1085///
1086/// Each field uses `Patch<T>`: `Undefined` leaves the column unchanged,
1087/// `Null` clears it, `Value(v)` sets it. `resource_type`, `resource_id`,
1088/// `subject_type`, `subject_id`, `created_by`, and `created_at` are
1089/// intentionally immutable post-create.
1090#[derive(Debug, Default)]
1091pub struct UpdateShareEntryOptions {
1092	/// `Value('R'|'C'|'W'|'A')`. `Null` is rejected at the handler
1093	/// boundary — to revoke access, DELETE the share entry instead.
1094	pub permission: Patch<char>,
1095	/// Expiration timestamp. `Null` clears expiration (share never expires).
1096	pub expires_at: Patch<Timestamp>,
1097}
1098
1099// Push Subscriptions
1100//********************
1101
1102/// Web Push subscription data (RFC 8030)
1103#[skip_serializing_none]
1104#[derive(Debug, Clone, Serialize, Deserialize)]
1105pub struct PushSubscriptionData {
1106	/// Push endpoint URL
1107	pub endpoint: String,
1108	/// Expiration time (Unix timestamp, if provided by browser)
1109	#[serde(rename = "expirationTime")]
1110	pub expiration_time: Option<i64>,
1111	/// Subscription keys (p256dh and auth)
1112	pub keys: PushSubscriptionKeys,
1113}
1114
1115/// Subscription keys for Web Push encryption
1116#[derive(Debug, Clone, Serialize, Deserialize)]
1117pub struct PushSubscriptionKeys {
1118	/// P-256 public key for encryption (base64url encoded)
1119	pub p256dh: String,
1120	/// Authentication secret (base64url encoded)
1121	pub auth: String,
1122}
1123
1124/// Full push subscription record stored in database
1125#[derive(Debug, Clone, Serialize)]
1126#[serde(rename_all = "camelCase")]
1127pub struct PushSubscription {
1128	/// Unique subscription ID
1129	pub id: u64,
1130	/// The subscription data (endpoint, keys, etc.)
1131	pub subscription: PushSubscriptionData,
1132	/// When this subscription was created
1133	#[serde(serialize_with = "serialize_timestamp_iso")]
1134	pub created_at: Timestamp,
1135}
1136
1137// Tasks
1138//*******
1139pub struct Task {
1140	pub task_id: u64,
1141	pub tn_id: TnId,
1142	pub kind: Box<str>,
1143	pub status: char,
1144	pub created_at: Timestamp,
1145	pub next_at: Option<Timestamp>,
1146	pub input: Box<str>,
1147	pub output: Box<str>,
1148	pub deps: Box<[u64]>,
1149	pub retry: Option<Box<str>>,
1150	pub cron: Option<Box<str>>,
1151}
1152
1153#[derive(Debug, Default)]
1154pub struct TaskPatch {
1155	pub input: Patch<String>,
1156	pub next_at: Patch<Timestamp>,
1157	pub deps: Patch<Vec<u64>>,
1158	pub retry: Patch<String>,
1159	pub cron: Patch<String>,
1160}
1161
1162#[derive(Debug, Default)]
1163pub struct ListTaskOptions {}
1164
1165// Installed Apps
1166//***************
1167
1168/// Data for installing an app
1169#[derive(Debug)]
1170pub struct InstallApp {
1171	pub app_name: Box<str>,
1172	pub publisher_tag: Box<str>,
1173	pub version: Box<str>,
1174	pub action_id: Box<str>,
1175	pub file_id: Box<str>,
1176	pub blob_id: Box<str>,
1177	pub capabilities: Option<Vec<Box<str>>>,
1178}
1179
1180/// Installed app record
1181#[derive(Debug, Serialize)]
1182#[serde(rename_all = "camelCase")]
1183pub struct InstalledApp {
1184	pub app_name: Box<str>,
1185	pub publisher_tag: Box<str>,
1186	pub version: Box<str>,
1187	pub action_id: Box<str>,
1188	pub file_id: Box<str>,
1189	pub blob_id: Box<str>,
1190	pub status: Box<str>,
1191	pub capabilities: Option<Vec<Box<str>>>,
1192	pub auto_update: bool,
1193	#[serde(serialize_with = "serialize_timestamp_iso")]
1194	pub installed_at: Timestamp,
1195}
1196
1197// Contacts / Address Books (CardDAV + JSON REST)
1198//*************************************************
1199
1200/// Address book collection metadata
1201#[derive(Debug, Clone, Serialize)]
1202#[serde(rename_all = "camelCase")]
1203pub struct AddressBook {
1204	pub ab_id: u64,
1205	pub name: Box<str>,
1206	pub description: Option<Box<str>>,
1207	/// Collection tag — changes on any contact mutation within this book (used by CardDAV sync)
1208	pub ctag: Box<str>,
1209	#[serde(serialize_with = "serialize_timestamp_iso")]
1210	pub created_at: Timestamp,
1211	#[serde(serialize_with = "serialize_timestamp_iso")]
1212	pub updated_at: Timestamp,
1213}
1214
1215#[derive(Debug, Default)]
1216pub struct UpdateAddressBookData {
1217	pub name: Patch<String>,
1218	pub description: Patch<String>,
1219}
1220
1221/// Indexed projection of a contact — lives in DB columns, parallel to the stored vCard blob.
1222/// Used both for REST API responses (via the handler layer's JSON conversion) and for
1223/// CardDAV `addressbook-query` REPORT text-match filtering.
1224#[derive(Debug, Clone, Default)]
1225pub struct ContactExtracted {
1226	pub fn_name: Option<Box<str>>,
1227	pub given_name: Option<Box<str>>,
1228	pub family_name: Option<Box<str>>,
1229	pub email: Option<Box<str>>,
1230	pub emails: Option<Box<str>>,
1231	pub tel: Option<Box<str>>,
1232	pub tels: Option<Box<str>>,
1233	pub org: Option<Box<str>>,
1234	pub title: Option<Box<str>>,
1235	pub note: Option<Box<str>>,
1236	pub photo_uri: Option<Box<str>>,
1237	pub profile_id_tag: Option<Box<str>>,
1238}
1239
1240/// Full contact row including the authoritative stored vCard blob.
1241#[derive(Debug, Clone)]
1242pub struct Contact {
1243	pub c_id: u64,
1244	pub ab_id: u64,
1245	pub uid: Box<str>,
1246	pub etag: Box<str>,
1247	pub vcard: Box<str>,
1248	pub extracted: ContactExtracted,
1249	pub created_at: Timestamp,
1250	pub updated_at: Timestamp,
1251}
1252
1253/// Contact summary without the vCard blob — for list endpoints (REST + CardDAV REPORTs that
1254/// don't need the full body).
1255#[derive(Debug, Clone)]
1256pub struct ContactView {
1257	pub c_id: u64,
1258	pub ab_id: u64,
1259	pub uid: Box<str>,
1260	pub etag: Box<str>,
1261	pub extracted: ContactExtracted,
1262	pub created_at: Timestamp,
1263	pub updated_at: Timestamp,
1264}
1265
1266/// One entry in a CardDAV `sync-collection` REPORT response. Tombstones (`deleted: true`)
1267/// let clients drop stale cards.
1268#[derive(Debug, Clone)]
1269pub struct ContactSyncEntry {
1270	pub uid: Box<str>,
1271	pub etag: Box<str>,
1272	pub deleted: bool,
1273	pub updated_at: Timestamp,
1274}
1275
1276#[derive(Debug, Default)]
1277pub struct ListContactOptions {
1278	/// Free-text query — matches against fn_name, emails, tels (SQL LIKE).
1279	pub q: Option<String>,
1280	/// Opaque cursor for pagination.
1281	pub cursor: Option<String>,
1282	/// Page size.
1283	pub limit: Option<u32>,
1284}
1285
1286// Calendars / Calendar Objects (CalDAV + JSON REST)
1287//***************************************************
1288
1289/// Calendar collection metadata. Parallels `AddressBook`.
1290#[derive(Debug, Clone, Serialize)]
1291#[serde(rename_all = "camelCase")]
1292pub struct Calendar {
1293	pub cal_id: u64,
1294	pub name: Box<str>,
1295	pub description: Option<Box<str>>,
1296	/// CSS `#RRGGBB` hex for client colouring (CalendarServer `calendar-color` ext).
1297	pub color: Option<Box<str>>,
1298	/// Default VTIMEZONE blob, surfaced via CalDAV `calendar-timezone`.
1299	pub timezone: Option<Box<str>>,
1300	/// Comma-separated component set (`VEVENT,VTODO`) — powers `supported-calendar-component-set`.
1301	pub components: Box<str>,
1302	/// Collection tag — bumps on any calendar-object mutation (used by CalDAV sync).
1303	pub ctag: Box<str>,
1304	#[serde(serialize_with = "serialize_timestamp_iso")]
1305	pub created_at: Timestamp,
1306	#[serde(serialize_with = "serialize_timestamp_iso")]
1307	pub updated_at: Timestamp,
1308}
1309
1310#[derive(Debug, Default)]
1311pub struct CreateCalendarData {
1312	pub name: String,
1313	pub description: Option<String>,
1314	pub color: Option<String>,
1315	pub timezone: Option<String>,
1316	/// If `None`, defaults to `VEVENT,VTODO`.
1317	pub components: Option<String>,
1318}
1319
1320#[derive(Debug, Default)]
1321pub struct UpdateCalendarData {
1322	pub name: Patch<String>,
1323	pub description: Patch<String>,
1324	pub color: Patch<String>,
1325	pub timezone: Patch<String>,
1326	pub components: Patch<String>,
1327}
1328
1329/// Indexed projection of a calendar object — lives in DB columns alongside the authoritative
1330/// iCalendar blob. Enables `calendar-query` time-range filtering and REST search.
1331#[derive(Debug, Clone, Default)]
1332pub struct CalendarObjectExtracted {
1333	/// `VEVENT` | `VTODO` (first primary component in the VCALENDAR; overrides share it).
1334	pub component: Box<str>,
1335	pub summary: Option<Box<str>>,
1336	pub location: Option<Box<str>>,
1337	pub description: Option<Box<str>>,
1338	/// Master DTSTART as unix seconds (UTC). `None` for floating/undated VTODO.
1339	pub dtstart: Option<Timestamp>,
1340	/// DTEND for VEVENT, DUE for VTODO, as unix seconds (UTC). `None` for open-ended.
1341	pub dtend: Option<Timestamp>,
1342	/// True when DTSTART is `VALUE=DATE`.
1343	pub all_day: bool,
1344	/// `STATUS` value (CONFIRMED / TENTATIVE / CANCELLED / NEEDS-ACTION / COMPLETED / IN-PROCESS).
1345	pub status: Option<Box<str>>,
1346	/// `PRIORITY` 0..9 (primarily VTODO).
1347	pub priority: Option<u8>,
1348	pub organizer: Option<Box<str>>,
1349	/// Raw RRULE string — presence signals recurrence; expansion is client-side.
1350	pub rrule: Option<Box<str>>,
1351	/// `EXDATE` exclusions on the master as unix seconds; empty for override rows.
1352	pub exdate: Vec<Timestamp>,
1353	/// `RECURRENCE-ID` as unix seconds for override instances; `None` for the master row.
1354	pub recurrence_id: Option<Timestamp>,
1355	pub sequence: i64,
1356}
1357
1358/// Borrowed write payload for calendar-object upserts. Groups the four fields that always
1359/// travel together (authoritative blob + its derived etag + indexed projection) so trait
1360/// methods writing multiple objects in one tx don't accumulate parallel-scalar parameter
1361/// lists.
1362#[derive(Debug, Clone, Copy)]
1363pub struct CalendarObjectWrite<'a> {
1364	pub uid: &'a str,
1365	pub ical: &'a str,
1366	pub etag: &'a str,
1367	pub extracted: &'a CalendarObjectExtracted,
1368}
1369
1370/// Full calendar object row including the authoritative stored VCALENDAR blob.
1371#[derive(Debug, Clone)]
1372pub struct CalendarObject {
1373	pub co_id: u64,
1374	pub cal_id: u64,
1375	pub uid: Box<str>,
1376	pub etag: Box<str>,
1377	pub ical: Box<str>,
1378	pub extracted: CalendarObjectExtracted,
1379	pub created_at: Timestamp,
1380	pub updated_at: Timestamp,
1381}
1382
1383/// Calendar object summary without the iCalendar blob — for list endpoints.
1384#[derive(Debug, Clone)]
1385pub struct CalendarObjectView {
1386	pub co_id: u64,
1387	pub cal_id: u64,
1388	pub uid: Box<str>,
1389	pub etag: Box<str>,
1390	pub extracted: CalendarObjectExtracted,
1391	pub created_at: Timestamp,
1392	pub updated_at: Timestamp,
1393}
1394
1395/// One entry in a CalDAV `sync-collection` REPORT response. Tombstones (`deleted: true`) let
1396/// clients drop stale objects.
1397#[derive(Debug, Clone)]
1398pub struct CalendarObjectSyncEntry {
1399	pub uid: Box<str>,
1400	pub etag: Box<str>,
1401	pub deleted: bool,
1402	pub updated_at: Timestamp,
1403}
1404
1405#[derive(Debug, Default)]
1406pub struct ListCalendarObjectOptions {
1407	/// Restrict to a component (`VEVENT` or `VTODO`); `None` lists both.
1408	pub component: Option<String>,
1409	/// Free-text query matched against summary / location / description.
1410	pub q: Option<String>,
1411	/// Time-range start (inclusive, unix seconds).
1412	pub start: Option<Timestamp>,
1413	/// Time-range end (exclusive, unix seconds).
1414	pub end: Option<Timestamp>,
1415	pub cursor: Option<String>,
1416	pub limit: Option<u32>,
1417	/// Include recurrence-exception rows (`RECURRENCE-ID IS NOT NULL`) in the result set.
1418	/// Default `false` preserves CalDAV/legacy semantics where list endpoints return masters only.
1419	pub include_exceptions: bool,
1420}
1421
1422#[async_trait]
1423pub trait MetaAdapter: Debug + Send + Sync {
1424	// Tenant management
1425	//*******************
1426
1427	/// Reads a tenant profile
1428	async fn read_tenant(&self, tn_id: TnId) -> ClResult<Tenant<Box<str>>>;
1429
1430	/// Creates a new tenant
1431	async fn create_tenant(&self, tn_id: TnId, id_tag: &str) -> ClResult<TnId>;
1432
1433	/// Updates a tenant
1434	async fn update_tenant(&self, tn_id: TnId, tenant: &UpdateTenantData) -> ClResult<()>;
1435
1436	/// Deletes a tenant
1437	async fn delete_tenant(&self, tn_id: TnId) -> ClResult<()>;
1438
1439	/// Lists all tenants (for admin use)
1440	async fn list_tenants(&self, opts: &ListTenantsMetaOptions) -> ClResult<Vec<TenantListMeta>>;
1441
1442	/// Lists all profiles matching a set of options
1443	async fn list_profiles(
1444		&self,
1445		tn_id: TnId,
1446		opts: &ListProfileOptions,
1447	) -> ClResult<Vec<Profile<Box<str>>>>;
1448
1449	/// List the id_tags of every profile that follows this tenant (i.e. should
1450	/// receive its broadcasts). This is the broadcast/Announce recipient set:
1451	/// profiles with `follower = true`, excluding Suspended/Blocked/Banned issuers.
1452	/// Unbounded (no LIMIT) — unlike `list_profiles`.
1453	async fn list_follower_tags(&self, tn_id: TnId) -> ClResult<Vec<Box<str>>>;
1454
1455	/// Get relationships between the current user and multiple target profiles
1456	///
1457	/// Efficiently queries relationship status (following, connected) for multiple profiles
1458	/// in a single database call, avoiding N+1 query patterns.
1459	///
1460	/// Returns: HashMap<target_id_tag, (following: bool, connected: bool)>
1461	async fn get_relationships(
1462		&self,
1463		tn_id: TnId,
1464		target_id_tags: &[&str],
1465	) -> ClResult<HashMap<String, (bool, bool)>>;
1466
1467	/// Reads a profile
1468	///
1469	/// Returns an `(etag, Profile)` tuple.
1470	async fn read_profile(
1471		&self,
1472		tn_id: TnId,
1473		id_tag: &str,
1474	) -> ClResult<(Box<str>, Profile<Box<str>>)>;
1475
1476	/// Read profile roles for access token generation
1477	async fn read_profile_roles(
1478		&self,
1479		tn_id: TnId,
1480		id_tag: &str,
1481	) -> ClResult<Option<Box<[Box<str>]>>>;
1482
1483	/// Insert a profile row if missing, otherwise update it.
1484	///
1485	/// Returns `UpsertResult::Created` if the row was inserted, or
1486	/// `UpsertResult::Updated` if an existing row was updated. Never returns
1487	/// `Error::Conflict` or `Error::NotFound` — the operation is idempotent
1488	/// with respect to row existence.
1489	async fn upsert_profile(
1490		&self,
1491		tn_id: TnId,
1492		id_tag: &str,
1493		fields: &UpsertProfileFields,
1494	) -> ClResult<UpsertResult>;
1495
1496	/// Reads the public key of a profile
1497	///
1498	/// Returns a `(public key, expiration)` tuple.
1499	async fn read_profile_public_key(
1500		&self,
1501		id_tag: &str,
1502		key_id: &str,
1503	) -> ClResult<(Box<str>, Timestamp)>;
1504	/// Cache a federated profile public key.
1505	///
1506	/// `expires_at` is the owner-declared key expiration from the remote profile.
1507	/// `None` means the owner did not declare an expiration; the implementation
1508	/// may store it as NULL (treated as "never expires" by `read_profile_public_key`).
1509	async fn add_profile_public_key(
1510		&self,
1511		id_tag: &str,
1512		key_id: &str,
1513		public_key: &str,
1514		expires_at: Option<Timestamp>,
1515	) -> ClResult<()>;
1516	/// List stale profiles that need refreshing
1517	///
1518	/// Returns profiles where:
1519	/// - `synced_at IS NULL` (never synced — always eligible), OR
1520	/// - `synced_at < now - max_age_secs` AND `synced_at >= now - disable_after_secs`
1521	///   (stale but not yet abandoned).
1522	///
1523	/// Profiles with `synced_at < now - disable_after_secs` are excluded so the
1524	/// refresh batch stops attempting persistently failing remotes.
1525	/// Returns `Vec<(tn_id, id_tag, etag)>` tuples for conditional refresh requests.
1526	async fn list_stale_profiles(
1527		&self,
1528		max_age_secs: i64,
1529		disable_after_secs: i64,
1530		limit: u32,
1531	) -> ClResult<Vec<(TnId, Box<str>, Option<Box<str>>)>>;
1532
1533	// Action management
1534	//*******************
1535	async fn get_action_id(&self, tn_id: TnId, a_id: u64) -> ClResult<Box<str>>;
1536	async fn list_actions(
1537		&self,
1538		tn_id: TnId,
1539		opts: &ListActionOptions,
1540	) -> ClResult<Vec<ActionView>>;
1541	async fn list_action_tokens(
1542		&self,
1543		tn_id: TnId,
1544		opts: &ListActionOptions,
1545	) -> ClResult<Box<[Box<str>]>>;
1546
1547	/// Count actions matching `opts`, grouped by `group_by`. Returns
1548	/// `(group_value, count)` pairs (group value NULL-able). Used to derive
1549	/// per-reaction-type counts without baking reaction semantics into the adapter.
1550	async fn count_actions_grouped(
1551		&self,
1552		tn_id: TnId,
1553		opts: &ListActionOptions,
1554		group_by: ActionCountGroupBy,
1555	) -> ClResult<Vec<(Option<String>, i64)>>;
1556
1557	/// Count actions matching `opts` (same filters as `list_actions`), no
1558	/// limit/sort/cursor. Backs the `count=true` flag on `GET /actions`. When
1559	/// `opts.visibility_guard` is set (`Null` guest / `Value` viewer) the count is
1560	/// post-visibility, applying the same ABAC translation of `can_view_item` the
1561	/// row-list pass uses. `Undefined` (default) counts every matching row.
1562	async fn count_actions(&self, tn_id: TnId, opts: &ListActionOptions) -> ClResult<i64>;
1563
1564	/// Set a read-watermark, forward-only (a lower `position` is a no-op).
1565	/// Dispatches by `scope`, all against the reader's own (`tn_id`) node:
1566	///   - `"feed"`   → `profiles.feed_read_at` for `id_tag = key`
1567	///   - `"msg"`    → `profiles.msg_read_at`  for `id_tag = key`
1568	///   - `"thread"` → `actions.comments_read_at` for `action_id = key`
1569	/// Unknown scope → bad-request error.
1570	async fn set_read_marker(
1571		&self,
1572		tn_id: TnId,
1573		scope: &str,
1574		key: &str,
1575		position: i64,
1576	) -> ClResult<()>;
1577
1578	/// Auto-subscribe at Tracking: set `sub_level='T'` only when it is currently
1579	/// NULL (never downgrade an existing Watching). No-op if the row is absent.
1580	/// (Manual W/T/M changes go through `update_action_data`'s `sub_level` patch.)
1581	async fn auto_track_action(&self, tn_id: TnId, action_id: &str) -> ClResult<()>;
1582
1583	async fn create_action(
1584		&self,
1585		tn_id: TnId,
1586		action: &Action<&str>,
1587		key: Option<&str>,
1588	) -> ClResult<ActionId<Box<str>>>;
1589
1590	async fn finalize_action(
1591		&self,
1592		tn_id: TnId,
1593		a_id: u64,
1594		action_id: &str,
1595		options: FinalizeActionOptions<'_>,
1596	) -> ClResult<()>;
1597
1598	async fn create_inbound_action(
1599		&self,
1600		tn_id: TnId,
1601		action_id: &str,
1602		token: &str,
1603		ack_token: Option<&str>,
1604	) -> ClResult<()>;
1605
1606	/// Get the root_id of an action
1607	async fn get_action_root_id(&self, tn_id: TnId, action_id: &str) -> ClResult<Box<str>>;
1608
1609	/// Get action data (subject, reaction count, comment count)
1610	async fn get_action_data(&self, tn_id: TnId, action_id: &str) -> ClResult<Option<ActionData>>;
1611
1612	/// Get action by key
1613	async fn get_action_by_key(
1614		&self,
1615		tn_id: TnId,
1616		action_key: &str,
1617	) -> ClResult<Option<Action<Box<str>>>>;
1618
1619	/// Store action token for federation (called when action is created)
1620	async fn store_action_token(&self, tn_id: TnId, action_id: &str, token: &str) -> ClResult<()>;
1621
1622	/// Get action token for federation
1623	async fn get_action_token(&self, tn_id: TnId, action_id: &str) -> ClResult<Option<Box<str>>>;
1624
1625	/// Update action data (subject, reactions, comments, status)
1626	async fn update_action_data(
1627		&self,
1628		tn_id: TnId,
1629		action_id: &str,
1630		opts: &UpdateActionDataOptions,
1631	) -> ClResult<()>;
1632
1633	/// Update inbound action status
1634	async fn update_inbound_action(
1635		&self,
1636		tn_id: TnId,
1637		action_id: &str,
1638		status: Option<char>,
1639	) -> ClResult<()>;
1640
1641	/// Get related action tokens by APRV action_id
1642	/// Returns list of (action_id, token) pairs for actions that have ack = aprv_action_id
1643	async fn get_related_action_tokens(
1644		&self,
1645		tn_id: TnId,
1646		aprv_action_id: &str,
1647	) -> ClResult<Vec<(Box<str>, Box<str>)>>;
1648
1649	// File management
1650	//*****************
1651	async fn get_file_id(&self, tn_id: TnId, f_id: u64) -> ClResult<Box<str>>;
1652	async fn list_files(&self, tn_id: TnId, opts: &ListFileOptions) -> ClResult<Vec<FileView>>;
1653	async fn list_file_variants(
1654		&self,
1655		tn_id: TnId,
1656		file_id: FileId<&str>,
1657	) -> ClResult<Vec<FileVariant<Box<str>>>>;
1658	/// List locally available variant names for a file (only those marked available)
1659	async fn list_available_variants(&self, tn_id: TnId, file_id: &str) -> ClResult<Vec<Box<str>>>;
1660	/// List every `variant_id` whose blob is expected to be present in the
1661	/// given tenant's blob store. For `TnId(0)` returns the union of all
1662	/// `global=1` variant rows across tenants; for other tenants returns only
1663	/// the variants whose `global=0` (i.e., stored locally, not in shared).
1664	async fn list_referenced_variant_ids(&self, tn_id: TnId) -> ClResult<Vec<Box<str>>>;
1665	/// Targeted recheck for the blob GC: is there *currently* a `file_variants`
1666	/// row that expects this blob to live in `tn_id`'s blob store? For
1667	/// `TnId(0)` matches any `global=1` row; for other tenants matches a
1668	/// `tn_id`-scoped `global=0` row. Used to close the race between the
1669	/// referenced-set snapshot and the actual `delete_blob` call.
1670	async fn is_variant_referenced(&self, tn_id: TnId, variant_id: &str) -> ClResult<bool>;
1671	async fn read_file_variant(
1672		&self,
1673		tn_id: TnId,
1674		variant_id: &str,
1675	) -> ClResult<FileVariant<Box<str>>>;
1676	/// Look up the file_id for a given variant_id
1677	async fn read_file_id_by_variant(&self, tn_id: TnId, variant_id: &str) -> ClResult<Box<str>>;
1678	/// Look up the internal f_id for a given file_id (for adding variants to existing files)
1679	async fn read_f_id_by_file_id(&self, tn_id: TnId, file_id: &str) -> ClResult<u64>;
1680	async fn create_file(&self, tn_id: TnId, opts: CreateFile) -> ClResult<FileId<Box<str>>>;
1681	async fn create_file_variant<'a>(
1682		&'a self,
1683		tn_id: TnId,
1684		f_id: u64,
1685		opts: FileVariant<&'a str>,
1686	) -> ClResult<&'a str>;
1687	async fn update_file_id(&self, tn_id: TnId, f_id: u64, file_id: &str) -> ClResult<()>;
1688
1689	/// Finalize a pending file - sets file_id and transitions status from 'P' to 'A' atomically
1690	async fn finalize_file(&self, tn_id: TnId, f_id: u64, file_id: &str) -> ClResult<()>;
1691
1692	/// List internal `f_id`s of files whose `parent_id` equals the given sentinel
1693	/// (e.g. [`MANAGED_PARENT_ID`]) and whose `created_at` is strictly before
1694	/// `before`. Used by the file GC to enumerate candidates inside the managed
1695	/// folder while honouring the safety window.
1696	async fn list_files_by_parent(
1697		&self,
1698		tn_id: TnId,
1699		parent_id: &str,
1700		before: Timestamp,
1701	) -> ClResult<Vec<u64>>;
1702
1703	/// Internal `f_id`s of files in the managed folder that are still referenced
1704	/// by at least one canonical column. The file GC keeps any candidate whose
1705	/// `f_id` is in this set.
1706	///
1707	/// Returning numeric `f_id`s (instead of string `file_id`s) keeps the
1708	/// reference set small — it is naturally scoped to managed-folder rows by
1709	/// the join, so even tenants with millions of references hold only the
1710	/// distinct managed-file count in memory.
1711	///
1712	/// Current sources:
1713	/// - `actions.attachments` (CSV-split, every action regardless of
1714	///   `actions.status`). Both raw `file_id` tokens and `@<f_id>` draft-time
1715	///   placeholders resolve via the `files` table — the latter must not be
1716	///   dropped, or files attached to drafts that finalized after the draft
1717	///   was saved would be reaped.
1718	/// - `tenants.profile_pic`, `tenants.cover_pic` (this tenant).
1719	/// - `profiles.profile_pic` (cached remote profile images, this tenant).
1720	///
1721	/// MUST be updated when a new column names a file in the managed folder.
1722	/// Missing a source here will cause the GC to reap files that are still
1723	/// referenced elsewhere.
1724	async fn list_referenced_managed_fids(&self, tn_id: TnId) -> ClResult<HashSet<u64>>;
1725
1726	/// Hard-delete a file: removes all `file_variants` rows and then the
1727	/// `files` row inside a single transaction. Intended for the file GC.
1728	async fn hard_delete_file(&self, tn_id: TnId, f_id: u64) -> ClResult<()>;
1729
1730	// Task scheduler
1731	//****************
1732	async fn list_tasks(&self, opts: ListTaskOptions) -> ClResult<Vec<Task>>;
1733	async fn list_task_ids(&self, kind: &str, keys: &[Box<str>]) -> ClResult<Vec<u64>>;
1734	async fn create_task(
1735		&self,
1736		kind: &'static str,
1737		key: Option<&str>,
1738		input: &str,
1739		deps: &[u64],
1740	) -> ClResult<u64>;
1741	async fn update_task_finished(&self, task_id: u64, output: &str) -> ClResult<()>;
1742	async fn update_task_error(
1743		&self,
1744		task_id: u64,
1745		output: &str,
1746		next_at: Option<Timestamp>,
1747	) -> ClResult<()>;
1748
1749	/// Find a pending task by its key
1750	async fn find_task_by_key(&self, key: &str) -> ClResult<Option<Task>>;
1751
1752	/// Update task fields with partial updates
1753	async fn update_task(&self, task_id: u64, patch: &TaskPatch) -> ClResult<()>;
1754
1755	/// Find deps that have completed (status != 'P')
1756	async fn find_completed_deps(&self, deps: &[u64]) -> ClResult<Vec<u64>>;
1757
1758	// Phase 1: Profile Management
1759	//****************************
1760	/// Get a single profile by id_tag
1761	async fn get_profile_info(&self, tn_id: TnId, id_tag: &str) -> ClResult<ProfileData>;
1762
1763	// Phase 2: Action Management
1764	//***************************
1765	/// Get a single action by action_id
1766	async fn get_action(&self, tn_id: TnId, action_id: &str) -> ClResult<Option<ActionView>>;
1767
1768	/// Lightweight probe: the action's `type` column only (no joins/hydration).
1769	async fn get_action_type(&self, tn_id: TnId, action_id: &str) -> ClResult<Option<Box<str>>>;
1770
1771	/// Update action content and attachments (if not yet federated)
1772	async fn update_action(
1773		&self,
1774		tn_id: TnId,
1775		action_id: &str,
1776		content: Option<&str>,
1777		attachments: Option<&[&str]>,
1778	) -> ClResult<()>;
1779
1780	/// Delete an action (soft delete with cleanup)
1781	async fn delete_action(&self, tn_id: TnId, action_id: &str) -> ClResult<()>;
1782
1783	// Phase 2: File Management Enhancements
1784	//**************************************
1785	/// Delete a file (set status to 'D')
1786	async fn delete_file(&self, tn_id: TnId, file_id: &str) -> ClResult<()>;
1787
1788	/// List all child files in a document tree (files with the given root_id)
1789	async fn list_children_by_root(&self, tn_id: TnId, root_id: &str) -> ClResult<Vec<Box<str>>>;
1790
1791	// Settings Management
1792	//*********************
1793	/// List all settings for a tenant, optionally filtered by prefix
1794	async fn list_settings(
1795		&self,
1796		tn_id: TnId,
1797		prefix: Option<&[String]>,
1798	) -> ClResult<std::collections::HashMap<String, serde_json::Value>>;
1799
1800	/// Read a single setting by name
1801	async fn read_setting(&self, tn_id: TnId, name: &str) -> ClResult<Option<serde_json::Value>>;
1802
1803	/// Update or delete a setting (None = delete)
1804	async fn update_setting(
1805		&self,
1806		tn_id: TnId,
1807		name: &str,
1808		value: Option<serde_json::Value>,
1809	) -> ClResult<()>;
1810
1811	// Reference / Bookmark Management
1812	//********************************
1813	/// List all references for a tenant
1814	async fn list_refs(&self, tn_id: TnId, opts: &ListRefsOptions) -> ClResult<Vec<RefData>>;
1815
1816	/// Get a specific reference by ID
1817	async fn get_ref(&self, tn_id: TnId, ref_id: &str) -> ClResult<Option<RefData>>;
1818
1819	/// Create a new reference
1820	async fn create_ref(
1821		&self,
1822		tn_id: TnId,
1823		ref_id: &str,
1824		opts: &CreateRefOptions,
1825	) -> ClResult<RefData>;
1826
1827	/// Delete a reference
1828	async fn delete_ref(&self, tn_id: TnId, ref_id: &str) -> ClResult<()>;
1829
1830	/// Update fields of an existing reference. Returns the updated row.
1831	async fn update_ref(
1832		&self,
1833		tn_id: TnId,
1834		ref_id: &str,
1835		opts: &UpdateRefOptions,
1836	) -> ClResult<RefData>;
1837
1838	/// Use/consume a reference - validates type, expiration, counter, decrements counter
1839	/// Returns (TnId, id_tag, RefData) of the tenant that owns this ref
1840	async fn use_ref(
1841		&self,
1842		ref_id: &str,
1843		expected_types: &[&str],
1844	) -> ClResult<(TnId, Box<str>, RefData)>;
1845
1846	/// Validate a reference without consuming it - checks type, expiration, counter
1847	/// Returns (TnId, id_tag, RefData) of the tenant that owns this ref if valid
1848	async fn validate_ref(
1849		&self,
1850		ref_id: &str,
1851		expected_types: &[&str],
1852	) -> ClResult<(TnId, Box<str>, RefData)>;
1853
1854	// Tag Management
1855	//***************
1856	/// List all tags for a tenant
1857	///
1858	/// # Arguments
1859	/// * `tn_id` - Tenant ID
1860	/// * `prefix` - Optional prefix filter
1861	/// * `with_counts` - If true, include file counts per tag
1862	/// * `limit` - Optional limit on number of tags returned
1863	async fn list_tags(
1864		&self,
1865		tn_id: TnId,
1866		prefix: Option<&str>,
1867		with_counts: bool,
1868		limit: Option<u32>,
1869	) -> ClResult<Vec<TagInfo>>;
1870
1871	/// Add a tag to a file
1872	async fn add_tag(&self, tn_id: TnId, file_id: &str, tag: &str) -> ClResult<Vec<String>>;
1873
1874	/// Remove a tag from a file
1875	async fn remove_tag(&self, tn_id: TnId, file_id: &str, tag: &str) -> ClResult<Vec<String>>;
1876
1877	// File Management Enhancements
1878	//****************************
1879	/// Update file metadata (name, visibility, status)
1880	async fn update_file_data(
1881		&self,
1882		tn_id: TnId,
1883		file_id: &str,
1884		opts: &UpdateFileOptions,
1885	) -> ClResult<()>;
1886
1887	/// Read file metadata
1888	async fn read_file(&self, tn_id: TnId, file_id: &str) -> ClResult<Option<FileView>>;
1889
1890	/// Like [`read_file`] but also populates `user_data` (pinned, starred,
1891	/// per-user timestamps, cached cross-context `access_level`) for the
1892	/// given user.
1893	async fn read_file_with_user_data(
1894		&self,
1895		tn_id: TnId,
1896		file_id: &str,
1897		id_tag: &str,
1898	) -> ClResult<Option<FileView>>;
1899
1900	// File User Data (per-user file activity tracking)
1901	//**************************************************
1902
1903	/// Record file access for a user (upserts record, updates accessed_at timestamp)
1904	async fn record_file_access(&self, tn_id: TnId, id_tag: &str, file_id: &str) -> ClResult<()>;
1905
1906	/// Record file modification for a user (upserts record, updates modified_at timestamp)
1907	async fn record_file_modification(
1908		&self,
1909		tn_id: TnId,
1910		id_tag: &str,
1911		file_id: &str,
1912	) -> ClResult<()>;
1913
1914	/// Update file user data (pinned/starred status, cached access_level).
1915	///
1916	/// All three fields share the same three-state `Patch` encoding:
1917	/// `Patch::Undefined` leaves the column untouched, `Patch::Null` clears it
1918	/// (writes NULL — `pinned`/`starred` read back as `false`),
1919	/// `Patch::Value(v)` sets it (`access_level` ch ∈ {'R', 'C', 'W'}).
1920	/// Used by the `POST /files/{id}/refresh` handler (and FSHR on_accept on
1921	/// the receiver side) to cache the source-reported cross-context access level.
1922	async fn update_file_user_data(
1923		&self,
1924		tn_id: TnId,
1925		id_tag: &str,
1926		file_id: &str,
1927		pinned: crate::types::Patch<bool>,
1928		starred: crate::types::Patch<bool>,
1929		access_level: crate::types::Patch<char>,
1930	) -> ClResult<FileUserData>;
1931
1932	/// Get file user data for a specific file
1933	async fn get_file_user_data(
1934		&self,
1935		tn_id: TnId,
1936		id_tag: &str,
1937		file_id: &str,
1938	) -> ClResult<Option<FileUserData>>;
1939
1940	// Push Subscription Management
1941	//*****************************
1942
1943	/// List all push subscriptions for a tenant (user)
1944	///
1945	/// Returns all active push subscriptions for this tenant.
1946	/// Each tenant represents a user, so this returns all their device subscriptions.
1947	async fn list_push_subscriptions(&self, tn_id: TnId) -> ClResult<Vec<PushSubscription>>;
1948
1949	/// Create a new push subscription
1950	///
1951	/// Stores a Web Push subscription for a tenant. The subscription contains
1952	/// the endpoint URL and encryption keys needed to send push notifications.
1953	/// Returns the generated subscription ID.
1954	async fn create_push_subscription(
1955		&self,
1956		tn_id: TnId,
1957		subscription: &PushSubscriptionData,
1958	) -> ClResult<u64>;
1959
1960	/// Delete a push subscription by ID
1961	///
1962	/// Removes a push subscription. Called when a subscription becomes invalid
1963	/// (e.g., 410 Gone response from push service) or when user unsubscribes.
1964	async fn delete_push_subscription(&self, tn_id: TnId, subscription_id: u64) -> ClResult<()>;
1965
1966	// Share Entry Management
1967	//***********************
1968
1969	/// Create a share entry (idempotent on unique constraint)
1970	async fn create_share_entry(
1971		&self,
1972		tn_id: TnId,
1973		resource_type: char,
1974		resource_id: &str,
1975		created_by: &str,
1976		entry: &CreateShareEntry,
1977	) -> ClResult<ShareEntry>;
1978
1979	/// Delete a share entry by ID
1980	async fn delete_share_entry(&self, tn_id: TnId, id: i64) -> ClResult<()>;
1981
1982	/// Update fields of an existing share entry using PATCH semantics.
1983	/// The update only applies if the row also matches `(resource_type, resource_id)`,
1984	/// which both prevents cross-resource targeting and removes the need for a
1985	/// caller-side pre-read. Returns the updated row via SQL `RETURNING`, or
1986	/// `Error::NotFound` if no row matched.
1987	async fn update_share_entry(
1988		&self,
1989		tn_id: TnId,
1990		id: i64,
1991		resource_type: char,
1992		resource_id: &str,
1993		opts: &UpdateShareEntryOptions,
1994	) -> ClResult<ShareEntry>;
1995
1996	/// List share entries for a resource
1997	async fn list_share_entries(
1998		&self,
1999		tn_id: TnId,
2000		resource_type: char,
2001		resource_id: &str,
2002	) -> ClResult<Vec<ShareEntry>>;
2003
2004	/// List share entries by subject (reverse lookup).
2005	/// If `subject_type` is None, matches all subject types.
2006	async fn list_share_entries_by_subject(
2007		&self,
2008		tn_id: TnId,
2009		subject_type: Option<char>,
2010		subject_id: &str,
2011	) -> ClResult<Vec<ShareEntry>>;
2012
2013	/// Check if a subject has share access to a resource
2014	/// Returns the permission char if access exists, None otherwise
2015	async fn check_share_access(
2016		&self,
2017		tn_id: TnId,
2018		resource_type: char,
2019		resource_id: &str,
2020		subject_type: char,
2021		subject_id: &str,
2022	) -> ClResult<Option<char>>;
2023
2024	/// Read a single share entry by ID (for delete validation)
2025	async fn read_share_entry(&self, tn_id: TnId, id: i64) -> ClResult<Option<ShareEntry>>;
2026
2027	// Installed App Management
2028	//*************************
2029
2030	/// Install an app package
2031	async fn install_app(&self, tn_id: TnId, install: &InstallApp) -> ClResult<()>;
2032
2033	/// Uninstall an app by name and publisher
2034	async fn uninstall_app(&self, tn_id: TnId, app_name: &str, publisher_tag: &str)
2035	-> ClResult<()>;
2036
2037	/// List installed apps, optionally filtered by search term
2038	async fn list_installed_apps(
2039		&self,
2040		tn_id: TnId,
2041		search: Option<&str>,
2042	) -> ClResult<Vec<InstalledApp>>;
2043
2044	/// Get a specific installed app
2045	async fn get_installed_app(
2046		&self,
2047		tn_id: TnId,
2048		app_name: &str,
2049		publisher_tag: &str,
2050	) -> ClResult<Option<InstalledApp>>;
2051
2052	// Address book / contact management
2053	//***********************************
2054
2055	/// Create a new address book collection.
2056	async fn create_address_book(
2057		&self,
2058		tn_id: TnId,
2059		name: &str,
2060		description: Option<&str>,
2061	) -> ClResult<AddressBook>;
2062
2063	/// List all address books for a tenant.
2064	async fn list_address_books(&self, tn_id: TnId) -> ClResult<Vec<AddressBook>>;
2065
2066	/// Read a single address book by id.
2067	async fn get_address_book(&self, tn_id: TnId, ab_id: u64) -> ClResult<Option<AddressBook>>;
2068
2069	/// Look up an address book by its name (for CardDAV path routing).
2070	async fn get_address_book_by_name(
2071		&self,
2072		tn_id: TnId,
2073		name: &str,
2074	) -> ClResult<Option<AddressBook>>;
2075
2076	/// Patch an address book's metadata.
2077	async fn update_address_book(
2078		&self,
2079		tn_id: TnId,
2080		ab_id: u64,
2081		patch: &UpdateAddressBookData,
2082	) -> ClResult<()>;
2083
2084	/// Delete an address book (and all its contacts).
2085	async fn delete_address_book(&self, tn_id: TnId, ab_id: u64) -> ClResult<()>;
2086
2087	/// List + search contacts. When `ab_id` is `Some`, scopes to that book (cursor
2088	/// is c_id-ordered). When `None`, queries across all books sorted by name.
2089	async fn list_contacts(
2090		&self,
2091		tn_id: TnId,
2092		ab_id: Option<u64>,
2093		opts: &ListContactOptions,
2094	) -> ClResult<Vec<ContactView>>;
2095
2096	/// Read a single contact (including vCard blob) by UID.
2097	async fn get_contact(&self, tn_id: TnId, ab_id: u64, uid: &str) -> ClResult<Option<Contact>>;
2098
2099	/// Insert or update a contact (keyed by UID). Also bumps the address book's ctag.
2100	/// Returns the new etag.
2101	async fn upsert_contact(
2102		&self,
2103		tn_id: TnId,
2104		ab_id: u64,
2105		uid: &str,
2106		vcard: &str,
2107		etag: &str,
2108		extracted: &ContactExtracted,
2109	) -> ClResult<Box<str>>;
2110
2111	/// Soft-delete a contact (sets `deleted_at`), leaving a tombstone row for CardDAV sync.
2112	/// Also bumps the address book's ctag.
2113	async fn delete_contact(&self, tn_id: TnId, ab_id: u64, uid: &str) -> ClResult<()>;
2114
2115	/// Fetch multiple contacts by UID — for CardDAV `addressbook-multiget` REPORT.
2116	async fn get_contacts_by_uids(
2117		&self,
2118		tn_id: TnId,
2119		ab_id: u64,
2120		uids: &[&str],
2121	) -> ClResult<Vec<Contact>>;
2122
2123	/// Return live + tombstone entries for CardDAV `sync-collection` REPORT.
2124	/// `since` is the sync token's timestamp; `None` means full sync.
2125	/// `limit` caps the number of rows returned; callers supply their own hard ceiling
2126	/// to keep responses bounded. `None` means no client-supplied limit — callers should
2127	/// still pass their server-side ceiling.
2128	async fn list_contacts_since(
2129		&self,
2130		tn_id: TnId,
2131		ab_id: u64,
2132		since: Option<Timestamp>,
2133		limit: Option<u32>,
2134	) -> ClResult<Vec<ContactSyncEntry>>;
2135
2136	/// List all contacts linked to a given profile id_tag (for bulk snapshot refresh).
2137	async fn list_contacts_by_profile(
2138		&self,
2139		tn_id: TnId,
2140		profile_id_tag: &str,
2141	) -> ClResult<Vec<Contact>>;
2142
2143	// Calendar / calendar-object management (CalDAV + JSON REST)
2144	//************************************************************
2145
2146	/// Create a new calendar collection.
2147	async fn create_calendar(&self, tn_id: TnId, input: &CreateCalendarData) -> ClResult<Calendar>;
2148
2149	/// List all calendars for a tenant.
2150	async fn list_calendars(&self, tn_id: TnId) -> ClResult<Vec<Calendar>>;
2151
2152	/// Read a single calendar by id.
2153	async fn get_calendar(&self, tn_id: TnId, cal_id: u64) -> ClResult<Option<Calendar>>;
2154
2155	/// Look up a calendar by its name (for CalDAV path routing).
2156	async fn get_calendar_by_name(&self, tn_id: TnId, name: &str) -> ClResult<Option<Calendar>>;
2157
2158	/// Patch a calendar's metadata.
2159	async fn update_calendar(
2160		&self,
2161		tn_id: TnId,
2162		cal_id: u64,
2163		patch: &UpdateCalendarData,
2164	) -> ClResult<()>;
2165
2166	/// Delete a calendar (and all its objects).
2167	async fn delete_calendar(&self, tn_id: TnId, cal_id: u64) -> ClResult<()>;
2168
2169	/// List + search calendar objects within a calendar. Excludes soft-deleted rows.
2170	async fn list_calendar_objects(
2171		&self,
2172		tn_id: TnId,
2173		cal_id: u64,
2174		opts: &ListCalendarObjectOptions,
2175	) -> ClResult<Vec<CalendarObjectView>>;
2176
2177	/// Read a single calendar object (including iCalendar blob) by UID.
2178	/// Returns the master row; recurrence-override rows live under the same UID but distinct
2179	/// `recurrence_id` and are not merged here.
2180	async fn get_calendar_object(
2181		&self,
2182		tn_id: TnId,
2183		cal_id: u64,
2184		uid: &str,
2185	) -> ClResult<Option<CalendarObject>>;
2186
2187	/// Read a single recurrence-override row keyed by `(uid, recurrence_id)`.
2188	async fn get_calendar_object_override(
2189		&self,
2190		tn_id: TnId,
2191		cal_id: u64,
2192		uid: &str,
2193		recurrence_id: Timestamp,
2194	) -> ClResult<Option<CalendarObject>>;
2195
2196	/// List all non-deleted recurrence-override rows for a given master UID.
2197	async fn list_calendar_object_overrides(
2198		&self,
2199		tn_id: TnId,
2200		cal_id: u64,
2201		uid: &str,
2202	) -> ClResult<Vec<CalendarObject>>;
2203
2204	/// Soft-delete a single recurrence-override row (leaves the master untouched).
2205	async fn delete_calendar_object_override(
2206		&self,
2207		tn_id: TnId,
2208		cal_id: u64,
2209		uid: &str,
2210		recurrence_id: Timestamp,
2211	) -> ClResult<()>;
2212
2213	/// Insert or update a calendar object (keyed by UID). Also bumps the calendar's ctag.
2214	/// Returns the new etag. The `extracted.recurrence_id` selects which row is written — the
2215	/// master row has `None`, recurrence overrides carry their own timestamp.
2216	async fn upsert_calendar_object(
2217		&self,
2218		tn_id: TnId,
2219		cal_id: u64,
2220		uid: &str,
2221		ical: &str,
2222		etag: &str,
2223		extracted: &CalendarObjectExtracted,
2224	) -> ClResult<Box<str>>;
2225
2226	/// Soft-delete a calendar object by UID (sets `deleted_at` on all rows sharing that UID),
2227	/// leaving tombstones for CalDAV sync. Also bumps the calendar's ctag.
2228	async fn delete_calendar_object(&self, tn_id: TnId, cal_id: u64, uid: &str) -> ClResult<()>;
2229
2230	/// Atomically split a recurring series at `split_at`:
2231	///   1. Upsert the existing master (typically with a truncated RRULE) using the
2232	///      caller-supplied ical / etag / extracted projection.
2233	///   2. Soft-delete every override row whose `recurrence_id >= split_at`.
2234	///   3. Insert the tail as a new master under its own UID.
2235	///   4. Bump the calendar's ctag once for the whole fork.
2236	///
2237	/// The whole operation runs in a single transaction; on any error the caller sees the
2238	/// original series unchanged. Returns the stored etags of the master and the tail,
2239	/// in that order.
2240	async fn split_calendar_object_series(
2241		&self,
2242		tn_id: TnId,
2243		cal_id: u64,
2244		master: CalendarObjectWrite<'_>,
2245		tail: CalendarObjectWrite<'_>,
2246		split_at: Timestamp,
2247	) -> ClResult<(Box<str>, Box<str>)>;
2248
2249	/// Fetch multiple calendar objects by UID — for CalDAV `calendar-multiget` REPORT.
2250	async fn get_calendar_objects_by_uids(
2251		&self,
2252		tn_id: TnId,
2253		cal_id: u64,
2254		uids: &[&str],
2255	) -> ClResult<Vec<CalendarObject>>;
2256
2257	/// Return live + tombstone entries for CalDAV `sync-collection` REPORT.
2258	/// `since` is the sync token's timestamp; `None` means full sync.
2259	async fn list_calendar_objects_since(
2260		&self,
2261		tn_id: TnId,
2262		cal_id: u64,
2263		since: Option<Timestamp>,
2264		limit: Option<u32>,
2265	) -> ClResult<Vec<CalendarObjectSyncEntry>>;
2266
2267	/// Return calendar objects overlapping a time range — for CalDAV `calendar-query` REPORT.
2268	/// Semantics are deliberately loose (superset): any object whose master `dtstart` is ≤ `end`
2269	/// AND (`rrule` is set OR `dtend` is ≥ `start` OR `dtend IS NULL`) is returned. Clients
2270	/// expand recurrence locally. A `None` component lists both VEVENT and VTODO.
2271	async fn query_calendar_objects_in_range(
2272		&self,
2273		tn_id: TnId,
2274		cal_id: u64,
2275		component: Option<&str>,
2276		start: Option<Timestamp>,
2277		end: Option<Timestamp>,
2278	) -> ClResult<Vec<CalendarObject>>;
2279}
2280
2281#[cfg(test)]
2282mod tests {
2283	use super::*;
2284	#[test]
2285	fn test_deserialize_list_action_options_with_multiple_statuses() {
2286		let query = "status=C,N&type=POST,REPLY";
2287		let opts: ListActionOptions =
2288			serde_urlencoded::from_str(query).expect("should deserialize");
2289
2290		assert!(opts.status.is_some());
2291		let statuses = opts.status.expect("status should be Some");
2292		assert_eq!(statuses.len(), 2);
2293		assert_eq!(statuses[0].as_str(), "C");
2294		assert_eq!(statuses[1].as_str(), "N");
2295
2296		assert!(opts.typ.is_some());
2297		let types = opts.typ.expect("type should be Some");
2298		assert_eq!(types.len(), 2);
2299		assert_eq!(types[0].as_str(), "POST");
2300		assert_eq!(types[1].as_str(), "REPLY");
2301	}
2302
2303	#[test]
2304	fn test_deserialize_list_action_options_without_status() {
2305		let query = "issuer=alice";
2306		let opts: ListActionOptions =
2307			serde_urlencoded::from_str(query).expect("should deserialize");
2308
2309		assert!(opts.status.is_none());
2310		assert!(opts.typ.is_none());
2311		assert_eq!(opts.issuer.as_deref(), Some("alice"));
2312	}
2313
2314	#[test]
2315	fn test_deserialize_list_action_options_single_status() {
2316		let query = "status=C";
2317		let opts: ListActionOptions =
2318			serde_urlencoded::from_str(query).expect("should deserialize");
2319
2320		assert!(opts.status.is_some());
2321		let statuses = opts.status.expect("status should be Some");
2322		assert_eq!(statuses.len(), 1);
2323		assert_eq!(statuses[0].as_str(), "C");
2324	}
2325
2326	#[test]
2327	fn test_deserialize_list_action_options_audience_type() {
2328		let opts: ListActionOptions = serde_urlencoded::from_str("audienceType=personal")
2329			.expect("should deserialize personal");
2330		assert!(matches!(opts.audience_type, Some(AudienceType::Personal)));
2331
2332		let opts: ListActionOptions = serde_urlencoded::from_str("audienceType=community")
2333			.expect("should deserialize community");
2334		assert!(matches!(opts.audience_type, Some(AudienceType::Community)));
2335
2336		let opts: ListActionOptions =
2337			serde_urlencoded::from_str("issuer=alice").expect("should deserialize");
2338		assert!(opts.audience_type.is_none());
2339
2340		let res: Result<ListActionOptions, _> = serde_urlencoded::from_str("audienceType=garbage");
2341		assert!(res.is_err(), "garbage audienceType should error");
2342	}
2343
2344	#[test]
2345	fn test_deserialize_list_action_options_multi_visibility() {
2346		let opts: ListActionOptions =
2347			serde_urlencoded::from_str("visibility=F,C").expect("should deserialize");
2348		let v = opts.visibility.expect("visibility should be Some");
2349		assert_eq!(v.len(), 2);
2350		assert_eq!(v[0].as_str(), "F");
2351		assert_eq!(v[1].as_str(), "C");
2352
2353		let opts: ListActionOptions =
2354			serde_urlencoded::from_str("visibility=P").expect("should deserialize");
2355		let v = opts.visibility.expect("visibility should be Some");
2356		assert_eq!(v.len(), 1);
2357		assert_eq!(v[0].as_str(), "P");
2358
2359		let opts: ListActionOptions =
2360			serde_urlencoded::from_str("issuer=alice").expect("should deserialize");
2361		assert!(opts.visibility.is_none());
2362	}
2363
2364	#[test]
2365	fn test_deserialize_list_action_options_visibility_with_direct() {
2366		let opts: ListActionOptions =
2367			serde_urlencoded::from_str("visibility=D,F").expect("should deserialize");
2368		let v = opts.visibility.expect("visibility should be Some");
2369		assert_eq!(v.len(), 2);
2370		assert_eq!(v[0].as_str(), "D");
2371		assert_eq!(v[1].as_str(), "F");
2372	}
2373
2374	#[test]
2375	fn test_broken_reason_as_str_matches_serde() {
2376		for reason in [BrokenReason::Deleted, BrokenReason::Revoked] {
2377			let via_serde = serde_json::to_value(reason)
2378				.expect("serialize")
2379				.as_str()
2380				.expect("string variant")
2381				.to_string();
2382			assert_eq!(reason.as_str(), via_serde, "as_str diverged from serde for {:?}", reason);
2383		}
2384	}
2385}
2386
2387// vim: ts=4