Skip to main content

thingd/
model.rs

1//! Data model types shared by storage adapters.
2
3use crate::{u64_to_i64, unix_timestamp_millis};
4
5/// Default queue lease duration in milliseconds.
6pub const DEFAULT_QUEUE_LEASE_MS: u64 = 30_000;
7
8/// Stable object key inside a collection.
9#[derive(
10    Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, serde::Serialize, serde::Deserialize,
11)]
12#[serde(rename_all = "camelCase")]
13pub struct ObjectKey {
14    /// Collection name, such as `decisions`, `documents`, or `customers`.
15    pub collection: String,
16    /// Stable object identifier inside the collection.
17    pub id: String,
18}
19
20impl ObjectKey {
21    /// Create a new object key.
22    pub fn new(collection: impl Into<String>, id: impl Into<String>) -> Self {
23        Self {
24            collection: collection.into(),
25            id: id.into(),
26        }
27    }
28}
29
30/// An object stored in a thingd collection.
31#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
32#[serde(rename_all = "camelCase")]
33pub struct MemoryObject {
34    /// Stable object key.
35    pub key: ObjectKey,
36    /// Serialized object body.
37    pub body: String,
38    /// Monotonic object version assigned by the store.
39    pub version: u64,
40    /// ISO 8601 creation timestamp, e.g. "2026-06-01T12:00:00.000Z". Empty if not set.
41    pub created_at: String,
42    /// ISO 8601 last-update timestamp. Empty if not set.
43    pub updated_at: String,
44    /// Optional vector embedding for vector search (e.g., for ANN search).
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub vector: Option<Vec<f32>>,
47}
48
49impl MemoryObject {
50    /// Create a new object record.
51    pub fn new(
52        collection: impl Into<String>,
53        id: impl Into<String>,
54        body: impl Into<String>,
55    ) -> Self {
56        Self {
57            key: ObjectKey::new(collection, id),
58            body: body.into(),
59            version: 0,
60            created_at: String::new(),
61            updated_at: String::new(),
62            vector: None,
63        }
64    }
65
66    /// Attach a vector embedding to this object.
67    #[must_use]
68    pub fn with_vector(mut self, vector: Vec<f32>) -> Self {
69        self.vector = Some(vector);
70        self
71    }
72}
73
74/// An append-only event stored in a thingd stream.
75#[derive(Clone, Debug, Eq, Hash, PartialEq, serde::Serialize, serde::Deserialize)]
76#[serde(rename_all = "camelCase")]
77pub struct MemoryEvent {
78    /// Stream name, such as `project:thingd` or `customer:cus_123`.
79    pub stream: String,
80    /// Event kind, such as `decision.made`.
81    pub event_type: String,
82    /// Serialized event body.
83    pub body: String,
84    /// Monotonic sequence assigned by the event log.
85    pub sequence: u64,
86    /// ISO 8601 creation timestamp. Empty if not set.
87    pub created_at: String,
88    /// Optional idempotency key for deduplication on retry.
89    /// When set, appending an event with the same (stream, `idempotency_key`)
90    /// pair returns the existing event instead of creating a duplicate.
91    pub idempotency_key: String,
92}
93
94impl MemoryEvent {
95    /// Create a new event record.
96    pub fn new(
97        stream: impl Into<String>,
98        event_type: impl Into<String>,
99        body: impl Into<String>,
100    ) -> Self {
101        Self {
102            stream: stream.into(),
103            event_type: event_type.into(),
104            body: body.into(),
105            sequence: 0,
106            created_at: String::new(),
107            idempotency_key: String::new(),
108        }
109    }
110}
111
112/// Queue job lifecycle state.
113#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Serialize, serde::Deserialize)]
114#[serde(rename_all = "camelCase")]
115pub enum QueueJobStatus {
116    /// Ready to be claimed by a worker.
117    Ready,
118    /// Claimed by a worker and awaiting ack/nack.
119    Leased,
120    /// Completed successfully.
121    Completed,
122    /// Exhausted retries and moved to the dead-letter set.
123    Dead,
124}
125
126/// A queued unit of work.
127#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
128#[serde(rename_all = "camelCase")]
129pub struct QueueJob {
130    /// Queue name.
131    pub queue: String,
132    /// Stable job identifier.
133    pub id: String,
134    /// Serialized job payload.
135    pub body: String,
136    /// Number of attempts already made.
137    pub attempts: u32,
138    /// Maximum attempts before the job should be considered dead.
139    pub max_attempts: u32,
140    /// Current job status.
141    pub status: QueueJobStatus,
142    /// Unix timestamp in milliseconds when this job becomes claimable.
143    pub available_at_ms: i64,
144    /// Unix timestamp in milliseconds when this job was leased.
145    pub leased_at_ms: Option<i64>,
146    /// Unix timestamp in milliseconds when this job lease expires.
147    pub lease_expires_at_ms: Option<i64>,
148    /// Unix timestamp in milliseconds when this job completed.
149    pub completed_at_ms: Option<i64>,
150    /// Unix timestamp in milliseconds when this job moved to dead-letter state.
151    pub dead_at_ms: Option<i64>,
152    /// ISO 8601 creation timestamp. Empty if not set.
153    pub created_at: String,
154    /// Error message from last nack. Empty if not set.
155    pub last_error: String,
156    /// Priority for claim ordering (higher = claimed sooner). Default: 0.
157    pub priority: i32,
158}
159
160impl QueueJob {
161    /// Create a new ready job.
162    pub fn new(
163        queue: impl Into<String>,
164        id: impl Into<String>,
165        body: impl Into<String>,
166        max_attempts: u32,
167    ) -> Self {
168        Self {
169            queue: queue.into(),
170            id: id.into(),
171            body: body.into(),
172            attempts: 0,
173            max_attempts,
174            status: QueueJobStatus::Ready,
175            available_at_ms: 0,
176            leased_at_ms: None,
177            lease_expires_at_ms: None,
178            completed_at_ms: None,
179            dead_at_ms: None,
180            created_at: String::new(),
181            last_error: String::new(),
182            priority: 0,
183        }
184    }
185
186    /// Set the priority for this job (higher = claimed sooner).
187    #[must_use]
188    pub const fn with_priority(mut self, priority: i32) -> Self {
189        self.priority = priority;
190        self
191    }
192
193    /// Make this job available after a delay.
194    #[must_use]
195    pub fn delay_by_ms(mut self, delay_ms: u64) -> Self {
196        self.available_at_ms = unix_timestamp_millis().saturating_add(u64_to_i64(delay_ms));
197        self
198    }
199
200    /// Set the exact Unix timestamp in milliseconds when this job is claimable.
201    #[must_use]
202    pub const fn available_at_ms(mut self, available_at_ms: i64) -> Self {
203        self.available_at_ms = available_at_ms;
204        self
205    }
206}
207
208/// Options for listing events.
209#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
210#[serde(rename_all = "camelCase")]
211pub struct ListEventsOptions {
212    /// Only return events with sequence greater than this value.
213    pub from_sequence: Option<u64>,
214    /// Maximum number of events to return.
215    pub limit: Option<u64>,
216    /// Only return events created at or after this ISO 8601 timestamp.
217    pub since: Option<String>,
218}
219
220/// Sort direction for list queries.
221#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
222#[serde(rename_all = "camelCase")]
223pub enum SortDirection {
224    /// Ascending order (A→Z, oldest→newest, smallest→largest).
225    #[default]
226    Asc,
227    /// Descending order (Z→A, newest→oldest, largest→smallest).
228    Desc,
229}
230
231/// Sort specification for list queries.
232#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
233#[serde(rename_all = "camelCase")]
234pub struct SortBy {
235    /// Field name: `id`, `collection`, `created_at`, `updated_at`, `version`.
236    pub field: String,
237    /// Sort direction.
238    pub direction: SortDirection,
239}
240
241impl SortBy {
242    /// Create ascending sort by field name.
243    pub fn asc(field: impl Into<String>) -> Self {
244        Self {
245            field: field.into(),
246            direction: SortDirection::Asc,
247        }
248    }
249
250    /// Create descending sort by field name.
251    pub fn desc(field: impl Into<String>) -> Self {
252        Self {
253            field: field.into(),
254            direction: SortDirection::Desc,
255        }
256    }
257}
258
259/// Options for listing objects in a collection.
260#[derive(Clone, Debug, Default)]
261pub struct ListObjectsOptions {
262    /// Filter key-value pairs serialised as JSON pairs: only objects whose body
263    /// contains every listed top-level key with the exact JSON value are returned.
264    /// Each string is `"key":<json-value>` without surrounding braces.
265    pub filter: Vec<(String, serde_json::Value)>,
266    /// Sort specification. Default is insertion order.
267    pub sort_by: Option<SortBy>,
268    /// Maximum number of objects to return.
269    pub limit: Option<u64>,
270    /// Number of objects to skip before returning results.
271    pub offset: Option<u64>,
272}
273
274/// Options for putting an object.
275#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
276#[serde(rename_all = "camelCase")]
277pub struct PutObjectOptions {
278    /// Whether to update the FTS search index. Default: `true`.
279    /// Set to `false` when only metadata changes (e.g. timestamp dedup)
280    /// and the body text is identical — skips FTS DELETE + INSERT.
281    pub index: bool,
282    /// Optional expected version for optimistic locking (CAS).
283    /// When `Some(v)`, the put succeeds only if the current version
284    /// equals `v`. If the object does not exist, returns `Conflict`.
285    /// When `None`, no version check is performed (default).
286    pub expected_version: Option<u64>,
287}
288
289impl Default for PutObjectOptions {
290    fn default() -> Self {
291        Self {
292            index: true,
293            expected_version: None,
294        }
295    }
296}
297
298/// Options used when claiming a queue job.
299#[derive(Clone, Copy, Debug, Eq, PartialEq)]
300pub struct QueueClaimOptions {
301    /// Lease duration in milliseconds.
302    pub lease_ms: u64,
303}
304
305impl Default for QueueClaimOptions {
306    fn default() -> Self {
307        Self {
308            lease_ms: DEFAULT_QUEUE_LEASE_MS,
309        }
310    }
311}
312
313impl QueueClaimOptions {
314    /// Create queue claim options with the given lease duration.
315    #[must_use]
316    pub const fn new(lease_ms: u64) -> Self {
317        Self { lease_ms }
318    }
319}
320
321/// Options used when rejecting a leased queue job.
322#[derive(Clone, Debug, Eq, PartialEq, Default)]
323pub struct QueueNackOptions {
324    /// Delay before a retry can be claimed.
325    pub delay_ms: u64,
326    /// Error message from the worker, stored as `last_error` on the job.
327    pub error: String,
328}
329
330impl QueueNackOptions {
331    /// Create queue nack options with the given retry delay.
332    #[must_use]
333    pub const fn new(delay_ms: u64) -> Self {
334        Self {
335            delay_ms,
336            error: String::new(),
337        }
338    }
339
340    /// Create queue nack options with retry delay and an error message.
341    #[must_use]
342    pub fn with_error(delay_ms: u64, error: impl Into<String>) -> Self {
343        Self {
344            delay_ms,
345            error: error.into(),
346        }
347    }
348}
349
350/// Options used when performing a search.
351#[derive(Clone, Debug, Eq, PartialEq, Default)]
352pub struct SearchOptions {
353    /// Limit search to these collection or stream names.
354    pub collections: Option<Vec<String>>,
355    /// Maximum number of hits to return.
356    pub limit: Option<usize>,
357    /// Metadata filters to match custom fields in the JSON body.
358    pub filter: Option<serde_json::Value>,
359}
360
361/// A single match returned by a search query.
362#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
363#[serde(rename_all = "camelCase")]
364pub struct SearchHit {
365    /// Result kind: "object" or "event".
366    pub kind: String,
367    /// Collection or stream name.
368    pub collection: String,
369    /// Object id or event sequence number.
370    pub id: String,
371    /// The indexed text that matched.
372    pub text: String,
373    /// Relevancy score.
374    pub score: f64,
375    /// The serialized body.
376    pub body: String,
377    /// Object version (only populated for objects).
378    pub version: Option<u64>,
379    /// Created timestamp.
380    pub created_at: String,
381    /// Updated timestamp (only populated for objects).
382    pub updated_at: Option<String>,
383    /// Event type (only populated for events).
384    pub event_type: Option<String>,
385}
386
387/// A single match returned by a vector search query.
388#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
389#[serde(rename_all = "camelCase")]
390pub struct VectorSearchHit {
391    /// Object id.
392    pub id: String,
393    /// Cosine similarity score (-1.0 to 1.0).
394    pub score: f64,
395    /// The full stored object.
396    pub value: MemoryObject,
397}
398
399/// Options for vector search.
400#[derive(Clone, Debug, Default)]
401pub struct VectorSearchOptions {
402    /// Maximum number of results to return (default: all matching).
403    pub top_k: Option<usize>,
404    /// Metadata filter: only objects whose body matches these fields are returned.
405    pub filter: Option<serde_json::Value>,
406}
407
408/// A graph link connecting two references.
409#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
410#[serde(rename_all = "camelCase")]
411pub struct Link {
412    /// Unique link identifier.
413    pub id: String,
414    /// Source reference (e.g. "collection/id" or "stream/sequence").
415    pub from_ref: String,
416    /// Relationship type (e.g. "supports", "`depends_on`", "`chunk_of`").
417    pub link_type: String,
418    /// Target reference.
419    pub to_ref: String,
420    /// Optional weight for ranking (0.0 to 1.0).
421    pub weight: Option<f64>,
422    /// Optional metadata as JSON string.
423    pub metadata_json: String,
424    /// ISO 8601 creation timestamp.
425    pub created_at: String,
426}
427
428impl Link {
429    /// Create a new graph link.
430    pub fn new(
431        from_ref: impl Into<String>,
432        link_type: impl Into<String>,
433        to_ref: impl Into<String>,
434    ) -> Self {
435        Self {
436            id: String::new(),
437            from_ref: from_ref.into(),
438            link_type: link_type.into(),
439            to_ref: to_ref.into(),
440            weight: None,
441            metadata_json: "{}".to_string(),
442            created_at: String::new(),
443        }
444    }
445
446    /// Set the link weight.
447    #[must_use]
448    pub const fn with_weight(mut self, weight: f64) -> Self {
449        self.weight = Some(weight);
450        self
451    }
452
453    /// Set the metadata JSON.
454    #[must_use]
455    pub fn with_metadata(mut self, metadata: impl Into<String>) -> Self {
456        self.metadata_json = metadata.into();
457        self
458    }
459}
460
461/// Options for querying graph links.
462#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
463#[serde(rename_all = "camelCase")]
464pub struct LinkQueryOptions {
465    /// Filter by relationship type.
466    pub link_type: Option<String>,
467    /// Maximum number of results.
468    pub limit: Option<usize>,
469}
470
471/// Direction for neighbor queries.
472#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
473#[serde(rename_all = "camelCase")]
474pub enum LinkDirection {
475    /// Only outgoing links (`from_ref` matches).
476    Outgoing,
477    /// Only incoming links (`to_ref` matches).
478    Incoming,
479    /// Both directions.
480    #[default]
481    Both,
482}
483
484/// Aggregation function to apply.
485#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
486#[serde(rename_all = "camelCase")]
487pub enum AggregateFunction {
488    /// Count objects (field ignored).
489    #[default]
490    Count,
491    /// Sum of numeric field values.
492    Sum,
493    /// Average of numeric field values.
494    Avg,
495    /// Minimum of field values.
496    Min,
497    /// Maximum of field values.
498    Max,
499}
500
501impl AggregateFunction {
502    /// Return the SQL function name for this aggregate.
503    pub const fn sql_func(&self) -> &str {
504        match self {
505            Self::Count => "COUNT(*)",
506            Self::Sum => "SUM",
507            Self::Avg => "AVG",
508            Self::Min => "MIN",
509            Self::Max => "MAX",
510        }
511    }
512}
513
514/// Options for a general aggregation query.
515#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
516#[serde(rename_all = "camelCase")]
517pub struct AggregateOptions {
518    /// Filter key-value pairs: only matching objects are aggregated.
519    pub filter: Vec<(String, serde_json::Value)>,
520    /// Group results by this top-level body field.
521    pub group_by: Option<String>,
522    /// Aggregation function to apply.
523    pub function: AggregateFunction,
524    /// Field to aggregate (required for sum/avg/min/max, ignored for count).
525    pub field: Option<String>,
526}
527
528/// Result of an aggregation query.
529#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
530#[serde(rename_all = "camelCase")]
531pub struct AggregateResult {
532    /// Total across all groups (or the single result if no `group_by`).
533    pub total: f64,
534    /// Per-group results (empty if no `group_by`).
535    pub groups: Vec<AggregateGroupResult>,
536}
537
538/// A single group result from aggregation.
539#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
540#[serde(rename_all = "camelCase")]
541pub struct AggregateGroupResult {
542    /// Group key (the field value).
543    pub key: String,
544    /// Aggregated value for this group.
545    pub value: f64,
546}
547
548/// Time bucket size for time-series aggregation.
549#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
550#[serde(rename_all = "camelCase")]
551pub enum TimeBucket {
552    /// Group by hour.
553    Hour,
554    /// Group by day.
555    #[default]
556    Day,
557    /// Group by week.
558    Week,
559    /// Group by month.
560    Month,
561}
562
563impl TimeBucket {
564    /// Return the `SQLite` strftime format for this bucket.
565    pub const fn strftime_format(&self) -> &str {
566        match self {
567            Self::Hour => "%Y-%m-%dT%H:00:00Z",
568            Self::Day => "%Y-%m-%d",
569            Self::Week => "%Y-W%W",
570            Self::Month => "%Y-%m",
571        }
572    }
573}
574
575/// Options for a time-series aggregation query.
576#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
577#[serde(rename_all = "camelCase")]
578pub struct TimeSeriesOptions {
579    /// Filter key-value pairs: only matching objects are aggregated.
580    pub filter: Vec<(String, serde_json::Value)>,
581    /// Time bucket size.
582    pub bucket: TimeBucket,
583    /// Aggregation function to apply.
584    pub function: AggregateFunction,
585    /// Field to aggregate (ignored for count).
586    pub field: Option<String>,
587    /// Start of time range (ISO 8601).
588    pub from: Option<String>,
589    /// End of time range (ISO 8601).
590    pub to: Option<String>,
591}
592
593/// Result of a time-series aggregation query.
594#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
595#[serde(rename_all = "camelCase")]
596pub struct TimeSeriesResult {
597    /// Ordered time buckets.
598    pub buckets: Vec<TimeSeriesBucket>,
599}
600
601/// A single time bucket from time-series aggregation.
602#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
603#[serde(rename_all = "camelCase")]
604pub struct TimeSeriesBucket {
605    /// Bucket label (ISO 8601 truncated to bucket granularity).
606    pub label: String,
607    /// Aggregated value for this bucket.
608    pub value: f64,
609}
610
611/// Inferred field metadata for a collection.
612#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
613#[serde(rename_all = "camelCase")]
614pub struct FieldSchema {
615    /// Field name.
616    pub name: String,
617    /// Inferred data type: `"string"`, `"number"`, `"boolean"`, `"date"`, `"null"`, or `"unknown"`.
618    pub field_type: String,
619    /// Whether the field is absent or null in sampled objects.
620    pub nullable: bool,
621    /// Example values from sampled objects (may be empty).
622    pub sample_values: Vec<serde_json::Value>,
623}
624
625/// Reflected schema for a collection.
626#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
627#[serde(rename_all = "camelCase")]
628pub struct CollectionSchema {
629    /// Collection name.
630    pub name: String,
631    /// Total number of objects in the collection.
632    pub object_count: u64,
633    /// Inferred fields from sampled objects.
634    pub fields: Vec<FieldSchema>,
635}
636
637/// Options for schema reflection.
638#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
639#[serde(rename_all = "camelCase")]
640pub struct SchemaOptions {
641    /// Number of objects to sample for type inference (default 50).
642    pub sample_size: Option<usize>,
643}
644
645/// Persisted canonical schema metadata.
646#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
647#[serde(rename_all = "camelCase")]
648pub struct StoredSchema {
649    /// Canonical schema JSON.
650    pub schema_json: String,
651    /// Stable schema hash.
652    pub hash: String,
653    /// Last update timestamp.
654    pub updated_at: String,
655}
656
657/// A durable record of an applied schema migration.
658#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
659#[serde(rename_all = "camelCase")]
660pub struct MigrationRecord {
661    /// Migration identifier, normally the numbered filename.
662    pub id: String,
663    /// Schema hash applied by this migration.
664    pub hash: String,
665    /// Application timestamp.
666    pub applied_at: String,
667}
668
669/// A functional index definition for a top-level JSON field.
670#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
671#[serde(rename_all = "camelCase")]
672pub struct IndexDefinition {
673    /// Collection containing the indexed objects.
674    pub collection: String,
675    /// Top-level JSON field covered by the index.
676    pub field: String,
677    /// Whether duplicate non-null values are rejected on writes.
678    #[serde(default)]
679    pub unique: bool,
680}
681
682impl Default for SchemaOptions {
683    fn default() -> Self {
684        Self {
685            sample_size: Some(50),
686        }
687    }
688}