clawpowers 2.0.0 → 2.2.1
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.
- package/CHANGELOG.md +32 -0
- package/COMPATIBILITY.md +13 -0
- package/KNOWN_LIMITATIONS.md +19 -0
- package/LICENSING.md +10 -0
- package/README.md +201 -9
- package/SECURITY.md +33 -53
- package/dist/index.d.ts +638 -5
- package/dist/index.js +986 -58
- package/dist/index.js.map +1 -1
- package/native/Cargo.lock +4863 -0
- package/native/Cargo.toml +73 -0
- package/native/crates/canonical/Cargo.toml +24 -0
- package/native/crates/canonical/src/lib.rs +673 -0
- package/native/crates/compression/Cargo.toml +20 -0
- package/native/crates/compression/benches/compression_bench.rs +42 -0
- package/native/crates/compression/src/lib.rs +393 -0
- package/native/crates/evm-eth/Cargo.toml +13 -0
- package/native/crates/evm-eth/src/lib.rs +105 -0
- package/native/crates/fee/Cargo.toml +15 -0
- package/native/crates/fee/src/lib.rs +281 -0
- package/native/crates/index/Cargo.toml +16 -0
- package/native/crates/index/src/lib.rs +277 -0
- package/native/crates/policy/Cargo.toml +17 -0
- package/native/crates/policy/src/lib.rs +614 -0
- package/native/crates/security/Cargo.toml +22 -0
- package/native/crates/security/src/lib.rs +478 -0
- package/native/crates/tokens/Cargo.toml +13 -0
- package/native/crates/tokens/src/lib.rs +534 -0
- package/native/crates/verification/Cargo.toml +23 -0
- package/native/crates/verification/src/lib.rs +333 -0
- package/native/crates/wallet/Cargo.toml +20 -0
- package/native/crates/wallet/src/lib.rs +261 -0
- package/native/crates/x402/Cargo.toml +30 -0
- package/native/crates/x402/src/lib.rs +423 -0
- package/native/ffi/Cargo.toml +34 -0
- package/native/ffi/build.rs +4 -0
- package/native/ffi/index.node +0 -0
- package/native/ffi/src/lib.rs +352 -0
- package/native/ffi/tests/integration.rs +354 -0
- package/native/pyo3/Cargo.toml +26 -0
- package/native/pyo3/pyproject.toml +16 -0
- package/native/pyo3/src/lib.rs +407 -0
- package/native/pyo3/tests/test_smoke.py +180 -0
- package/native/wasm/Cargo.toml +44 -0
- package/native/wasm/pkg/.gitignore +6 -0
- package/native/wasm/pkg/clawpowers_wasm.d.ts +208 -0
- package/native/wasm/pkg/clawpowers_wasm.js +872 -0
- package/native/wasm/pkg/clawpowers_wasm_bg.wasm +0 -0
- package/native/wasm/pkg/clawpowers_wasm_bg.wasm.d.ts +40 -0
- package/native/wasm/pkg/package.json +17 -0
- package/native/wasm/pkg-node/.gitignore +6 -0
- package/native/wasm/pkg-node/clawpowers_wasm.d.ts +143 -0
- package/native/wasm/pkg-node/clawpowers_wasm.js +798 -0
- package/native/wasm/pkg-node/clawpowers_wasm_bg.wasm +0 -0
- package/native/wasm/pkg-node/clawpowers_wasm_bg.wasm.d.ts +40 -0
- package/native/wasm/pkg-node/package.json +13 -0
- package/native/wasm/src/lib.rs +433 -0
- package/package.json +24 -3
- package/src/skills/catalog.ts +435 -0
- package/src/skills/executor.ts +56 -0
- package/src/skills/index.ts +3 -0
- package/src/skills/itp/SKILL.md +112 -0
- 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
|