Skip to main content

pulsedb/storage/
schema.rs

1//! Database schema definitions and versioning.
2//!
3//! This module defines the table structure for the redb storage engine.
4//! All table definitions are compile-time constants to ensure consistency.
5//!
6//! # Schema Versioning
7//!
8//! The schema version is stored in the metadata table. When opening an
9//! existing database, we check the version and fail if it doesn't match.
10//! Migration support will be added in a future release.
11//!
12//! # Table Layout
13//!
14//! ```text
15//! ┌─────────────────────────────────────────────────────────────┐
16//! │ METADATA_TABLE                                               │
17//! │   Key: &str                                                  │
18//! │   Value: &[u8] (JSON for human-readable, bincode for data)  │
19//! │   Entries: "db_metadata" -> DatabaseMetadata                 │
20//! └─────────────────────────────────────────────────────────────┘
21//!
22//! ┌─────────────────────────────────────────────────────────────┐
23//! │ COLLECTIVES_TABLE                                            │
24//! │   Key: &[u8; 16] (CollectiveId as UUID bytes)               │
25//! │   Value: &[u8] (bincode-serialized Collective)              │
26//! └─────────────────────────────────────────────────────────────┘
27//!
28//! ┌─────────────────────────────────────────────────────────────┐
29//! │ EXPERIENCES_TABLE                                            │
30//! │   Key: &[u8; 16] (ExperienceId as UUID bytes)               │
31//! │   Value: &[u8] (bincode-serialized Experience)              │
32//! └─────────────────────────────────────────────────────────────┘
33//! ```
34
35use redb::{MultimapTableDefinition, TableDefinition};
36use serde::{Deserialize, Serialize};
37
38use crate::config::EmbeddingDimension;
39use crate::experience::ExperienceType;
40use crate::types::{AgentId, CollectiveId, ExperienceId, TaskId, Timestamp};
41
42/// Current schema version.
43///
44/// Increment this when making breaking changes to the schema.
45/// Version 2 adds `entity_type` to `WatchEventRecord` for sync protocol support.
46/// Version 3 reshapes `Experience` for temporal decay and G-counter applications.
47pub const SCHEMA_VERSION: u32 = 3;
48
49// ============================================================================
50// Substrate Format Marker (storage-format axis — distinct from SCHEMA_VERSION)
51// ============================================================================
52//
53// The substrate-format marker tracks the **physical storage substrate**:
54// the redb file-format generation AND the value serializer (bincode → postcard).
55// It is a SEPARATE AXIS from `SCHEMA_VERSION`, which tracks the *logical* record
56// shapes (Experience/WatchEventRecord field layout). A database is described by
57// the tuple { redb-file-format, value-codec, logical-schema }; `SCHEMA_VERSION`
58// versions only the last component, while `CURRENT_SUBSTRATE_FORMAT` versions the
59// first two. Do NOT overload `SCHEMA_VERSION` for substrate changes.
60//
61// # Bootstrapping requirement (load-bearing)
62//
63// The marker answers "are this file's values bincode-era or postcard-era?", so it
64// **cannot itself be a serde/bincode blob** — it must be readable BEFORE the
65// serializer identity is known. It is therefore stored as **raw, hand-encoded
66// bytes** under a dedicated `METADATA_TABLE` key, never through serde.
67
68/// `METADATA_TABLE` key holding the raw substrate-format marker.
69///
70/// Lives alongside `"db_metadata"` (`METADATA_KEY`) and [`INSTANCE_ID_KEY`].
71/// The value under this key is the 3-byte fixed layout
72/// `[SUBSTRATE_MAGIC[0], SUBSTRATE_MAGIC[1], substrate_format_version: u8]`,
73/// written and read as raw bytes (NEVER serde).
74pub const SUBSTRATE_FORMAT_KEY: &str = "substrate_format";
75
76/// Two-byte magic prefix for the substrate-format marker.
77///
78/// Spells `PS` (PulseDB Substrate). Validated on read; a mismatch is a
79/// corruption signal, not a legacy/Absent database.
80pub const SUBSTRATE_MAGIC: [u8; 2] = *b"PS";
81
82/// Current substrate-format version (redb file-format + value serializer axis).
83///
84/// The marker is **monotonic** over the storage-substrate axes:
85///
86/// - `0` / **absent** = `{redb-v2, bincode}` — the legacy v0.5.1 state (no marker
87///   key existed before Sprint 4.0).
88/// - `1` = `{redb-v3, bincode}` — the **VS-4.0.2 end-state**. The redb file format
89///   is upgraded v2→v3, but values stay bincode.
90/// - `2` = `{redb-v3, postcard}` — the **VS-4.0.3 end-state** (what this slice
91///   produces). `CURRENT` is `2`; the bincode→postcard codec migration is gated on
92///   `marker < 2` (an `Absent | Older` store re-encodes every serde-blob row to
93///   postcard, then the marker bumps to `2` as the commit point).
94///
95/// Bumped whenever *either* the redb file format *or* the serializer changes —
96/// independently of [`SCHEMA_VERSION`]. A fresh database is written at this value;
97/// an existing database whose marker is **absent** is treated as substrate-format
98/// `0` (the pre-4.0 `{redb-v2, bincode}` era).
99pub const CURRENT_SUBSTRATE_FORMAT: u8 = 2;
100
101/// Substrate-format version implied by an **absent** marker.
102///
103/// A pre-4.0 (bincode-era) database carries no `substrate_format` key, so its
104/// substrate format is `0` by definition.
105pub const LEGACY_SUBSTRATE_FORMAT: u8 = 0;
106
107/// Encoded length of the raw substrate-format marker: 2 magic bytes + 1 version.
108pub const SUBSTRATE_MARKER_LEN: usize = 3;
109
110/// Maximum content size in bytes (100 KB).
111pub const MAX_CONTENT_SIZE: usize = 100 * 1024;
112
113/// Maximum number of domain tags per experience.
114pub const MAX_DOMAIN_TAGS: usize = 50;
115
116/// Maximum length of a single domain tag.
117pub const MAX_TAG_LENGTH: usize = 100;
118
119/// Maximum number of source files per experience.
120pub const MAX_SOURCE_FILES: usize = 100;
121
122/// Maximum length of a single source file path.
123pub const MAX_FILE_PATH_LENGTH: usize = 500;
124
125/// Maximum length of a source agent identifier.
126pub const MAX_SOURCE_AGENT_LENGTH: usize = 256;
127
128/// Maximum relation metadata size in bytes (10 KB).
129pub const MAX_RELATION_METADATA_SIZE: usize = 10 * 1024;
130
131/// Maximum insight content size in bytes (50 KB).
132pub const MAX_INSIGHT_CONTENT_SIZE: usize = 50 * 1024;
133
134/// Maximum number of source experiences per insight.
135pub const MAX_INSIGHT_SOURCES: usize = 100;
136
137/// Maximum agent ID length in bytes.
138///
139/// Agent IDs are UTF-8 strings identifying a specific AI agent instance.
140/// 255 bytes is generous for identifiers like "claude-opus-4" or UUIDs.
141pub const MAX_ACTIVITY_AGENT_ID_LENGTH: usize = 255;
142
143/// Maximum size for activity optional fields (current_task, context_summary) in bytes (1 KB).
144///
145/// These fields are short descriptions, not full content — 1KB is sufficient
146/// for a task name or brief context summary.
147pub const MAX_ACTIVITY_FIELD_SIZE: usize = 1024;
148
149// ============================================================================
150// Table Definitions
151// ============================================================================
152
153/// Metadata table for database-level information.
154///
155/// Stores schema version, creation time, and other database-wide settings.
156/// Key is a string identifier, value is serialized data.
157pub const METADATA_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("metadata");
158
159/// Collectives table.
160///
161/// Key: CollectiveId as 16-byte UUID
162/// Value: bincode-serialized Collective struct
163pub const COLLECTIVES_TABLE: TableDefinition<&[u8; 16], &[u8]> =
164    TableDefinition::new("collectives");
165
166/// Decay configuration table.
167///
168/// Key: CollectiveId as 16-byte UUID
169/// Value: bincode-serialized per-collective decay configuration
170pub const DECAY_CONFIGS_TABLE: TableDefinition<&[u8; 16], &[u8]> =
171    TableDefinition::new("decay_configs");
172
173/// Experiences table.
174///
175/// Key: ExperienceId as 16-byte UUID
176/// Value: bincode-serialized Experience struct (without embedding)
177pub const EXPERIENCES_TABLE: TableDefinition<&[u8; 16], &[u8]> =
178    TableDefinition::new("experiences");
179
180/// Index: Experiences by collective and timestamp.
181///
182/// Enables efficient queries like "recent experiences in collective X".
183/// Key: CollectiveId as 16-byte UUID
184/// Value (multimap): (Timestamp big-endian 8 bytes, ExperienceId 16 bytes) = 24 bytes
185///
186/// Using a multimap allows multiple experiences per collective. Values are
187/// sorted lexicographically, so big-endian timestamps ensure time ordering.
188pub const EXPERIENCES_BY_COLLECTIVE_TABLE: MultimapTableDefinition<&[u8; 16], &[u8; 24]> =
189    MultimapTableDefinition::new("experiences_by_collective");
190
191/// Index: Experiences by collective and type.
192///
193/// Enables efficient queries like "all ErrorPattern experiences in collective X".
194/// Key: (CollectiveId bytes, ExperienceTypeTag byte) = 17 bytes
195/// Value: ExperienceId as 16-byte UUID
196///
197/// Using a multimap allows multiple experiences of the same type.
198pub const EXPERIENCES_BY_TYPE_TABLE: MultimapTableDefinition<&[u8; 17], &[u8; 16]> =
199    MultimapTableDefinition::new("experiences_by_type");
200
201/// Embeddings table.
202///
203/// Stored separately from experiences to keep the main table compact.
204/// Key: ExperienceId as 16-byte UUID
205/// Value: raw f32 bytes (dimension * 4 bytes)
206pub const EMBEDDINGS_TABLE: TableDefinition<&[u8; 16], &[u8]> = TableDefinition::new("embeddings");
207
208// ============================================================================
209// Relation Tables (E3-S01)
210// ============================================================================
211
212/// Relations table.
213///
214/// Primary storage for experience relations.
215/// Key: RelationId as 16-byte UUID
216/// Value: bincode-serialized ExperienceRelation struct
217pub const RELATIONS_TABLE: TableDefinition<&[u8; 16], &[u8]> = TableDefinition::new("relations");
218
219/// Index: Relations by source experience.
220///
221/// Enables efficient queries like "find all outgoing relations from experience X".
222/// Key: ExperienceId (source) as 16-byte UUID
223/// Value (multimap): RelationId as 16-byte UUID
224///
225/// Multiple relations per source experience. Iterate values with
226/// `table.get(source_id)?` to find all outgoing relation IDs.
227pub const RELATIONS_BY_SOURCE_TABLE: MultimapTableDefinition<&[u8; 16], &[u8; 16]> =
228    MultimapTableDefinition::new("relations_by_source");
229
230/// Index: Relations by target experience.
231///
232/// Enables efficient queries like "find all incoming relations to experience X".
233/// Key: ExperienceId (target) as 16-byte UUID
234/// Value (multimap): RelationId as 16-byte UUID
235pub const RELATIONS_BY_TARGET_TABLE: MultimapTableDefinition<&[u8; 16], &[u8; 16]> =
236    MultimapTableDefinition::new("relations_by_target");
237
238// ============================================================================
239// Insight Tables (E3-S02)
240// ============================================================================
241
242/// Insights table.
243///
244/// Primary storage for derived insights.
245/// Key: InsightId as 16-byte UUID
246/// Value: bincode-serialized DerivedInsight struct (with inline embedding)
247pub const INSIGHTS_TABLE: TableDefinition<&[u8; 16], &[u8]> = TableDefinition::new("insights");
248
249/// Index: Insights by collective.
250///
251/// Enables efficient queries like "find all insights in collective X".
252/// Key: CollectiveId as 16-byte UUID
253/// Value (multimap): InsightId as 16-byte UUID
254pub const INSIGHTS_BY_COLLECTIVE_TABLE: MultimapTableDefinition<&[u8; 16], &[u8; 16]> =
255    MultimapTableDefinition::new("insights_by_collective");
256
257// ============================================================================
258// Activity Tables (E3-S03)
259// ============================================================================
260
261/// Activities table — agent presence tracking.
262///
263/// First PulseDB table using variable-length keys. Activities are keyed by
264/// a composite `(collective_id, agent_id)` rather than a UUID, since each
265/// agent can have at most one active session per collective.
266///
267/// Key: `[collective_id: 16B][agent_id_len: 2B BE][agent_id: NB]`
268/// Value: bincode-serialized Activity struct
269pub const ACTIVITIES_TABLE: TableDefinition<&[u8], &[u8]> = TableDefinition::new("activities");
270
271// ============================================================================
272// Watch Events Tables (E4-S02)
273// ============================================================================
274
275// ============================================================================
276// Sync Metadata (feature: sync)
277// ============================================================================
278
279/// Metadata key for the instance ID (16-byte UUID v7).
280///
281/// Stored in `METADATA_TABLE` as raw 16 bytes. Generated on first open
282/// and persisted for the lifetime of the database. Used by the sync
283/// protocol to identify this PulseDB instance.
284pub const INSTANCE_ID_KEY: &str = "instance_id";
285
286/// Sync cursors table — per-peer sync position tracking.
287///
288/// Each entry records the last WAL sequence number successfully synced
289/// with a specific peer instance. Key is the peer's InstanceId (16 bytes),
290/// value is bincode-serialized `SyncCursor`.
291#[cfg(feature = "sync")]
292pub const SYNC_CURSORS_TABLE: TableDefinition<&[u8; 16], &[u8]> =
293    TableDefinition::new("sync_cursors");
294
295// ============================================================================
296// Watch Events Tables (E4-S02)
297// ============================================================================
298
299/// Metadata key for the current WAL sequence number.
300///
301/// Stored in `METADATA_TABLE` as 8-byte big-endian `u64`.
302/// Starts at 0 (no writes yet), incremented atomically within each
303/// experience write transaction.
304pub const WAL_SEQUENCE_KEY: &str = "wal_sequence";
305
306/// Watch events table — cross-process change detection log.
307///
308/// Each experience mutation (create, update, archive, delete) records an
309/// entry here with a monotonically increasing sequence number as the key.
310/// Reader processes poll this table to discover changes made by the writer.
311///
312/// Key: u64 sequence number as 8-byte big-endian (lexicographic = numeric order)
313/// Value: bincode-serialized `WatchEventRecord`
314///
315/// The table grows unboundedly; a future compaction feature will allow
316/// trimming old entries.
317pub const WATCH_EVENTS_TABLE: TableDefinition<&[u8; 8], &[u8]> =
318    TableDefinition::new("watch_events");
319
320/// A persisted watch event for cross-process change detection (schema v2).
321///
322/// This is the on-disk representation — compact and self-contained.
323/// Converted to the public `WatchEvent` type when returned to callers.
324///
325/// Uses raw byte arrays for IDs (not UUID wrappers) to keep serialization
326/// simple and avoid coupling the storage format to the public type system.
327///
328/// # Schema v2 Changes
329///
330/// In v1, this struct only tracked experiences (`experience_id`). In v2,
331/// the field is renamed to `entity_id` and an `entity_type` discriminant
332/// is added to track all entity types (relations, insights, collectives).
333#[derive(Clone, Debug, Serialize, Deserialize)]
334pub struct WatchEventRecord {
335    /// The entity that changed (16-byte UUID).
336    ///
337    /// For experiences this is an ExperienceId, for relations a RelationId, etc.
338    pub entity_id: [u8; 16],
339
340    /// The collective this entity belongs to (16-byte UUID).
341    pub collective_id: [u8; 16],
342
343    /// What kind of change occurred.
344    pub event_type: WatchEventTypeTag,
345
346    /// When the change occurred (milliseconds since Unix epoch).
347    pub timestamp_ms: i64,
348
349    /// What kind of entity changed (schema v2).
350    pub entity_type: EntityTypeTag,
351}
352
353/// Schema v1 watch event record (for migration deserialization only).
354///
355/// In v1, the WAL only tracked experience mutations. This struct matches
356/// the v1 bincode layout for reading old records during migration.
357#[derive(Deserialize)]
358pub(crate) struct WatchEventRecordV1 {
359    pub experience_id: [u8; 16],
360    pub collective_id: [u8; 16],
361    pub event_type: WatchEventTypeTag,
362    pub timestamp_ms: i64,
363}
364
365/// Schema v2 experience record (for migration deserialization only).
366///
367/// This mirrors the exact bincode layout of `Experience` before schema v3:
368/// scalar `applications`, no `last_reinforced`, and a skipped embedding.
369#[derive(Deserialize)]
370pub(crate) struct ExperienceV2 {
371    pub id: ExperienceId,
372    pub collective_id: CollectiveId,
373    pub content: String,
374    #[serde(skip)]
375    pub embedding: Vec<f32>,
376    pub experience_type: ExperienceType,
377    pub importance: f32,
378    pub confidence: f32,
379    pub applications: u32,
380    pub domain: Vec<String>,
381    pub related_files: Vec<String>,
382    pub source_agent: AgentId,
383    pub source_task: Option<TaskId>,
384    pub timestamp: Timestamp,
385    pub archived: bool,
386}
387
388/// Compact tag for watch event types stored on disk.
389///
390/// Mirrors `WatchEventType` from `watch/types.rs` but uses `repr(u8)` for
391/// minimal storage footprint. Derives `Serialize`/`Deserialize` since it's
392/// part of the bincode-serialized `WatchEventRecord`.
393#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
394#[repr(u8)]
395pub enum WatchEventTypeTag {
396    /// A new experience was recorded.
397    Created = 0,
398    /// An existing experience was modified or reinforced.
399    Updated = 1,
400    /// An experience was soft-deleted (archived).
401    Archived = 2,
402    /// An experience was permanently deleted.
403    Deleted = 3,
404}
405
406impl WatchEventTypeTag {
407    /// Converts a raw byte to a WatchEventTypeTag.
408    ///
409    /// Returns `None` if the byte doesn't correspond to a known variant.
410    pub fn from_u8(value: u8) -> Option<Self> {
411        match value {
412            0 => Some(Self::Created),
413            1 => Some(Self::Updated),
414            2 => Some(Self::Archived),
415            3 => Some(Self::Deleted),
416            _ => None,
417        }
418    }
419}
420
421// ============================================================================
422// Entity Type Tag (schema v2)
423// ============================================================================
424
425/// Compact discriminant for entity types in WAL records.
426///
427/// Identifies what kind of entity a WAL event refers to. Added in schema v2
428/// to extend WAL tracking beyond just experiences.
429#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
430#[repr(u8)]
431pub enum EntityTypeTag {
432    /// An experience entity.
433    #[default]
434    Experience = 0,
435    /// A relation between experiences.
436    Relation = 1,
437    /// A derived insight.
438    Insight = 2,
439    /// A collective.
440    Collective = 3,
441}
442
443impl EntityTypeTag {
444    /// Converts a raw byte to an EntityTypeTag.
445    ///
446    /// Returns `None` if the byte doesn't correspond to a known variant.
447    pub fn from_u8(value: u8) -> Option<Self> {
448        match value {
449            0 => Some(Self::Experience),
450            1 => Some(Self::Relation),
451            2 => Some(Self::Insight),
452            3 => Some(Self::Collective),
453            _ => None,
454        }
455    }
456}
457
458// ============================================================================
459// Experience Type Tag
460// ============================================================================
461
462/// Compact discriminant for experience types, used in secondary index keys.
463///
464/// Each variant maps to a single byte (`repr(u8)`), making index keys small
465/// and comparison fast. The full `ExperienceType` enum (with associated data)
466/// lives in `experience/types.rs` and bridges to this tag via `type_tag()`.
467///
468/// # Variants (9, per ADR-004 / Data Model spec)
469///
470/// - `Difficulty` — Problem encountered by the agent
471/// - `Solution` — Fix for a problem (can link to Difficulty)
472/// - `ErrorPattern` — Reusable error signature + fix + prevention
473/// - `SuccessPattern` — Proven approach with quality rating
474/// - `UserPreference` — User preference with strength
475/// - `ArchitecturalDecision` — Design decision with rationale
476/// - `TechInsight` — Technical knowledge about a technology
477/// - `Fact` — Verified factual statement with source
478/// - `Generic` — Catch-all for uncategorized experiences
479#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
480#[repr(u8)]
481pub enum ExperienceTypeTag {
482    /// Problem encountered by the agent.
483    Difficulty = 0,
484    /// Fix for a problem (can reference a Difficulty).
485    Solution = 1,
486    /// Reusable error signature with fix and prevention.
487    ErrorPattern = 2,
488    /// Proven approach with quality rating.
489    SuccessPattern = 3,
490    /// User preference with strength.
491    UserPreference = 4,
492    /// Design decision with rationale.
493    ArchitecturalDecision = 5,
494    /// Technical knowledge about a technology.
495    TechInsight = 6,
496    /// Verified factual statement with source.
497    Fact = 7,
498    /// Catch-all for uncategorized experiences.
499    Generic = 8,
500}
501
502impl ExperienceTypeTag {
503    /// Converts a raw byte to an ExperienceTypeTag.
504    ///
505    /// Returns `None` if the byte doesn't correspond to a known variant.
506    pub fn from_u8(value: u8) -> Option<Self> {
507        match value {
508            0 => Some(Self::Difficulty),
509            1 => Some(Self::Solution),
510            2 => Some(Self::ErrorPattern),
511            3 => Some(Self::SuccessPattern),
512            4 => Some(Self::UserPreference),
513            5 => Some(Self::ArchitecturalDecision),
514            6 => Some(Self::TechInsight),
515            7 => Some(Self::Fact),
516            8 => Some(Self::Generic),
517            _ => None,
518        }
519    }
520
521    /// Returns all variants in discriminant order.
522    pub fn all() -> &'static [Self] {
523        &[
524            Self::Difficulty,
525            Self::Solution,
526            Self::ErrorPattern,
527            Self::SuccessPattern,
528            Self::UserPreference,
529            Self::ArchitecturalDecision,
530            Self::TechInsight,
531            Self::Fact,
532            Self::Generic,
533        ]
534    }
535}
536
537// ============================================================================
538// Database Metadata
539// ============================================================================
540
541/// Database metadata stored in the metadata table.
542///
543/// This is serialized with bincode and stored under the key "db_metadata".
544#[derive(Clone, Debug, Serialize, Deserialize)]
545pub struct DatabaseMetadata {
546    /// Schema version for compatibility checking.
547    pub schema_version: u32,
548
549    /// Embedding dimension configured for this database.
550    ///
551    /// Once set, this cannot be changed without recreating the database.
552    pub embedding_dimension: EmbeddingDimension,
553
554    /// Timestamp when the database was created.
555    pub created_at: Timestamp,
556
557    /// Last time the database was opened (updated on each open).
558    pub last_opened_at: Timestamp,
559}
560
561impl DatabaseMetadata {
562    /// Creates new metadata for a fresh database.
563    pub fn new(embedding_dimension: EmbeddingDimension) -> Self {
564        let now = Timestamp::now();
565        Self {
566            schema_version: SCHEMA_VERSION,
567            embedding_dimension,
568            created_at: now,
569            last_opened_at: now,
570        }
571    }
572
573    /// Updates the last_opened_at timestamp.
574    pub fn touch(&mut self) {
575        self.last_opened_at = Timestamp::now();
576    }
577
578    /// Checks if this metadata is compatible with the current schema.
579    pub fn is_compatible(&self) -> bool {
580        self.schema_version == SCHEMA_VERSION
581    }
582}
583
584// ============================================================================
585// Key Encoding Helpers
586// ============================================================================
587
588/// Encodes a (CollectiveId, Timestamp, ExperienceId) tuple for the index.
589///
590/// Format: [collective_id: 16 bytes][timestamp_be: 8 bytes] = 24 bytes
591/// (ExperienceId is the multimap value, not part of the key)
592///
593/// Big-endian timestamp ensures lexicographic ordering matches time ordering.
594#[inline]
595pub fn encode_collective_timestamp_key(collective_id: &[u8; 16], timestamp: Timestamp) -> [u8; 24] {
596    let mut key = [0u8; 24];
597    key[..16].copy_from_slice(collective_id);
598    key[16..24].copy_from_slice(&timestamp.to_be_bytes());
599    key
600}
601
602/// Decodes the timestamp from a collective index key.
603#[inline]
604pub fn decode_timestamp_from_key(key: &[u8; 24]) -> Timestamp {
605    let mut bytes = [0u8; 8];
606    bytes.copy_from_slice(&key[16..24]);
607    Timestamp::from_millis(i64::from_be_bytes(bytes))
608}
609
610/// Creates a range start key for querying experiences in a collective.
611///
612/// Uses timestamp 0 (Unix epoch) as the start. We don't support timestamps
613/// before 1970 since that predates computers being useful for AI agents.
614#[inline]
615pub fn collective_range_start(collective_id: &[u8; 16]) -> [u8; 24] {
616    encode_collective_timestamp_key(collective_id, Timestamp::from_millis(0))
617}
618
619/// Creates a range end key for querying experiences in a collective.
620///
621/// Uses maximum positive timestamp to include all experiences.
622#[inline]
623pub fn collective_range_end(collective_id: &[u8; 16]) -> [u8; 24] {
624    encode_collective_timestamp_key(collective_id, Timestamp::from_millis(i64::MAX))
625}
626
627// ============================================================================
628// Type Index Key Encoding
629// ============================================================================
630
631/// Encodes a (CollectiveId, ExperienceTypeTag) key for the type index.
632///
633/// Format: [collective_id: 16 bytes][type_tag: 1 byte] = 17 bytes
634///
635/// This key design allows efficient range queries: to find all experiences
636/// of a given type in a collective, we do a point lookup on this 17-byte key
637/// and iterate the multimap values (ExperienceIds).
638#[inline]
639pub fn encode_type_index_key(collective_id: &[u8; 16], type_tag: ExperienceTypeTag) -> [u8; 17] {
640    let mut key = [0u8; 17];
641    key[..16].copy_from_slice(collective_id);
642    key[16] = type_tag as u8;
643    key
644}
645
646/// Decodes the ExperienceTypeTag from a type index key.
647///
648/// Returns `None` if the tag byte doesn't correspond to a known variant.
649#[inline]
650pub fn decode_type_tag_from_key(key: &[u8; 17]) -> Option<ExperienceTypeTag> {
651    ExperienceTypeTag::from_u8(key[16])
652}
653
654/// Decodes the CollectiveId bytes from a type index key.
655#[inline]
656pub fn decode_collective_from_type_key(key: &[u8; 17]) -> [u8; 16] {
657    let mut id = [0u8; 16];
658    id.copy_from_slice(&key[..16]);
659    id
660}
661
662// ============================================================================
663// Activity Key Encoding (E3-S03)
664// ============================================================================
665
666/// Encodes a `(collective_id, agent_id)` composite key for the activities table.
667///
668/// Format: `[collective_id: 16 bytes][agent_id_len: 2 bytes BE u16][agent_id: N bytes]`
669///
670/// The collective_id prefix allows efficient filtering by collective (prefix scan).
671/// The 2-byte length field enables safe decoding of the variable-length agent_id.
672#[inline]
673pub fn encode_activity_key(collective_id: &[u8; 16], agent_id: &str) -> Vec<u8> {
674    let agent_bytes = agent_id.as_bytes();
675    let len = agent_bytes.len() as u16;
676    let mut key = Vec::with_capacity(16 + 2 + agent_bytes.len());
677    key.extend_from_slice(collective_id);
678    key.extend_from_slice(&len.to_be_bytes());
679    key.extend_from_slice(agent_bytes);
680    key
681}
682
683/// Extracts the 16-byte CollectiveId from an activity key.
684///
685/// # Panics
686///
687/// Panics if the key is shorter than 16 bytes (should never happen with
688/// properly encoded keys from `encode_activity_key`).
689#[inline]
690pub fn decode_collective_from_activity_key(key: &[u8]) -> [u8; 16] {
691    let mut id = [0u8; 16];
692    id.copy_from_slice(&key[..16]);
693    id
694}
695
696/// Extracts the agent_id string from an activity key.
697///
698/// Reads the 2-byte length at offset 16, then slices the UTF-8 agent_id.
699///
700/// # Panics
701///
702/// Panics if the key is malformed (insufficient length or invalid UTF-8).
703/// This should never happen with keys created by `encode_activity_key`.
704#[inline]
705pub fn decode_agent_id_from_activity_key(key: &[u8]) -> &str {
706    let len = u16::from_be_bytes([key[16], key[17]]) as usize;
707    std::str::from_utf8(&key[18..18 + len]).expect("activity key contains invalid UTF-8")
708}
709
710#[cfg(test)]
711mod tests {
712    use super::*;
713
714    #[test]
715    fn test_schema_version() {
716        assert_eq!(SCHEMA_VERSION, 3);
717    }
718
719    #[test]
720    fn test_database_metadata_new() {
721        let meta = DatabaseMetadata::new(EmbeddingDimension::D384);
722        assert_eq!(meta.schema_version, SCHEMA_VERSION);
723        assert_eq!(meta.embedding_dimension, EmbeddingDimension::D384);
724        assert!(meta.is_compatible());
725    }
726
727    #[test]
728    fn test_database_metadata_touch() {
729        let mut meta = DatabaseMetadata::new(EmbeddingDimension::D384);
730        let original = meta.last_opened_at;
731        std::thread::sleep(std::time::Duration::from_millis(1));
732        meta.touch();
733        assert!(meta.last_opened_at > original);
734    }
735
736    #[test]
737    fn test_database_metadata_serialization() {
738        let meta = DatabaseMetadata::new(EmbeddingDimension::D768);
739        let bytes = postcard::to_stdvec(&meta).unwrap();
740        let restored: DatabaseMetadata = postcard::from_bytes(&bytes).unwrap();
741        assert_eq!(meta.schema_version, restored.schema_version);
742        assert_eq!(meta.embedding_dimension, restored.embedding_dimension);
743    }
744
745    #[test]
746    fn test_encode_collective_timestamp_key() {
747        let collective_id = [1u8; 16];
748        let timestamp = Timestamp::from_millis(1234567890);
749
750        let key = encode_collective_timestamp_key(&collective_id, timestamp);
751
752        assert_eq!(&key[..16], &collective_id);
753        assert_eq!(decode_timestamp_from_key(&key), timestamp);
754    }
755
756    #[test]
757    fn test_key_ordering() {
758        let collective_id = [1u8; 16];
759        let t1 = Timestamp::from_millis(1000);
760        let t2 = Timestamp::from_millis(2000);
761
762        let key1 = encode_collective_timestamp_key(&collective_id, t1);
763        let key2 = encode_collective_timestamp_key(&collective_id, t2);
764
765        // Lexicographic ordering should match timestamp ordering
766        assert!(key1 < key2);
767    }
768
769    #[test]
770    fn test_collective_range() {
771        let collective_id = [42u8; 16];
772        let start = collective_range_start(&collective_id);
773        let end = collective_range_end(&collective_id);
774
775        // Any timestamp should fall within this range
776        let mid = encode_collective_timestamp_key(&collective_id, Timestamp::now());
777        assert!(start <= mid);
778        assert!(mid <= end);
779    }
780
781    // ====================================================================
782    // ExperienceTypeTag tests
783    // ====================================================================
784
785    #[test]
786    fn test_experience_type_tag_from_u8_roundtrip() {
787        for tag in ExperienceTypeTag::all() {
788            let byte = *tag as u8;
789            let restored = ExperienceTypeTag::from_u8(byte).unwrap();
790            assert_eq!(*tag, restored);
791        }
792    }
793
794    #[test]
795    fn test_experience_type_tag_from_u8_invalid() {
796        assert!(ExperienceTypeTag::from_u8(255).is_none());
797        assert!(ExperienceTypeTag::from_u8(9).is_none());
798    }
799
800    #[test]
801    fn test_experience_type_tag_all_variants() {
802        let all = ExperienceTypeTag::all();
803        assert_eq!(all.len(), 9);
804        assert_eq!(all[0], ExperienceTypeTag::Difficulty);
805        assert_eq!(all[5], ExperienceTypeTag::ArchitecturalDecision);
806        assert_eq!(all[8], ExperienceTypeTag::Generic);
807    }
808
809    #[test]
810    fn test_experience_type_tag_postcard_roundtrip() {
811        for tag in ExperienceTypeTag::all() {
812            let bytes = postcard::to_stdvec(tag).unwrap();
813            let restored: ExperienceTypeTag = postcard::from_bytes(&bytes).unwrap();
814            assert_eq!(*tag, restored);
815        }
816    }
817
818    // ====================================================================
819    // Type index key encoding tests
820    // ====================================================================
821
822    #[test]
823    fn test_encode_type_index_key_roundtrip() {
824        let collective_id = [7u8; 16];
825        let tag = ExperienceTypeTag::SuccessPattern;
826
827        let key = encode_type_index_key(&collective_id, tag);
828
829        assert_eq!(decode_collective_from_type_key(&key), collective_id);
830        assert_eq!(decode_type_tag_from_key(&key), Some(tag));
831    }
832
833    #[test]
834    fn test_type_index_key_different_types_produce_different_keys() {
835        let collective_id = [1u8; 16];
836
837        let key_obs = encode_type_index_key(&collective_id, ExperienceTypeTag::Difficulty);
838        let key_les = encode_type_index_key(&collective_id, ExperienceTypeTag::SuccessPattern);
839
840        assert_ne!(key_obs, key_les);
841        // Same collective prefix
842        assert_eq!(&key_obs[..16], &key_les[..16]);
843        // Different type byte
844        assert_ne!(key_obs[16], key_les[16]);
845    }
846
847    #[test]
848    fn test_type_index_key_different_collectives_produce_different_keys() {
849        let id_a = [1u8; 16];
850        let id_b = [2u8; 16];
851        let tag = ExperienceTypeTag::Solution;
852
853        let key_a = encode_type_index_key(&id_a, tag);
854        let key_b = encode_type_index_key(&id_b, tag);
855
856        assert_ne!(key_a, key_b);
857        // Same type byte
858        assert_eq!(key_a[16], key_b[16]);
859    }
860
861    // ====================================================================
862    // Activity key encoding tests (E3-S03)
863    // ====================================================================
864
865    #[test]
866    fn test_activity_key_encode_decode_roundtrip() {
867        let collective_id = [42u8; 16];
868        let agent_id = "claude-opus";
869
870        let key = encode_activity_key(&collective_id, agent_id);
871
872        assert_eq!(decode_collective_from_activity_key(&key), collective_id);
873        assert_eq!(decode_agent_id_from_activity_key(&key), agent_id);
874    }
875
876    #[test]
877    fn test_activity_key_different_agents_produce_different_keys() {
878        let collective_id = [1u8; 16];
879
880        let key_a = encode_activity_key(&collective_id, "agent-alpha");
881        let key_b = encode_activity_key(&collective_id, "agent-beta");
882
883        assert_ne!(key_a, key_b);
884        // Same collective prefix
885        assert_eq!(&key_a[..16], &key_b[..16]);
886    }
887
888    #[test]
889    fn test_activity_key_different_collectives_produce_different_keys() {
890        let id_a = [1u8; 16];
891        let id_b = [2u8; 16];
892
893        let key_a = encode_activity_key(&id_a, "same-agent");
894        let key_b = encode_activity_key(&id_b, "same-agent");
895
896        assert_ne!(key_a, key_b);
897        // Same agent_id suffix
898        assert_eq!(
899            decode_agent_id_from_activity_key(&key_a),
900            decode_agent_id_from_activity_key(&key_b)
901        );
902    }
903
904    #[test]
905    fn test_activity_key_format() {
906        let collective_id = [0xAB; 16];
907        let agent_id = "hi";
908
909        let key = encode_activity_key(&collective_id, agent_id);
910
911        // 16 (collective) + 2 (len) + 2 (agent "hi") = 20 bytes
912        assert_eq!(key.len(), 20);
913        // Collective prefix
914        assert_eq!(&key[..16], &[0xAB; 16]);
915        // Length field (big-endian u16 = 2)
916        assert_eq!(&key[16..18], &[0, 2]);
917        // Agent ID bytes
918        assert_eq!(&key[18..], b"hi");
919    }
920}