clawpowers 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/COMPATIBILITY.md +13 -0
  3. package/KNOWN_LIMITATIONS.md +19 -0
  4. package/LICENSING.md +10 -0
  5. package/README.md +201 -9
  6. package/SECURITY.md +33 -53
  7. package/dist/index.d.ts +638 -5
  8. package/dist/index.js +986 -58
  9. package/dist/index.js.map +1 -1
  10. package/native/Cargo.lock +4863 -0
  11. package/native/Cargo.toml +73 -0
  12. package/native/crates/canonical/Cargo.toml +24 -0
  13. package/native/crates/canonical/src/lib.rs +673 -0
  14. package/native/crates/compression/Cargo.toml +20 -0
  15. package/native/crates/compression/benches/compression_bench.rs +42 -0
  16. package/native/crates/compression/src/lib.rs +393 -0
  17. package/native/crates/evm-eth/Cargo.toml +13 -0
  18. package/native/crates/evm-eth/src/lib.rs +105 -0
  19. package/native/crates/fee/Cargo.toml +15 -0
  20. package/native/crates/fee/src/lib.rs +281 -0
  21. package/native/crates/index/Cargo.toml +16 -0
  22. package/native/crates/index/src/lib.rs +277 -0
  23. package/native/crates/policy/Cargo.toml +17 -0
  24. package/native/crates/policy/src/lib.rs +614 -0
  25. package/native/crates/security/Cargo.toml +22 -0
  26. package/native/crates/security/src/lib.rs +478 -0
  27. package/native/crates/tokens/Cargo.toml +13 -0
  28. package/native/crates/tokens/src/lib.rs +534 -0
  29. package/native/crates/verification/Cargo.toml +23 -0
  30. package/native/crates/verification/src/lib.rs +333 -0
  31. package/native/crates/wallet/Cargo.toml +20 -0
  32. package/native/crates/wallet/src/lib.rs +261 -0
  33. package/native/crates/x402/Cargo.toml +30 -0
  34. package/native/crates/x402/src/lib.rs +423 -0
  35. package/native/ffi/Cargo.toml +34 -0
  36. package/native/ffi/build.rs +4 -0
  37. package/native/ffi/index.node +0 -0
  38. package/native/ffi/src/lib.rs +352 -0
  39. package/native/ffi/tests/integration.rs +354 -0
  40. package/native/pyo3/Cargo.toml +26 -0
  41. package/native/pyo3/pyproject.toml +16 -0
  42. package/native/pyo3/src/lib.rs +407 -0
  43. package/native/pyo3/tests/test_smoke.py +180 -0
  44. package/native/wasm/Cargo.toml +44 -0
  45. package/native/wasm/pkg/.gitignore +6 -0
  46. package/native/wasm/pkg/clawpowers_wasm.d.ts +208 -0
  47. package/native/wasm/pkg/clawpowers_wasm.js +872 -0
  48. package/native/wasm/pkg/clawpowers_wasm_bg.wasm +0 -0
  49. package/native/wasm/pkg/clawpowers_wasm_bg.wasm.d.ts +40 -0
  50. package/native/wasm/pkg/package.json +17 -0
  51. package/native/wasm/pkg-node/.gitignore +6 -0
  52. package/native/wasm/pkg-node/clawpowers_wasm.d.ts +143 -0
  53. package/native/wasm/pkg-node/clawpowers_wasm.js +798 -0
  54. package/native/wasm/pkg-node/clawpowers_wasm_bg.wasm +0 -0
  55. package/native/wasm/pkg-node/clawpowers_wasm_bg.wasm.d.ts +40 -0
  56. package/native/wasm/pkg-node/package.json +13 -0
  57. package/native/wasm/src/lib.rs +433 -0
  58. package/package.json +24 -3
  59. package/src/skills/catalog.ts +435 -0
  60. package/src/skills/executor.ts +56 -0
  61. package/src/skills/index.ts +3 -0
  62. package/src/skills/itp/SKILL.md +112 -0
  63. package/src/skills/loader.ts +193 -0
@@ -0,0 +1,673 @@
1
+ //! Canonical immutable record store for TurboMemory.
2
+ //!
3
+ //! [`CanonicalStore`] provides an append-only store of [`CanonicalRecord`]
4
+ //! values that are write-once (no updates), soft-deleted via `deleted_at`,
5
+ //! and integrity-checked via SHA-256 content hashes.
6
+ //!
7
+ //! # Backends
8
+ //!
9
+ //! - **`native` feature (default):** SQLite via `rusqlite` — persistent, file-backed.
10
+ //! - **`wasm` feature:** In-memory `HashMap` — suitable for WASM environments
11
+ //! where native SQLite is unavailable. Persistence can be layered on top via
12
+ //! IndexedDB or other JS-side storage.
13
+
14
+ use chrono::{DateTime, Utc};
15
+ use serde_json::Value;
16
+ use sha2::{Digest, Sha256};
17
+ use thiserror::Error;
18
+ use uuid::Uuid;
19
+
20
+ /// Errors returned by the canonical store.
21
+ #[derive(Debug, Error)]
22
+ pub enum CanonicalError {
23
+ /// Underlying storage error.
24
+ #[error("database error: {0}")]
25
+ Database(String),
26
+
27
+ /// The provided content hash does not match the computed hash.
28
+ #[error("content hash mismatch: expected {expected}, computed {computed}")]
29
+ HashMismatch {
30
+ /// Hash supplied by the caller.
31
+ expected: String,
32
+ /// Hash computed from the content.
33
+ computed: String,
34
+ },
35
+
36
+ /// A record with the same content hash already exists.
37
+ #[error("duplicate record: hash {hash} already stored as id {existing_id}")]
38
+ Duplicate {
39
+ /// The duplicate hash.
40
+ hash: String,
41
+ /// The id of the existing record.
42
+ existing_id: Uuid,
43
+ },
44
+
45
+ /// JSON serialisation / deserialisation error.
46
+ #[error("json error: {0}")]
47
+ Json(#[from] serde_json::Error),
48
+ }
49
+
50
+ #[cfg(feature = "native")]
51
+ impl From<rusqlite::Error> for CanonicalError {
52
+ fn from(e: rusqlite::Error) -> Self {
53
+ CanonicalError::Database(e.to_string())
54
+ }
55
+ }
56
+
57
+ /// A shorthand result type for [`CanonicalError`].
58
+ pub type Result<T> = std::result::Result<T, CanonicalError>;
59
+
60
+ // ─── Record ──────────────────────────────────────────────────────────────────
61
+
62
+ /// A single immutable record in the canonical store.
63
+ #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
64
+ pub struct CanonicalRecord {
65
+ /// Unique record identifier.
66
+ pub id: Uuid,
67
+ /// Logical namespace the record belongs to.
68
+ pub namespace: String,
69
+ /// The raw textual content.
70
+ pub content: String,
71
+ /// SHA-256 hex digest of `content`.
72
+ pub content_hash: String,
73
+ /// Optional dense embedding vector.
74
+ pub embedding: Option<Vec<f32>>,
75
+ /// Arbitrary structured metadata.
76
+ pub metadata: Value,
77
+ /// Wall-clock time of insertion.
78
+ pub created_at: DateTime<Utc>,
79
+ /// Human-readable description of where this record came from.
80
+ pub provenance: String,
81
+ }
82
+
83
+ impl CanonicalRecord {
84
+ /// Create a new record, computing the content hash automatically.
85
+ pub fn new(
86
+ namespace: impl Into<String>,
87
+ content: impl Into<String>,
88
+ embedding: Option<Vec<f32>>,
89
+ metadata: Value,
90
+ provenance: impl Into<String>,
91
+ ) -> Self {
92
+ let content = content.into();
93
+ let content_hash = compute_sha256(&content);
94
+ Self {
95
+ id: Uuid::new_v4(),
96
+ namespace: namespace.into(),
97
+ content,
98
+ content_hash,
99
+ embedding,
100
+ metadata,
101
+ created_at: Utc::now(),
102
+ provenance: provenance.into(),
103
+ }
104
+ }
105
+ }
106
+
107
+ // ─── Compute SHA256 (shared) ─────────────────────────────────────────────────
108
+
109
+ /// Compute the SHA-256 hex digest of `content`.
110
+ pub fn compute_sha256(content: &str) -> String {
111
+ let mut hasher = Sha256::new();
112
+ hasher.update(content.as_bytes());
113
+ format!("{:x}", hasher.finalize())
114
+ }
115
+
116
+ // =============================================================================
117
+ // Native (SQLite) backend
118
+ // =============================================================================
119
+
120
+ #[cfg(feature = "native")]
121
+ mod native_store {
122
+ use super::*;
123
+ use rusqlite::{Connection, params};
124
+
125
+ /// Immutable SQLite-backed store for [`CanonicalRecord`] values.
126
+ pub struct CanonicalStore {
127
+ conn: Connection,
128
+ }
129
+
130
+ impl CanonicalStore {
131
+ /// Open or create a file-backed store at `path`.
132
+ pub fn new(path: &str) -> Result<Self> {
133
+ let conn = Connection::open(path)?;
134
+ let store = Self { conn };
135
+ store.migrate()?;
136
+ Ok(store)
137
+ }
138
+
139
+ /// Create an in-memory store, suitable for unit tests.
140
+ pub fn in_memory() -> Result<Self> {
141
+ let conn = Connection::open_in_memory()?;
142
+ let store = Self { conn };
143
+ store.migrate()?;
144
+ Ok(store)
145
+ }
146
+
147
+ fn migrate(&self) -> Result<()> {
148
+ self.conn.execute_batch(
149
+ "PRAGMA journal_mode=WAL;
150
+ CREATE TABLE IF NOT EXISTS canonical_records (
151
+ id TEXT PRIMARY KEY NOT NULL,
152
+ namespace TEXT NOT NULL,
153
+ content TEXT NOT NULL,
154
+ content_hash TEXT NOT NULL UNIQUE,
155
+ embedding BLOB,
156
+ metadata TEXT NOT NULL DEFAULT '{}',
157
+ created_at TEXT NOT NULL,
158
+ provenance TEXT NOT NULL,
159
+ deleted_at TEXT
160
+ );
161
+ CREATE INDEX IF NOT EXISTS idx_namespace ON canonical_records(namespace);
162
+ CREATE INDEX IF NOT EXISTS idx_hash ON canonical_records(content_hash);",
163
+ )?;
164
+ Ok(())
165
+ }
166
+
167
+ /// Insert `record` into the store.
168
+ pub fn insert(&self, record: &CanonicalRecord) -> Result<Uuid> {
169
+ let computed = compute_sha256(&record.content);
170
+ if !record.content_hash.is_empty() && record.content_hash != computed {
171
+ return Err(CanonicalError::HashMismatch {
172
+ expected: record.content_hash.clone(),
173
+ computed,
174
+ });
175
+ }
176
+
177
+ if let Some(existing) = self.get_by_hash(&computed)? {
178
+ return Err(CanonicalError::Duplicate {
179
+ hash: computed,
180
+ existing_id: existing.id,
181
+ });
182
+ }
183
+
184
+ let embedding_blob: Option<Vec<u8>> = record
185
+ .embedding
186
+ .as_ref()
187
+ .map(|v| v.iter().flat_map(|f| f.to_le_bytes()).collect());
188
+
189
+ self.conn.execute(
190
+ "INSERT INTO canonical_records
191
+ (id, namespace, content, content_hash, embedding, metadata,
192
+ created_at, provenance)
193
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
194
+ params![
195
+ record.id.to_string(),
196
+ record.namespace,
197
+ record.content,
198
+ computed,
199
+ embedding_blob,
200
+ serde_json::to_string(&record.metadata)?,
201
+ record.created_at.to_rfc3339(),
202
+ record.provenance,
203
+ ],
204
+ )?;
205
+
206
+ Ok(record.id)
207
+ }
208
+
209
+ /// Retrieve a record by its UUID.
210
+ pub fn get(&self, id: &Uuid) -> Result<Option<CanonicalRecord>> {
211
+ let mut stmt = self.conn.prepare(
212
+ "SELECT id, namespace, content, content_hash, embedding, metadata,
213
+ created_at, provenance
214
+ FROM canonical_records
215
+ WHERE id = ?1 AND deleted_at IS NULL",
216
+ )?;
217
+ let result = stmt.query_row(params![id.to_string()], row_to_record);
218
+ match result {
219
+ Ok(r) => Ok(Some(r)),
220
+ Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
221
+ Err(e) => Err(e.into()),
222
+ }
223
+ }
224
+
225
+ /// Retrieve a record by its SHA-256 content hash.
226
+ pub fn get_by_hash(&self, hash: &str) -> Result<Option<CanonicalRecord>> {
227
+ let mut stmt = self.conn.prepare(
228
+ "SELECT id, namespace, content, content_hash, embedding, metadata,
229
+ created_at, provenance
230
+ FROM canonical_records
231
+ WHERE content_hash = ?1 AND deleted_at IS NULL",
232
+ )?;
233
+ let result = stmt.query_row(params![hash], row_to_record);
234
+ match result {
235
+ Ok(r) => Ok(Some(r)),
236
+ Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
237
+ Err(e) => Err(e.into()),
238
+ }
239
+ }
240
+
241
+ /// Return up to `limit` non-deleted records from `namespace`.
242
+ pub fn query_namespace(
243
+ &self,
244
+ namespace: &str,
245
+ limit: usize,
246
+ ) -> Result<Vec<CanonicalRecord>> {
247
+ let mut stmt = self.conn.prepare(
248
+ "SELECT id, namespace, content, content_hash, embedding, metadata,
249
+ created_at, provenance
250
+ FROM canonical_records
251
+ WHERE namespace = ?1 AND deleted_at IS NULL
252
+ ORDER BY created_at DESC
253
+ LIMIT ?2",
254
+ )?;
255
+ let rows = stmt.query_map(params![namespace, limit as i64], row_to_record)?;
256
+ rows.collect::<std::result::Result<Vec<_>, _>>()
257
+ .map_err(|e| CanonicalError::Database(e.to_string()))
258
+ }
259
+
260
+ /// Recompute the hash and compare.
261
+ pub fn verify_integrity(&self, id: &Uuid) -> Result<bool> {
262
+ match self.get(id)? {
263
+ None => Ok(false),
264
+ Some(record) => {
265
+ let recomputed = compute_sha256(&record.content);
266
+ Ok(recomputed == record.content_hash)
267
+ }
268
+ }
269
+ }
270
+
271
+ /// Soft-delete a record by setting `deleted_at`.
272
+ pub fn soft_delete(&self, id: &Uuid) -> Result<bool> {
273
+ let affected = self.conn.execute(
274
+ "UPDATE canonical_records SET deleted_at = ?1
275
+ WHERE id = ?2 AND deleted_at IS NULL",
276
+ params![Utc::now().to_rfc3339(), id.to_string()],
277
+ )?;
278
+ Ok(affected > 0)
279
+ }
280
+ }
281
+
282
+ fn row_to_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<CanonicalRecord> {
283
+ let id_str: String = row.get(0)?;
284
+ let namespace: String = row.get(1)?;
285
+ let content: String = row.get(2)?;
286
+ let content_hash: String = row.get(3)?;
287
+ let embedding_blob: Option<Vec<u8>> = row.get(4)?;
288
+ let metadata_str: String = row.get(5)?;
289
+ let created_at_str: String = row.get(6)?;
290
+ let provenance: String = row.get(7)?;
291
+
292
+ let id = Uuid::parse_str(&id_str).map_err(|e| {
293
+ rusqlite::Error::FromSqlConversionFailure(
294
+ 0,
295
+ rusqlite::types::Type::Text,
296
+ Box::new(e),
297
+ )
298
+ })?;
299
+
300
+ let embedding = embedding_blob.map(|blob| {
301
+ blob.chunks_exact(4)
302
+ .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
303
+ .collect()
304
+ });
305
+
306
+ let metadata: Value = serde_json::from_str(&metadata_str).unwrap_or(Value::Null);
307
+
308
+ let created_at = DateTime::parse_from_rfc3339(&created_at_str)
309
+ .map(|dt| dt.with_timezone(&Utc))
310
+ .unwrap_or_else(|_| Utc::now());
311
+
312
+ Ok(CanonicalRecord {
313
+ id,
314
+ namespace,
315
+ content,
316
+ content_hash,
317
+ embedding,
318
+ metadata,
319
+ created_at,
320
+ provenance,
321
+ })
322
+ }
323
+ }
324
+
325
+ #[cfg(feature = "native")]
326
+ pub use native_store::CanonicalStore;
327
+
328
+ // =============================================================================
329
+ // WASM (in-memory HashMap) backend
330
+ // =============================================================================
331
+
332
+ #[cfg(feature = "wasm")]
333
+ mod wasm_store {
334
+ use super::*;
335
+ use std::cell::RefCell;
336
+ use std::collections::HashMap;
337
+
338
+ /// A record stored in the in-memory backend, with soft-delete support.
339
+ #[derive(Clone)]
340
+ struct StoredRecord {
341
+ record: CanonicalRecord,
342
+ deleted_at: Option<DateTime<Utc>>,
343
+ }
344
+
345
+ struct StoreInner {
346
+ records: HashMap<Uuid, StoredRecord>,
347
+ hash_index: HashMap<String, Uuid>,
348
+ }
349
+
350
+ /// In-memory store for [`CanonicalRecord`] values, used in WASM environments
351
+ /// where native SQLite is unavailable.
352
+ ///
353
+ /// This provides the same API as the SQLite-backed store. Persistence can be
354
+ /// layered on top by serializing the store contents to IndexedDB or
355
+ /// localStorage via JS interop.
356
+ pub struct CanonicalStore {
357
+ inner: RefCell<StoreInner>,
358
+ }
359
+
360
+ impl CanonicalStore {
361
+ /// Create an in-memory store (the only option for WASM).
362
+ /// The `path` argument is accepted for API compatibility but ignored.
363
+ pub fn new(_path: &str) -> Result<Self> {
364
+ Ok(Self::create())
365
+ }
366
+
367
+ /// Create an in-memory store, suitable for unit tests and WASM.
368
+ pub fn in_memory() -> Result<Self> {
369
+ Ok(Self::create())
370
+ }
371
+
372
+ fn create() -> Self {
373
+ Self {
374
+ inner: RefCell::new(StoreInner {
375
+ records: HashMap::new(),
376
+ hash_index: HashMap::new(),
377
+ }),
378
+ }
379
+ }
380
+
381
+ /// Insert `record` into the store.
382
+ pub fn insert(&self, record: &CanonicalRecord) -> Result<Uuid> {
383
+ let computed = compute_sha256(&record.content);
384
+ if !record.content_hash.is_empty() && record.content_hash != computed {
385
+ return Err(CanonicalError::HashMismatch {
386
+ expected: record.content_hash.clone(),
387
+ computed,
388
+ });
389
+ }
390
+
391
+ let mut inner = self.inner.borrow_mut();
392
+
393
+ if let Some(existing_id) = inner.hash_index.get(&computed) {
394
+ if let Some(stored) = inner.records.get(existing_id) {
395
+ if stored.deleted_at.is_none() {
396
+ return Err(CanonicalError::Duplicate {
397
+ hash: computed,
398
+ existing_id: *existing_id,
399
+ });
400
+ }
401
+ }
402
+ }
403
+
404
+ inner.records.insert(
405
+ record.id,
406
+ StoredRecord {
407
+ record: CanonicalRecord {
408
+ content_hash: computed.clone(),
409
+ ..record.clone()
410
+ },
411
+ deleted_at: None,
412
+ },
413
+ );
414
+ inner.hash_index.insert(computed, record.id);
415
+
416
+ Ok(record.id)
417
+ }
418
+
419
+ /// Retrieve a record by its UUID.
420
+ pub fn get(&self, id: &Uuid) -> Result<Option<CanonicalRecord>> {
421
+ let inner = self.inner.borrow();
422
+ Ok(inner
423
+ .records
424
+ .get(id)
425
+ .filter(|s| s.deleted_at.is_none())
426
+ .map(|s| s.record.clone()))
427
+ }
428
+
429
+ /// Retrieve a record by its SHA-256 content hash.
430
+ pub fn get_by_hash(&self, hash: &str) -> Result<Option<CanonicalRecord>> {
431
+ let inner = self.inner.borrow();
432
+ if let Some(id) = inner.hash_index.get(hash) {
433
+ let id = *id;
434
+ drop(inner);
435
+ self.get(&id)
436
+ } else {
437
+ Ok(None)
438
+ }
439
+ }
440
+
441
+ /// Return up to `limit` non-deleted records from `namespace`.
442
+ pub fn query_namespace(
443
+ &self,
444
+ namespace: &str,
445
+ limit: usize,
446
+ ) -> Result<Vec<CanonicalRecord>> {
447
+ let inner = self.inner.borrow();
448
+ let mut records: Vec<_> = inner
449
+ .records
450
+ .values()
451
+ .filter(|s| s.deleted_at.is_none() && s.record.namespace == namespace)
452
+ .map(|s| s.record.clone())
453
+ .collect();
454
+ records.sort_by(|a, b| b.created_at.cmp(&a.created_at));
455
+ records.truncate(limit);
456
+ Ok(records)
457
+ }
458
+
459
+ /// Recompute the hash and compare.
460
+ pub fn verify_integrity(&self, id: &Uuid) -> Result<bool> {
461
+ match self.get(id)? {
462
+ None => Ok(false),
463
+ Some(record) => {
464
+ let recomputed = compute_sha256(&record.content);
465
+ Ok(recomputed == record.content_hash)
466
+ }
467
+ }
468
+ }
469
+
470
+ /// Soft-delete a record by setting `deleted_at`.
471
+ pub fn soft_delete(&self, id: &Uuid) -> Result<bool> {
472
+ let mut inner = self.inner.borrow_mut();
473
+ if let Some(stored) = inner.records.get_mut(id) {
474
+ if stored.deleted_at.is_none() {
475
+ stored.deleted_at = Some(Utc::now());
476
+ return Ok(true);
477
+ }
478
+ }
479
+ Ok(false)
480
+ }
481
+
482
+ /// Export all non-deleted records as JSON for persistence to IndexedDB.
483
+ pub fn export_json(&self) -> Result<String> {
484
+ let inner = self.inner.borrow();
485
+ let records: Vec<&CanonicalRecord> = inner
486
+ .records
487
+ .values()
488
+ .filter(|s| s.deleted_at.is_none())
489
+ .map(|s| &s.record)
490
+ .collect();
491
+ Ok(serde_json::to_string(&records)?)
492
+ }
493
+
494
+ /// Import records from JSON (e.g., loaded from IndexedDB on startup).
495
+ pub fn import_json(&self, json: &str) -> Result<usize> {
496
+ let records: Vec<CanonicalRecord> = serde_json::from_str(json)?;
497
+ let count = records.len();
498
+ for record in &records {
499
+ // Skip duplicates silently during import
500
+ let _ = self.insert(record);
501
+ }
502
+ Ok(count)
503
+ }
504
+ }
505
+ }
506
+
507
+ #[cfg(all(feature = "wasm", not(feature = "native")))]
508
+ pub use wasm_store::CanonicalStore;
509
+
510
+ // If neither feature is enabled, provide the native store as default
511
+ // This handles the case where the crate is used without explicit features
512
+ #[cfg(not(any(feature = "native", feature = "wasm")))]
513
+ compile_error!("Either 'native' or 'wasm' feature must be enabled for clawpowers-canonical");
514
+
515
+ // ─── Tests ───────────────────────────────────────────────────────────────────
516
+
517
+ #[cfg(test)]
518
+ mod tests {
519
+ use super::*;
520
+ use serde_json::json;
521
+
522
+ fn make_store() -> CanonicalStore {
523
+ CanonicalStore::in_memory().expect("in-memory store")
524
+ }
525
+
526
+ fn simple_record(namespace: &str, content: &str) -> CanonicalRecord {
527
+ CanonicalRecord::new(namespace, content, None, json!({}), "test")
528
+ }
529
+
530
+ #[test]
531
+ fn test_insert_and_get() {
532
+ let store = make_store();
533
+ let rec = simple_record("ns1", "hello world");
534
+ let id = store.insert(&rec).expect("insert");
535
+ let fetched = store.get(&id).expect("get").expect("present");
536
+ assert_eq!(fetched.content, "hello world");
537
+ assert_eq!(fetched.namespace, "ns1");
538
+ }
539
+
540
+ #[test]
541
+ fn test_get_nonexistent_returns_none() {
542
+ let store = make_store();
543
+ assert!(store.get(&Uuid::new_v4()).expect("get").is_none());
544
+ }
545
+
546
+ #[test]
547
+ fn test_get_by_hash() {
548
+ let store = make_store();
549
+ let rec = simple_record("ns1", "unique content abc");
550
+ let id = store.insert(&rec).expect("insert");
551
+ let hash = compute_sha256("unique content abc");
552
+ let found = store
553
+ .get_by_hash(&hash)
554
+ .expect("get_by_hash")
555
+ .expect("present");
556
+ assert_eq!(found.id, id);
557
+ }
558
+
559
+ #[test]
560
+ fn test_get_by_hash_nonexistent() {
561
+ let store = make_store();
562
+ assert!(
563
+ store
564
+ .get_by_hash("nonexistent_hash")
565
+ .expect("get_by_hash")
566
+ .is_none()
567
+ );
568
+ }
569
+
570
+ #[test]
571
+ fn test_content_hash_is_computed_correctly() {
572
+ let content = "test content 123";
573
+ let expected = compute_sha256(content);
574
+ let rec = simple_record("ns", content);
575
+ assert_eq!(rec.content_hash, expected);
576
+ }
577
+
578
+ #[test]
579
+ fn test_insert_rejects_wrong_hash() {
580
+ let store = make_store();
581
+ let mut rec = simple_record("ns", "some text");
582
+ rec.content_hash = "badhash0000000000".to_string();
583
+ assert!(matches!(
584
+ store.insert(&rec).expect_err("should fail"),
585
+ CanonicalError::HashMismatch { .. }
586
+ ));
587
+ }
588
+
589
+ #[test]
590
+ fn test_insert_accepts_correct_hash() {
591
+ let store = make_store();
592
+ let rec = simple_record("ns", "content with correct hash");
593
+ assert!(store.insert(&rec).is_ok());
594
+ }
595
+
596
+ #[test]
597
+ fn test_duplicate_insert_rejected() {
598
+ let store = make_store();
599
+ let rec = simple_record("ns", "duplicate content");
600
+ store.insert(&rec).expect("first insert");
601
+ let rec2 = simple_record("ns", "duplicate content");
602
+ assert!(matches!(
603
+ store.insert(&rec2).expect_err("should fail"),
604
+ CanonicalError::Duplicate { .. }
605
+ ));
606
+ }
607
+
608
+ #[test]
609
+ fn test_query_namespace() {
610
+ let store = make_store();
611
+ store.insert(&simple_record("alpha", "rec 1")).unwrap();
612
+ store.insert(&simple_record("alpha", "rec 2")).unwrap();
613
+ store.insert(&simple_record("beta", "rec 3")).unwrap();
614
+ let alpha = store.query_namespace("alpha", 10).expect("query");
615
+ assert_eq!(alpha.len(), 2);
616
+ for r in &alpha {
617
+ assert_eq!(r.namespace, "alpha");
618
+ }
619
+ }
620
+
621
+ #[test]
622
+ fn test_query_namespace_limit() {
623
+ let store = make_store();
624
+ for i in 0..5 {
625
+ store
626
+ .insert(&simple_record("limited", &format!("content {i}")))
627
+ .unwrap();
628
+ }
629
+ let results = store.query_namespace("limited", 3).expect("query");
630
+ assert_eq!(results.len(), 3);
631
+ }
632
+
633
+ #[test]
634
+ fn test_query_namespace_empty() {
635
+ let store = make_store();
636
+ let results = store.query_namespace("nonexistent", 10).expect("query");
637
+ assert!(results.is_empty());
638
+ }
639
+
640
+ #[test]
641
+ fn test_verify_integrity_passes() {
642
+ let store = make_store();
643
+ let rec = simple_record("ns", "integrity check content");
644
+ let id = store.insert(&rec).expect("insert");
645
+ assert!(store.verify_integrity(&id).expect("verify"));
646
+ }
647
+
648
+ #[test]
649
+ fn test_verify_integrity_missing_record() {
650
+ let store = make_store();
651
+ assert!(!store.verify_integrity(&Uuid::new_v4()).expect("verify"));
652
+ }
653
+
654
+ #[test]
655
+ fn test_soft_delete_hides_record() {
656
+ let store = make_store();
657
+ let rec = simple_record("ns", "to be deleted");
658
+ let id = store.insert(&rec).expect("insert");
659
+ assert!(store.soft_delete(&id).expect("soft_delete"));
660
+ assert!(store.get(&id).expect("get after delete").is_none());
661
+ }
662
+
663
+ #[test]
664
+ fn test_metadata_roundtrip() {
665
+ let store = make_store();
666
+ let meta = json!({"ttl_seconds": 3600, "source": "agent-x"});
667
+ let rec = CanonicalRecord::new("ns", "meta test", None, meta.clone(), "prov");
668
+ let id = store.insert(&rec).expect("insert");
669
+ let fetched = store.get(&id).expect("get").expect("present");
670
+ assert_eq!(fetched.metadata["ttl_seconds"], 3600);
671
+ assert_eq!(fetched.metadata["source"], "agent-x");
672
+ }
673
+ }
@@ -0,0 +1,20 @@
1
+ [package]
2
+ name = "clawpowers-compression"
3
+ version.workspace = true
4
+ edition.workspace = true
5
+ license.workspace = true
6
+
7
+ [dependencies]
8
+ serde = { workspace = true }
9
+ serde_json = { workspace = true }
10
+ sha2 = { workspace = true }
11
+ rand = { workspace = true }
12
+ thiserror = { workspace = true }
13
+ tracing = { workspace = true }
14
+
15
+ [dev-dependencies]
16
+ criterion = { workspace = true }
17
+
18
+ [[bench]]
19
+ name = "compression_bench"
20
+ harness = false