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// The vocabulary of the `refs.type` column. Lives here because the authorization table in
111// `cloudillo-ref` and the redemption allowlists in `cloudillo-auth`, `cloudillo-profile` and
112// `cloudillo-idp` describe the same set and must be checkable against each other.
113
114/// The one ref type an ordinary member may mint, list or revoke: a file share link.
115pub const SHARE_FILE_REF_TYPE: &str = "share.file";
116/// Grants the right to create a NEW TENANT on this server — server-scoped, so `SADM` only.
117pub const REGISTER_REF_TYPE: &str = "register";
118/// Buys membership of one community — the tenant's leadership legitimately hands these out.
119pub const PROFILE_INVITE_REF_TYPE: &str = "profile.invite";
120/// Password-reset capability against one tenant *account*.
121pub const PASSWORD_REF_TYPE: &str = "password";
122/// First-login capability; `POST /api/auth/set-password` accepts it exactly like `password`.
123pub const WELCOME_REF_TYPE: &str = "welcome";
124/// Activates an identity at the IdP — power over the tenant account, not over its membership.
125pub const IDP_ACTIVATION_REF_TYPE: &str = "idp.activation";
126
127#[skip_serializing_none]
128#[derive(Debug, Clone, Serialize)]
129#[serde(rename_all = "camelCase")]
130pub struct RefData {
131	pub ref_id: Box<str>,
132	pub r#type: Box<str>,
133	pub description: Option<Box<str>>,
134	#[serde(serialize_with = "serialize_timestamp_iso")]
135	pub created_at: Timestamp,
136	#[serde(serialize_with = "serialize_timestamp_iso_opt")]
137	pub expires_at: Option<Timestamp>,
138	/// Usage count: None = unlimited, Some(n) = n uses remaining
139	pub count: Option<u32>,
140	/// Resource ID for share links (e.g., file_id for share.file type)
141	pub resource_id: Option<Box<str>>,
142	/// Access level for share links: `'R'`=Read, `'C'`=Comment, `'W'`=Write. Never `'A'` — a link
143	/// cannot delegate share management, so `cloudillo_ref::handler::parse_access_level` refuses it.
144	pub access_level: Option<char>,
145	/// Launch params as serialized query string (e.g., "mode=present")
146	pub params: Option<Box<str>>,
147}
148
149pub struct ListRefsOptions {
150	pub typ: Option<String>,
151	pub filter: Option<String>, // 'active', 'used', 'expired', 'all'
152	/// Filter by resource_id (for listing share links for a specific resource)
153	pub resource_id: Option<String>,
154}
155
156#[derive(Default)]
157pub struct CreateRefOptions {
158	pub typ: String,
159	pub description: Option<String>,
160	pub expires_at: Option<Timestamp>,
161	pub count: Option<u32>,
162	/// Resource ID for share links (e.g., file_id for share.file type)
163	pub resource_id: Option<String>,
164	/// Access level for share links: `'R'`=Read, `'C'`=Comment, `'W'`=Write. Never `'A'` — a link
165	/// cannot delegate share management, so `cloudillo_ref::handler::parse_access_level` refuses it.
166	pub access_level: Option<char>,
167	/// Launch params as serialized query string (e.g., "mode=present")
168	pub params: Option<String>,
169}
170
171/// Options for updating an existing reference via PATCH semantics.
172///
173/// Each field uses `Patch<T>`: `Undefined` leaves the column unchanged,
174/// `Null` clears it, `Value(v)` sets it. `type`, `resource_id`, and
175/// `params` are intentionally immutable post-create.
176#[derive(Debug, Default)]
177pub struct UpdateRefOptions {
178	pub description: Patch<String>,
179	/// Expiration timestamp. `Null` clears expiration (link never expires).
180	pub expires_at: Patch<Timestamp>,
181	/// `Null` clears the counter (unlimited uses).
182	pub count: Patch<u32>,
183	/// `Value('R'|'C'|'W')`.
184	pub access_level: Patch<char>,
185}
186
187#[skip_serializing_none]
188#[derive(Debug, Serialize)]
189#[serde(rename_all = "camelCase")]
190pub struct Tenant<S: AsRef<str>> {
191	#[serde(rename = "id")]
192	pub tn_id: TnId,
193	pub id_tag: S,
194	pub name: S,
195	#[serde(rename = "type")]
196	pub typ: ProfileType,
197	pub profile_pic: Option<S>,
198	pub cover_pic: Option<S>,
199	#[serde(serialize_with = "serialize_timestamp_iso")]
200	pub created_at: Timestamp,
201	/// Presence: stamped when the tenant's last ws-bus connection closes.
202	#[serde(skip_serializing_if = "Option::is_none")]
203	pub last_seen_at: Option<Timestamp>,
204	/// Offline-throttle watermark for the 'direct' group (MSG/CONN/FSHR).
205	#[serde(skip_serializing_if = "Option::is_none")]
206	pub notify_email_direct_at: Option<Timestamp>,
207	/// Offline-throttle watermark for the 'engagement' group (CMNT/REACT).
208	#[serde(skip_serializing_if = "Option::is_none")]
209	pub notify_email_engagement_at: Option<Timestamp>,
210	/// Offline-throttle watermark for the 'social' group (FLLW/POST).
211	#[serde(skip_serializing_if = "Option::is_none")]
212	pub notify_email_social_at: Option<Timestamp>,
213	pub x: HashMap<S, S>,
214}
215
216/// Options for listing tenants in meta adapter
217#[derive(Debug, Default)]
218pub struct ListTenantsMetaOptions {
219	pub limit: Option<u32>,
220	pub offset: Option<u32>,
221}
222
223/// Tenant list item from meta adapter (without cover_pic and x fields)
224#[skip_serializing_none]
225#[derive(Debug, Clone, Serialize)]
226#[serde(rename_all = "camelCase")]
227pub struct TenantListMeta {
228	pub tn_id: TnId,
229	pub id_tag: Box<str>,
230	pub name: Box<str>,
231	#[serde(rename = "type")]
232	pub typ: ProfileType,
233	pub profile_pic: Option<Box<str>>,
234	#[serde(serialize_with = "serialize_timestamp_iso")]
235	pub created_at: Timestamp,
236}
237
238#[derive(Debug, Default, Deserialize)]
239pub struct UpdateTenantData {
240	#[serde(rename = "idTag", default)]
241	pub id_tag: Patch<String>,
242	#[serde(default)]
243	pub name: Patch<String>,
244	#[serde(rename = "type", default)]
245	pub typ: Patch<ProfileType>,
246	#[serde(rename = "profilePic", default)]
247	pub profile_pic: Patch<String>,
248	#[serde(rename = "coverPic", default)]
249	pub cover_pic: Patch<String>,
250	/// Partial merge for x JSON field: Some(value) = upsert, None = delete key
251	#[serde(default)]
252	pub x: Option<std::collections::HashMap<String, Option<String>>>,
253	/// Presence watermark, server-set only (not deserialized from API requests).
254	/// Stamped when the tenant's last ws-bus connection closes.
255	#[serde(skip)]
256	pub last_seen_at: Patch<Timestamp>,
257	/// Offline-throttle watermarks, server-set only (stamped after an offline
258	/// notification email is scheduled for the group).
259	#[serde(skip)]
260	pub notify_email_direct_at: Patch<Timestamp>,
261	#[serde(skip)]
262	pub notify_email_engagement_at: Patch<Timestamp>,
263	#[serde(skip)]
264	pub notify_email_social_at: Patch<Timestamp>,
265}
266
267#[derive(Debug)]
268pub struct Profile<S: AsRef<str>> {
269	pub id_tag: S,
270	pub name: S,
271	pub typ: ProfileType,
272	pub profile_pic: Option<S>,
273	pub status: Option<ProfileStatus>,
274	pub synced_at: Option<Timestamp>,
275	pub following: bool,
276	pub follower: bool,
277	pub connected: ProfileConnectionStatus,
278	pub roles: Option<Box<[Box<str>]>>,
279	pub trust: Option<ProfileTrust>,
280	/// Reader's feed read-watermark for this context (own/community profile).
281	pub feed_read_at: Option<Timestamp>,
282	/// Reader's DM read-watermark for this peer profile.
283	pub msg_read_at: Option<Timestamp>,
284	/// Composition control for the home feed: `Some(true)` = this community is
285	/// hidden from the merged home feed (shown only in its own feed); `None` =
286	/// shown (the default). Only meaningful for community profiles.
287	pub hidden_in_home: Option<bool>,
288}
289
290/// Reduced, public-safe profile projection returned by
291/// [`MetaAdapter::read_profiles`]. Deliberately not [`Profile`], which carries
292/// the reading tenant's private relationship state — status (including
293/// Blocked/Muted/Banned), connected/following/follower, trust and the
294/// feed/msg read watermarks. None of that may reach a batch caller.
295#[derive(Debug, Clone)]
296pub struct PublicProfileRow {
297	pub id_tag: Box<str>,
298	pub name: Box<str>,
299	pub typ: ProfileType,
300	pub profile_pic: Option<Box<str>>,
301}
302
303#[derive(Debug, Default, Deserialize)]
304pub struct ListProfileOptions {
305	#[serde(rename = "type")]
306	pub typ: Option<ProfileType>,
307	pub status: Option<Box<[ProfileStatus]>>,
308	pub connected: Option<ProfileConnectionStatus>,
309	pub following: Option<bool>,
310	pub follower: Option<bool>,
311	pub q: Option<String>,
312	pub id_tag: Option<String>,
313	/// Filter profiles by whether a trust preference is set.
314	/// `Some(true)` returns only profiles with a non-null trust value;
315	/// `Some(false)` returns only profiles with NULL trust; `None` does not filter.
316	pub trust_set: Option<bool>,
317	/// Filter by home-feed composition flag. Some(true) → only communities hidden
318	/// from the home feed (hidden_in_home = 1); Some(false) → only shown; None → no filter.
319	pub hidden_in_home: Option<bool>,
320	/// Page size. Setting this (or [`Self::after_id_tag`]) switches the listing
321	/// from its default name-ordered top-100 to an `id_tag`-ordered keyset page,
322	/// making a full walk of a tenant's profiles possible.
323	pub limit: Option<u32>,
324	/// Keyset cursor: return only profiles whose `id_tag` sorts after this one.
325	pub after_id_tag: Option<String>,
326}
327
328/// Profile data returned from adapter queries
329#[derive(Debug, Clone, Serialize, Deserialize)]
330#[serde(rename_all = "camelCase")]
331pub struct ProfileData {
332	pub id_tag: Box<str>,
333	pub name: Box<str>,
334	#[serde(rename = "type")]
335	pub r#type: Box<str>, // "person" or "community"
336	pub profile_pic: Option<Box<str>>,
337	/// Federation lifecycle: "active" | "trusted" | "suspended" | "blocked" | "muted" | "banned"
338	#[serde(default, skip_serializing_if = "Option::is_none")]
339	pub status: Option<Box<str>>,
340	#[serde(serialize_with = "serialize_timestamp_iso")]
341	pub created_at: Timestamp,
342}
343
344/// List of profiles response
345#[derive(Debug, Clone, Serialize, Deserialize)]
346pub struct ProfileList {
347	pub profiles: Vec<ProfileData>,
348	pub total: usize,
349	pub limit: usize,
350	pub offset: usize,
351}
352
353#[derive(Debug, Default, Deserialize)]
354pub struct UpdateProfileData {
355	// Profile content fields
356	#[serde(default)]
357	pub name: Patch<Box<str>>,
358	#[serde(default, rename = "profilePic")]
359	pub profile_pic: Patch<Option<Box<str>>>,
360	#[serde(default)]
361	pub roles: Patch<Option<Vec<Box<str>>>>,
362
363	// Status and moderation
364	#[serde(default)]
365	pub status: Patch<ProfileStatus>,
366
367	// Relationship fields
368	#[serde(default)]
369	pub synced: Patch<bool>,
370	#[serde(default)]
371	pub trust: Patch<ProfileTrust>,
372	/// Composition control: `Value(true)` hides this community from the home
373	/// feed (column → 1), `Null`/`Value(false)` clears it (column → NULL = shown).
374	#[serde(default)]
375	pub hidden_in_home: Patch<bool>,
376
377	// Sync metadata
378	#[serde(default)]
379	pub etag: Patch<Box<str>>,
380}
381
382/// Outcome of an `upsert_profile` call.
383#[derive(Debug, Clone, Copy, PartialEq, Eq)]
384pub enum UpsertResult {
385	/// The profile row did not exist and was inserted.
386	Created,
387	/// The profile row existed and was updated.
388	Updated,
389}
390
391/// Fields for `MetaAdapter::upsert_profile`.
392///
393/// All fields are `Patch` and apply to both INSERT and UPDATE:
394/// * `Patch::Value(v)` / `Patch::Null` → set the column on both branches.
395/// * `Patch::Undefined` → leave the column at its current value on UPDATE,
396///   and use the column default (NULL or `""` for `name`) on INSERT.
397///
398/// **Note on the INSERT branch:** `Patch::Null` and `Patch::Undefined`
399/// collapse to the same column default for most fields — the INSERT can't
400/// distinguish "user explicitly set to NULL" from "user didn't touch this
401/// field." This is fine semantically (both mean "no value here"), but
402/// differs from UPDATE, which preserves the existing value on `Undefined`.
403///
404/// **Stub-row idiom:** `upsert_profile` creates a row with `type = NULL`
405/// when `typ` is `Patch::Undefined`. These stub rows are filtered out of
406/// `list_profiles` (which requires `type IS NOT NULL`), but `read_profile` /
407/// `get_info` will return `Error::NotFound` for them. This is intentional:
408/// relationship hooks (FOLLOW, FSHR) create stubs first and federation sync
409/// populates `type` later. Callers performing read-then-write should not
410/// rely on `read_profile` finding a freshly-inserted stub.
411#[derive(Default)]
412pub struct UpsertProfileFields {
413	pub name: Patch<Box<str>>,
414	pub typ: Patch<ProfileType>,
415	pub profile_pic: Patch<Option<Box<str>>>,
416	pub roles: Patch<Option<Vec<Box<str>>>>,
417	pub status: Patch<ProfileStatus>,
418	pub synced: Patch<bool>,
419	pub following: Patch<bool>,
420	pub follower: Patch<bool>,
421	pub connected: Patch<ProfileConnectionStatus>,
422	pub trust: Patch<ProfileTrust>,
423	/// Composition: `Value(true)` → column 1 (hidden from home); `Null` → column
424	/// NULL (shown). Callers normalize a `false` request to `Null` so the column
425	/// stays in the NULL/1 encoding.
426	pub hidden_in_home: Patch<bool>,
427	pub etag: Patch<Box<str>>,
428}
429
430impl UpsertProfileFields {
431	/// Whether this upsert touches a column the full-text index reads, and so
432	/// needs a `search_index_profile` call afterwards. Only `name` qualifies, so
433	/// relationship-only upserts (a CONN accept, an FLLW, a sync watermark) —
434	/// which dominate the call sites — cost nothing.
435	pub fn affects_search_index(&self) -> bool {
436		!matches!(self.name, Patch::Undefined)
437	}
438
439	/// Build an `UpsertProfileFields` from an existing `UpdateProfileData`.
440	///
441	/// `typ` is left `Undefined` — callers that know the profile type should
442	/// set it explicitly.
443	pub fn from_update(update: UpdateProfileData) -> Self {
444		Self {
445			name: update.name,
446			typ: Patch::Undefined,
447			profile_pic: update.profile_pic,
448			roles: update.roles,
449			status: update.status,
450			synced: update.synced,
451			// `following`, `follower`, and `connected` are set only by the
452			// FLLW/CONN native hooks, never via the client-facing update DTO;
453			// leave them untouched here.
454			following: Patch::Undefined,
455			follower: Patch::Undefined,
456			connected: Patch::Undefined,
457			trust: update.trust,
458			hidden_in_home: update.hidden_in_home,
459			etag: update.etag,
460		}
461	}
462}
463
464// Actions
465//*********
466
467/// Additional action data (cached counts/stats)
468#[derive(Debug, Clone)]
469pub struct ActionData {
470	pub subject: Option<Box<str>>,
471	pub reactions: Option<Box<str>>,
472	/// Total comment count (active child CMNT rows). Federated as STAT `c`.
473	pub comments: Option<i64>,
474	/// Last-comment timestamp (epoch seconds = created_at of the newest active
475	/// child comment). Federated as STAT `ct`; drives the unread comment dot.
476	pub comments_ts: Option<Timestamp>,
477	/// Highest `created_at` of any STAT mirror update applied to this row
478	/// on the non-authoritative side. Used to reject reordered inbound
479	/// STATs. Always `None` on the authoritative node (REACT/CMNT write
480	/// the counters there; STAT `on_receive` never touches the row — see
481	/// the counter-update exclusivity invariant in
482	/// `cloudillo_action::native_hooks::ownership`).
483	pub stat_at: Option<Timestamp>,
484}
485
486/// Options for updating action metadata
487#[derive(Debug, Clone, Default)]
488pub struct UpdateActionDataOptions {
489	pub subject: Patch<String>,
490	pub reactions: Patch<String>,
491	/// Total comment count, federated as STAT `c`.
492	pub comments: Patch<u32>,
493	/// Last-comment timestamp (epoch seconds), federated as STAT `ct`.
494	pub comments_ts: Patch<Timestamp>,
495	pub reposts: Patch<u32>,
496	/// Watermark for inbound STAT mirror updates — see [`ActionData::stat_at`].
497	pub stat_at: Patch<Timestamp>,
498	pub status: Patch<char>,
499	pub visibility: Patch<char>,
500	pub x: Patch<serde_json::Value>, // Extensible metadata (x.role for SUBS, etc.)
501	pub content: Patch<String>,
502	pub attachments: Patch<String>, // Comma-separated list of attachment IDs
503	pub flags: Patch<String>,
504	/// Reader's W/T/M thread subscription level. `Patch::Null` clears it.
505	pub sub_level: Patch<char>,
506	pub sub_typ: Patch<String>,
507	/// Dual-purpose for actions in status `R` (draft) or `S` (scheduled): the
508	/// `actions.created_at` column holds the target publish instant, not the
509	/// row's actual creation time. PATCH /actions, `publish_draft`, and
510	/// `task::handle_create_action` all rely on this overload. For any other
511	/// status, leave this `Patch::Undefined` — overwriting `created_at` on a
512	/// finalized (`A`) action would corrupt the timeline.
513	pub created_at: Patch<Timestamp>,
514}
515
516impl UpdateActionDataOptions {
517	/// Whether this patch touches a column the full-text index reads, and so
518	/// needs a `search_index_action` call afterwards. The hottest writes on this
519	/// table — `reactions`, `comments`, `reposts`, `stat_at` bumps from
520	/// REACT/CMNT/STAT hooks — set none of these and cost nothing.
521	pub fn affects_search_index(&self) -> bool {
522		!matches!(
523			(&self.content, &self.status, &self.visibility, &self.sub_typ),
524			(Patch::Undefined, Patch::Undefined, Patch::Undefined, Patch::Undefined)
525		)
526	}
527}
528
529/// Options for finalizing an action (resolved fields from ActionCreatorTask)
530#[derive(Debug, Clone, Default)]
531pub struct FinalizeActionOptions<'a> {
532	pub attachments: Option<&'a [&'a str]>,
533	pub subject: Option<&'a str>,
534	pub audience_tag: Option<&'a str>,
535	pub key: Option<&'a str>,
536}
537
538fn deserialize_split<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
539where
540	D: serde::Deserializer<'de>,
541{
542	let s = String::deserialize(deserializer)?;
543	let values: Vec<String> =
544		s.split(',').map(|v| v.trim().to_string()).filter(|v| !v.is_empty()).collect();
545	if values.is_empty() { Ok(None) } else { Ok(Some(values)) }
546}
547
548/// Audience filter axis: classify actions by the **type of the effective wall
549/// owner** (`coalesce(audience, issuer_tag)` joined to `profiles.type`).
550/// `Personal` matches `pa.type='P'` (with NULL→Personal fallback for unknown
551/// remote profiles). `Community` matches `pa.type='C'`.
552/// Combines with `audience` (specific community) as AND.
553#[derive(Debug, Clone, Copy, Deserialize)]
554#[serde(rename_all = "lowercase")]
555pub enum AudienceType {
556	Personal,
557	Community,
558}
559
560/// Field to group an action count by. Mapped to a fixed column server-side
561/// (never interpolated from caller input) to keep the query injection-safe.
562#[derive(Debug, Clone, Copy)]
563pub enum ActionCountGroupBy {
564	SubType,
565}
566
567/// Options for listing actions
568#[derive(Debug, Default, Deserialize)]
569#[serde(deny_unknown_fields)]
570pub struct ListActionOptions {
571	/// Maximum number of items to return (default: 20)
572	pub limit: Option<u32>,
573	/// Cursor for pagination (opaque base64-encoded string)
574	pub cursor: Option<String>,
575	/// Sort order: 'created' (default, created_at) or 'received' (received_at,
576	/// the home feed's ingestion-order sort). Also selects the column used by the
577	/// keyset cursor and the created_after/created_before range filters.
578	pub sort: Option<String>,
579	/// Sort direction: 'asc' or 'desc' (default: desc)
580	#[serde(rename = "sortDir")]
581	pub sort_dir: Option<String>,
582	#[serde(default, rename = "type", deserialize_with = "deserialize_split")]
583	pub typ: Option<Vec<String>>,
584	#[serde(default, deserialize_with = "deserialize_split")]
585	pub status: Option<Vec<String>>,
586	pub tag: Option<String>,
587	pub search: Option<String>,
588	#[serde(default, deserialize_with = "deserialize_split")]
589	pub visibility: Option<Vec<String>>,
590	pub issuer: Option<String>,
591	pub audience: Option<String>,
592	#[serde(rename = "audienceType")]
593	pub audience_type: Option<AudienceType>,
594	pub involved: Option<String>,
595	/// The authenticated user's id_tag (set by handler, not from query params)
596	#[serde(skip)]
597	pub viewer_id_tag: Option<String>,
598	#[serde(rename = "actionId")]
599	pub action_id: Option<String>,
600	#[serde(rename = "parentId")]
601	pub parent_id: Option<String>,
602	#[serde(rename = "rootId")]
603	pub root_id: Option<String>,
604	#[serde(default, deserialize_with = "deserialize_split")]
605	pub subject: Option<Vec<String>>,
606	#[serde(rename = "createdAfter")]
607	pub created_after: Option<Timestamp>,
608	#[serde(rename = "createdBefore")]
609	pub created_before: Option<Timestamp>,
610	/// HTTP boolean flag: when true, return only rows the viewer is subscribed
611	/// to (`sub_level` set to a followed level). Uses `idx_actions_sub_level`.
612	pub subscribed: Option<bool>,
613	/// When true, the list path populates each `ActionView.token` with the raw
614	/// signed JWS from `action_tokens`. Opt-in so normal feed payloads stay lean.
615	#[serde(rename = "includeTokens")]
616	pub include_tokens: Option<bool>,
617	/// When true, hydrate each row's `subject_action` (the referenced action with
618	/// its full `stat`) for any row whose `subject` is a real action id (not an
619	/// `@`-prefixed placeholder). Opt-in — unread-dot count probes omit it to stay
620	/// lean; feed/banner/conversation-list paths set it to get the subject's
621	/// commentCount/lastCommentAt/commentsReadAt in one round-trip.
622	#[serde(rename = "includeSubject")]
623	pub include_subject: Option<bool>,
624	/// Exclude actions whose issuer's profile has any of these statuses.
625	/// LEFT JOIN profiles ON (tn_id, id_tag=issuer.id_tag) — missing-profile
626	/// rows are NOT excluded (open-federation default).
627	#[serde(skip)]
628	pub exclude_issuer_profile_status: Option<Box<[ProfileStatus]>>,
629	/// Exclude action rows whose `sub_type` is in this set. Used by relationship
630	/// fan-out queries to drop tombstone rows (e.g. FLLW:DEL / SUBS:DEL), which
631	/// rest at status 'A' but represent a severed relationship. NULL sub_type
632	/// (the active join/follow row) is always kept.
633	#[serde(skip)]
634	pub exclude_sub_typ: Option<Box<[Box<str>]>>,
635	/// Exclude actions whose *effective audience* (coalesce(audience, issuer_tag))
636	/// is in this set. Server-set, not from query params. Used by the home feed
637	/// to drop posts addressed to communities the reader opted out of home
638	/// (`profiles.hidden_in_home = 1`).
639	#[serde(skip)]
640	pub exclude_audiences: Option<Box<[String]>>,
641	/// When true, exclude actions issued by the requesting tenant (issuer == viewer).
642	/// Requires an authenticated request (viewer_id_tag set by the handler).
643	#[serde(rename = "excludeOwnIssuer")]
644	pub exclude_own_issuer: Option<bool>,
645	/// When true, `GET /actions` returns only a `COUNT(*)` of matching rows (under
646	/// `cursorPagination.count`) instead of the row list. The count applies
647	/// `visibility_guard` below, so it's a post-visibility count.
648	pub count: Option<bool>,
649	/// Visibility guard for the aggregate count path (H1). NEVER deserialized from
650	/// the client — set only by the `/actions` handler. Reuses `Patch<String>`:
651	/// `Undefined` → no guard (tenant see-all, internal callers, list path);
652	/// `Null` → guest, only Public ('P') rows; `Value(id_tag)` → viewer, full ABAC
653	/// translation for `id_tag`.
654	///
655	/// `#[serde(skip)]` is load-bearing: it keeps the field out of client
656	/// deserialization so a client cannot forge a see-all (`Undefined`) count.
657	/// `Patch` defaults to `Undefined`, so existing `..Default::default()` sites
658	/// keep the "no guard" behavior.
659	#[serde(skip)]
660	pub visibility_guard: Patch<String>,
661}
662
663#[skip_serializing_none]
664#[derive(Debug, Clone, Serialize, serde::Deserialize)]
665pub struct ProfileInfo {
666	#[serde(rename = "idTag")]
667	pub id_tag: Box<str>,
668	pub name: Box<str>,
669	#[serde(rename = "type")]
670	pub typ: ProfileType,
671	#[serde(rename = "profilePic")]
672	pub profile_pic: Option<Box<str>>,
673}
674
675#[derive(Default)]
676pub struct Action<S: AsRef<str>> {
677	pub action_id: S,
678	pub typ: S,
679	pub sub_typ: Option<S>,
680	pub issuer_tag: S,
681	pub parent_id: Option<S>,
682	pub root_id: Option<S>,
683	pub audience_tag: Option<S>,
684	pub content: Option<S>,
685	pub attachments: Option<Vec<S>>,
686	pub subject: Option<S>,
687	pub created_at: Timestamp,
688	pub expires_at: Option<Timestamp>,
689	pub visibility: Option<char>, // None: Direct, P: Public, V: Verified, 2: 2nd degree, F: Follower, C: Connected
690	pub flags: Option<S>,         // Action flags: R/r (reactions), C/c (comments), O/o (open)
691	pub x: Option<serde_json::Value>, // Extensible metadata (x.role for SUBS, etc.)
692}
693
694#[skip_serializing_none]
695#[derive(Debug, Clone, Serialize)]
696pub struct AttachmentView {
697	#[serde(rename = "fileId")]
698	pub file_id: Box<str>,
699	pub dim: Option<(u32, u32)>,
700	#[serde(rename = "localVariants")]
701	pub local_variants: Option<Vec<Box<str>>>,
702}
703
704#[skip_serializing_none]
705#[derive(Debug, Clone, Serialize)]
706#[serde(rename_all = "camelCase")]
707pub struct ActionView {
708	pub action_id: Box<str>,
709	#[serde(rename = "type")]
710	pub typ: Box<str>,
711	#[serde(rename = "subType")]
712	pub sub_typ: Option<Box<str>>,
713	pub parent_id: Option<Box<str>>,
714	pub root_id: Option<Box<str>>,
715	pub issuer: ProfileInfo,
716	pub audience: Option<ProfileInfo>,
717	pub content: Option<serde_json::Value>,
718	pub attachments: Option<Vec<AttachmentView>>,
719	pub subject: Option<Box<str>>,
720	pub subject_profile: Option<ProfileInfo>,
721	/// Hydrated original action referenced by `subject` (e.g. the post a REPOST
722	/// shares). Populated by the listing path for REPOST rows so the client can
723	/// render the embedded original card without a second fetch. Boxed to keep
724	/// the recursive type sized.
725	#[serde(default, skip_serializing_if = "Option::is_none")]
726	pub subject_action: Option<Box<ActionView>>,
727	#[serde(serialize_with = "serialize_timestamp_iso")]
728	pub created_at: Timestamp,
729	/// LOCAL ingestion time (when this action was inserted on this node), emitted
730	/// as `receivedAt`. Drives the home feed's arrival-order sort and its unread
731	/// watermark so late-federated posts (old `created_at`, recent arrival)
732	/// surface correctly. Optional: NULL on relationship/system rows inserted via
733	/// paths that don't stamp it. See `meta-adapter-sqlite` migration 36.
734	#[serde(
735		serialize_with = "serialize_timestamp_iso_opt",
736		skip_serializing_if = "Option::is_none"
737	)]
738	pub received_at: Option<Timestamp>,
739	#[serde(serialize_with = "serialize_timestamp_iso_opt")]
740	pub expires_at: Option<Timestamp>,
741	pub status: Option<Box<str>>,
742	pub stat: Option<serde_json::Value>,
743	pub visibility: Option<char>,
744	pub flags: Option<Box<str>>, // Action flags: R/r (reactions), C/c (comments), O/o (open)
745	/// Reader's W/T/M thread subscription level on this (cached) action row.
746	#[serde(rename = "subLevel", skip_serializing_if = "Option::is_none")]
747	pub sub_level: Option<Box<str>>,
748	pub x: Option<serde_json::Value>, // Extensible metadata (x.role for SUBS, etc.)
749	/// Raw signed JWS for this action, populated only when the list query sets
750	/// `includeTokens=true`. Lets clients verify action signatures locally.
751	#[serde(default, skip_serializing_if = "Option::is_none")]
752	pub token: Option<Box<str>>,
753}
754
755// Files
756//*******
757#[derive(Debug)]
758pub enum FileId<S: AsRef<str>> {
759	FileId(S),
760	FId(u64),
761}
762
763pub enum ActionId<S: AsRef<str>> {
764	ActionId(S),
765	AId(u64),
766}
767
768/// File status enum
769/// Note: Mutability is determined by fileTp (BLOB=immutable, CRDT/RTDB=mutable)
770#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
771pub enum FileStatus {
772	#[serde(rename = "A")]
773	Active,
774	#[serde(rename = "P")]
775	Pending,
776	#[serde(rename = "D")]
777	Deleted,
778}
779
780/// User-specific file metadata (access tracking, pinned/starred status)
781#[skip_serializing_none]
782#[derive(Debug, Clone, Default, Serialize, serde::Deserialize)]
783#[serde(rename_all = "camelCase")]
784pub struct FileUserData {
785	#[serde(default, serialize_with = "serialize_timestamp_iso_opt")]
786	pub accessed_at: Option<Timestamp>,
787	#[serde(default, serialize_with = "serialize_timestamp_iso_opt")]
788	pub modified_at: Option<Timestamp>,
789	#[serde(default)]
790	pub pinned: bool,
791	#[serde(default)]
792	pub starred: bool,
793	/// Cached source-reported access level for cross-context (hand-pinned)
794	/// rows. Written by `POST /files/{id}/refresh` and FSHR on_accept on the
795	/// receiver side. Cross-context list responses prefer this over the
796	/// FSHR-fallback path in `get_access_level`. `None` means the row has
797	/// never been refreshed (frontend renders no badge).
798	#[serde(default)]
799	pub access_level: Option<crate::types::AccessLevel>,
800}
801
802#[skip_serializing_none]
803#[derive(Debug, Clone, Serialize, serde::Deserialize)]
804#[serde(rename_all = "camelCase")]
805pub struct FileView {
806	pub file_id: Box<str>,
807	#[serde(default)]
808	pub parent_id: Option<Box<str>>, // Parent folder file_id (None = root)
809	#[serde(default)]
810	pub root_id: Option<Box<str>>, // Document tree root file_id (None = standalone)
811	#[serde(default)]
812	pub owner: Option<ProfileInfo>,
813	/// Raw `files.owner_tag` column — `None` for a locally-owned file.
814	///
815	/// Not part of the API surface: `owner` above carries the resolved owner,
816	/// falling back to the tenant's own profile when this column is NULL.
817	/// Consumers that must agree with the stored column rather than the resolved
818	/// profile — the search indexer, which denormalises it into
819	/// `search_docs.owner_tag` — need the raw value.
820	#[serde(skip)]
821	pub owner_tag: Option<Box<str>>,
822	#[serde(default)]
823	pub creator: Option<ProfileInfo>,
824	#[serde(default)]
825	pub preset: Option<Box<str>>,
826	#[serde(default)]
827	pub content_type: Option<Box<str>>,
828	pub file_name: Box<str>,
829	#[serde(default)]
830	pub file_tp: Option<Box<str>>, // 'BLOB', 'CRDT', 'RTDB', 'FLDR'
831	#[serde(serialize_with = "serialize_timestamp_iso")]
832	pub created_at: Timestamp,
833	#[serde(default, serialize_with = "crate::types::serialize_timestamp_iso_opt")]
834	pub accessed_at: Option<Timestamp>, // Global: when anyone last accessed
835	#[serde(default, serialize_with = "crate::types::serialize_timestamp_iso_opt")]
836	pub modified_at: Option<Timestamp>, // Global: when anyone last modified
837	pub status: FileStatus,
838	#[serde(default)]
839	pub tags: Option<Vec<Box<str>>>,
840	#[serde(default)]
841	pub visibility: Option<char>, // None: Direct, P: Public, V: Verified, 2: 2nd degree, F: Follower, C: Connected
842	/// LEGACY: read-only flag from pre-managed-folder schema. New writes route
843	/// system-managed files into `parent_id = MANAGED_PARENT_ID` instead; the
844	/// `hidden` column is preserved only so existing rows from earlier DB
845	/// versions still list-filter correctly until they are migrated.
846	#[serde(default)]
847	pub hidden: bool,
848	#[serde(default)]
849	pub access_level: Option<crate::types::AccessLevel>, // User's access level to this file (R/W)
850	#[serde(default)]
851	pub user_data: Option<FileUserData>, // User-specific data (only when authenticated)
852	#[serde(default)]
853	pub x: Option<serde_json::Value>, // Extensible metadata (e.g., {"dim": [width, height]} for images)
854	/// Immediate parent folder name. Populated only when listing requests
855	/// `withParent=true`; `None` for root, trash, managed-parent, or when not
856	/// requested. Serialized as `parentName` and omitted when `None`.
857	#[serde(default)]
858	pub parent_name: Option<Box<str>>,
859	/// Full path from root → immediate parent (not including the file itself).
860	/// Populated only when listing requests `withPath=true` (typically a
861	/// single-file fetch). Serialized as `path` and omitted when `None`.
862	#[serde(default)]
863	pub path: Option<Vec<PathSegment>>,
864	/// Tombstone: when set, the source of this cross-context row has issued
865	/// an authoritative permanent signal (deleted or revoked). Written by
866	/// `POST /api/files/{file_id}/refresh`; the frontend calls that endpoint
867	/// when it detects an inconsistency (broken thumbnail, 404 on blob,
868	/// stale access). Transient network failures do NOT set this — they
869	/// surface via the response wrapper's `refreshStatus` field instead.
870	#[serde(default, serialize_with = "crate::types::serialize_timestamp_iso_opt")]
871	pub broken_at: Option<Timestamp>,
872	/// Tombstone reason, set together with `broken_at`. See
873	/// [`BrokenReason`] for the closed set of values.
874	#[serde(default)]
875	pub broken_reason: Option<BrokenReason>,
876}
877
878/// Reason a cross-context file row is tombstoned. Written by the refresh
879/// endpoint based on the source's response. Tombstones are sticky, so this
880/// is reserved for permanent / authoritative source signals — transient
881/// network failures DO NOT mutate the row (the handler surfaces them
882/// out-of-band via `refreshStatus` in the response wrapper).
883#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
884#[serde(rename_all = "lowercase")]
885pub enum BrokenReason {
886	/// Source returned 404 / 410: the row is gone upstream.
887	Deleted,
888	/// Source returned 403: the caller's grant on the source has been revoked.
889	Revoked,
890}
891
892impl BrokenReason {
893	pub fn as_str(&self) -> &'static str {
894		match self {
895			Self::Deleted => "deleted",
896			Self::Revoked => "revoked",
897		}
898	}
899}
900
901/// Single hop in a file's folder ancestry chain.
902#[derive(Debug, Clone, Serialize, serde::Deserialize)]
903#[serde(rename_all = "camelCase")]
904pub struct PathSegment {
905	pub id: Box<str>,
906	pub name: Box<str>,
907}
908
909#[skip_serializing_none]
910#[derive(Debug, Clone, Serialize)]
911pub struct FileVariant<S: AsRef<str> + Debug> {
912	#[serde(rename = "variantId")]
913	pub variant_id: S,
914	pub variant: S,
915	pub format: S,
916	pub size: u64,
917	pub resolution: (u32, u32),
918	pub available: bool,
919	/// Blob stored in the shared `TnId(0)` store instead of this tenant's store.
920	#[serde(skip_serializing_if = "std::ops::Not::not")]
921	pub global: bool,
922	/// Duration in seconds (for video/audio)
923	pub duration: Option<f64>,
924	/// Bitrate in kbps (for video/audio)
925	pub bitrate: Option<u32>,
926	/// Page count (for documents like PDF)
927	#[serde(rename = "pageCount")]
928	pub page_count: Option<u32>,
929}
930
931// `global` is a storage location, not part of content identity, so it is
932// deliberately excluded from PartialEq/Ord.
933impl<S: AsRef<str> + Debug> PartialEq for FileVariant<S> {
934	fn eq(&self, other: &Self) -> bool {
935		self.variant_id.as_ref() == other.variant_id.as_ref()
936			&& self.variant.as_ref() == other.variant.as_ref()
937			&& self.format.as_ref() == other.format.as_ref()
938			&& self.size == other.size
939			&& self.resolution == other.resolution
940			&& self.available == other.available
941			&& self.duration == other.duration
942			&& self.bitrate == other.bitrate
943			&& self.page_count == other.page_count
944	}
945}
946
947impl<S: AsRef<str> + Debug> Eq for FileVariant<S> {}
948
949impl<S: AsRef<str> + Debug + Ord> PartialOrd for FileVariant<S> {
950	fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
951		Some(self.cmp(other))
952	}
953}
954
955impl<S: AsRef<str> + Debug + Ord> Ord for FileVariant<S> {
956	fn cmp(&self, other: &Self) -> Ordering {
957		self.size
958			.cmp(&other.size)
959			.then_with(|| self.resolution.0.cmp(&other.resolution.0))
960			.then_with(|| self.resolution.1.cmp(&other.resolution.1))
961			.then_with(|| self.variant.as_ref().cmp(other.variant.as_ref()))
962	}
963}
964
965/// Options for listing files
966///
967/// By default (when `status` is `None`), deleted files (status 'D') are excluded.
968/// To include deleted files, explicitly set `status` to `FileStatus::Deleted`.
969#[derive(Debug, Default, Deserialize)]
970#[serde(deny_unknown_fields)]
971#[allow(clippy::struct_excessive_bools)]
972pub struct ListFileOptions {
973	/// Maximum number of items to return (default: 30)
974	pub limit: Option<u32>,
975	/// Cursor for pagination (opaque base64-encoded string)
976	pub cursor: Option<String>,
977	#[serde(default, rename = "fileId", deserialize_with = "deserialize_split")]
978	pub file_id: Option<Vec<String>>,
979	#[serde(rename = "parentId")]
980	pub parent_id: Option<String>, // Filter by parent folder (None = root, "__trash__" = trash)
981	/// Exclude files whose immediate parent is this folder. Used by the
982	/// frontend "more matches exist outside this folder" probe so it can ask
983	/// a single global question without re-finding the in-folder matches.
984	#[serde(rename = "notParentId")]
985	pub not_parent_id: Option<String>,
986	#[serde(rename = "rootId")]
987	pub root_id: Option<String>, // Filter by document tree root
988	pub tag: Option<String>,
989	pub preset: Option<String>,
990	pub variant: Option<String>,
991	/// File status filter. If None, excludes deleted files by default.
992	pub status: Option<FileStatus>,
993	#[serde(default, rename = "fileTp", deserialize_with = "deserialize_split")]
994	pub file_type: Option<Vec<String>>,
995	/// Filter by content type pattern (e.g., "image/*", "video/*")
996	#[serde(default, rename = "contentType", deserialize_with = "deserialize_split")]
997	pub content_type: Option<Vec<String>>,
998	/// Include folders (file_tp='FLDR') even when a content_type/file_type filter
999	/// is set, so folder navigation keeps working in type-filtered pickers.
1000	#[serde(default, rename = "includeFolders")]
1001	pub include_folders: bool,
1002	/// Substring search in file name
1003	#[serde(rename = "fileName")]
1004	pub file_name: Option<String>,
1005	/// Filter by owner id_tag
1006	#[serde(rename = "ownerIdTag")]
1007	pub owner_id_tag: Option<String>,
1008	/// Exclude files by this owner id_tag
1009	#[serde(rename = "notOwnerIdTag")]
1010	pub not_owner_id_tag: Option<String>,
1011	/// Restrict to files owned by the active tenant (owner_tag IS NULL), excluding
1012	/// remote/federated cached copies. Unlike `owner_id_tag` (which keys off
1013	/// COALESCE(creator_tag, owner_tag, tenant) and so matches the *creator*),
1014	/// this keys purely off ownership — the right test for "can be embedded".
1015	#[serde(default, rename = "localOnly")]
1016	pub local_only: bool,
1017	/// Filter by pinned status (user-specific)
1018	pub pinned: Option<bool>,
1019	/// Filter by starred status (user-specific)
1020	pub starred: Option<bool>,
1021	/// LEGACY hidden filter. None = exclude hidden (default). Some(true) = only hidden.
1022	/// Kept so pre-migration `hidden=1` rows still drop out of user-library
1023	/// listings; new system-managed files use `parent_id = MANAGED_PARENT_ID`
1024	/// instead and are filtered by the managed-folder rule above.
1025	pub hidden: Option<bool>,
1026	/// Sort order: 'recent' (accessed_at), 'modified' (modified_at), 'name', 'created'
1027	pub sort: Option<String>,
1028	/// Sort direction: 'asc' or 'desc' (default: desc for dates, asc for name)
1029	#[serde(rename = "sortDir")]
1030	pub sort_dir: Option<String>,
1031	/// User id_tag for user-specific data (set by handler, not from query)
1032	#[serde(skip)]
1033	pub user_id_tag: Option<String>,
1034	/// Scope file_id filter: returns files matching this file_id OR having this root_id.
1035	/// Overrides the normal root_id IS NULL constraint. Set by handler for scoped tokens.
1036	#[serde(skip)]
1037	pub scope_file_id: Option<String>,
1038	/// Allowed visibility levels for SQL-level filtering (correct pagination).
1039	/// None = no filter (owner sees all including NULL/Direct).
1040	/// Set by handler based on subject's access level via `SubjectAccessLevel::visible_levels()`.
1041	#[serde(skip)]
1042	pub visible_levels: Option<Vec<char>>,
1043	/// Include files that belong to a document tree (`root_id IS NOT NULL`) as
1044	/// well as standalone ones. Server-only: set by maintenance sweeps, never by
1045	/// a request. The default listing hides tree children because a file browser
1046	/// shows containers, not their parts.
1047	#[serde(skip)]
1048	pub include_tree_children: bool,
1049	/// Drop the browse-listing exclusions: trashed, managed, hidden and
1050	/// soft-deleted (`status = 'D'`) files are all returned. Server-only: set by
1051	/// maintenance sweeps that must see every row in order to *remove* stale
1052	/// derived state.
1053	#[serde(skip)]
1054	pub sweep_all: bool,
1055	/// When true, populate `FileView.parent_name` with the immediate parent
1056	/// folder's name (one level). Resolved via a shared LRU cache; on cache
1057	/// misses, one SQL round-trip per distinct missing parent on the page.
1058	#[serde(default, rename = "withParent")]
1059	pub with_parent: bool,
1060	/// When true, populate `FileView.path` with the full root→parent chain.
1061	/// Typically used together with `file_id` to fetch a single file's location.
1062	#[serde(default, rename = "withPath")]
1063	pub with_path: bool,
1064}
1065
1066#[derive(Debug, Clone, Default)]
1067pub struct CreateFile {
1068	pub orig_variant_id: Option<Box<str>>,
1069	pub file_id: Option<Box<str>>,
1070	pub parent_id: Option<Box<str>>, // Parent folder file_id (None = root)
1071	pub root_id: Option<Box<str>>,   // Document tree root file_id (None = standalone)
1072	pub owner_tag: Option<Box<str>>, // Set only for files owned by someone OTHER than the tenant (e.g., shared files)
1073	pub creator_tag: Option<Box<str>>, // The user who actually created the file
1074	pub preset: Option<Box<str>>,
1075	pub content_type: Box<str>,
1076	pub file_name: Box<str>,
1077	pub file_tp: Option<Box<str>>, // 'BLOB', 'CRDT', 'RTDB', 'FLDR' - defaults to 'BLOB'
1078	pub created_at: Option<Timestamp>,
1079	pub tags: Option<Vec<Box<str>>>,
1080	pub x: Option<serde_json::Value>,
1081	pub visibility: Option<char>, // None: Direct (default), P: Public, V: Verified, 2: 2nd degree, F: Follower, C: Connected
1082	/// LEGACY: do not set on new rows. System-managed files should be created
1083	/// with `parent_id = MANAGED_PARENT_ID` so the file GC can reap them.
1084	pub hidden: bool,
1085	pub status: Option<FileStatus>, // None defaults to Pending, can set to Active for shared files
1086}
1087
1088#[derive(Debug, Clone, Deserialize)]
1089pub struct CreateFileVariant {
1090	pub variant: Box<str>,
1091	pub format: Box<str>,
1092	pub resolution: (u32, u32),
1093	pub size: u64,
1094	pub available: bool,
1095}
1096
1097/// Options for updating file metadata
1098#[derive(Debug, Clone, Default, Deserialize)]
1099pub struct UpdateFileOptions {
1100	#[serde(default, rename = "fileName")]
1101	pub file_name: Patch<String>,
1102	#[serde(default, rename = "parentId")]
1103	pub parent_id: Patch<String>, // Move file to different folder (null = root)
1104	#[serde(default)]
1105	pub visibility: Patch<char>,
1106	#[serde(default)]
1107	pub status: Patch<char>,
1108	/// LEGACY: writes to the `hidden` column. Prefer moving files into the
1109	/// managed folder via `parent_id = MANAGED_PARENT_ID`.
1110	#[serde(default)]
1111	pub hidden: Patch<bool>,
1112	// Fields below (content_type, file_tp, tags, preset, x, broken) are set
1113	// only by the cross-context refresh handler; not exposed as PATCH fields.
1114	#[serde(default, rename = "contentType", skip_deserializing)]
1115	pub content_type: Patch<String>,
1116	#[serde(default, rename = "fileTp", skip_deserializing)]
1117	pub file_tp: Patch<String>,
1118	#[serde(default, skip_deserializing)]
1119	pub tags: Patch<Vec<String>>,
1120	#[serde(default, skip_deserializing)]
1121	pub preset: Patch<String>,
1122	#[serde(default, skip_deserializing)]
1123	pub x: Patch<serde_json::Value>,
1124	/// Paired tombstone field. `Patch::Value(reason)` sets `broken_reason` and
1125	/// stamps `broken_at = unixepoch()`. `Patch::Null` clears both. `Undefined`
1126	/// touches neither.
1127	#[serde(default, skip_deserializing)]
1128	pub broken: Patch<BrokenReason>,
1129}
1130
1131impl UpdateFileOptions {
1132	/// Whether this patch touches a column the full-text index reads, and so
1133	/// needs a `search_index_file` call afterwards.
1134	///
1135	/// `parent_id` and `hidden` are here not as indexed text but because they
1136	/// decide whether the file has an index row at all: moving into
1137	/// [`TRASH_PARENT_ID`] or hiding it must drop it from `search_docs`
1138	/// (`objects::is_indexable` gates on `!file.hidden`). `file_tp`, `preset`, `x`
1139	/// and `broken` are deliberately absent: none of them reaches `search_docs`.
1140	pub fn affects_search_index(&self) -> bool {
1141		!matches!(
1142			(
1143				&self.file_name,
1144				&self.visibility,
1145				&self.status,
1146				&self.content_type,
1147				&self.tags,
1148				&self.parent_id,
1149				&self.hidden,
1150			),
1151			(
1152				Patch::Undefined,
1153				Patch::Undefined,
1154				Patch::Undefined,
1155				Patch::Undefined,
1156				Patch::Undefined,
1157				Patch::Undefined,
1158				Patch::Undefined
1159			)
1160		)
1161	}
1162}
1163
1164/// What [`MetaAdapter::delete_file`] removed.
1165#[derive(Debug, Clone, Default)]
1166pub struct DeleteFileResult {
1167	/// Every file id deleted, root first, so the caller can evict each from its folder cache.
1168	/// Content ids only — a row whose `file_id` is still NULL (an unfinalized upload) is tombstoned
1169	/// but has no cache key and nothing that can reference it, so it is absent here.
1170	pub file_ids: Vec<Box<str>>,
1171	/// How many `files` rows were actually tombstoned, including the NULL-`file_id` ones missing
1172	/// from `file_ids`. Always `>= file_ids.len()`.
1173	pub files_deleted: u64,
1174	pub refs_removed: u64,
1175	pub share_entries_removed: u64,
1176}
1177
1178// Share Entries
1179//**************
1180
1181#[skip_serializing_none]
1182#[derive(Debug, Clone, Serialize)]
1183#[serde(rename_all = "camelCase")]
1184pub struct ShareEntry {
1185	pub id: i64,
1186	pub resource_type: char,
1187	pub resource_id: Box<str>,
1188	pub subject_type: char,
1189	pub subject_id: Box<str>,
1190	pub permission: char,
1191	#[serde(serialize_with = "serialize_timestamp_iso_opt")]
1192	pub expires_at: Option<Timestamp>,
1193	pub created_by: Box<str>,
1194	#[serde(serialize_with = "serialize_timestamp_iso")]
1195	pub created_at: Timestamp,
1196	// Enrichment fields (populated by JOINs in list_by_resource)
1197	pub subject_file_name: Option<Box<str>>,
1198	pub subject_content_type: Option<Box<str>>,
1199	pub subject_file_tp: Option<Box<str>>,
1200}
1201
1202#[derive(Debug, Deserialize)]
1203#[serde(rename_all = "camelCase")]
1204pub struct CreateShareEntry {
1205	pub subject_type: char,
1206	pub subject_id: String,
1207	pub permission: char,
1208	pub expires_at: Option<Timestamp>,
1209}
1210
1211/// Options for updating an existing share entry via PATCH semantics.
1212///
1213/// Each field uses `Patch<T>`: `Undefined` leaves the column unchanged,
1214/// `Null` clears it, `Value(v)` sets it. `resource_type`, `resource_id`,
1215/// `subject_type`, `subject_id`, `created_by`, and `created_at` are
1216/// intentionally immutable post-create.
1217#[derive(Debug, Default)]
1218pub struct UpdateShareEntryOptions {
1219	/// `Value('R'|'C'|'W'|'A')`. `Null` is rejected at the handler
1220	/// boundary — to revoke access, DELETE the share entry instead.
1221	pub permission: Patch<char>,
1222	/// Expiration timestamp. `Null` clears expiration (share never expires).
1223	pub expires_at: Patch<Timestamp>,
1224}
1225
1226// Push Subscriptions
1227//********************
1228
1229/// Web Push subscription data (RFC 8030)
1230#[skip_serializing_none]
1231#[derive(Debug, Clone, Serialize, Deserialize)]
1232pub struct PushSubscriptionData {
1233	/// Push endpoint URL
1234	pub endpoint: String,
1235	/// Expiration time (Unix timestamp, if provided by browser)
1236	#[serde(rename = "expirationTime")]
1237	pub expiration_time: Option<i64>,
1238	/// Subscription keys (p256dh and auth)
1239	pub keys: PushSubscriptionKeys,
1240}
1241
1242/// Subscription keys for Web Push encryption
1243#[derive(Debug, Clone, Serialize, Deserialize)]
1244pub struct PushSubscriptionKeys {
1245	/// P-256 public key for encryption (base64url encoded)
1246	pub p256dh: String,
1247	/// Authentication secret (base64url encoded)
1248	pub auth: String,
1249}
1250
1251/// Full push subscription record stored in database
1252#[derive(Debug, Clone, Serialize)]
1253#[serde(rename_all = "camelCase")]
1254pub struct PushSubscription {
1255	/// Unique subscription ID
1256	pub id: u64,
1257	/// The subscription data (endpoint, keys, etc.)
1258	pub subscription: PushSubscriptionData,
1259	/// When this subscription was created
1260	#[serde(serialize_with = "serialize_timestamp_iso")]
1261	pub created_at: Timestamp,
1262}
1263
1264// Tasks
1265//*******
1266pub struct Task {
1267	pub task_id: u64,
1268	pub tn_id: TnId,
1269	pub kind: Box<str>,
1270	pub status: char,
1271	pub created_at: Timestamp,
1272	pub next_at: Option<Timestamp>,
1273	pub input: Box<str>,
1274	pub output: Box<str>,
1275	pub deps: Box<[u64]>,
1276	pub retry: Option<Box<str>>,
1277	pub cron: Option<Box<str>>,
1278}
1279
1280#[derive(Debug, Default)]
1281pub struct TaskPatch {
1282	pub input: Patch<String>,
1283	pub next_at: Patch<Timestamp>,
1284	pub deps: Patch<Vec<u64>>,
1285	pub retry: Patch<String>,
1286	pub cron: Patch<String>,
1287}
1288
1289#[derive(Debug, Default)]
1290pub struct ListTaskOptions {}
1291
1292// Installed Apps
1293//***************
1294
1295/// Data for installing an app
1296#[derive(Debug)]
1297pub struct InstallApp {
1298	pub app_name: Box<str>,
1299	pub publisher_tag: Box<str>,
1300	pub version: Box<str>,
1301	pub action_id: Box<str>,
1302	pub file_id: Box<str>,
1303	pub blob_id: Box<str>,
1304	pub capabilities: Option<Vec<Box<str>>>,
1305}
1306
1307/// Installed app record
1308#[derive(Debug, Serialize)]
1309#[serde(rename_all = "camelCase")]
1310pub struct InstalledApp {
1311	pub app_name: Box<str>,
1312	pub publisher_tag: Box<str>,
1313	pub version: Box<str>,
1314	pub action_id: Box<str>,
1315	pub file_id: Box<str>,
1316	pub blob_id: Box<str>,
1317	pub status: Box<str>,
1318	pub capabilities: Option<Vec<Box<str>>>,
1319	pub auto_update: bool,
1320	#[serde(serialize_with = "serialize_timestamp_iso")]
1321	pub installed_at: Timestamp,
1322}
1323
1324// Full-text search
1325//******************
1326
1327/// One indexable unit of an object.
1328///
1329/// A whole object (a file, an action, a profile) has a single part with
1330/// `part_id = ""`. A deep-indexed document emits one part per sub-unit, where
1331/// `part_id` is the app's deep-link key (e.g. a notillo page id).
1332#[derive(Debug, Default)]
1333pub struct SearchPart<'a> {
1334	/// Deep-link key; `""` for whole-object rows.
1335	pub part_id: &'a str,
1336	/// Rule kind that produced this part (the RTDB collection name).
1337	pub part_kind: Option<&'a str>,
1338	/// Parent part id, for tree display of results.
1339	pub parent_part: Option<&'a str>,
1340	/// Finest-grained anchor inside the part (e.g. a notillo block id).
1341	pub anchor_id: Option<&'a str>,
1342	pub title: Option<&'a str>,
1343	pub body: Option<&'a str>,
1344	/// Space-separated tag list.
1345	pub tags: Option<&'a str>,
1346}
1347
1348/// The object a set of [`SearchPart`]s belongs to. The ACL columns are
1349/// denormalised mirrors of the source row so the search query can pre-filter
1350/// in SQL.
1351#[derive(Debug, Default)]
1352pub struct SearchObject<'a> {
1353	/// `'F'` file, `'D'` deep document part, `'A'` action, `'P'` profile.
1354	pub obj_tp: char,
1355	/// file_id / action_id / id_tag; for `'D'` the container file_id.
1356	pub obj_id: &'a str,
1357	pub content_type: Option<&'a str>,
1358	pub owner_tag: Option<&'a str>,
1359	/// None: Direct, P: Public, V: Verified, 2: 2nd degree, F: Follower, C: Connected
1360	pub visibility: Option<char>,
1361	pub root_id: Option<&'a str>,
1362	pub created_at: Option<Timestamp>,
1363	/// Which of the two FTS indexes these rows belong to. `false` (the default)
1364	/// keeps the plain-text extract alongside an external-content index; `true`
1365	/// stores no text at all and indexes the body into a contentless one —
1366	/// matching and ranking unchanged, but no result snippets.
1367	///
1368	/// Decided per tenant from `search.store_text`, not per write: flipping it
1369	/// moves an object's rows between two physically separate indexes, so it only
1370	/// takes effect through a full reindex.
1371	pub fts_cl: bool,
1372}
1373
1374/// Bounds a [`SearchOptions`] is clamped to at both ends — the handler clamps what
1375/// a caller asked for, the adapter re-clamps what it was handed — so a programmatic
1376/// caller cannot widen them either. Deep offsets in a relevance-ordered FTS scan get
1377/// expensive fast.
1378pub const SEARCH_MAX_LIMIT: u32 = 100;
1379pub const SEARCH_MAX_OFFSET: u32 = 1000;
1380
1381/// Bounds on the two list-valued filters, clamped at both ends the same way:
1382/// ~33k `contentType` values overrun SQLite's 32766 bound-variable limit into a
1383/// 500, and a long `tags` list builds an arbitrarily deep FTS5 `MATCH` expression.
1384pub const SEARCH_MAX_TAGS: usize = 16;
1385pub const SEARCH_MAX_CONTENT_TYPES: usize = 16;
1386
1387/// Search query options. The fields below the marker are server-derived and are
1388/// never deserialized from the wire.
1389#[derive(Debug, Default)]
1390pub struct SearchOptions {
1391	/// Raw user query text — the adapter sanitizes it into FTS5 syntax.
1392	pub q: String,
1393	pub obj_tp: Option<Vec<char>>,
1394	/// Restrict to one container document (its own row plus its parts).
1395	pub file_id: Option<String>,
1396	pub content_type: Option<Vec<String>>,
1397	/// AND-combined tag filter, applied inside the FTS match rather than after
1398	/// it — filtering the top-`limit` rows afterwards would silently drop a
1399	/// document that matches both the text and the tag but ranks below the cut.
1400	pub tags: Option<Vec<String>>,
1401	pub limit: u32,
1402	pub offset: u32,
1403
1404	// --- server-only ---
1405	/// Visibility levels the caller may see. `None` means "everything",
1406	/// including Direct (tenant owner).
1407	pub visible_levels: Option<Vec<char>>,
1408	pub viewer_id_tag: Option<String>,
1409	/// File-scoped token: only this file and its document tree are visible.
1410	pub scope_file_id: Option<String>,
1411	/// File id a delegated (share-link / app) token was scoped to. Its own row and
1412	/// the deep `'D'` parts of its document tree bypass the visibility filter —
1413	/// the share itself is the grant. Child `'F'` rows in the same tree stay
1414	/// visibility-filtered, matching `GET /api/files`' document-scope branch.
1415	pub scope_grant_file_id: Option<Box<str>>,
1416	/// Query the contentless index instead of the external-content one. Must
1417	/// match the tenant's `search.store_text` setting, since a tenant's rows live
1418	/// in exactly one of the two. Hits from the contentless index carry no
1419	/// `snippet`.
1420	pub fts_cl: bool,
1421}
1422
1423/// A highlighted range inside a snippet.
1424///
1425/// **Offsets are UTF-16 code units**, counted from the start of the snippet —
1426/// the client's unit, not Rust's: the frontend slices with
1427/// `String.prototype.slice`, so byte offsets or code-point counts would misplace
1428/// every highlight in a snippet containing an astral-plane character, and only
1429/// there. See `Highlight` in `libs/react/src/components/Highlight/Highlight.tsx`.
1430///
1431/// Ranges are ascending, non-overlapping, and half-open (`start..end`).
1432#[derive(Debug, Clone, Copy, Serialize)]
1433pub struct SearchMatch {
1434	pub start: u32,
1435	pub end: u32,
1436}
1437
1438/// A single search index row plus its FTS ranking data.
1439#[derive(Debug)]
1440pub struct SearchRow {
1441	pub s_id: i64,
1442	pub obj_tp: char,
1443	pub obj_id: Box<str>,
1444	pub part_id: Box<str>,
1445	pub part_kind: Option<Box<str>>,
1446	pub parent_part: Option<Box<str>>,
1447	pub anchor_id: Option<Box<str>>,
1448	pub title: Option<Box<str>>,
1449	pub tags: Option<Box<str>>,
1450	pub content_type: Option<Box<str>>,
1451	pub owner_tag: Option<Box<str>>,
1452	pub visibility: Option<char>,
1453	pub root_id: Option<Box<str>>,
1454	pub updated_at: Timestamp,
1455	/// Server-built excerpt as **plain text** — no markup of any kind.
1456	///
1457	/// The text is document content, so any in-band delimiter is ambiguous with a
1458	/// document containing that delimiter literally — an `<mark>` scheme deletes
1459	/// the literal string from the excerpt and lets it widen the highlight over
1460	/// unmatched text. `snippet_matches` carries the highlight out of band
1461	/// instead, which no document content can forge.
1462	///
1463	/// An adapter implementing this trait **must** honour that: the value is
1464	/// handed to the client unmodified.
1465	pub snippet: Option<Box<str>>,
1466	/// Ranges within `snippet` to emphasise, ascending and non-overlapping.
1467	/// `None` when there is no snippet or nothing matched inside it.
1468	pub snippet_matches: Option<Box<[SearchMatch]>>,
1469	/// Raw `bm25()` value: negative, more negative = more relevant.
1470	pub score: f64,
1471}
1472
1473/// What [`MetaAdapter::reclaim_space`] found, and whether it acted on it.
1474///
1475/// `page_size * page_count` is the file's size in bytes and
1476/// `page_size * freelist_count` the dead space inside it, both measured *after*
1477/// the rewrite when `vacuumed` is true and before it otherwise: the report is
1478/// quoted by the log line and the admin notification, so it describes the
1479/// database as it stands when the call returns. An all-zero report with
1480/// `vacuumed: false` is the trait default, meaning the adapter does not reclaim
1481/// space at all.
1482#[derive(Debug, Clone, Copy, Default)]
1483pub struct SpaceReport {
1484	pub page_size: i64,
1485	pub page_count: i64,
1486	pub freelist_count: i64,
1487	/// Whether the free-page ratio cleared the caller's threshold and a full
1488	/// rewrite actually ran.
1489	pub vacuumed: bool,
1490}
1491
1492/// Document format manifest — how an app declares what it indexes.
1493#[derive(Debug, Clone, Serialize)]
1494#[serde(rename_all = "camelCase")]
1495pub struct DocFormat {
1496	pub content_type: Box<str>,
1497	pub publisher_tag: Box<str>,
1498	pub app_name: Box<str>,
1499	/// Encoded document format version, `MMMmmmppp` (three decimal digits per
1500	/// component of `major.minor.patch`). `None` on rows written before the
1501	/// integer encoding existed, which the handler reads as "no ordering known".
1502	#[serde(skip_serializing_if = "Option::is_none")]
1503	pub format_version: Option<i64>,
1504	/// `'RTDB'` | `'CRDT'` | `'BLOB'`
1505	#[serde(skip_serializing_if = "Option::is_none")]
1506	pub store_tp: Option<Box<str>>,
1507	/// Deep-link query param name, e.g. `"nav"`.
1508	#[serde(skip_serializing_if = "Option::is_none")]
1509	pub nav_param: Option<Box<str>>,
1510	/// The FTS index manifest.
1511	#[serde(skip_serializing_if = "Option::is_none")]
1512	pub search: Option<serde_json::Value>,
1513	#[serde(skip_serializing_if = "Option::is_none")]
1514	pub x: Option<serde_json::Value>,
1515	#[serde(serialize_with = "serialize_timestamp_iso")]
1516	pub updated_at: Timestamp,
1517}
1518
1519/// Writable subset of [`DocFormat`].
1520#[derive(Debug)]
1521pub struct UpsertDocFormat<'a> {
1522	pub content_type: &'a str,
1523	pub publisher_tag: &'a str,
1524	pub app_name: &'a str,
1525	pub format_version: Option<i64>,
1526	pub store_tp: Option<&'a str>,
1527	pub nav_param: Option<&'a str>,
1528	pub search: Option<&'a serde_json::Value>,
1529	pub x: Option<&'a serde_json::Value>,
1530}
1531
1532// Contacts / Address Books (CardDAV + JSON REST)
1533//*************************************************
1534
1535/// Address book collection metadata
1536#[derive(Debug, Clone, Serialize)]
1537#[serde(rename_all = "camelCase")]
1538pub struct AddressBook {
1539	pub ab_id: u64,
1540	pub name: Box<str>,
1541	pub description: Option<Box<str>>,
1542	/// Collection tag — changes on any contact mutation within this book (used by CardDAV sync)
1543	pub ctag: Box<str>,
1544	#[serde(serialize_with = "serialize_timestamp_iso")]
1545	pub created_at: Timestamp,
1546	#[serde(serialize_with = "serialize_timestamp_iso")]
1547	pub updated_at: Timestamp,
1548}
1549
1550#[derive(Debug, Default)]
1551pub struct UpdateAddressBookData {
1552	pub name: Patch<String>,
1553	pub description: Patch<String>,
1554}
1555
1556/// Indexed projection of a contact — lives in DB columns, parallel to the stored vCard blob.
1557/// Used both for REST API responses (via the handler layer's JSON conversion) and for
1558/// CardDAV `addressbook-query` REPORT text-match filtering.
1559#[derive(Debug, Clone, Default)]
1560pub struct ContactExtracted {
1561	pub fn_name: Option<Box<str>>,
1562	pub given_name: Option<Box<str>>,
1563	pub family_name: Option<Box<str>>,
1564	pub email: Option<Box<str>>,
1565	pub emails: Option<Box<str>>,
1566	pub tel: Option<Box<str>>,
1567	pub tels: Option<Box<str>>,
1568	pub org: Option<Box<str>>,
1569	pub title: Option<Box<str>>,
1570	pub note: Option<Box<str>>,
1571	pub photo_uri: Option<Box<str>>,
1572	pub profile_id_tag: Option<Box<str>>,
1573}
1574
1575/// Full contact row including the authoritative stored vCard blob.
1576#[derive(Debug, Clone)]
1577pub struct Contact {
1578	pub c_id: u64,
1579	pub ab_id: u64,
1580	pub uid: Box<str>,
1581	pub etag: Box<str>,
1582	pub vcard: Box<str>,
1583	pub extracted: ContactExtracted,
1584	pub created_at: Timestamp,
1585	pub updated_at: Timestamp,
1586}
1587
1588/// Contact summary without the vCard blob — for list endpoints (REST + CardDAV REPORTs that
1589/// don't need the full body).
1590#[derive(Debug, Clone)]
1591pub struct ContactView {
1592	pub c_id: u64,
1593	pub ab_id: u64,
1594	pub uid: Box<str>,
1595	pub etag: Box<str>,
1596	pub extracted: ContactExtracted,
1597	pub created_at: Timestamp,
1598	pub updated_at: Timestamp,
1599}
1600
1601/// One entry in a CardDAV `sync-collection` REPORT response. Tombstones (`deleted: true`)
1602/// let clients drop stale cards.
1603#[derive(Debug, Clone)]
1604pub struct ContactSyncEntry {
1605	pub uid: Box<str>,
1606	pub etag: Box<str>,
1607	pub deleted: bool,
1608	pub updated_at: Timestamp,
1609}
1610
1611#[derive(Debug, Default)]
1612pub struct ListContactOptions {
1613	/// Free-text query — matches against fn_name, emails, tels (SQL LIKE).
1614	pub q: Option<String>,
1615	/// Opaque cursor for pagination.
1616	pub cursor: Option<String>,
1617	/// Page size.
1618	pub limit: Option<u32>,
1619}
1620
1621// Calendars / Calendar Objects (CalDAV + JSON REST)
1622//***************************************************
1623
1624/// Calendar collection metadata. Parallels `AddressBook`.
1625#[derive(Debug, Clone, Serialize)]
1626#[serde(rename_all = "camelCase")]
1627pub struct Calendar {
1628	pub cal_id: u64,
1629	pub name: Box<str>,
1630	pub description: Option<Box<str>>,
1631	/// CSS `#RRGGBB` hex for client colouring (CalendarServer `calendar-color` ext).
1632	pub color: Option<Box<str>>,
1633	/// Default VTIMEZONE blob, surfaced via CalDAV `calendar-timezone`.
1634	pub timezone: Option<Box<str>>,
1635	/// Comma-separated component set (`VEVENT,VTODO`) — powers `supported-calendar-component-set`.
1636	pub components: Box<str>,
1637	/// Collection tag — bumps on any calendar-object mutation (used by CalDAV sync).
1638	pub ctag: Box<str>,
1639	#[serde(serialize_with = "serialize_timestamp_iso")]
1640	pub created_at: Timestamp,
1641	#[serde(serialize_with = "serialize_timestamp_iso")]
1642	pub updated_at: Timestamp,
1643}
1644
1645#[derive(Debug, Default)]
1646pub struct CreateCalendarData {
1647	pub name: String,
1648	pub description: Option<String>,
1649	pub color: Option<String>,
1650	pub timezone: Option<String>,
1651	/// If `None`, defaults to `VEVENT,VTODO`.
1652	pub components: Option<String>,
1653}
1654
1655#[derive(Debug, Default)]
1656pub struct UpdateCalendarData {
1657	pub name: Patch<String>,
1658	pub description: Patch<String>,
1659	pub color: Patch<String>,
1660	pub timezone: Patch<String>,
1661	pub components: Patch<String>,
1662}
1663
1664/// Indexed projection of a calendar object — lives in DB columns alongside the authoritative
1665/// iCalendar blob. Enables `calendar-query` time-range filtering and REST search.
1666#[derive(Debug, Clone, Default)]
1667pub struct CalendarObjectExtracted {
1668	/// `VEVENT` | `VTODO` (first primary component in the VCALENDAR; overrides share it).
1669	pub component: Box<str>,
1670	pub summary: Option<Box<str>>,
1671	pub location: Option<Box<str>>,
1672	pub description: Option<Box<str>>,
1673	/// Master DTSTART as unix seconds (UTC). `None` for floating/undated VTODO.
1674	pub dtstart: Option<Timestamp>,
1675	/// DTEND for VEVENT, DUE for VTODO, as unix seconds (UTC). `None` for open-ended.
1676	pub dtend: Option<Timestamp>,
1677	/// True when DTSTART is `VALUE=DATE`.
1678	pub all_day: bool,
1679	/// `STATUS` value (CONFIRMED / TENTATIVE / CANCELLED / NEEDS-ACTION / COMPLETED / IN-PROCESS).
1680	pub status: Option<Box<str>>,
1681	/// `PRIORITY` 0..9 (primarily VTODO).
1682	pub priority: Option<u8>,
1683	pub organizer: Option<Box<str>>,
1684	/// Raw RRULE string — presence signals recurrence; expansion is client-side.
1685	pub rrule: Option<Box<str>>,
1686	/// `EXDATE` exclusions on the master as unix seconds; empty for override rows.
1687	pub exdate: Vec<Timestamp>,
1688	/// `RECURRENCE-ID` as unix seconds for override instances; `None` for the master row.
1689	pub recurrence_id: Option<Timestamp>,
1690	pub sequence: i64,
1691}
1692
1693/// Borrowed write payload for calendar-object upserts. Groups the four fields that always
1694/// travel together (authoritative blob + its derived etag + indexed projection) so trait
1695/// methods writing multiple objects in one tx don't accumulate parallel-scalar parameter
1696/// lists.
1697#[derive(Debug, Clone, Copy)]
1698pub struct CalendarObjectWrite<'a> {
1699	pub uid: &'a str,
1700	pub ical: &'a str,
1701	pub etag: &'a str,
1702	pub extracted: &'a CalendarObjectExtracted,
1703}
1704
1705/// Full calendar object row including the authoritative stored VCALENDAR blob.
1706#[derive(Debug, Clone)]
1707pub struct CalendarObject {
1708	pub co_id: u64,
1709	pub cal_id: u64,
1710	pub uid: Box<str>,
1711	pub etag: Box<str>,
1712	pub ical: Box<str>,
1713	pub extracted: CalendarObjectExtracted,
1714	pub created_at: Timestamp,
1715	pub updated_at: Timestamp,
1716}
1717
1718/// Calendar object summary without the iCalendar blob — for list endpoints.
1719#[derive(Debug, Clone)]
1720pub struct CalendarObjectView {
1721	pub co_id: u64,
1722	pub cal_id: u64,
1723	pub uid: Box<str>,
1724	pub etag: Box<str>,
1725	pub extracted: CalendarObjectExtracted,
1726	pub created_at: Timestamp,
1727	pub updated_at: Timestamp,
1728}
1729
1730/// One entry in a CalDAV `sync-collection` REPORT response. Tombstones (`deleted: true`) let
1731/// clients drop stale objects.
1732#[derive(Debug, Clone)]
1733pub struct CalendarObjectSyncEntry {
1734	pub uid: Box<str>,
1735	pub etag: Box<str>,
1736	pub deleted: bool,
1737	pub updated_at: Timestamp,
1738}
1739
1740#[derive(Debug, Default)]
1741pub struct ListCalendarObjectOptions {
1742	/// Restrict to a component (`VEVENT` or `VTODO`); `None` lists both.
1743	pub component: Option<String>,
1744	/// Free-text query matched against summary / location / description.
1745	pub q: Option<String>,
1746	/// Time-range start (inclusive, unix seconds).
1747	pub start: Option<Timestamp>,
1748	/// Time-range end (exclusive, unix seconds).
1749	pub end: Option<Timestamp>,
1750	pub cursor: Option<String>,
1751	pub limit: Option<u32>,
1752	/// Include recurrence-exception rows (`RECURRENCE-ID IS NOT NULL`) in the result set.
1753	/// Default `false` preserves CalDAV/legacy semantics where list endpoints return masters only.
1754	pub include_exceptions: bool,
1755}
1756
1757#[async_trait]
1758pub trait MetaAdapter: Debug + Send + Sync {
1759	// Tenant management
1760	//*******************
1761
1762	/// Reads a tenant profile
1763	async fn read_tenant(&self, tn_id: TnId) -> ClResult<Tenant<Box<str>>>;
1764
1765	/// Creates a new tenant
1766	async fn create_tenant(&self, tn_id: TnId, id_tag: &str) -> ClResult<TnId>;
1767
1768	/// Updates a tenant
1769	async fn update_tenant(&self, tn_id: TnId, tenant: &UpdateTenantData) -> ClResult<()>;
1770
1771	/// Deletes a tenant
1772	async fn delete_tenant(&self, tn_id: TnId) -> ClResult<()>;
1773
1774	/// Lists all tenants (for admin use)
1775	async fn list_tenants(&self, opts: &ListTenantsMetaOptions) -> ClResult<Vec<TenantListMeta>>;
1776
1777	/// Lists all profiles matching a set of options
1778	async fn list_profiles(
1779		&self,
1780		tn_id: TnId,
1781		opts: &ListProfileOptions,
1782	) -> ClResult<Vec<Profile<Box<str>>>>;
1783
1784	/// List the id_tags of every profile that follows this tenant (i.e. should
1785	/// receive its broadcasts). This is the broadcast/Announce recipient set:
1786	/// profiles with `follower = true`, excluding Suspended/Blocked/Banned issuers.
1787	/// Unbounded (no LIMIT) — unlike `list_profiles`.
1788	async fn list_follower_tags(&self, tn_id: TnId) -> ClResult<Vec<Box<str>>>;
1789
1790	/// Get relationships between the current user and multiple target profiles
1791	///
1792	/// Efficiently queries relationship status (following, connected) for multiple profiles
1793	/// in a single database call, avoiding N+1 query patterns.
1794	///
1795	/// Returns: HashMap<target_id_tag, (following: bool, connected: bool)>
1796	///
1797	/// Keys are the id_tags in `target_id_tags`, **verbatim** — an implementation
1798	/// that canonicalises id_tags for storage (id_tags are case-insensitive DNS
1799	/// names) still keys the result by what the caller passed in, so a mixed-case
1800	/// needle can be looked back up. Targets with no mirrored profile are absent.
1801	async fn get_relationships(
1802		&self,
1803		tn_id: TnId,
1804		target_id_tags: &[&str],
1805	) -> ClResult<HashMap<String, (bool, bool)>>;
1806
1807	/// Reads a profile
1808	///
1809	/// Returns an `(etag, Profile)` tuple.
1810	async fn read_profile(
1811		&self,
1812		tn_id: TnId,
1813		id_tag: &str,
1814	) -> ClResult<(Box<str>, Profile<Box<str>>)>;
1815
1816	/// Batch sibling of [`Self::read_profile`], reduced to the public projection.
1817	///
1818	/// Unknown / not-mirrored id_tags are omitted from the result rather than
1819	/// reported, and order is unspecified — callers key by `id_tag`. A row whose
1820	/// `type` is NULL or unrecognised is likewise omitted: it is a never-synced
1821	/// relationship stub with no name and no picture, so it has nothing to
1822	/// contribute to this projection.
1823	///
1824	/// Implementations MUST chunk internally: callers are not required to cap
1825	/// `id_tags`, and no caller-side cap is part of this contract.
1826	async fn read_profiles(&self, tn_id: TnId, id_tags: &[&str])
1827	-> ClResult<Vec<PublicProfileRow>>;
1828
1829	/// Read profile roles for access token generation
1830	async fn read_profile_roles(
1831		&self,
1832		tn_id: TnId,
1833		id_tag: &str,
1834	) -> ClResult<Option<Box<[Box<str>]>>>;
1835
1836	/// Insert a profile row if missing, otherwise update it.
1837	///
1838	/// Returns `UpsertResult::Created` if the row was inserted, or
1839	/// `UpsertResult::Updated` if an existing row was updated. Never returns
1840	/// `Error::Conflict` or `Error::NotFound` — the operation is idempotent
1841	/// with respect to row existence.
1842	async fn upsert_profile(
1843		&self,
1844		tn_id: TnId,
1845		id_tag: &str,
1846		fields: &UpsertProfileFields,
1847	) -> ClResult<UpsertResult>;
1848
1849	/// Reads the public key of a profile
1850	///
1851	/// Returns a `(public key, expiration)` tuple.
1852	async fn read_profile_public_key(
1853		&self,
1854		id_tag: &str,
1855		key_id: &str,
1856	) -> ClResult<(Box<str>, Timestamp)>;
1857	/// Cache a federated profile public key.
1858	///
1859	/// `expires_at` is the owner-declared key expiration from the remote profile.
1860	/// `None` means the owner did not declare an expiration; the implementation
1861	/// may store it as NULL (treated as "never expires" by `read_profile_public_key`).
1862	async fn add_profile_public_key(
1863		&self,
1864		id_tag: &str,
1865		key_id: &str,
1866		public_key: &str,
1867		expires_at: Option<Timestamp>,
1868	) -> ClResult<()>;
1869	/// List stale profiles that need refreshing
1870	///
1871	/// Returns profiles where:
1872	/// - `synced_at IS NULL` (never synced — always eligible), OR
1873	/// - `synced_at < now - max_age_secs` AND `synced_at >= now - disable_after_secs`
1874	///   (stale but not yet abandoned).
1875	///
1876	/// Profiles with `synced_at < now - disable_after_secs` are excluded so the
1877	/// refresh batch stops attempting persistently failing remotes.
1878	/// Returns `Vec<(tn_id, id_tag, etag)>` tuples for conditional refresh requests.
1879	async fn list_stale_profiles(
1880		&self,
1881		max_age_secs: i64,
1882		disable_after_secs: i64,
1883		limit: u32,
1884	) -> ClResult<Vec<(TnId, Box<str>, Option<Box<str>>)>>;
1885
1886	// Action management
1887	//*******************
1888	async fn get_action_id(&self, tn_id: TnId, a_id: u64) -> ClResult<Box<str>>;
1889	async fn list_actions(
1890		&self,
1891		tn_id: TnId,
1892		opts: &ListActionOptions,
1893	) -> ClResult<Vec<ActionView>>;
1894	async fn list_action_tokens(
1895		&self,
1896		tn_id: TnId,
1897		opts: &ListActionOptions,
1898	) -> ClResult<Box<[Box<str>]>>;
1899
1900	/// Count actions matching `opts`, grouped by `group_by`. Returns
1901	/// `(group_value, count)` pairs (group value NULL-able). Used to derive
1902	/// per-reaction-type counts without baking reaction semantics into the adapter.
1903	async fn count_actions_grouped(
1904		&self,
1905		tn_id: TnId,
1906		opts: &ListActionOptions,
1907		group_by: ActionCountGroupBy,
1908	) -> ClResult<Vec<(Option<String>, i64)>>;
1909
1910	/// Count actions matching `opts` (same filters as `list_actions`), no
1911	/// limit/sort/cursor. Backs the `count=true` flag on `GET /actions`. When
1912	/// `opts.visibility_guard` is set (`Null` guest / `Value` viewer) the count is
1913	/// post-visibility, applying the same ABAC translation of `can_view_item` the
1914	/// row-list pass uses. `Undefined` (default) counts every matching row.
1915	async fn count_actions(&self, tn_id: TnId, opts: &ListActionOptions) -> ClResult<i64>;
1916
1917	/// Set a read-watermark, forward-only (a lower `position` is a no-op).
1918	/// Dispatches by `scope`, all against the reader's own (`tn_id`) node:
1919	///   - `"feed"`   → `profiles.feed_read_at` for `id_tag = key`
1920	///   - `"msg"`    → `profiles.msg_read_at`  for `id_tag = key`
1921	///   - `"thread"` → `actions.comments_read_at` for `action_id = key`
1922	/// Unknown scope → bad-request error.
1923	async fn set_read_marker(
1924		&self,
1925		tn_id: TnId,
1926		scope: &str,
1927		key: &str,
1928		position: i64,
1929	) -> ClResult<()>;
1930
1931	/// Auto-subscribe at Tracking: set `sub_level='T'` only when it is currently
1932	/// NULL (never downgrade an existing Watching). No-op if the row is absent.
1933	/// (Manual W/T/M changes go through `update_action_data`'s `sub_level` patch.)
1934	async fn auto_track_action(&self, tn_id: TnId, action_id: &str) -> ClResult<()>;
1935
1936	async fn create_action(
1937		&self,
1938		tn_id: TnId,
1939		action: &Action<&str>,
1940		key: Option<&str>,
1941	) -> ClResult<ActionId<Box<str>>>;
1942
1943	async fn finalize_action(
1944		&self,
1945		tn_id: TnId,
1946		a_id: u64,
1947		action_id: &str,
1948		options: FinalizeActionOptions<'_>,
1949	) -> ClResult<()>;
1950
1951	async fn create_inbound_action(
1952		&self,
1953		tn_id: TnId,
1954		action_id: &str,
1955		token: &str,
1956		ack_token: Option<&str>,
1957	) -> ClResult<()>;
1958
1959	/// Get the root_id of an action
1960	async fn get_action_root_id(&self, tn_id: TnId, action_id: &str) -> ClResult<Box<str>>;
1961
1962	/// Get action data (subject, reaction count, comment count)
1963	async fn get_action_data(&self, tn_id: TnId, action_id: &str) -> ClResult<Option<ActionData>>;
1964
1965	/// Get action by key
1966	async fn get_action_by_key(
1967		&self,
1968		tn_id: TnId,
1969		action_key: &str,
1970	) -> ClResult<Option<Action<Box<str>>>>;
1971
1972	/// Store action token for federation (called when action is created)
1973	async fn store_action_token(&self, tn_id: TnId, action_id: &str, token: &str) -> ClResult<()>;
1974
1975	/// Get action token for federation
1976	async fn get_action_token(&self, tn_id: TnId, action_id: &str) -> ClResult<Option<Box<str>>>;
1977
1978	/// Update action data (subject, reactions, comments, status)
1979	async fn update_action_data(
1980		&self,
1981		tn_id: TnId,
1982		action_id: &str,
1983		opts: &UpdateActionDataOptions,
1984	) -> ClResult<()>;
1985
1986	/// Update inbound action status
1987	async fn update_inbound_action(
1988		&self,
1989		tn_id: TnId,
1990		action_id: &str,
1991		status: Option<char>,
1992	) -> ClResult<()>;
1993
1994	/// Get related action tokens by APRV action_id
1995	/// Returns list of (action_id, token) pairs for actions that have ack = aprv_action_id
1996	async fn get_related_action_tokens(
1997		&self,
1998		tn_id: TnId,
1999		aprv_action_id: &str,
2000	) -> ClResult<Vec<(Box<str>, Box<str>)>>;
2001
2002	// File management
2003	//*****************
2004	async fn get_file_id(&self, tn_id: TnId, f_id: u64) -> ClResult<Box<str>>;
2005	async fn list_files(&self, tn_id: TnId, opts: &ListFileOptions) -> ClResult<Vec<FileView>>;
2006	async fn list_file_variants(
2007		&self,
2008		tn_id: TnId,
2009		file_id: FileId<&str>,
2010	) -> ClResult<Vec<FileVariant<Box<str>>>>;
2011	/// List locally available variant names for a file (only those marked available)
2012	async fn list_available_variants(&self, tn_id: TnId, file_id: &str) -> ClResult<Vec<Box<str>>>;
2013	/// List every `variant_id` whose blob is expected to be present in the
2014	/// given tenant's blob store. For `TnId(0)` returns the union of all
2015	/// `global=1` variant rows across tenants; for other tenants returns only
2016	/// the variants whose `global=0` (i.e., stored locally, not in shared).
2017	async fn list_referenced_variant_ids(&self, tn_id: TnId) -> ClResult<Vec<Box<str>>>;
2018	/// Targeted recheck for the blob GC: is there *currently* a `file_variants`
2019	/// row that expects this blob to live in `tn_id`'s blob store? For
2020	/// `TnId(0)` matches any `global=1` row; for other tenants matches a
2021	/// `tn_id`-scoped `global=0` row. Used to close the race between the
2022	/// referenced-set snapshot and the actual `delete_blob` call.
2023	async fn is_variant_referenced(&self, tn_id: TnId, variant_id: &str) -> ClResult<bool>;
2024	async fn read_file_variant(
2025		&self,
2026		tn_id: TnId,
2027		variant_id: &str,
2028	) -> ClResult<FileVariant<Box<str>>>;
2029	/// Look up the file_id for a given variant_id
2030	async fn read_file_id_by_variant(&self, tn_id: TnId, variant_id: &str) -> ClResult<Box<str>>;
2031	/// Look up the internal f_id for a given file_id (for adding variants to existing files)
2032	async fn read_f_id_by_file_id(&self, tn_id: TnId, file_id: &str) -> ClResult<u64>;
2033	async fn create_file(&self, tn_id: TnId, opts: CreateFile) -> ClResult<FileId<Box<str>>>;
2034	async fn create_file_variant<'a>(
2035		&'a self,
2036		tn_id: TnId,
2037		f_id: u64,
2038		opts: FileVariant<&'a str>,
2039	) -> ClResult<&'a str>;
2040	async fn update_file_id(&self, tn_id: TnId, f_id: u64, file_id: &str) -> ClResult<()>;
2041
2042	/// Finalize a pending file - sets file_id and transitions status from 'P' to 'A' atomically
2043	async fn finalize_file(&self, tn_id: TnId, f_id: u64, file_id: &str) -> ClResult<()>;
2044
2045	/// List internal `f_id`s of files whose `parent_id` equals the given sentinel
2046	/// (e.g. [`MANAGED_PARENT_ID`]) and whose `created_at` is strictly before
2047	/// `before`. Used by the file GC to enumerate candidates inside the managed
2048	/// folder while honouring the safety window.
2049	async fn list_files_by_parent(
2050		&self,
2051		tn_id: TnId,
2052		parent_id: &str,
2053		before: Timestamp,
2054	) -> ClResult<Vec<u64>>;
2055
2056	/// Internal `f_id`s of files in the managed folder that are still referenced
2057	/// by at least one canonical column. The file GC keeps any candidate whose
2058	/// `f_id` is in this set.
2059	///
2060	/// Returning numeric `f_id`s (instead of string `file_id`s) keeps the
2061	/// reference set small — it is naturally scoped to managed-folder rows by
2062	/// the join, so even tenants with millions of references hold only the
2063	/// distinct managed-file count in memory.
2064	///
2065	/// Current sources:
2066	/// - `actions.attachments` (CSV-split, every action regardless of
2067	///   `actions.status`). Both raw `file_id` tokens and `@<f_id>` draft-time
2068	///   placeholders resolve via the `files` table — the latter must not be
2069	///   dropped, or files attached to drafts that finalized after the draft
2070	///   was saved would be reaped.
2071	/// - `tenants.profile_pic`, `tenants.cover_pic` (this tenant).
2072	/// - `profiles.profile_pic` (cached remote profile images, this tenant).
2073	///
2074	/// MUST be updated when a new column names a file in the managed folder.
2075	/// Missing a source here will cause the GC to reap files that are still
2076	/// referenced elsewhere.
2077	async fn list_referenced_managed_fids(&self, tn_id: TnId) -> ClResult<HashSet<u64>>;
2078
2079	/// Hard-delete a file: removes all `file_variants` rows and then the
2080	/// `files` row inside a single transaction. Intended for the file GC.
2081	///
2082	/// Returns the deleted row's `file_id`, which the caller needs to drop the
2083	/// search index entry: an `f_id` alone cannot be mapped back to one once the
2084	/// row is gone. `None` for an unfinalized upload, which never had one.
2085	async fn hard_delete_file(&self, tn_id: TnId, f_id: u64) -> ClResult<Option<Box<str>>>;
2086
2087	// Task scheduler
2088	//****************
2089	async fn list_tasks(&self, opts: ListTaskOptions) -> ClResult<Vec<Task>>;
2090	async fn list_task_ids(&self, kind: &str, keys: &[Box<str>]) -> ClResult<Vec<u64>>;
2091	async fn create_task(
2092		&self,
2093		kind: &'static str,
2094		key: Option<&str>,
2095		input: &str,
2096		deps: &[u64],
2097	) -> ClResult<u64>;
2098	async fn update_task_finished(&self, task_id: u64, output: &str) -> ClResult<()>;
2099	async fn update_task_error(
2100		&self,
2101		task_id: u64,
2102		output: &str,
2103		next_at: Option<Timestamp>,
2104	) -> ClResult<()>;
2105
2106	/// Find a pending task by its key
2107	async fn find_task_by_key(&self, key: &str) -> ClResult<Option<Task>>;
2108
2109	/// Update task fields with partial updates
2110	async fn update_task(&self, task_id: u64, patch: &TaskPatch) -> ClResult<()>;
2111
2112	/// Find deps that have completed (status != 'P')
2113	async fn find_completed_deps(&self, deps: &[u64]) -> ClResult<Vec<u64>>;
2114
2115	// Phase 1: Profile Management
2116	//****************************
2117	/// Get a single profile by id_tag
2118	async fn get_profile_info(&self, tn_id: TnId, id_tag: &str) -> ClResult<ProfileData>;
2119
2120	// Phase 2: Action Management
2121	//***************************
2122	/// Get a single action by action_id
2123	async fn get_action(&self, tn_id: TnId, action_id: &str) -> ClResult<Option<ActionView>>;
2124
2125	/// Lightweight probe: the action's `type` column only (no joins/hydration).
2126	async fn get_action_type(&self, tn_id: TnId, action_id: &str) -> ClResult<Option<Box<str>>>;
2127
2128	/// Update action content and attachments (if not yet federated)
2129	async fn update_action(
2130		&self,
2131		tn_id: TnId,
2132		action_id: &str,
2133		content: Option<&str>,
2134		attachments: Option<&[&str]>,
2135	) -> ClResult<()>;
2136
2137	/// Delete an action (soft delete with cleanup)
2138	async fn delete_action(&self, tn_id: TnId, action_id: &str) -> ClResult<()>;
2139
2140	// Phase 2: File Management Enhancements
2141	//**************************************
2142	/// Delete `file_id` and its document-tree children (tombstoned as `status = 'D'`; the file GC
2143	/// reclaims blobs and hard-deletes later), cascading everything that would otherwise outlive
2144	/// them: [`SHARE_FILE_REF_TYPE`] refs naming any of the ids, and `share_entries` where any of
2145	/// the ids is either the resource or the subject.
2146	///
2147	/// One transaction, because file ids are content-addressed: re-uploading identical content
2148	/// resurrects the row, and a half-run cascade would resurrect stale grants with it. Not soft
2149	/// delete — that moves the file to the trash folder and keeps links and grants working.
2150	///
2151	/// `file_id` may be `@{f_id}`; the result reports the resolved content ids.
2152	async fn delete_file(&self, tn_id: TnId, file_id: &str) -> ClResult<DeleteFileResult>;
2153
2154	// Settings Management
2155	//*********************
2156	/// List all settings for a tenant, optionally filtered by prefix
2157	async fn list_settings(
2158		&self,
2159		tn_id: TnId,
2160		prefix: Option<&[String]>,
2161	) -> ClResult<std::collections::HashMap<String, serde_json::Value>>;
2162
2163	/// Read a single setting by name
2164	async fn read_setting(&self, tn_id: TnId, name: &str) -> ClResult<Option<serde_json::Value>>;
2165
2166	/// Update or delete a setting (None = delete)
2167	async fn update_setting(
2168		&self,
2169		tn_id: TnId,
2170		name: &str,
2171		value: Option<serde_json::Value>,
2172	) -> ClResult<()>;
2173
2174	// Reference / Bookmark Management
2175	//********************************
2176	/// List all references for a tenant
2177	async fn list_refs(&self, tn_id: TnId, opts: &ListRefsOptions) -> ClResult<Vec<RefData>>;
2178
2179	/// Get a specific reference by ID
2180	async fn get_ref(&self, tn_id: TnId, ref_id: &str) -> ClResult<Option<RefData>>;
2181
2182	/// Create a new reference
2183	async fn create_ref(
2184		&self,
2185		tn_id: TnId,
2186		ref_id: &str,
2187		opts: &CreateRefOptions,
2188	) -> ClResult<RefData>;
2189
2190	/// Delete a reference
2191	async fn delete_ref(&self, tn_id: TnId, ref_id: &str) -> ClResult<()>;
2192
2193	/// Update fields of an existing reference. Returns the updated row.
2194	async fn update_ref(
2195		&self,
2196		tn_id: TnId,
2197		ref_id: &str,
2198		opts: &UpdateRefOptions,
2199	) -> ClResult<RefData>;
2200
2201	/// Use/consume a reference - validates type, expiration, counter, decrements counter
2202	/// Returns (TnId, id_tag, RefData) of the tenant that owns this ref
2203	async fn use_ref(
2204		&self,
2205		ref_id: &str,
2206		expected_types: &[&str],
2207	) -> ClResult<(TnId, Box<str>, RefData)>;
2208
2209	/// Validate a reference without consuming it - checks type, expiration, counter
2210	/// Returns (TnId, id_tag, RefData) of the tenant that owns this ref if valid
2211	async fn validate_ref(
2212		&self,
2213		ref_id: &str,
2214		expected_types: &[&str],
2215	) -> ClResult<(TnId, Box<str>, RefData)>;
2216
2217	// Tag Management
2218	//***************
2219	/// List all tags for a tenant
2220	///
2221	/// # Arguments
2222	/// * `tn_id` - Tenant ID
2223	/// * `prefix` - Optional prefix filter
2224	/// * `with_counts` - If true, include file counts per tag
2225	/// * `limit` - Optional limit on number of tags returned
2226	async fn list_tags(
2227		&self,
2228		tn_id: TnId,
2229		prefix: Option<&str>,
2230		with_counts: bool,
2231		limit: Option<u32>,
2232	) -> ClResult<Vec<TagInfo>>;
2233
2234	/// Add a tag to a file
2235	async fn add_tag(&self, tn_id: TnId, file_id: &str, tag: &str) -> ClResult<Vec<String>>;
2236
2237	/// Remove a tag from a file
2238	async fn remove_tag(&self, tn_id: TnId, file_id: &str, tag: &str) -> ClResult<Vec<String>>;
2239
2240	// File Management Enhancements
2241	//****************************
2242	/// Update file metadata (name, visibility, status)
2243	async fn update_file_data(
2244		&self,
2245		tn_id: TnId,
2246		file_id: &str,
2247		opts: &UpdateFileOptions,
2248	) -> ClResult<()>;
2249
2250	/// Read file metadata
2251	async fn read_file(&self, tn_id: TnId, file_id: &str) -> ClResult<Option<FileView>>;
2252
2253	/// Like [`read_file`] but also populates `user_data` (pinned, starred,
2254	/// per-user timestamps, cached cross-context `access_level`) for the
2255	/// given user.
2256	async fn read_file_with_user_data(
2257		&self,
2258		tn_id: TnId,
2259		file_id: &str,
2260		id_tag: &str,
2261	) -> ClResult<Option<FileView>>;
2262
2263	// File User Data (per-user file activity tracking)
2264	//**************************************************
2265
2266	/// Record file access for a user (upserts record, updates accessed_at timestamp)
2267	async fn record_file_access(&self, tn_id: TnId, id_tag: &str, file_id: &str) -> ClResult<()>;
2268
2269	/// Record file modification for a user (upserts record, updates modified_at timestamp)
2270	async fn record_file_modification(
2271		&self,
2272		tn_id: TnId,
2273		id_tag: &str,
2274		file_id: &str,
2275	) -> ClResult<()>;
2276
2277	/// Update file user data (pinned/starred status, cached access_level).
2278	///
2279	/// All three fields share the same three-state `Patch` encoding:
2280	/// `Patch::Undefined` leaves the column untouched, `Patch::Null` clears it
2281	/// (writes NULL — `pinned`/`starred` read back as `false`),
2282	/// `Patch::Value(v)` sets it (`access_level` ch ∈ {'R', 'C', 'W', 'A'} — the
2283	/// [`crate::types::AccessLevel::to_perm_char`] vocabulary, `'A'` included).
2284	/// Used by the `POST /files/{id}/refresh` handler (and FSHR on_accept on
2285	/// the receiver side) to cache the source-reported cross-context access level.
2286	async fn update_file_user_data(
2287		&self,
2288		tn_id: TnId,
2289		id_tag: &str,
2290		file_id: &str,
2291		pinned: crate::types::Patch<bool>,
2292		starred: crate::types::Patch<bool>,
2293		access_level: crate::types::Patch<char>,
2294	) -> ClResult<FileUserData>;
2295
2296	/// Get file user data for a specific file
2297	async fn get_file_user_data(
2298		&self,
2299		tn_id: TnId,
2300		id_tag: &str,
2301		file_id: &str,
2302	) -> ClResult<Option<FileUserData>>;
2303
2304	// Push Subscription Management
2305	//*****************************
2306
2307	/// List all push subscriptions for a tenant (user)
2308	///
2309	/// Returns all active push subscriptions for this tenant.
2310	/// Each tenant represents a user, so this returns all their device subscriptions.
2311	async fn list_push_subscriptions(&self, tn_id: TnId) -> ClResult<Vec<PushSubscription>>;
2312
2313	/// Create a new push subscription
2314	///
2315	/// Stores a Web Push subscription for a tenant. The subscription contains
2316	/// the endpoint URL and encryption keys needed to send push notifications.
2317	/// Returns the generated subscription ID.
2318	async fn create_push_subscription(
2319		&self,
2320		tn_id: TnId,
2321		subscription: &PushSubscriptionData,
2322	) -> ClResult<u64>;
2323
2324	/// Delete a push subscription by ID
2325	///
2326	/// Removes a push subscription. Called when a subscription becomes invalid
2327	/// (e.g., 410 Gone response from push service) or when user unsubscribes.
2328	async fn delete_push_subscription(&self, tn_id: TnId, subscription_id: u64) -> ClResult<()>;
2329
2330	// Share Entry Management
2331	//***********************
2332
2333	/// Create a share entry (idempotent on unique constraint)
2334	async fn create_share_entry(
2335		&self,
2336		tn_id: TnId,
2337		resource_type: char,
2338		resource_id: &str,
2339		created_by: &str,
2340		entry: &CreateShareEntry,
2341	) -> ClResult<ShareEntry>;
2342
2343	/// Delete a share entry by ID
2344	async fn delete_share_entry(&self, tn_id: TnId, id: i64) -> ClResult<()>;
2345
2346	/// Update fields of an existing share entry using PATCH semantics.
2347	/// The update only applies if the row also matches `(resource_type, resource_id)`,
2348	/// which both prevents cross-resource targeting and removes the need for a
2349	/// caller-side pre-read. Returns the updated row via SQL `RETURNING`, or
2350	/// `Error::NotFound` if no row matched.
2351	async fn update_share_entry(
2352		&self,
2353		tn_id: TnId,
2354		id: i64,
2355		resource_type: char,
2356		resource_id: &str,
2357		opts: &UpdateShareEntryOptions,
2358	) -> ClResult<ShareEntry>;
2359
2360	/// List share entries for a resource
2361	async fn list_share_entries(
2362		&self,
2363		tn_id: TnId,
2364		resource_type: char,
2365		resource_id: &str,
2366	) -> ClResult<Vec<ShareEntry>>;
2367
2368	/// List share entries by subject (reverse lookup).
2369	/// If `subject_type` is None, matches all subject types.
2370	async fn list_share_entries_by_subject(
2371		&self,
2372		tn_id: TnId,
2373		subject_type: Option<char>,
2374		subject_id: &str,
2375	) -> ClResult<Vec<ShareEntry>>;
2376
2377	/// Check if a subject has share access to a resource
2378	/// Returns the permission char if access exists, None otherwise
2379	async fn check_share_access(
2380		&self,
2381		tn_id: TnId,
2382		resource_type: char,
2383		resource_id: &str,
2384		subject_type: char,
2385		subject_id: &str,
2386	) -> ClResult<Option<char>>;
2387
2388	/// Read a single share entry by ID (for delete validation)
2389	async fn read_share_entry(&self, tn_id: TnId, id: i64) -> ClResult<Option<ShareEntry>>;
2390
2391	// Installed App Management
2392	//*************************
2393
2394	/// Install an app package
2395	async fn install_app(&self, tn_id: TnId, install: &InstallApp) -> ClResult<()>;
2396
2397	/// Uninstall an app by name and publisher
2398	async fn uninstall_app(&self, tn_id: TnId, app_name: &str, publisher_tag: &str)
2399	-> ClResult<()>;
2400
2401	/// List installed apps, optionally filtered by search term
2402	async fn list_installed_apps(
2403		&self,
2404		tn_id: TnId,
2405		search: Option<&str>,
2406	) -> ClResult<Vec<InstalledApp>>;
2407
2408	/// Get a specific installed app
2409	async fn get_installed_app(
2410		&self,
2411		tn_id: TnId,
2412		app_name: &str,
2413		publisher_tag: &str,
2414	) -> ClResult<Option<InstalledApp>>;
2415
2416	// Full-text search
2417	//******************
2418
2419	/// Replace every index row of one object atomically: delete the existing
2420	/// `(obj_tp, obj_id)` rows, then insert `parts`. An empty `parts` slice is
2421	/// equivalent to [`MetaAdapter::delete_search_object`].
2422	async fn replace_search_object(
2423		&self,
2424		tn_id: TnId,
2425		obj: &SearchObject<'_>,
2426		parts: &[SearchPart<'_>],
2427	) -> ClResult<()>;
2428
2429	/// Replace the single whole-object index row for `(obj_tp, obj_id)`.
2430	///
2431	/// `obj_tp` selects the source table — `'F'` files, `'P'` profiles, `'A'`
2432	/// actions — and the adapter derives `content_type`, `owner_tag`,
2433	/// `visibility`, `root_id`, `created_at` and `part_kind` from that row, so the
2434	/// index and its source can never disagree about who may see it. Only `title`,
2435	/// `body` and `tags` come from the caller; `part_id`, `parent_part` and
2436	/// `anchor_id` have no place in a whole-object row and may be rejected rather
2437	/// than dropped silently.
2438	///
2439	/// `part = None` deletes the row, and so does a `Some` whose source row has
2440	/// meanwhile vanished. For `'F'` the call also refreshes the ACL columns of
2441	/// the file's deep `'D'` rows, and drops them when the file is gone.
2442	///
2443	/// `fts_cl` selects the index route as [`SearchObject::fts_cl`] does; flipping
2444	/// it for an existing object only takes effect through a full reindex.
2445	async fn replace_search_row(
2446		&self,
2447		tn_id: TnId,
2448		obj_tp: char,
2449		obj_id: &str,
2450		part: Option<&SearchPart<'_>>,
2451		fts_cl: bool,
2452	) -> ClResult<()>;
2453
2454	/// Remove every index row of one object.
2455	async fn delete_search_object(&self, tn_id: TnId, obj_tp: char, obj_id: &str) -> ClResult<()>;
2456
2457	/// Drop the **deep** `'D'` index rows of one content type — used when a
2458	/// format manifest's index rules change and the parts they produced must be
2459	/// rebuilt.
2460	///
2461	/// The whole-object `'F'` rows are server-owned — a file name and a tag list —
2462	/// and outlive any manifest, so they are left in place: dropping them would
2463	/// make every such file unfindable by name until the next weekly sweep.
2464	async fn delete_deep_search_by_content_type(
2465		&self,
2466		tn_id: TnId,
2467		content_type: &str,
2468	) -> ClResult<()>;
2469
2470	/// Delete the whole-object index rows of one tenant whose source row is gone.
2471	///
2472	/// Not a rebuild: what an object contributes is decided in Rust and written
2473	/// through [`MetaAdapter::replace_search_row`], so all SQL can catch is a
2474	/// source row hard-deleted without its index row going with it. Deep `'D'`
2475	/// rows are not touched.
2476	async fn reap_search_orphans(&self, tn_id: TnId) -> ClResult<()>;
2477
2478	/// Merge the search indexes' segments. `full` runs the exhaustive pass, worth
2479	/// its cost only after a bulk rebuild. Defaults to a no-op: an adapter whose
2480	/// index needs no compaction — or has none — implements nothing.
2481	///
2482	/// Both indexes are database-wide, not per tenant, so this takes no `TnId`
2483	/// and must be called once per sweep rather than once per tenant.
2484	async fn optimize_search_index(&self, full: bool) -> ClResult<()> {
2485		let _ = full;
2486		Ok(())
2487	}
2488
2489	/// Checkpoint, analyse, and — only if at least `min_free_pct` percent of
2490	/// pages are free — rewrite the database to give the space back to the
2491	/// filesystem.
2492	///
2493	/// The gate exists because the rewrite holds the single write connection for
2494	/// its whole duration. Defaults to reclaiming nothing and reporting an
2495	/// all-zero [`SpaceReport`].
2496	async fn reclaim_space(&self, min_free_pct: i64) -> ClResult<SpaceReport> {
2497		let _ = min_free_pct;
2498		Ok(SpaceReport::default())
2499	}
2500
2501	/// Run a full-text query. Results are relevance-ordered, so pagination is
2502	/// `limit`/`offset` rather than the keyset cursor used elsewhere.
2503	async fn search(&self, tn_id: TnId, opts: &SearchOptions) -> ClResult<Vec<SearchRow>>;
2504
2505	/// How many rows [`MetaAdapter::search`] would match for the same `opts`,
2506	/// ignoring `limit` and `offset` — a relevance ordering gives pagination no
2507	/// other has-more signal to anchor on.
2508	///
2509	/// Counted with the same SQL filters as `search`, and therefore *before* the
2510	/// handler's ABAC post-filter: for a scoped token it is an upper bound.
2511	async fn count_search(&self, tn_id: TnId, opts: &SearchOptions) -> ClResult<i64>;
2512
2513	// Per-tenant subsystem state
2514	//****************************
2515
2516	/// Read one opaque per-tenant value written by a subsystem.
2517	///
2518	/// Distinct from `read_setting`: a setting is user-facing, registered,
2519	/// validated and shown in the admin UI; this is a subsystem's own bookkeeping
2520	/// (a watermark, a schema revision) that no operator should see or change.
2521	async fn read_tenant_data(&self, tn_id: TnId, name: &str) -> ClResult<Option<Box<str>>>;
2522
2523	/// Write, or with `value = None` delete, one such value.
2524	async fn write_tenant_data(&self, tn_id: TnId, name: &str, value: Option<&str>)
2525	-> ClResult<()>;
2526
2527	// Document format manifests
2528	//**************************
2529
2530	/// Read the manifest claiming `content_type`, if any.
2531	async fn read_doc_format(&self, tn_id: TnId, content_type: &str)
2532	-> ClResult<Option<DocFormat>>;
2533
2534	/// List every active manifest of this tenant.
2535	async fn list_doc_formats(&self, tn_id: TnId) -> ClResult<Vec<DocFormat>>;
2536
2537	/// Create or update a manifest. Callers must enforce the claim rule first.
2538	async fn upsert_doc_format(&self, tn_id: TnId, fmt: &UpsertDocFormat<'_>) -> ClResult<()>;
2539
2540	/// Remove a manifest.
2541	async fn delete_doc_format(&self, tn_id: TnId, content_type: &str) -> ClResult<()>;
2542
2543	// Address book / contact management
2544	//***********************************
2545
2546	/// Create a new address book collection.
2547	async fn create_address_book(
2548		&self,
2549		tn_id: TnId,
2550		name: &str,
2551		description: Option<&str>,
2552	) -> ClResult<AddressBook>;
2553
2554	/// List all address books for a tenant.
2555	async fn list_address_books(&self, tn_id: TnId) -> ClResult<Vec<AddressBook>>;
2556
2557	/// Read a single address book by id.
2558	async fn get_address_book(&self, tn_id: TnId, ab_id: u64) -> ClResult<Option<AddressBook>>;
2559
2560	/// Look up an address book by its name (for CardDAV path routing).
2561	async fn get_address_book_by_name(
2562		&self,
2563		tn_id: TnId,
2564		name: &str,
2565	) -> ClResult<Option<AddressBook>>;
2566
2567	/// Patch an address book's metadata.
2568	async fn update_address_book(
2569		&self,
2570		tn_id: TnId,
2571		ab_id: u64,
2572		patch: &UpdateAddressBookData,
2573	) -> ClResult<()>;
2574
2575	/// Delete an address book (and all its contacts).
2576	async fn delete_address_book(&self, tn_id: TnId, ab_id: u64) -> ClResult<()>;
2577
2578	/// List + search contacts. When `ab_id` is `Some`, scopes to that book (cursor
2579	/// is c_id-ordered). When `None`, queries across all books sorted by name.
2580	async fn list_contacts(
2581		&self,
2582		tn_id: TnId,
2583		ab_id: Option<u64>,
2584		opts: &ListContactOptions,
2585	) -> ClResult<Vec<ContactView>>;
2586
2587	/// Read a single contact (including vCard blob) by UID.
2588	async fn get_contact(&self, tn_id: TnId, ab_id: u64, uid: &str) -> ClResult<Option<Contact>>;
2589
2590	/// Insert or update a contact (keyed by UID). Also bumps the address book's ctag.
2591	/// Returns the new etag.
2592	async fn upsert_contact(
2593		&self,
2594		tn_id: TnId,
2595		ab_id: u64,
2596		uid: &str,
2597		vcard: &str,
2598		etag: &str,
2599		extracted: &ContactExtracted,
2600	) -> ClResult<Box<str>>;
2601
2602	/// Soft-delete a contact (sets `deleted_at`), leaving a tombstone row for CardDAV sync.
2603	/// Also bumps the address book's ctag.
2604	async fn delete_contact(&self, tn_id: TnId, ab_id: u64, uid: &str) -> ClResult<()>;
2605
2606	/// Fetch multiple contacts by UID — for CardDAV `addressbook-multiget` REPORT.
2607	async fn get_contacts_by_uids(
2608		&self,
2609		tn_id: TnId,
2610		ab_id: u64,
2611		uids: &[&str],
2612	) -> ClResult<Vec<Contact>>;
2613
2614	/// Return live + tombstone entries for CardDAV `sync-collection` REPORT.
2615	/// `since` is the sync token's timestamp; `None` means full sync.
2616	/// `limit` caps the number of rows returned; callers supply their own hard ceiling
2617	/// to keep responses bounded. `None` means no client-supplied limit — callers should
2618	/// still pass their server-side ceiling.
2619	async fn list_contacts_since(
2620		&self,
2621		tn_id: TnId,
2622		ab_id: u64,
2623		since: Option<Timestamp>,
2624		limit: Option<u32>,
2625	) -> ClResult<Vec<ContactSyncEntry>>;
2626
2627	/// List all contacts linked to a given profile id_tag (for bulk snapshot refresh).
2628	async fn list_contacts_by_profile(
2629		&self,
2630		tn_id: TnId,
2631		profile_id_tag: &str,
2632	) -> ClResult<Vec<Contact>>;
2633
2634	// Calendar / calendar-object management (CalDAV + JSON REST)
2635	//************************************************************
2636
2637	/// Create a new calendar collection.
2638	async fn create_calendar(&self, tn_id: TnId, input: &CreateCalendarData) -> ClResult<Calendar>;
2639
2640	/// List all calendars for a tenant.
2641	async fn list_calendars(&self, tn_id: TnId) -> ClResult<Vec<Calendar>>;
2642
2643	/// Read a single calendar by id.
2644	async fn get_calendar(&self, tn_id: TnId, cal_id: u64) -> ClResult<Option<Calendar>>;
2645
2646	/// Look up a calendar by its name (for CalDAV path routing).
2647	async fn get_calendar_by_name(&self, tn_id: TnId, name: &str) -> ClResult<Option<Calendar>>;
2648
2649	/// Patch a calendar's metadata.
2650	async fn update_calendar(
2651		&self,
2652		tn_id: TnId,
2653		cal_id: u64,
2654		patch: &UpdateCalendarData,
2655	) -> ClResult<()>;
2656
2657	/// Delete a calendar (and all its objects).
2658	async fn delete_calendar(&self, tn_id: TnId, cal_id: u64) -> ClResult<()>;
2659
2660	/// List + search calendar objects within a calendar. Excludes soft-deleted rows.
2661	async fn list_calendar_objects(
2662		&self,
2663		tn_id: TnId,
2664		cal_id: u64,
2665		opts: &ListCalendarObjectOptions,
2666	) -> ClResult<Vec<CalendarObjectView>>;
2667
2668	/// Read a single calendar object (including iCalendar blob) by UID.
2669	/// Returns the master row; recurrence-override rows live under the same UID but distinct
2670	/// `recurrence_id` and are not merged here.
2671	async fn get_calendar_object(
2672		&self,
2673		tn_id: TnId,
2674		cal_id: u64,
2675		uid: &str,
2676	) -> ClResult<Option<CalendarObject>>;
2677
2678	/// Read a single recurrence-override row keyed by `(uid, recurrence_id)`.
2679	async fn get_calendar_object_override(
2680		&self,
2681		tn_id: TnId,
2682		cal_id: u64,
2683		uid: &str,
2684		recurrence_id: Timestamp,
2685	) -> ClResult<Option<CalendarObject>>;
2686
2687	/// List all non-deleted recurrence-override rows for a given master UID.
2688	async fn list_calendar_object_overrides(
2689		&self,
2690		tn_id: TnId,
2691		cal_id: u64,
2692		uid: &str,
2693	) -> ClResult<Vec<CalendarObject>>;
2694
2695	/// Soft-delete a single recurrence-override row (leaves the master untouched).
2696	async fn delete_calendar_object_override(
2697		&self,
2698		tn_id: TnId,
2699		cal_id: u64,
2700		uid: &str,
2701		recurrence_id: Timestamp,
2702	) -> ClResult<()>;
2703
2704	/// Insert or update a calendar object (keyed by UID). Also bumps the calendar's ctag.
2705	/// Returns the new etag. The `extracted.recurrence_id` selects which row is written — the
2706	/// master row has `None`, recurrence overrides carry their own timestamp.
2707	async fn upsert_calendar_object(
2708		&self,
2709		tn_id: TnId,
2710		cal_id: u64,
2711		uid: &str,
2712		ical: &str,
2713		etag: &str,
2714		extracted: &CalendarObjectExtracted,
2715	) -> ClResult<Box<str>>;
2716
2717	/// Soft-delete a calendar object by UID (sets `deleted_at` on all rows sharing that UID),
2718	/// leaving tombstones for CalDAV sync. Also bumps the calendar's ctag.
2719	async fn delete_calendar_object(&self, tn_id: TnId, cal_id: u64, uid: &str) -> ClResult<()>;
2720
2721	/// Atomically split a recurring series at `split_at`:
2722	///   1. Upsert the existing master (typically with a truncated RRULE) using the
2723	///      caller-supplied ical / etag / extracted projection.
2724	///   2. Soft-delete every override row whose `recurrence_id >= split_at`.
2725	///   3. Insert the tail as a new master under its own UID.
2726	///   4. Bump the calendar's ctag once for the whole fork.
2727	///
2728	/// The whole operation runs in a single transaction; on any error the caller sees the
2729	/// original series unchanged. Returns the stored etags of the master and the tail,
2730	/// in that order.
2731	async fn split_calendar_object_series(
2732		&self,
2733		tn_id: TnId,
2734		cal_id: u64,
2735		master: CalendarObjectWrite<'_>,
2736		tail: CalendarObjectWrite<'_>,
2737		split_at: Timestamp,
2738	) -> ClResult<(Box<str>, Box<str>)>;
2739
2740	/// Fetch multiple calendar objects by UID — for CalDAV `calendar-multiget` REPORT.
2741	async fn get_calendar_objects_by_uids(
2742		&self,
2743		tn_id: TnId,
2744		cal_id: u64,
2745		uids: &[&str],
2746	) -> ClResult<Vec<CalendarObject>>;
2747
2748	/// Return live + tombstone entries for CalDAV `sync-collection` REPORT.
2749	/// `since` is the sync token's timestamp; `None` means full sync.
2750	async fn list_calendar_objects_since(
2751		&self,
2752		tn_id: TnId,
2753		cal_id: u64,
2754		since: Option<Timestamp>,
2755		limit: Option<u32>,
2756	) -> ClResult<Vec<CalendarObjectSyncEntry>>;
2757
2758	/// Return calendar objects overlapping a time range — for CalDAV `calendar-query` REPORT.
2759	/// Semantics are deliberately loose (superset): any object whose master `dtstart` is ≤ `end`
2760	/// AND (`rrule` is set OR `dtend` is ≥ `start` OR `dtend IS NULL`) is returned. Clients
2761	/// expand recurrence locally. A `None` component lists both VEVENT and VTODO.
2762	async fn query_calendar_objects_in_range(
2763		&self,
2764		tn_id: TnId,
2765		cal_id: u64,
2766		component: Option<&str>,
2767		start: Option<Timestamp>,
2768		end: Option<Timestamp>,
2769	) -> ClResult<Vec<CalendarObject>>;
2770}
2771
2772#[cfg(test)]
2773mod tests {
2774	use super::*;
2775
2776	#[test]
2777	fn test_affects_search_index_hidden() {
2778		let opts = UpdateFileOptions { hidden: Patch::Value(true), ..Default::default() };
2779		assert!(opts.affects_search_index());
2780
2781		let opts = UpdateFileOptions::default();
2782		assert!(!opts.affects_search_index());
2783	}
2784
2785	#[test]
2786	fn test_deserialize_list_action_options_with_multiple_statuses() {
2787		let query = "status=C,N&type=POST,REPLY";
2788		let opts: ListActionOptions =
2789			serde_urlencoded::from_str(query).expect("should deserialize");
2790
2791		assert!(opts.status.is_some());
2792		let statuses = opts.status.expect("status should be Some");
2793		assert_eq!(statuses.len(), 2);
2794		assert_eq!(statuses[0].as_str(), "C");
2795		assert_eq!(statuses[1].as_str(), "N");
2796
2797		assert!(opts.typ.is_some());
2798		let types = opts.typ.expect("type should be Some");
2799		assert_eq!(types.len(), 2);
2800		assert_eq!(types[0].as_str(), "POST");
2801		assert_eq!(types[1].as_str(), "REPLY");
2802	}
2803
2804	#[test]
2805	fn test_deserialize_list_action_options_without_status() {
2806		let query = "issuer=alice";
2807		let opts: ListActionOptions =
2808			serde_urlencoded::from_str(query).expect("should deserialize");
2809
2810		assert!(opts.status.is_none());
2811		assert!(opts.typ.is_none());
2812		assert_eq!(opts.issuer.as_deref(), Some("alice"));
2813	}
2814
2815	#[test]
2816	fn test_deserialize_list_action_options_single_status() {
2817		let query = "status=C";
2818		let opts: ListActionOptions =
2819			serde_urlencoded::from_str(query).expect("should deserialize");
2820
2821		assert!(opts.status.is_some());
2822		let statuses = opts.status.expect("status should be Some");
2823		assert_eq!(statuses.len(), 1);
2824		assert_eq!(statuses[0].as_str(), "C");
2825	}
2826
2827	#[test]
2828	fn test_deserialize_list_action_options_audience_type() {
2829		let opts: ListActionOptions = serde_urlencoded::from_str("audienceType=personal")
2830			.expect("should deserialize personal");
2831		assert!(matches!(opts.audience_type, Some(AudienceType::Personal)));
2832
2833		let opts: ListActionOptions = serde_urlencoded::from_str("audienceType=community")
2834			.expect("should deserialize community");
2835		assert!(matches!(opts.audience_type, Some(AudienceType::Community)));
2836
2837		let opts: ListActionOptions =
2838			serde_urlencoded::from_str("issuer=alice").expect("should deserialize");
2839		assert!(opts.audience_type.is_none());
2840
2841		let res: Result<ListActionOptions, _> = serde_urlencoded::from_str("audienceType=garbage");
2842		assert!(res.is_err(), "garbage audienceType should error");
2843	}
2844
2845	#[test]
2846	fn test_deserialize_list_action_options_multi_visibility() {
2847		let opts: ListActionOptions =
2848			serde_urlencoded::from_str("visibility=F,C").expect("should deserialize");
2849		let v = opts.visibility.expect("visibility should be Some");
2850		assert_eq!(v.len(), 2);
2851		assert_eq!(v[0].as_str(), "F");
2852		assert_eq!(v[1].as_str(), "C");
2853
2854		let opts: ListActionOptions =
2855			serde_urlencoded::from_str("visibility=P").expect("should deserialize");
2856		let v = opts.visibility.expect("visibility should be Some");
2857		assert_eq!(v.len(), 1);
2858		assert_eq!(v[0].as_str(), "P");
2859
2860		let opts: ListActionOptions =
2861			serde_urlencoded::from_str("issuer=alice").expect("should deserialize");
2862		assert!(opts.visibility.is_none());
2863	}
2864
2865	#[test]
2866	fn test_deserialize_list_action_options_visibility_with_direct() {
2867		let opts: ListActionOptions =
2868			serde_urlencoded::from_str("visibility=D,F").expect("should deserialize");
2869		let v = opts.visibility.expect("visibility should be Some");
2870		assert_eq!(v.len(), 2);
2871		assert_eq!(v[0].as_str(), "D");
2872		assert_eq!(v[1].as_str(), "F");
2873	}
2874
2875	#[test]
2876	fn test_broken_reason_as_str_matches_serde() {
2877		for reason in [BrokenReason::Deleted, BrokenReason::Revoked] {
2878			let via_serde = serde_json::to_value(reason)
2879				.expect("serialize")
2880				.as_str()
2881				.expect("string variant")
2882				.to_string();
2883			assert_eq!(reason.as_str(), via_serde, "as_str diverged from serde for {:?}", reason);
2884		}
2885	}
2886}
2887
2888// vim: ts=4