iroh_db_derive/
lib.rs

1//! Derive macros for typed iroh-db records.
2
3#![forbid(unsafe_code)]
4
5use std::collections::BTreeMap;
6
7use proc_macro::TokenStream;
8use proc_macro_crate::{FoundCrate, crate_name};
9use proc_macro2::{Span, TokenStream as TokenStream2};
10use quote::{format_ident, quote};
11use syn::{
12    Data, DeriveInput, Fields, GenericArgument, Ident, LitInt, LitStr, PathArguments, Type,
13    parse_macro_input, spanned::Spanned as _,
14};
15
16/// Generates the stable schema, canonical record codec and typed field metadata.
17#[proc_macro_derive(IrohRecord, attributes(iroh_db))]
18pub fn derive_iroh_record(input: TokenStream) -> TokenStream {
19    let input = parse_macro_input!(input as DeriveInput);
20    expand(input)
21        .unwrap_or_else(syn::Error::into_compile_error)
22        .into()
23}
24
25#[derive(Default)]
26struct RecordAttrs {
27    collection: Option<String>,
28    version: Option<u32>,
29}
30
31#[derive(Default)]
32struct FieldAttrs {
33    id: bool,
34    indexed: bool,
35    field_id: Option<u32>,
36}
37
38struct FieldInfo {
39    ident: Ident,
40    ty: Type,
41    field_id: u32,
42    is_id: bool,
43    indexed: bool,
44    value_ty: Type,
45    kind: Ident,
46}
47
48// Keeping validation and emitted fragments together makes diagnostic spans precise.
49#[allow(clippy::too_many_lines)]
50fn expand(input: DeriveInput) -> syn::Result<TokenStream2> {
51    if !input.generics.params.is_empty() {
52        return Err(syn::Error::new_spanned(
53            input.generics,
54            "IrohRecord does not support generic records; use a concrete durable schema type",
55        ));
56    }
57
58    let attrs = parse_record_attrs(&input)?;
59    let fields = match &input.data {
60        Data::Struct(data) => match &data.fields {
61            Fields::Named(fields) => &fields.named,
62            Fields::Unnamed(_) | Fields::Unit => {
63                return Err(syn::Error::new_spanned(
64                    &input.ident,
65                    "IrohRecord requires a struct with named fields",
66                ));
67            }
68        },
69        Data::Enum(_) | Data::Union(_) => {
70            return Err(syn::Error::new_spanned(
71                &input.ident,
72                "IrohRecord can only be derived for structs",
73            ));
74        }
75    };
76
77    let mut infos: Vec<FieldInfo> = Vec::with_capacity(fields.len());
78    let mut numeric_ids = BTreeMap::<u32, Span>::new();
79    let mut record_id_index = None;
80    for field in fields {
81        let ident = field
82            .ident
83            .clone()
84            .expect("named fields always carry identifiers");
85        let field_attrs = parse_field_attrs(field)?;
86        let field_id = field_attrs
87            .field_id
88            .unwrap_or_else(|| stable_field_id(&ident.to_string()));
89        if field_id == 0 {
90            return Err(syn::Error::new(
91                ident.span(),
92                "field_id 0 is reserved for record-level operations",
93            ));
94        }
95        if let Some(previous) = numeric_ids.insert(field_id, ident.span()) {
96            let mut error = syn::Error::new(
97                ident.span(),
98                format!("duplicate iroh-db field_id {field_id}"),
99            );
100            error.combine(syn::Error::new(previous, "first used by this field"));
101            return Err(error);
102        }
103
104        let (kind, value_ty) = if field_attrs.id {
105            if let Some(previous) = record_id_index.replace(infos.len()) {
106                let mut error = syn::Error::new(ident.span(), "multiple #[iroh_db(id)] fields");
107                error.combine(syn::Error::new(
108                    infos[previous].ident.span(),
109                    "first record ID is here",
110                ));
111                return Err(error);
112            }
113            (format_ident!("Immutable"), field.ty.clone())
114        } else {
115            replicated_type(&field.ty)?
116        };
117
118        infos.push(FieldInfo {
119            ident,
120            ty: field.ty.clone(),
121            field_id,
122            is_id: field_attrs.id,
123            indexed: field_attrs.indexed,
124            value_ty,
125            kind,
126        });
127    }
128
129    let id_index = record_id_index.ok_or_else(|| {
130        syn::Error::new_spanned(
131            &input.ident,
132            "IrohRecord requires exactly one #[iroh_db(id)] field",
133        )
134    })?;
135    if infos[id_index].indexed {
136        return Err(syn::Error::new(
137            infos[id_index].ident.span(),
138            "the record ID is always indexed; remove #[iroh_db(index)]",
139        ));
140    }
141
142    let core = core_path()?;
143    let name = &input.ident;
144    let collection = attrs.collection.unwrap_or_else(|| name.to_string());
145    let collection = LitStr::new(&collection, name.span());
146    let version = attrs.version.unwrap_or(1);
147    let id_ident = &infos[id_index].ident;
148    let id_ty = &infos[id_index].ty;
149
150    let descriptors = infos.iter().map(|field| {
151        let field_id = field.field_id;
152        let field_name = LitStr::new(&field.ident.to_string(), field.ident.span());
153        let kind = &field.kind;
154        let value_ty = &field.value_ty;
155        let indexed = field.indexed;
156        let record_id = field.is_id;
157        quote! {
158            {
159                let descriptor = #core::FieldDescriptor::new(
160                    #field_id,
161                    #field_name,
162                    #core::CrdtKind::#kind,
163                    stringify!(#value_ty),
164                );
165                let descriptor = if #indexed { descriptor.indexed() } else { descriptor };
166                if #record_id { descriptor.record_id() } else { descriptor }
167            }
168        }
169    });
170
171    let encoders = infos.iter().map(|field| {
172        let ident = &field.ident;
173        let ty = &field.ty;
174        let field_id = field.field_id;
175        if field.is_id {
176            quote! { (#field_id, <#ty as #core::RecordKey>::encode_key(&self.#ident)?) }
177        } else {
178            quote! { (#field_id, <#ty as #core::FieldState>::encode_state(&self.#ident)?) }
179        }
180    });
181
182    let decoders = infos.iter().map(|field| {
183        let ident = &field.ident;
184        let ty = &field.ty;
185        let field_id = field.field_id;
186        if field.is_id {
187            quote! { #ident: <#ty as #core::RecordKey>::decode_key(fields.required(#field_id)?)? }
188        } else {
189            quote! { #ident: <#ty as #core::FieldState>::decode_state(fields.required(#field_id)?)? }
190        }
191    });
192
193    let constants = infos.iter().map(|field| {
194        let ident = &field.ident;
195        let field_id = field.field_id;
196        let field_name = LitStr::new(&ident.to_string(), ident.span());
197        let value_ty = &field.value_ty;
198        let indexed = field.indexed || field.is_id;
199        quote! {
200            #[allow(non_upper_case_globals)]
201            pub const #ident: #core::TypedField<Self, #value_ty> =
202                #core::TypedField::new(#field_id, #field_name, #indexed);
203        }
204    });
205
206    let value_extractors = infos.iter().map(|field| {
207        let ident = &field.ident;
208        let ty = &field.ty;
209        let field_id = field.field_id;
210        if field.is_id {
211            quote! { #field_id => Ok(vec![<#ty as #core::RecordKey>::encode_key(&self.#ident)?]) }
212        } else {
213            quote! { #field_id => <#ty as #core::FieldState>::visible_values(&self.#ident) }
214        }
215    });
216
217    let mergers = infos.iter().filter(|field| !field.is_id).map(|field| {
218        let ident = &field.ident;
219        let ty = &field.ty;
220        quote! {
221            <#ty as #core::FieldState>::merge_state(&mut self.#ident, &other.#ident)?;
222        }
223    });
224
225    let causal_normalizers = infos.iter().filter(|field| !field.is_id).map(|field| {
226        let ident = &field.ident;
227        let ty = &field.ty;
228        quote! {
229            self.#ident = <#ty as #core::FieldState>::canonicalize_for_commit(
230                &self.#ident,
231                author,
232                sequence,
233                context,
234                next_operation,
235            )?;
236        }
237    });
238
239    Ok(quote! {
240        impl #core::IrohRecord for #name {
241            type Id = #id_ty;
242
243            fn schema() -> ::core::result::Result<#core::SchemaDescriptor, #core::RecordError> {
244                Ok(#core::SchemaDescriptor::new(
245                    #collection,
246                    #version,
247                    vec![#(#descriptors),*],
248                )?)
249            }
250
251            fn id(&self) -> &Self::Id {
252                &self.#id_ident
253            }
254
255            fn encode_record(&self) -> ::core::result::Result<Vec<u8>, #core::RecordError> {
256                #core::encode_record_fields([#(#encoders),*])
257            }
258
259            fn decode_record(bytes: &[u8]) -> ::core::result::Result<Self, #core::RecordError> {
260                let fields = #core::decode_record_fields(bytes)?;
261                Ok(Self { #(#decoders),* })
262            }
263
264            fn field_values(
265                &self,
266                field_id: u32,
267            ) -> ::core::result::Result<Vec<Vec<u8>>, #core::RecordError> {
268                match field_id {
269                    #(#value_extractors),*,
270                    _ => Ok(Vec::new()),
271                }
272            }
273
274            fn merge_record(&mut self, other: &Self) -> ::core::result::Result<(), #core::RecordError> {
275                if self.record_id()? != other.record_id()? {
276                    return Err(#core::RecordError::MergeUnavailable);
277                }
278                #(#mergers)*
279                Ok(())
280            }
281
282            fn canonicalize_causality(
283                &mut self,
284                author: #core::AuthorId,
285                sequence: u64,
286                context: &#core::VersionVector,
287                next_operation: &mut u32,
288            ) -> ::core::result::Result<(), #core::RecordError> {
289                #(#causal_normalizers)*
290                Ok(())
291            }
292        }
293
294        impl #name {
295            #(#constants)*
296        }
297    })
298}
299
300fn parse_record_attrs(input: &DeriveInput) -> syn::Result<RecordAttrs> {
301    let mut result = RecordAttrs::default();
302    for attr in &input.attrs {
303        if !attr.path().is_ident("iroh_db") {
304            continue;
305        }
306        attr.parse_nested_meta(|meta| {
307            if meta.path.is_ident("collection") {
308                if result.collection.is_some() {
309                    return Err(meta.error("duplicate collection attribute"));
310                }
311                result.collection = Some(meta.value()?.parse::<LitStr>()?.value());
312                Ok(())
313            } else if meta.path.is_ident("version") {
314                if result.version.is_some() {
315                    return Err(meta.error("duplicate version attribute"));
316                }
317                result.version = Some(meta.value()?.parse::<LitInt>()?.base10_parse()?);
318                Ok(())
319            } else {
320                Err(meta.error("unsupported iroh_db record attribute"))
321            }
322        })?;
323    }
324    Ok(result)
325}
326
327fn parse_field_attrs(field: &syn::Field) -> syn::Result<FieldAttrs> {
328    let mut result = FieldAttrs::default();
329    for attr in &field.attrs {
330        if !attr.path().is_ident("iroh_db") {
331            continue;
332        }
333        attr.parse_nested_meta(|meta| {
334            if meta.path.is_ident("id") {
335                if result.id {
336                    return Err(meta.error("duplicate id attribute"));
337                }
338                result.id = true;
339                Ok(())
340            } else if meta.path.is_ident("index") {
341                if result.indexed {
342                    return Err(meta.error("duplicate index attribute"));
343                }
344                result.indexed = true;
345                Ok(())
346            } else if meta.path.is_ident("field_id") {
347                if result.field_id.is_some() {
348                    return Err(meta.error("duplicate field_id attribute"));
349                }
350                result.field_id = Some(meta.value()?.parse::<LitInt>()?.base10_parse()?);
351                Ok(())
352            } else {
353                Err(meta.error("unsupported iroh_db field attribute"))
354            }
355        })?;
356    }
357    Ok(result)
358}
359
360fn replicated_type(ty: &Type) -> syn::Result<(Ident, Type)> {
361    let Type::Path(path) = ty else {
362        return Err(syn::Error::new(
363            ty.span(),
364            "replicated fields must use a built-in iroh-db CRDT type",
365        ));
366    };
367    let segment =
368        path.path.segments.last().ok_or_else(|| {
369            syn::Error::new(ty.span(), "replicated field type path cannot be empty")
370        })?;
371    let kind = segment.ident.to_string();
372    if kind == "BlobRef" {
373        if !matches!(segment.arguments, PathArguments::None) {
374            return Err(syn::Error::new_spanned(
375                &segment.arguments,
376                "BlobRef does not accept a value type",
377            ));
378        }
379        return Ok((format_ident!("Immutable"), ty.clone()));
380    }
381    if kind == "PnCounter" {
382        if !matches!(segment.arguments, PathArguments::None) {
383            return Err(syn::Error::new_spanned(
384                &segment.arguments,
385                "PnCounter does not accept a value type",
386            ));
387        }
388        return Ok((segment.ident.clone(), syn::parse_quote!(i128)));
389    }
390    if !matches!(
391        kind.as_str(),
392        "Lww" | "OrSet" | "GrowOnlySet" | "MultiValue" | "Immutable" | "OrderedList"
393    ) {
394        return Err(syn::Error::new(
395            ty.span(),
396            "replicated fields must use Lww, OrSet, GrowOnlySet, PnCounter, MultiValue, Immutable, or OrderedList",
397        ));
398    }
399    let PathArguments::AngleBracketed(arguments) = &segment.arguments else {
400        return Err(syn::Error::new_spanned(
401            &segment.ident,
402            "this CRDT requires exactly one value type",
403        ));
404    };
405    let mut types = arguments.args.iter().filter_map(|argument| match argument {
406        GenericArgument::Type(ty) => Some(ty.clone()),
407        _ => None,
408    });
409    let value_ty = types.next().ok_or_else(|| {
410        syn::Error::new_spanned(arguments, "this CRDT requires exactly one value type")
411    })?;
412    if types.next().is_some() || arguments.args.len() != 1 {
413        return Err(syn::Error::new_spanned(
414            arguments,
415            "this CRDT requires exactly one value type",
416        ));
417    }
418    Ok((segment.ident.clone(), value_ty))
419}
420
421fn stable_field_id(name: &str) -> u32 {
422    let mut hasher = blake3::Hasher::new();
423    hasher.update(b"iroh-db/field/v1");
424    hasher.update(name.as_bytes());
425    let bytes: [u8; 4] = hasher.finalize().as_bytes()[..4]
426        .try_into()
427        .expect("a BLAKE3 digest always has four bytes");
428    u32::from_be_bytes(bytes).max(1)
429}
430
431fn core_path() -> syn::Result<TokenStream2> {
432    if let Ok(found) = crate_name("iroh-db") {
433        return Ok(match found {
434            FoundCrate::Itself => quote!(::iroh_db::__private::core),
435            FoundCrate::Name(name) => {
436                let name = format_ident!("{}", name.replace('-', "_"));
437                quote!(::#name::__private::core)
438            }
439        });
440    }
441    match crate_name("iroh-db-core") {
442        Ok(FoundCrate::Itself) => Ok(quote!(crate)),
443        Ok(FoundCrate::Name(name)) => {
444            let name = format_ident!("{}", name.replace('-', "_"));
445            Ok(quote!(::#name))
446        }
447        Err(error) => Err(syn::Error::new(
448            Span::call_site(),
449            format!("IrohRecord needs the iroh-db or iroh-db-core crate: {error}"),
450        )),
451    }
452}