Skip to main content

kindling_service/
service.rs

1//! `KindlingService` — in-process orchestration over the store/provider/filter
2//! crates. Ports `KindlingService` from
3//! `packages/kindling-core/src/service/kindling-service.ts`.
4
5use std::path::Path;
6use std::time::{SystemTime, UNIX_EPOCH};
7
8use crate::filter::mask_secrets;
9use kindling_provider::{retrieve_at, LocalFtsProvider};
10use kindling_store::{SqliteKindlingStore, StoreError, StoreOptions};
11use kindling_types::{
12    Capsule, CapsuleInput, CapsuleStatus, CapsuleType, Id, ListObservationsRequest,
13    ListObservationsResult, Observation, ObservationInput, Pin, PinInput, PinTargetType,
14    RetrieveOptions, RetrieveResult, ScopeIds, Summary, SummaryInput, Timestamp, ValidationError,
15};
16
17use crate::context::{PreCompactContext, ResolvedPin, SessionStartContext};
18use crate::error::{ServiceError, ServiceResult};
19use crate::validation;
20
21/// Options for [`KindlingService::open_capsule`].
22///
23/// Mirrors `OpenCapsuleOptions` in `packages/kindling-core/src/capsule/types.ts`.
24#[derive(Debug, Clone)]
25pub struct OpenCapsuleOptions {
26    /// Capsule type (`type` in the TS option object).
27    pub kind: CapsuleType,
28    /// Human-readable intent/purpose.
29    pub intent: String,
30    /// Scope dimensions for isolation.
31    pub scope_ids: ScopeIds,
32    /// Optional pre-generated id; defaults to a fresh UUID.
33    pub id: Option<Id>,
34}
35
36/// Options for [`KindlingService::close_capsule`].
37///
38/// Mirrors `CloseCapsuleOptions` in the TS service. A summary is generated and
39/// persisted only when `generate_summary` is set AND `summary_content` is
40/// present.
41#[derive(Debug, Clone, Default)]
42pub struct CloseCapsuleOptions {
43    /// Whether to generate a closing summary.
44    pub generate_summary: bool,
45    /// Content for the generated summary.
46    pub summary_content: Option<String>,
47    /// Summary confidence; defaults to `1.0` (the TS service default).
48    pub confidence: Option<f64>,
49}
50
51/// Options for [`KindlingService::append_observation`].
52///
53/// Mirrors `AppendObservationOptions` in the TS service. `validate` defaults to
54/// `true`; use [`AppendObservationOptions::default`] for the common case.
55#[derive(Debug, Clone)]
56pub struct AppendObservationOptions {
57    /// Capsule to attach the observation to, if any.
58    pub capsule_id: Option<Id>,
59    /// Run validation before storing (TS default: `true`).
60    pub validate: bool,
61}
62
63/// Outcome of [`KindlingService::append_observation`].
64///
65/// Carries the stored observation plus whether the write was deduplicated.
66/// When `deduplicated` is `true`, the incoming observation collided with an
67/// already-stored id: `observation` is the **existing** stored row (loaded
68/// fresh), never the incoming one, and the stored row was left untouched (no
69/// re-masking, no overwrite). When `false`, a new row was written and
70/// `observation` is that freshly-stored observation.
71///
72/// This makes spool replay after a crash exactly-once-ish on id: a replayed
73/// observation is surfaced as `deduplicated: true` rather than erroring or
74/// duplicating.
75#[derive(Debug, Clone, PartialEq)]
76pub struct AppendOutcome {
77    /// The stored observation. On a dedup hit this is the pre-existing row.
78    pub observation: Observation,
79    /// `true` when the id already existed and the incoming write was ignored.
80    pub deduplicated: bool,
81}
82
83impl Default for AppendObservationOptions {
84    fn default() -> Self {
85        Self {
86            capsule_id: None,
87            validate: true,
88        }
89    }
90}
91
92/// Options for [`KindlingService::pin`].
93///
94/// Mirrors `CreatePinOptions` in the TS service. `note` becomes the pin's
95/// `reason`; `ttl_ms` sets `expires_at = now + ttl_ms`.
96#[derive(Debug, Clone)]
97pub struct CreatePinOptions {
98    /// What kind of entity the pin targets.
99    pub target_type: PinTargetType,
100    /// Id of the targeted observation or summary.
101    pub target_id: Id,
102    /// Optional human note (stored as the pin `reason`).
103    pub note: Option<String>,
104    /// Time-to-live in ms; `None` means the pin never expires.
105    pub ttl_ms: Option<i64>,
106    /// Scope for the pin; defaults to empty.
107    pub scope_ids: Option<ScopeIds>,
108}
109
110/// In-process kindling orchestration service.
111///
112/// Owns the [`SqliteKindlingStore`]. The retrieval provider borrows the store
113/// connection, so it is constructed per-retrieve rather than held as a field.
114pub struct KindlingService {
115    store: SqliteKindlingStore,
116}
117
118impl KindlingService {
119    /// Build a service over an already-open store.
120    pub fn new(store: SqliteKindlingStore) -> Self {
121        Self { store }
122    }
123
124    /// Open (and initialise if fresh) the database at `path`.
125    pub fn open(path: &Path) -> ServiceResult<Self> {
126        Ok(Self::new(SqliteKindlingStore::open(path)?))
127    }
128
129    /// Open the database at `path` with explicit store options.
130    pub fn open_with_options(path: &Path, options: &StoreOptions) -> ServiceResult<Self> {
131        Ok(Self::new(SqliteKindlingStore::open_with_options(
132            path, options,
133        )?))
134    }
135
136    /// Open a fresh in-memory store (test/scratch use).
137    pub fn open_in_memory() -> ServiceResult<Self> {
138        Ok(Self::new(SqliteKindlingStore::open_in_memory()?))
139    }
140
141    /// Borrow the underlying store (e.g. for read-only callers).
142    pub fn store(&self) -> &SqliteKindlingStore {
143        &self.store
144    }
145
146    // ===== capsule lifecycle =====
147
148    /// Open a new capsule (`status = open`). For `Session` capsules with a
149    /// session scope, rejects opening when one is already open for that
150    /// session. Ports `openCapsule` lifecycle.
151    pub fn open_capsule(&self, options: OpenCapsuleOptions) -> ServiceResult<Capsule> {
152        self.open_capsule_at(options, now_ms())
153    }
154
155    /// [`Self::open_capsule`] with an explicit clock for deterministic tests.
156    pub fn open_capsule_at(
157        &self,
158        options: OpenCapsuleOptions,
159        now: Timestamp,
160    ) -> ServiceResult<Capsule> {
161        // Duplicate-open guard (session-scoped only), matching the TS lifecycle.
162        if options.kind == CapsuleType::Session {
163            if let Some(session_id) = options.scope_ids.session_id.as_deref() {
164                if let Some(existing) = self.store.get_open_capsule_for_session(session_id)? {
165                    return Err(ServiceError::Conflict(format!(
166                        "session {session_id} already has an open capsule ({})",
167                        existing.id
168                    )));
169                }
170            }
171        }
172
173        let capsule = validation::validate_capsule(
174            CapsuleInput {
175                id: options.id,
176                kind: options.kind,
177                intent: options.intent,
178                status: Some(CapsuleStatus::Open),
179                opened_at: None,
180                closed_at: None,
181                scope_ids: options.scope_ids,
182                observation_ids: None,
183                summary_id: None,
184            },
185            now,
186        )
187        .map_err(ServiceError::Validation)?;
188
189        self.store.create_capsule(&capsule)?;
190        Ok(capsule)
191    }
192
193    /// Close a capsule, optionally persisting a generated summary first.
194    /// Errors if the capsule is missing ([`ServiceError::NotFound`]) or already
195    /// closed ([`ServiceError::AlreadyClosed`]). Ports the service `closeCapsule`.
196    pub fn close_capsule(
197        &self,
198        capsule_id: &str,
199        options: CloseCapsuleOptions,
200    ) -> ServiceResult<Capsule> {
201        self.close_capsule_at(capsule_id, options, now_ms())
202    }
203
204    /// [`Self::close_capsule`] with an explicit clock for deterministic tests.
205    pub fn close_capsule_at(
206        &self,
207        capsule_id: &str,
208        options: CloseCapsuleOptions,
209        now: Timestamp,
210    ) -> ServiceResult<Capsule> {
211        // Distinguish not-found from already-closed up front (the store
212        // collapses both into one error; the TS service reports them
213        // separately).
214        let mut capsule = match self.store.get_capsule(capsule_id)? {
215            None => return Err(ServiceError::NotFound(capsule_id.to_string())),
216            Some(capsule) => capsule,
217        };
218        if capsule.status == CapsuleStatus::Closed {
219            return Err(ServiceError::AlreadyClosed(capsule_id.to_string()));
220        }
221
222        // Generate + persist the summary before closing, matching TS ordering.
223        if options.generate_summary {
224            if let Some(content) = options.summary_content {
225                let summary = validation::validate_summary(
226                    SummaryInput {
227                        id: None,
228                        capsule_id: capsule_id.to_string(),
229                        content,
230                        confidence: options.confidence.unwrap_or(1.0),
231                        created_at: Some(now),
232                        evidence_refs: Vec::new(),
233                    },
234                    now,
235                )
236                .map_err(ServiceError::Validation)?;
237                self.store.insert_summary(&summary)?;
238            }
239        }
240
241        // Close in the store. The summary (if any) is linked via
242        // summaries.capsule_id, so no summary_id is threaded here.
243        match self.store.close_capsule(capsule_id, Some(now), None) {
244            Ok(()) => {}
245            // Lost a race: someone closed it between our read and this write.
246            Err(StoreError::CapsuleNotOpen(_)) => {
247                return Err(ServiceError::AlreadyClosed(capsule_id.to_string()))
248            }
249            Err(err) => return Err(err.into()),
250        }
251
252        capsule.status = CapsuleStatus::Closed;
253        capsule.closed_at = Some(now);
254        Ok(capsule)
255    }
256
257    // ===== observations =====
258
259    /// Validate/normalise, secret-mask, store, and (optionally) attach an
260    /// observation. Returns an [`AppendOutcome`] carrying the stored
261    /// observation and whether the write was deduplicated. Ports
262    /// `appendObservation`, plus the service-boundary secret masking that has
263    /// no TS equivalent.
264    pub fn append_observation(
265        &self,
266        input: ObservationInput,
267        options: AppendObservationOptions,
268    ) -> ServiceResult<AppendOutcome> {
269        self.append_observation_at(input, options, now_ms())
270    }
271
272    /// [`Self::append_observation`] with an explicit clock for deterministic
273    /// tests.
274    ///
275    /// Dedup contract: if an observation with the same id already exists, the
276    /// incoming write is ignored — the stored row is **not** overwritten and
277    /// the (already-masked) incoming content is discarded. The existing row is
278    /// loaded fresh and returned with `deduplicated: true`. This keeps spool
279    /// replay idempotent on id.
280    pub fn append_observation_at(
281        &self,
282        input: ObservationInput,
283        options: AppendObservationOptions,
284        now: Timestamp,
285    ) -> ServiceResult<AppendOutcome> {
286        let mut observation = if options.validate {
287            validation::validate_observation(input, now).map_err(ServiceError::Validation)?
288        } else {
289            validation::normalize_observation(input, now)
290        };
291
292        // Service-boundary secret masking: mask (do NOT truncate — truncation
293        // is a hook-layer concern owned by PORT-009) so no consumer can route
294        // around secret filtering. Applies on both the validated and the
295        // validate:false path.
296        observation.content = mask_secrets(&observation.content);
297
298        let written = self.store.insert_observation(&observation)?;
299
300        // On a dedup hit, the stored row wins: load it fresh and return THAT,
301        // never the incoming (masked) observation. Re-masking or overwriting a
302        // previously stored row must never happen on replay.
303        let (observation, deduplicated) = if written {
304            (observation, false)
305        } else {
306            let existing = self
307                .store
308                .get_observation_by_id(&observation.id)?
309                .ok_or_else(|| {
310                    // INSERT OR IGNORE reported "not written" yet no row exists:
311                    // this is an internal invariant violation, not a dedup hit.
312                    ServiceError::Store(StoreError::ObservationNotFound(observation.id.clone()))
313                })?;
314            (existing, true)
315        };
316
317        // Attach is idempotent (INSERT OR IGNORE on the link's primary key), so
318        // re-attaching a deduplicated observation to the same capsule is a
319        // safe no-op.
320        if let Some(capsule_id) = options.capsule_id.as_deref() {
321            self.store
322                .attach_observation_to_capsule(capsule_id, &observation.id)?;
323        }
324
325        Ok(AppendOutcome {
326            observation,
327            deduplicated,
328        })
329    }
330
331    // ===== retrieval =====
332
333    /// Retrieve relevant context, scored as of the current time.
334    pub fn retrieve(&self, options: RetrieveOptions) -> ServiceResult<RetrieveResult> {
335        self.retrieve_at(options, now_ms())
336    }
337
338    /// [`Self::retrieve`] with an explicit clock for deterministic tests.
339    pub fn retrieve_at(
340        &self,
341        options: RetrieveOptions,
342        now: Timestamp,
343    ) -> ServiceResult<RetrieveResult> {
344        let provider = LocalFtsProvider::from_store(&self.store);
345        Ok(retrieve_at(&self.store, &provider, &options, now)?)
346    }
347
348    /// Exhaustive, deterministically-paginated observation list — the
349    /// FTS-independent read path (no BM25 query string). Filters by kind, scope,
350    /// and a half-open `[since, until)` time window; paginates with an opaque
351    /// keyset cursor over the store's stable `(ts ASC, id ASC)` order.
352    ///
353    /// `limit` is clamped to `[1, 1000]` (default 100). A malformed `cursor`
354    /// yields a validation error (`400`). `scope_ids.task_id` is ignored — task
355    /// id is not a retrieval-filterable dimension.
356    pub fn list_observations(
357        &self,
358        request: ListObservationsRequest,
359    ) -> ServiceResult<ListObservationsResult> {
360        const DEFAULT_LIMIT: u32 = 100;
361        const MAX_LIMIT: u32 = 1000;
362
363        let limit = request.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT);
364
365        let cursor = match request.cursor.as_deref() {
366            Some(raw) => Some(cursor::decode(raw).map_err(|()| {
367                ServiceError::Validation(vec![ValidationError {
368                    field: "cursor".to_string(),
369                    message: "malformed pagination cursor".to_string(),
370                    value: None,
371                }])
372            })?),
373            None => None,
374        };
375
376        // task_id is carried for provenance only; never a filter dimension.
377        let mut scope = request.scope_ids.clone();
378        scope.task_id = None;
379
380        let include_redacted = request.include_redacted.unwrap_or(false);
381        let cursor_ref = cursor.as_ref().map(|(ts, id)| (*ts, id.as_str()));
382
383        // Fetch one extra row to detect (and anchor) the next page.
384        let fetch = limit.saturating_add(1);
385        let mut observations = self.store.list_observations(
386            Some(&scope),
387            &request.kinds,
388            request.since,
389            request.until,
390            cursor_ref,
391            include_redacted,
392            fetch,
393        )?;
394
395        let next_cursor = if observations.len() as u32 > limit {
396            observations.truncate(limit as usize);
397            observations.last().map(|o| cursor::encode(o.ts, &o.id))
398        } else {
399            None
400        };
401
402        Ok(ListObservationsResult {
403            observations,
404            next_cursor,
405        })
406    }
407
408    // ===== pins =====
409
410    /// Create a pin. `note` → reason; `ttl_ms` → `expires_at = now + ttl_ms`.
411    /// Ports `pin`.
412    pub fn pin(&self, options: CreatePinOptions) -> ServiceResult<Pin> {
413        self.pin_at(options, now_ms())
414    }
415
416    /// [`Self::pin`] with an explicit clock for deterministic tests.
417    pub fn pin_at(&self, options: CreatePinOptions, now: Timestamp) -> ServiceResult<Pin> {
418        let expires_at = options.ttl_ms.map(|ttl| now + ttl);
419        let pin = validation::validate_pin(
420            PinInput {
421                id: None,
422                target_type: options.target_type,
423                target_id: options.target_id,
424                reason: options.note,
425                created_at: Some(now),
426                expires_at,
427                scope_ids: options.scope_ids.unwrap_or_default(),
428            },
429            now,
430        )
431        .map_err(ServiceError::Validation)?;
432
433        self.store.insert_pin(&pin)?;
434        Ok(pin)
435    }
436
437    /// Remove a pin. Errors with [`ServiceError::Store`] if it does not exist.
438    /// Ports `unpin`.
439    pub fn unpin(&self, pin_id: &str) -> ServiceResult<()> {
440        self.store.delete_pin(pin_id)?;
441        Ok(())
442    }
443
444    // ===== read accessors =====
445
446    /// Redact an observation (content replaced, `redacted` set). Ports `forget`.
447    pub fn forget(&self, observation_id: &str) -> ServiceResult<()> {
448        self.store.redact_observation(observation_id)?;
449        Ok(())
450    }
451
452    /// Capsule by id. Ports `getCapsule`.
453    pub fn get_capsule(&self, capsule_id: &str) -> ServiceResult<Option<Capsule>> {
454        Ok(self.store.get_capsule(capsule_id)?)
455    }
456
457    /// Open capsule for a session, if any. Ports `getOpenCapsule`.
458    pub fn get_open_capsule(&self, session_id: &str) -> ServiceResult<Option<Capsule>> {
459        Ok(self.store.get_open_capsule_for_session(session_id)?)
460    }
461
462    /// Observation by id. Ports `getObservation`.
463    pub fn get_observation(&self, observation_id: &str) -> ServiceResult<Option<Observation>> {
464        Ok(self.store.get_observation_by_id(observation_id)?)
465    }
466
467    /// Summary by id. Ports `getSummary`.
468    pub fn get_summary(&self, summary_id: &str) -> ServiceResult<Option<Summary>> {
469        Ok(self.store.get_summary_by_id(summary_id)?)
470    }
471
472    /// Latest summary for a capsule, if any. (Read helper used by callers and
473    /// tests; the TS service exposes the same via the store.)
474    pub fn get_latest_summary(&self, capsule_id: &str) -> ServiceResult<Option<Summary>> {
475        Ok(self.store.get_latest_summary_for_capsule(capsule_id)?)
476    }
477
478    /// Active pins for a scope, as of the current time. Ports `listPins`.
479    pub fn list_pins(&self, scope: Option<&ScopeIds>) -> ServiceResult<Vec<Pin>> {
480        self.list_pins_at(scope, now_ms())
481    }
482
483    /// [`Self::list_pins`] with an explicit clock for deterministic tests.
484    pub fn list_pins_at(
485        &self,
486        scope: Option<&ScopeIds>,
487        now: Timestamp,
488    ) -> ServiceResult<Vec<Pin>> {
489        Ok(self.store.list_active_pins(scope, Some(now))?)
490    }
491
492    // ===== injection context (hook support) =====
493
494    /// Assemble the structured data for the SessionStart injection, scored as
495    /// of the current time.
496    pub fn session_start_context(
497        &self,
498        scope: &ScopeIds,
499        max_results: u32,
500    ) -> ServiceResult<SessionStartContext> {
501        self.session_start_context_at(scope, max_results, now_ms())
502    }
503
504    /// [`Self::session_start_context`] with an explicit clock for deterministic
505    /// tests (controls active-pin expiry).
506    ///
507    /// Ports the inline queries in
508    /// `plugins/kindling-claude-code/hooks/session-start.js`:
509    /// active pins for the scope (resolved to target content) plus the most
510    /// recent non-redacted observations for the scope, capped at `max_results`.
511    pub fn session_start_context_at(
512        &self,
513        scope: &ScopeIds,
514        max_results: u32,
515        now: Timestamp,
516    ) -> ServiceResult<SessionStartContext> {
517        let pins = self.resolved_active_pins(scope, now)?;
518        // The Node hook orders by `ts DESC LIMIT maxResults`, excluding
519        // redacted rows — exactly `query_observations` with no time bounds.
520        let recent = self
521            .store
522            .query_observations(Some(scope), None, None, max_results)?;
523        Ok(SessionStartContext { pins, recent })
524    }
525
526    /// Assemble the structured data for the PreCompact injection, scored as of
527    /// the current time.
528    pub fn pre_compact_context(&self, scope: &ScopeIds) -> ServiceResult<PreCompactContext> {
529        self.pre_compact_context_at(scope, now_ms())
530    }
531
532    /// [`Self::pre_compact_context`] with an explicit clock for deterministic
533    /// tests (controls active-pin expiry).
534    ///
535    /// Ports the inline queries in
536    /// `plugins/kindling-claude-code/hooks/pre-compact.js`: active pins for the
537    /// scope (resolved to target content) plus the single latest summary across
538    /// the scope's capsules. An empty-content summary is normalised to `None`
539    /// here, matching the Node hook's `latestSummary.content` truthiness gate,
540    /// so the server never has to second-guess it.
541    pub fn pre_compact_context_at(
542        &self,
543        scope: &ScopeIds,
544        now: Timestamp,
545    ) -> ServiceResult<PreCompactContext> {
546        let pins = self.resolved_active_pins(scope, now)?;
547        let latest_summary = self
548            .store
549            .latest_summary_for_scope(Some(scope))?
550            .filter(|s| !s.content.is_empty());
551        Ok(PreCompactContext {
552            pins,
553            latest_summary,
554        })
555    }
556
557    /// Active pins for `scope` at `now`, each resolved to its target's content.
558    ///
559    /// Mirrors the TS `listActivePins` join: `note` is the pin reason, `content`
560    /// is the target observation/summary content (redacted observations carry
561    /// their `[redacted]` placeholder; missing targets resolve to `None`).
562    fn resolved_active_pins(
563        &self,
564        scope: &ScopeIds,
565        now: Timestamp,
566    ) -> ServiceResult<Vec<ResolvedPin>> {
567        let pins = self.store.list_active_pins(Some(scope), Some(now))?;
568        pins.into_iter()
569            .map(|pin| {
570                let content = match pin.target_type {
571                    PinTargetType::Observation => self
572                        .store
573                        .get_observation_by_id(&pin.target_id)?
574                        .map(|o| o.content),
575                    PinTargetType::Summary => self
576                        .store
577                        .get_summary_by_id(&pin.target_id)?
578                        .map(|s| s.content),
579                };
580                Ok(ResolvedPin {
581                    note: pin.reason,
582                    content,
583                })
584            })
585            .collect()
586    }
587}
588
589/// Current time in epoch milliseconds.
590fn now_ms() -> Timestamp {
591    SystemTime::now()
592        .duration_since(UNIX_EPOCH)
593        .expect("system clock before Unix epoch")
594        .as_millis() as Timestamp
595}
596
597/// Opaque keyset cursor for [`KindlingService::list_observations`].
598///
599/// Encodes `(ts, id)` as URL-safe base64 (no padding) of `"<ts>:<id>"`. Kept
600/// dependency-free and opaque so the wire token is stable and consumers cannot
601/// usefully parse or forge it. `id` (a UUID) never contains `:`.
602mod cursor {
603    use kindling_types::Timestamp;
604
605    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
606
607    fn b64_encode(input: &[u8]) -> String {
608        let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
609        for chunk in input.chunks(3) {
610            let b0 = chunk[0] as u32;
611            let b1 = *chunk.get(1).unwrap_or(&0) as u32;
612            let b2 = *chunk.get(2).unwrap_or(&0) as u32;
613            let n = (b0 << 16) | (b1 << 8) | b2;
614            out.push(ALPHABET[((n >> 18) & 0x3f) as usize] as char);
615            out.push(ALPHABET[((n >> 12) & 0x3f) as usize] as char);
616            if chunk.len() > 1 {
617                out.push(ALPHABET[((n >> 6) & 0x3f) as usize] as char);
618            }
619            if chunk.len() > 2 {
620                out.push(ALPHABET[(n & 0x3f) as usize] as char);
621            }
622        }
623        out
624    }
625
626    fn b64_decode(input: &str) -> Result<Vec<u8>, ()> {
627        fn val(c: u8) -> Result<u32, ()> {
628            match c {
629                b'A'..=b'Z' => Ok((c - b'A') as u32),
630                b'a'..=b'z' => Ok((c - b'a' + 26) as u32),
631                b'0'..=b'9' => Ok((c - b'0' + 52) as u32),
632                b'-' => Ok(62),
633                b'_' => Ok(63),
634                _ => Err(()),
635            }
636        }
637        let bytes = input.as_bytes();
638        let mut out = Vec::with_capacity(input.len() / 4 * 3);
639        for chunk in bytes.chunks(4) {
640            if chunk.len() < 2 {
641                return Err(());
642            }
643            let mut n = 0u32;
644            for (i, &c) in chunk.iter().enumerate() {
645                n |= val(c)? << (18 - 6 * i);
646            }
647            out.push((n >> 16) as u8);
648            if chunk.len() > 2 {
649                out.push((n >> 8) as u8);
650            }
651            if chunk.len() > 3 {
652                out.push(n as u8);
653            }
654        }
655        Ok(out)
656    }
657
658    /// Encode a `(ts, id)` anchor into an opaque cursor token.
659    pub(crate) fn encode(ts: Timestamp, id: &str) -> String {
660        b64_encode(format!("{ts}:{id}").as_bytes())
661    }
662
663    /// Decode a cursor token back to `(ts, id)`. `Err(())` on any malformed input.
664    pub(crate) fn decode(raw: &str) -> Result<(Timestamp, String), ()> {
665        let s = String::from_utf8(b64_decode(raw)?).map_err(|_| ())?;
666        let (ts_str, id) = s.split_once(':').ok_or(())?;
667        let ts = ts_str.parse::<Timestamp>().map_err(|_| ())?;
668        if id.is_empty() {
669            return Err(());
670        }
671        Ok((ts, id.to_string()))
672    }
673
674    #[cfg(test)]
675    mod tests {
676        use super::*;
677
678        #[test]
679        fn round_trips_various_lengths() {
680            for id in [
681                "a",
682                "ab",
683                "abc",
684                "abcd",
685                "550e8400-e29b-41d4-a716-446655440000",
686            ] {
687                let token = encode(1_750_000_000_123, id);
688                assert_eq!(decode(&token).unwrap(), (1_750_000_000_123, id.to_string()));
689            }
690        }
691
692        #[test]
693        fn rejects_malformed() {
694            assert!(decode("").is_err()); // empty
695            assert!(decode("####").is_err()); // non-alphabet chars
696            assert!(decode(&b64_encode(b"no-colon-here")).is_err()); // missing ':'
697            assert!(decode(&b64_encode(b"notanumber:abc")).is_err()); // bad ts
698            assert!(decode(&b64_encode(b"123:")).is_err()); // empty id
699        }
700    }
701}