Skip to main content

cloudillo_types/
meta_adapter.rs

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