Bring language packages into CultLib monorepo
This commit is contained in:
parent
1fd8a9f226
commit
5c69bfc581
121 changed files with 24581 additions and 0 deletions
19
.gitignore
vendored
19
.gitignore
vendored
|
|
@ -293,6 +293,25 @@ FakesAssemblies/
|
|||
.ntvs_analysis.dat
|
||||
node_modules/
|
||||
|
||||
# CultLib language packages are source workspaces, not NuGet restore output.
|
||||
!packages/
|
||||
!packages/cultcache-ts/
|
||||
!packages/cultcache-ts/**
|
||||
!packages/cultnet-ts/
|
||||
!packages/cultnet-ts/**
|
||||
!packages/cultmesh-ts/
|
||||
!packages/cultmesh-ts/**
|
||||
!packages/cultcache-rs/
|
||||
!packages/cultcache-rs/**
|
||||
!packages/cultnet-rs/
|
||||
!packages/cultnet-rs/**
|
||||
packages/**/node_modules/
|
||||
packages/**/dist/
|
||||
packages/**/dist-test/
|
||||
packages/**/dist-inspector/
|
||||
packages/**/release/
|
||||
packages/**/release-inspector/
|
||||
|
||||
# Visual Studio 6 build log
|
||||
*.plg
|
||||
|
||||
|
|
|
|||
13
package.json
Normal file
13
package.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"name": "cultlib",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/cultcache-ts",
|
||||
"packages/cultnet-ts",
|
||||
"packages/cultmesh-ts"
|
||||
],
|
||||
"scripts": {
|
||||
"build:ts": "npm --workspaces run build",
|
||||
"test:ts": "npm --workspaces run test"
|
||||
}
|
||||
}
|
||||
2
packages/cultcache-rs/.gitignore
vendored
Normal file
2
packages/cultcache-rs/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/target
|
||||
Cargo.lock
|
||||
27
packages/cultcache-rs/Cargo.toml
Normal file
27
packages/cultcache-rs/Cargo.toml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
[package]
|
||||
name = "cultcache-rs"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
members = ["cultcache-rs-derive"]
|
||||
|
||||
[lib]
|
||||
name = "cultcache_rs"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
cultcache-rs-derive = { path = "cultcache-rs-derive" }
|
||||
fs2 = "0.4"
|
||||
rmp-serde = "1.3"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_bytes = "0.11"
|
||||
serde_json = "1"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = "1.4"
|
||||
tempfile = "3"
|
||||
332
packages/cultcache-rs/README.md
Normal file
332
packages/cultcache-rs/README.md
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
# CultCache Rust
|
||||
|
||||
`cultcache-rs` is the Rust port of the useful part of GameCult's CultCache:
|
||||
a polymorphism-aware in-memory cache that remembers through pluggable backing
|
||||
stores.
|
||||
|
||||
Consumer code should think in domain types:
|
||||
|
||||
```rust
|
||||
let player = cache.get_required::<PlayerData>("player:meta")?;
|
||||
cache.put("player:meta", &player)?;
|
||||
```
|
||||
|
||||
The persistence layer should deal with envelopes, routing, MessagePack payload
|
||||
bytes, and schema identity. Application code should not paw at loose JSON files
|
||||
like a sad little bureaucrat with a clipboard.
|
||||
|
||||
## What It Is
|
||||
|
||||
CultCache is a domain cache with persistence adapters.
|
||||
|
||||
- `CultCache` is the query and mutation surface.
|
||||
- Domain structs implement `DatabaseEntry`.
|
||||
- Entries are stored behind a `type::key` identity, so multiple entry types
|
||||
can share a logical key without colliding.
|
||||
- Each payload is encoded directly from the known `DatabaseEntry` type into
|
||||
MessagePack bytes.
|
||||
- Backing stores are adapters, not the public data model.
|
||||
- Writes persist to the resolved backing store before the in-memory cache is
|
||||
updated.
|
||||
- Type-specific backing stores beat generic backing stores.
|
||||
- `SingleFileMessagePackBackingStore` is the first concrete store and guards
|
||||
file access with a sidecar lock file.
|
||||
|
||||
This is not an ORM, not a database, and not distributed consensus in a novelty
|
||||
hat. If multiple processes write the same backing file, use an external lock or
|
||||
a coordinator.
|
||||
|
||||
## Current API
|
||||
|
||||
```rust
|
||||
use cultcache_rs::{CultCache, DatabaseEntry, SingleFileMessagePackBackingStore};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "settings")]
|
||||
struct Settings {
|
||||
#[cultcache(key = 0)]
|
||||
theme: String,
|
||||
|
||||
#[cultcache(key = 1, default)]
|
||||
retries: u32,
|
||||
}
|
||||
|
||||
let mut cache = CultCache::new();
|
||||
cache.register_entry_type::<Settings>()?;
|
||||
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new("cache.msgpack"));
|
||||
cache.pull_all_backing_stores()?;
|
||||
|
||||
cache.put("app", &Settings {
|
||||
theme: "ash".to_string(),
|
||||
retries: 3,
|
||||
})?;
|
||||
|
||||
let settings = cache.get_required::<Settings>("app")?;
|
||||
# Ok::<(), anyhow::Error>(())
|
||||
```
|
||||
|
||||
For a closed set of entries, generate a registry instead of repeating yourself:
|
||||
|
||||
```rust
|
||||
use cultcache_rs::cultcache_registry;
|
||||
|
||||
cultcache_registry!(GameCultEntries {
|
||||
Settings,
|
||||
PlayerData,
|
||||
});
|
||||
|
||||
let mut cache = CultCache::new();
|
||||
cache.register_registry(GameCultEntries)?;
|
||||
```
|
||||
|
||||
The important part is that callers retrieve domain values directly:
|
||||
|
||||
```rust
|
||||
let settings: Settings = cache.get_required("app")?;
|
||||
let all_settings: Vec<Settings> = cache.get_all()?;
|
||||
```
|
||||
|
||||
The cache is the ergonomic surface. The backing store is the memory prosthetic.
|
||||
|
||||
## How This Maps From Original CultCache
|
||||
|
||||
The original C# CultCache relies on runtime reflection:
|
||||
|
||||
- cacheable models inherit from `DatabaseEntry`
|
||||
- the cache scans child classes at startup
|
||||
- `Get<T>(id)` returns a `T`
|
||||
- optional `INamedEntry` enables name lookup
|
||||
- optional field/property indexes are registered by member name
|
||||
- backing stores pull/push/delete `DatabaseEntry` values
|
||||
|
||||
Rust does not have C#-style assembly scanning or runtime subclass discovery.
|
||||
The closest honest Rust translation is:
|
||||
|
||||
- domain structs implement a marker trait, `DatabaseEntry`
|
||||
- the derive macro generates a positional array formatter from explicit integer
|
||||
field slots
|
||||
- the envelope carries the polymorphic type discriminator
|
||||
- unknown persisted type ids fail closed instead of constructing arbitrary
|
||||
runtime types
|
||||
- a generated registry should eventually replace repetitive manual
|
||||
registration
|
||||
|
||||
Manual `register_entry_type::<T>()` is still accepted, but entry identity no
|
||||
longer needs a hand-written impl: `#[derive(DatabaseEntry)]` declares it beside
|
||||
the domain type. `cultcache_registry!` provides the current generated resolver
|
||||
surface for a closed entry set.
|
||||
|
||||
## Intended Rust Ergonomics
|
||||
|
||||
The target user-facing shape should be:
|
||||
|
||||
```rust
|
||||
#[derive(Clone, DatabaseEntry)]
|
||||
#[cultcache(type = "player")]
|
||||
pub struct PlayerData {
|
||||
#[cultcache(key = 0)]
|
||||
pub id: String,
|
||||
|
||||
#[cultcache(key = 1)]
|
||||
pub name: String,
|
||||
|
||||
#[cultcache(key = 2)]
|
||||
pub faction: String,
|
||||
}
|
||||
|
||||
let mut cache = CultCache::builder()
|
||||
.with_generated_entries(game_entries())
|
||||
.with_generic_store(SingleFileMessagePackBackingStore::new("cache.msgpack"))
|
||||
.build()?;
|
||||
|
||||
cache.pull_all_backing_stores()?;
|
||||
let player = cache.get_required::<PlayerData>("player:ari")?;
|
||||
```
|
||||
|
||||
The current `cultcache_registry!` macro is deliberately simple. The remaining
|
||||
ergonomic endpoint is automatic inventory or build-script generation so users
|
||||
do not have to maintain the registry list by hand.
|
||||
|
||||
The registry is the Rust equivalent of C# reflection and MessagePack resolver
|
||||
setup. It should know:
|
||||
|
||||
- entry type id
|
||||
- generated MessagePack/serde formatter path
|
||||
- Rust type name / schema name
|
||||
- optional store domain route
|
||||
- optional name key extractor
|
||||
- optional secondary indexes
|
||||
- optional global singleton marker
|
||||
|
||||
In other words: code generation should restore the original CultCache feeling
|
||||
without pretending Rust has runtime reflection hiding under the floorboards.
|
||||
|
||||
## Current Surface
|
||||
|
||||
- `CultCache::new`
|
||||
- `register_entry_type::<T>`
|
||||
- `add_generic_backing_store`
|
||||
- `add_backing_store`
|
||||
- `pull_all_backing_stores`
|
||||
- `get::<T>`
|
||||
- `get_required::<T>`
|
||||
- `get_envelope::<T>`
|
||||
- `get_required_envelope::<T>`
|
||||
- `get_all::<T>`
|
||||
- `put::<T>`
|
||||
- `put_envelope::<T>`
|
||||
- `update::<T>`
|
||||
- `delete::<T>`
|
||||
- `snapshot`
|
||||
- `SingleFileMessagePackBackingStore`
|
||||
|
||||
## Backing Store Routing
|
||||
|
||||
Generic store:
|
||||
|
||||
```rust
|
||||
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new("cache.msgpack"));
|
||||
```
|
||||
|
||||
Type-specific store:
|
||||
|
||||
```rust
|
||||
cache.add_backing_store(
|
||||
SingleFileMessagePackBackingStore::new("players.msgpack"),
|
||||
["player"],
|
||||
);
|
||||
```
|
||||
|
||||
When writing a `PlayerData`, the cache checks type-specific stores first. If none
|
||||
match, it writes to the first generic store. Later matching stores are mirrors.
|
||||
|
||||
This mirrors the C# behavior:
|
||||
|
||||
- specific domain stores own their domain
|
||||
- the first generic store is the primary generic write target
|
||||
- later generic stores mirror writes
|
||||
- this is not multi-master
|
||||
|
||||
## Persistence Semantics
|
||||
|
||||
`put` persists before mutating the in-memory cache. If persistence fails, the
|
||||
cache does not pretend the write succeeded.
|
||||
|
||||
The envelope is MessagePack, and the payload inside each envelope is also raw
|
||||
MessagePack bytes encoded from the registered concrete `DatabaseEntry` type.
|
||||
That avoids the old bootstrap path where payloads were normalized through
|
||||
`serde_json::Value`.
|
||||
|
||||
For bit-compatible neighbors, that also enables a real fast lane:
|
||||
|
||||
- `get_envelope::<T>()` exports the canonical persisted bytes for a typed entry
|
||||
- `put_envelope::<T>()` ingests the same envelope into another cache instance
|
||||
without re-encoding the payload first
|
||||
|
||||
It still decodes once for validation and typed reads. We are not pretending one
|
||||
`Vec<u8>` became metaphysically zero-copy because we believed in it harder. But
|
||||
the stupid decode/re-encode loop is gone.
|
||||
|
||||
The `DatabaseEntry` derive does not trust Rust source field order as the durable
|
||||
schema. Every persisted member must declare a stable integer slot:
|
||||
|
||||
```rust
|
||||
#[derive(Clone, DatabaseEntry)]
|
||||
#[cultcache(type = "player")]
|
||||
pub struct PlayerData {
|
||||
#[cultcache(key = 0)]
|
||||
pub id: String,
|
||||
|
||||
#[cultcache(key = 1)]
|
||||
pub name: String,
|
||||
|
||||
#[cultcache(key = 2, default)]
|
||||
pub level: u32,
|
||||
}
|
||||
```
|
||||
|
||||
The derive emits a tuple/array formatter. Gaps serialize as nil. Missing fields
|
||||
marked `default` use `Default::default()` when older payloads are read. Deleted
|
||||
field slots should stay reserved until an explicit store migration rewrites the
|
||||
data.
|
||||
|
||||
The single-file MessagePack store rewrites an atomic snapshot. That is a sane
|
||||
starting point for small typed state surfaces, settings, Epiphany agent memory,
|
||||
heartbeat state, and other compact control-plane data. Large corpora should use
|
||||
a sharded store or a real database instead of asking one file to become a
|
||||
warehouse and then acting wounded when physics invoices us.
|
||||
|
||||
The single-file store uses a sidecar lock file for shared reads and exclusive
|
||||
writes. That protects ordinary multi-process access to the same file. It is
|
||||
still not a multi-master replication protocol, and it is not a substitute for a
|
||||
coordinator when higher-level write ordering matters.
|
||||
|
||||
## Near-Term Ergonomic Improvements
|
||||
|
||||
1. **Derive macro**
|
||||
- `#[derive(DatabaseEntry)]`
|
||||
- `#[cultcache(type = "...")]`
|
||||
- `#[cultcache(key = N)]` on every persisted field
|
||||
- tuple/array formatter generation landed
|
||||
- optional `#[cultcache(name)]`, `#[cultcache(index)]`, and
|
||||
`#[cultcache(global)]` are still future work
|
||||
|
||||
2. **Generated registry**
|
||||
- a `CultCacheRegistry` trait
|
||||
- `cache.register_registry(GameCultEntries)`
|
||||
- basic macro registry landed
|
||||
- automatic inventory/build-script generation and store routes are future work
|
||||
|
||||
3. **Name and index lookups**
|
||||
- `cache.get_by_name::<T>("Potion")`
|
||||
- `cache.get_by_index::<T>("faction", "Lucent")`
|
||||
- generated extractors instead of reflection strings
|
||||
|
||||
4. **Schema/version metadata**
|
||||
- schema version in the envelope or payload metadata
|
||||
- explicit migration hooks
|
||||
- refusal on unknown persisted entry types unless a migration/resolver is
|
||||
installed
|
||||
|
||||
5. **Projection helpers**
|
||||
- JSON projection for review and git diffs
|
||||
- vector stripping / large-field elision for agent memory surfaces
|
||||
|
||||
## Why Not Skip Registration Immediately?
|
||||
|
||||
Rust can only deserialize polymorphic data into concrete types if something
|
||||
maps the persisted `type` discriminator to the Rust type. In C#, reflection and
|
||||
MessagePack resolvers can discover a closed `DatabaseEntry` inheritance tree at
|
||||
runtime and route it through known formatters. In Rust, that map has to come from
|
||||
somewhere:
|
||||
|
||||
- explicit registration
|
||||
- a generated registry
|
||||
- a macro inventory crate
|
||||
- a hand-written resolver
|
||||
|
||||
Explicit registration is the simplest honest implementation. Generated registry
|
||||
is the ergonomic destination. Hand-written resolver tables are the punishment
|
||||
we give ourselves if we get lazy.
|
||||
|
||||
## Security Model
|
||||
|
||||
CultCache should never parse arbitrary structured data into arbitrary runtime
|
||||
types. It only accepts persisted envelopes whose `type` discriminator resolves
|
||||
to a known `DatabaseEntry` type registered in the cache or generated registry.
|
||||
|
||||
That is the Rust version of the original trick:
|
||||
|
||||
- C# refuses to store objects outside the `DatabaseEntry` root type.
|
||||
- Runtime-visible `DatabaseEntry` subclasses get dedicated formatters.
|
||||
- Unknown or unregistered types are rejected.
|
||||
- MessagePack payload bytes deserialize through known concrete formatters, not
|
||||
an open-world type loader or generic structured-value bridge.
|
||||
|
||||
The derive/registry work should preserve that shape. Its job is to remove
|
||||
manual registration ceremony, not to loosen the closed-world boundary.
|
||||
|
||||
## License
|
||||
|
||||
Private GameCult infrastructure for now. Public packaging can wait until the
|
||||
API grows enough teeth to deserve strangers.
|
||||
13
packages/cultcache-rs/cultcache-rs-derive/Cargo.toml
Normal file
13
packages/cultcache-rs/cultcache-rs-derive/Cargo.toml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
[package]
|
||||
name = "cultcache-rs-derive"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
proc-macro = true
|
||||
|
||||
[dependencies]
|
||||
proc-macro2 = "1"
|
||||
quote = "1"
|
||||
syn = { version = "2", features = ["derive", "parsing"] }
|
||||
243
packages/cultcache-rs/cultcache-rs-derive/src/lib.rs
Normal file
243
packages/cultcache-rs/cultcache-rs-derive/src/lib.rs
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
use proc_macro::TokenStream;
|
||||
use quote::format_ident;
|
||||
use quote::quote;
|
||||
use syn::Data;
|
||||
use syn::DeriveInput;
|
||||
use syn::Fields;
|
||||
use syn::LitInt;
|
||||
use syn::LitStr;
|
||||
use syn::Type;
|
||||
use syn::parse_macro_input;
|
||||
|
||||
#[proc_macro_derive(DatabaseEntry, attributes(cultcache))]
|
||||
pub fn derive_database_entry(input: TokenStream) -> TokenStream {
|
||||
let input = parse_macro_input!(input as DeriveInput);
|
||||
let ident = input.ident.clone();
|
||||
let mut entry_type: Option<LitStr> = None;
|
||||
let mut schema_name: Option<LitStr> = None;
|
||||
|
||||
for attribute in input
|
||||
.attrs
|
||||
.iter()
|
||||
.filter(|attr| attr.path().is_ident("cultcache"))
|
||||
{
|
||||
let parse_result = attribute.parse_nested_meta(|meta| {
|
||||
if meta.path.is_ident("type") {
|
||||
entry_type = Some(meta.value()?.parse()?);
|
||||
Ok(())
|
||||
} else if meta.path.is_ident("schema") {
|
||||
schema_name = Some(meta.value()?.parse()?);
|
||||
Ok(())
|
||||
} else {
|
||||
Err(meta.error("unsupported cultcache attribute"))
|
||||
}
|
||||
});
|
||||
if let Err(error) = parse_result {
|
||||
return error.to_compile_error().into();
|
||||
}
|
||||
}
|
||||
|
||||
let Some(entry_type) = entry_type else {
|
||||
return syn::Error::new_spanned(
|
||||
ident,
|
||||
"DatabaseEntry derive requires #[cultcache(type = \"...\")]",
|
||||
)
|
||||
.to_compile_error()
|
||||
.into();
|
||||
};
|
||||
let schema_name = schema_name.unwrap_or_else(|| LitStr::new(&ident.to_string(), ident.span()));
|
||||
|
||||
let fields = match input.data {
|
||||
Data::Struct(data) => match data.fields {
|
||||
Fields::Named(fields) => fields.named,
|
||||
_ => {
|
||||
return syn::Error::new_spanned(
|
||||
ident,
|
||||
"DatabaseEntry derive requires a named struct",
|
||||
)
|
||||
.to_compile_error()
|
||||
.into();
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
return syn::Error::new_spanned(ident, "DatabaseEntry derive requires a struct")
|
||||
.to_compile_error()
|
||||
.into();
|
||||
}
|
||||
};
|
||||
|
||||
let mut slots: Vec<CultCacheSlot> = Vec::new();
|
||||
for field in fields {
|
||||
let Some(field_ident) = field.ident else {
|
||||
return syn::Error::new_spanned(field.ty, "DatabaseEntry derive requires named fields")
|
||||
.to_compile_error()
|
||||
.into();
|
||||
};
|
||||
let mut key: Option<usize> = None;
|
||||
let mut default = false;
|
||||
for attribute in field
|
||||
.attrs
|
||||
.iter()
|
||||
.filter(|attr| attr.path().is_ident("cultcache"))
|
||||
{
|
||||
let parse_result = attribute.parse_nested_meta(|meta| {
|
||||
if meta.path.is_ident("key") {
|
||||
let value: LitInt = meta.value()?.parse()?;
|
||||
key = Some(value.base10_parse()?);
|
||||
Ok(())
|
||||
} else if meta.path.is_ident("default") {
|
||||
default = true;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(meta.error("unsupported field cultcache attribute"))
|
||||
}
|
||||
});
|
||||
if let Err(error) = parse_result {
|
||||
return error.to_compile_error().into();
|
||||
}
|
||||
}
|
||||
let Some(key) = key else {
|
||||
return syn::Error::new_spanned(
|
||||
field_ident,
|
||||
"DatabaseEntry fields require #[cultcache(key = N)]",
|
||||
)
|
||||
.to_compile_error()
|
||||
.into();
|
||||
};
|
||||
if slots.iter().any(|slot| slot.key == key) {
|
||||
return syn::Error::new_spanned(
|
||||
field_ident,
|
||||
format!("duplicate DatabaseEntry slot {key}"),
|
||||
)
|
||||
.to_compile_error()
|
||||
.into();
|
||||
}
|
||||
slots.push(CultCacheSlot {
|
||||
key,
|
||||
ident: field_ident,
|
||||
ty: field.ty,
|
||||
default,
|
||||
});
|
||||
}
|
||||
slots.sort_by_key(|slot| slot.key);
|
||||
let max_slot = slots.iter().map(|slot| slot.key).max().unwrap_or(0);
|
||||
|
||||
let serialize_elements = (0..=max_slot).map(|slot| {
|
||||
if let Some(field) = slots.iter().find(|field| field.key == slot) {
|
||||
let field_ident = &field.ident;
|
||||
quote! {
|
||||
tuple.serialize_element(&self.#field_ident)?;
|
||||
}
|
||||
} else {
|
||||
quote! {
|
||||
tuple.serialize_element(&())?;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let option_declarations = slots.iter().map(|field| {
|
||||
let variable = format_ident!("slot_{}", field.key);
|
||||
let ty = &field.ty;
|
||||
quote! {
|
||||
let mut #variable: ::std::option::Option<#ty> = ::std::option::Option::None;
|
||||
}
|
||||
});
|
||||
|
||||
let deserialize_match_arms = (0..=max_slot).map(|slot| {
|
||||
if let Some(field) = slots.iter().find(|field| field.key == slot) {
|
||||
let variable = format_ident!("slot_{}", field.key);
|
||||
let ty = &field.ty;
|
||||
quote! {
|
||||
#slot => {
|
||||
#variable = seq.next_element::<#ty>()?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
quote! {
|
||||
#slot => {
|
||||
let _ = seq.next_element::<::serde::de::IgnoredAny>()?;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let build_fields = slots.iter().map(|field| {
|
||||
let field_ident = &field.ident;
|
||||
let variable = format_ident!("slot_{}", field.key);
|
||||
if field.default {
|
||||
quote! {
|
||||
#field_ident: #variable.unwrap_or_default()
|
||||
}
|
||||
} else {
|
||||
let field_name = field_ident.to_string();
|
||||
quote! {
|
||||
#field_ident: #variable.ok_or_else(|| ::serde::de::Error::missing_field(#field_name))?
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
quote! {
|
||||
impl ::cultcache_rs::DatabaseEntry for #ident {
|
||||
const TYPE: &'static str = #entry_type;
|
||||
const SCHEMA_NAME: &'static str = #schema_name;
|
||||
}
|
||||
|
||||
impl ::serde::Serialize for #ident {
|
||||
fn serialize<S>(&self, serializer: S) -> ::std::result::Result<S::Ok, S::Error>
|
||||
where
|
||||
S: ::serde::Serializer,
|
||||
{
|
||||
use ::serde::ser::SerializeTuple;
|
||||
let mut tuple = serializer.serialize_tuple(#max_slot + 1)?;
|
||||
#(#serialize_elements)*
|
||||
tuple.end()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> ::serde::Deserialize<'de> for #ident {
|
||||
fn deserialize<D>(deserializer: D) -> ::std::result::Result<Self, D::Error>
|
||||
where
|
||||
D: ::serde::Deserializer<'de>,
|
||||
{
|
||||
struct CultCacheVisitor;
|
||||
|
||||
impl<'de> ::serde::de::Visitor<'de> for CultCacheVisitor {
|
||||
type Value = #ident;
|
||||
|
||||
fn expecting(&self, formatter: &mut ::std::fmt::Formatter) -> ::std::fmt::Result {
|
||||
formatter.write_str(concat!("a CultCache array for ", stringify!(#ident)))
|
||||
}
|
||||
|
||||
fn visit_seq<A>(self, mut seq: A) -> ::std::result::Result<Self::Value, A::Error>
|
||||
where
|
||||
A: ::serde::de::SeqAccess<'de>,
|
||||
{
|
||||
#(#option_declarations)*
|
||||
let mut index = 0usize;
|
||||
while index <= #max_slot {
|
||||
match index {
|
||||
#(#deserialize_match_arms)*
|
||||
_ => unreachable!(),
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
while let ::std::option::Option::Some(_) = seq.next_element::<::serde::de::IgnoredAny>()? {}
|
||||
Ok(#ident {
|
||||
#(#build_fields),*
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_tuple(#max_slot + 1, CultCacheVisitor)
|
||||
}
|
||||
}
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
struct CultCacheSlot {
|
||||
key: usize,
|
||||
ident: syn::Ident,
|
||||
ty: Type,
|
||||
default: bool,
|
||||
}
|
||||
121
packages/cultcache-rs/examples/cultcache_interop.rs
Normal file
121
packages/cultcache-rs/examples/cultcache_interop.rs
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use cultcache_rs::CultCache;
|
||||
use cultcache_rs::DatabaseEntry;
|
||||
use cultcache_rs::SingleFileMessagePackBackingStore;
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const INTEROP_TYPE: &str = "cultcache.interop-note";
|
||||
const INTEROP_SCHEMA_VERSION: &str = "cultcache.interop_note.v1";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "cultcache.interop-note", schema = "CultCacheInteropNote")]
|
||||
struct CultCacheInteropNote {
|
||||
#[cultcache(key = 0)]
|
||||
schema_version: String,
|
||||
#[cultcache(key = 1)]
|
||||
document_id: String,
|
||||
#[cultcache(key = 2)]
|
||||
author_runtime_id: String,
|
||||
#[cultcache(key = 3)]
|
||||
title: String,
|
||||
#[cultcache(key = 4)]
|
||||
body: String,
|
||||
#[cultcache(key = 5, default)]
|
||||
tags: Vec<String>,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let mode = args
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("expected mode: write | read"))?;
|
||||
let options = parse_args(args.collect());
|
||||
let file = require_arg(&options, "file")?;
|
||||
|
||||
match mode.as_str() {
|
||||
"write" => write_note(file, require_arg(&options, "runtime-id")?)?,
|
||||
"read" => read_note(file)?,
|
||||
_ => return Err(anyhow!("unknown mode {mode}")),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_note(file: &str, runtime_id: &str) -> Result<()> {
|
||||
let mut cache = build_cache(file)?;
|
||||
cache.pull_all_backing_stores()?;
|
||||
let note = CultCacheInteropNote {
|
||||
schema_version: INTEROP_SCHEMA_VERSION.to_string(),
|
||||
document_id: format!("note:{runtime_id}"),
|
||||
author_runtime_id: runtime_id.to_string(),
|
||||
title: format!("{runtime_id} wrote a CultCache note"),
|
||||
body: "The v1 store format is the contract.".to_string(),
|
||||
tags: vec![
|
||||
runtime_id.to_string(),
|
||||
"rust".to_string(),
|
||||
"interop".to_string(),
|
||||
],
|
||||
};
|
||||
cache.put(¬e.document_id, ¬e)?;
|
||||
print_note(¬e)
|
||||
}
|
||||
|
||||
fn read_note(file: &str) -> Result<()> {
|
||||
let mut cache = build_cache(file)?;
|
||||
cache.pull_all_backing_stores()?;
|
||||
let notes = cache.get_all::<CultCacheInteropNote>()?;
|
||||
let note = notes
|
||||
.first()
|
||||
.ok_or_else(|| anyhow!("no {INTEROP_TYPE} records found"))?;
|
||||
print_note(note)
|
||||
}
|
||||
|
||||
fn build_cache(file: &str) -> Result<CultCache> {
|
||||
let mut cache = CultCache::new();
|
||||
cache.register_entry_type::<CultCacheInteropNote>()?;
|
||||
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(file));
|
||||
Ok(cache)
|
||||
}
|
||||
|
||||
fn print_note(note: &CultCacheInteropNote) -> Result<()> {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&json!({
|
||||
"schemaVersion": note.schema_version,
|
||||
"documentId": note.document_id,
|
||||
"authorRuntimeId": note.author_runtime_id,
|
||||
"title": note.title,
|
||||
"body": note.body,
|
||||
"tags": note.tags,
|
||||
}))?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_args(args: Vec<String>) -> BTreeMap<String, String> {
|
||||
let mut parsed = BTreeMap::new();
|
||||
let mut index = 0;
|
||||
while index < args.len() {
|
||||
let token = &args[index];
|
||||
if !token.starts_with("--") {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
let name = token.trim_start_matches("--").to_string();
|
||||
let value = args
|
||||
.get(index + 1)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| panic!("missing value for --{name}"));
|
||||
parsed.insert(name, value);
|
||||
index += 2;
|
||||
}
|
||||
parsed
|
||||
}
|
||||
|
||||
fn require_arg<'a>(options: &'a BTreeMap<String, String>, name: &str) -> Result<&'a str> {
|
||||
options
|
||||
.get(name)
|
||||
.map(String::as_str)
|
||||
.ok_or_else(|| anyhow!("missing required argument --{name}"))
|
||||
}
|
||||
885
packages/cultcache-rs/src/lib.rs
Normal file
885
packages/cultcache-rs/src/lib.rs
Normal file
|
|
@ -0,0 +1,885 @@
|
|||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::BTreeSet;
|
||||
use std::fs;
|
||||
use std::fs::File;
|
||||
use std::fs::OpenOptions;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
extern crate self as cultcache_rs;
|
||||
|
||||
pub use cultcache_rs_derive::DatabaseEntry;
|
||||
|
||||
pub trait DatabaseEntry: Serialize + DeserializeOwned + Clone + Send + 'static {
|
||||
const TYPE: &'static str;
|
||||
const SCHEMA_NAME: &'static str = "DatabaseEntry";
|
||||
}
|
||||
|
||||
pub trait CultCacheRegistry {
|
||||
fn register_entries(&self, cache: &mut CultCache) -> Result<()>;
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! cultcache_registry {
|
||||
($name:ident { $($entry:ty),* $(,)? }) => {
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct $name;
|
||||
|
||||
impl $crate::CultCacheRegistry for $name {
|
||||
fn register_entries(&self, cache: &mut $crate::CultCache) -> ::anyhow::Result<()> {
|
||||
$(
|
||||
cache.register_entry_type::<$entry>()?;
|
||||
)*
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CultCacheEnvelope {
|
||||
pub key: String,
|
||||
#[serde(rename = "type")]
|
||||
pub r#type: String,
|
||||
#[serde(with = "serde_bytes")]
|
||||
pub payload: Vec<u8>,
|
||||
pub stored_at: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub schema_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
struct PersistedStoreSnapshot(
|
||||
String,
|
||||
Vec<PersistedSchemaCatalogEntry>,
|
||||
Vec<PersistedRecord>,
|
||||
);
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
struct PersistedSchemaCatalogEntry(
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
Vec<String>,
|
||||
Vec<PersistedSchemaCatalogMember>,
|
||||
);
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
struct PersistedSchemaCatalogMember(
|
||||
u32,
|
||||
String,
|
||||
String,
|
||||
bool,
|
||||
bool,
|
||||
Option<String>,
|
||||
bool,
|
||||
Option<String>,
|
||||
);
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
struct PersistedRecord(
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
#[serde(with = "serde_bytes")] Vec<u8>,
|
||||
);
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct PushAllOptions {
|
||||
pub soft: bool,
|
||||
}
|
||||
|
||||
pub trait CacheBackingStore: Send {
|
||||
fn pull_all(&self) -> Result<Vec<CultCacheEnvelope>>;
|
||||
fn push(&mut self, entry: &CultCacheEnvelope) -> Result<()>;
|
||||
fn delete(&mut self, entry: &CultCacheEnvelope) -> Result<()>;
|
||||
|
||||
fn push_all(&mut self, entries: &[CultCacheEnvelope], _options: PushAllOptions) -> Result<()> {
|
||||
let existing = self.pull_all()?;
|
||||
for entry in existing {
|
||||
self.delete(&entry)?;
|
||||
}
|
||||
for entry in entries {
|
||||
self.push(entry)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SingleFileMessagePackBackingStore {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl SingleFileMessagePackBackingStore {
|
||||
pub fn new(path: impl Into<PathBuf>) -> Self {
|
||||
Self { path: path.into() }
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
fn read_all_unlocked(&self) -> Result<Vec<CultCacheEnvelope>> {
|
||||
if !self.path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let bytes = fs::read(&self.path)
|
||||
.with_context(|| format!("failed to read {}", self.path.display()))?;
|
||||
if bytes.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
decode_store_snapshot(&bytes).or_else(|_| {
|
||||
rmp_serde::from_slice(&bytes)
|
||||
.with_context(|| format!("failed to decode MessagePack {}", self.path.display()))
|
||||
})
|
||||
}
|
||||
|
||||
fn write_all_unlocked(&self, entries: &[CultCacheEnvelope]) -> Result<()> {
|
||||
if let Some(parent) = self.path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create {}", parent.display()))?;
|
||||
}
|
||||
let bytes = rmp_serde::to_vec(&encode_store_snapshot(entries))
|
||||
.context("failed to encode MessagePack")?;
|
||||
let tmp_path = temporary_path_for(&self.path);
|
||||
fs::write(&tmp_path, bytes)
|
||||
.with_context(|| format!("failed to write {}", tmp_path.display()))?;
|
||||
if self.path.exists() {
|
||||
fs::remove_file(&self.path)
|
||||
.with_context(|| format!("failed to replace {}", self.path.display()))?;
|
||||
}
|
||||
fs::rename(&tmp_path, &self.path).with_context(|| {
|
||||
format!(
|
||||
"failed to move {} to {}",
|
||||
tmp_path.display(),
|
||||
self.path.display()
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn with_shared_lock<T>(&self, action: impl FnOnce() -> Result<T>) -> Result<T> {
|
||||
let lock = self.open_lock_file()?;
|
||||
fs2::FileExt::lock_shared(&lock)
|
||||
.with_context(|| format!("failed to lock {}", self.lock_path().display()))?;
|
||||
let result = action();
|
||||
fs2::FileExt::unlock(&lock)
|
||||
.with_context(|| format!("failed to unlock {}", self.lock_path().display()))?;
|
||||
result
|
||||
}
|
||||
|
||||
fn with_exclusive_lock<T>(&self, action: impl FnOnce() -> Result<T>) -> Result<T> {
|
||||
let lock = self.open_lock_file()?;
|
||||
fs2::FileExt::lock_exclusive(&lock)
|
||||
.with_context(|| format!("failed to lock {}", self.lock_path().display()))?;
|
||||
let result = action();
|
||||
fs2::FileExt::unlock(&lock)
|
||||
.with_context(|| format!("failed to unlock {}", self.lock_path().display()))?;
|
||||
result
|
||||
}
|
||||
|
||||
fn open_lock_file(&self) -> Result<File> {
|
||||
let lock_path = self.lock_path();
|
||||
if let Some(parent) = lock_path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create {}", parent.display()))?;
|
||||
}
|
||||
OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.truncate(false)
|
||||
.write(true)
|
||||
.open(&lock_path)
|
||||
.with_context(|| format!("failed to open {}", lock_path.display()))
|
||||
}
|
||||
|
||||
fn lock_path(&self) -> PathBuf {
|
||||
let mut lock_name = self
|
||||
.path
|
||||
.file_name()
|
||||
.map(|value| value.to_os_string())
|
||||
.unwrap_or_else(|| "cultcache.msgpack".into());
|
||||
lock_name.push(".lock");
|
||||
self.path.with_file_name(lock_name)
|
||||
}
|
||||
}
|
||||
|
||||
impl CacheBackingStore for SingleFileMessagePackBackingStore {
|
||||
fn pull_all(&self) -> Result<Vec<CultCacheEnvelope>> {
|
||||
self.with_shared_lock(|| self.read_all_unlocked())
|
||||
}
|
||||
|
||||
fn push(&mut self, entry: &CultCacheEnvelope) -> Result<()> {
|
||||
self.with_exclusive_lock(|| {
|
||||
let mut entries = self.read_all_unlocked()?;
|
||||
entries.retain(|candidate| entry_id(candidate) != entry_id(entry));
|
||||
entries.push(entry.clone());
|
||||
entries.sort_by_key(entry_id);
|
||||
self.write_all_unlocked(&entries)
|
||||
})
|
||||
}
|
||||
|
||||
fn delete(&mut self, entry: &CultCacheEnvelope) -> Result<()> {
|
||||
self.with_exclusive_lock(|| {
|
||||
let mut entries = self.read_all_unlocked()?;
|
||||
entries.retain(|candidate| entry_id(candidate) != entry_id(entry));
|
||||
self.write_all_unlocked(&entries)
|
||||
})
|
||||
}
|
||||
|
||||
fn push_all(&mut self, entries: &[CultCacheEnvelope], _options: PushAllOptions) -> Result<()> {
|
||||
self.with_exclusive_lock(|| {
|
||||
let mut entries = entries.to_vec();
|
||||
entries.sort_by_key(entry_id);
|
||||
self.write_all_unlocked(&entries)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct CultCacheStoreRegistration {
|
||||
store: Box<dyn CacheBackingStore>,
|
||||
types: BTreeSet<String>,
|
||||
}
|
||||
|
||||
pub struct CultCache {
|
||||
definitions: BTreeMap<String, &'static str>,
|
||||
entries: BTreeMap<String, CultCacheEnvelope>,
|
||||
stores: Vec<CultCacheStoreRegistration>,
|
||||
}
|
||||
|
||||
impl CultCache {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
definitions: BTreeMap::new(),
|
||||
entries: BTreeMap::new(),
|
||||
stores: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register_entry_type<T: DatabaseEntry>(&mut self) -> Result<()> {
|
||||
if T::TYPE.trim().is_empty() {
|
||||
return Err(anyhow!(
|
||||
"CultCache entry types must declare a non-empty type"
|
||||
));
|
||||
}
|
||||
if let Some(existing_schema) = self.definitions.get(T::TYPE)
|
||||
&& *existing_schema != T::SCHEMA_NAME
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"CultCache already has a different definition registered for type {:?}",
|
||||
T::TYPE
|
||||
));
|
||||
}
|
||||
self.definitions.insert(T::TYPE.to_string(), T::SCHEMA_NAME);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn register_document_type<T: DatabaseEntry>(&mut self) -> Result<()> {
|
||||
self.register_entry_type::<T>()
|
||||
}
|
||||
|
||||
pub fn register_registry<R: CultCacheRegistry>(&mut self, registry: R) -> Result<&mut Self> {
|
||||
registry.register_entries(self)?;
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
pub fn add_backing_store(
|
||||
&mut self,
|
||||
store: impl CacheBackingStore + 'static,
|
||||
types: impl IntoIterator<Item = impl Into<String>>,
|
||||
) {
|
||||
self.stores.push(CultCacheStoreRegistration {
|
||||
store: Box::new(store),
|
||||
types: types.into_iter().map(Into::into).collect(),
|
||||
});
|
||||
}
|
||||
|
||||
pub fn add_generic_backing_store(&mut self, store: impl CacheBackingStore + 'static) {
|
||||
self.add_backing_store(store, Vec::<String>::new());
|
||||
}
|
||||
|
||||
pub fn pull_all_backing_stores(&mut self) -> Result<()> {
|
||||
self.entries.clear();
|
||||
let known_types: BTreeSet<String> = self.definitions.keys().cloned().collect();
|
||||
for registration in &mut self.stores {
|
||||
for entry in registration.store.pull_all()? {
|
||||
if !known_types.contains(&entry.r#type) {
|
||||
return Err(anyhow!(
|
||||
"No schema is registered for persisted entry type {:?}",
|
||||
entry.r#type
|
||||
));
|
||||
}
|
||||
self.entries.insert(entry_id(&entry), entry);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get<T: DatabaseEntry>(&self, key: &str) -> Result<Option<T>> {
|
||||
self.require_entry_type::<T>()?;
|
||||
let Some(entry) = self.entries.get(&entry_id_parts(T::TYPE, key)) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let payload = rmp_serde::from_slice(&entry.payload).with_context(|| {
|
||||
format!(
|
||||
"failed to decode CultCache entry {:?} at key {:?} as {}",
|
||||
T::TYPE,
|
||||
key,
|
||||
T::SCHEMA_NAME
|
||||
)
|
||||
})?;
|
||||
Ok(Some(payload))
|
||||
}
|
||||
|
||||
pub fn get_required<T: DatabaseEntry>(&self, key: &str) -> Result<T> {
|
||||
self.get::<T>(key)?
|
||||
.ok_or_else(|| anyhow!("CultCache has no {:?} entry at key {:?}", T::TYPE, key))
|
||||
}
|
||||
|
||||
pub fn get_envelope<T: DatabaseEntry>(&self, key: &str) -> Result<Option<CultCacheEnvelope>> {
|
||||
self.require_entry_type::<T>()?;
|
||||
Ok(self.entries.get(&entry_id_parts(T::TYPE, key)).cloned())
|
||||
}
|
||||
|
||||
pub fn get_required_envelope<T: DatabaseEntry>(&self, key: &str) -> Result<CultCacheEnvelope> {
|
||||
self.get_envelope::<T>(key)?
|
||||
.ok_or_else(|| anyhow!("CultCache has no {:?} envelope at key {:?}", T::TYPE, key))
|
||||
}
|
||||
|
||||
pub fn get_all<T: DatabaseEntry>(&self) -> Result<Vec<T>> {
|
||||
self.require_entry_type::<T>()?;
|
||||
let mut values = Vec::new();
|
||||
for entry in self.entries.values() {
|
||||
if entry.r#type != T::TYPE {
|
||||
continue;
|
||||
}
|
||||
values.push(rmp_serde::from_slice(&entry.payload).with_context(|| {
|
||||
format!(
|
||||
"failed to decode CultCache entry {:?} at key {:?} as {}",
|
||||
T::TYPE,
|
||||
entry.key,
|
||||
T::SCHEMA_NAME
|
||||
)
|
||||
})?);
|
||||
}
|
||||
Ok(values)
|
||||
}
|
||||
|
||||
pub fn put<T: DatabaseEntry>(&mut self, key: impl Into<String>, value: &T) -> Result<T> {
|
||||
self.require_entry_type::<T>()?;
|
||||
let key = key.into();
|
||||
let payload = rmp_serde::to_vec(value).with_context(|| {
|
||||
format!(
|
||||
"failed to encode CultCache entry {:?} at key {:?} as {}",
|
||||
T::TYPE,
|
||||
key,
|
||||
T::SCHEMA_NAME
|
||||
)
|
||||
})?;
|
||||
let parsed: T = rmp_serde::from_slice(&payload).with_context(|| {
|
||||
format!(
|
||||
"failed to validate CultCache entry {:?} at key {:?} as {}",
|
||||
T::TYPE,
|
||||
key,
|
||||
T::SCHEMA_NAME
|
||||
)
|
||||
})?;
|
||||
let entry = CultCacheEnvelope {
|
||||
key: key.clone(),
|
||||
r#type: T::TYPE.to_string(),
|
||||
payload,
|
||||
stored_at: now_utc_second(),
|
||||
schema_id: Some(T::TYPE.to_string()),
|
||||
};
|
||||
let route = self.resolve_route_indices(T::TYPE);
|
||||
let Some(primary_index) = route.first().copied() else {
|
||||
return Err(anyhow!(
|
||||
"No backing store is registered for entry type {:?}",
|
||||
T::TYPE
|
||||
));
|
||||
};
|
||||
self.stores[primary_index].store.push(&entry)?;
|
||||
for mirror_index in route.iter().skip(1).copied() {
|
||||
self.stores[mirror_index].store.push(&entry)?;
|
||||
}
|
||||
self.entries.insert(entry_id(&entry), entry);
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
pub fn put_envelope<T: DatabaseEntry>(&mut self, entry: CultCacheEnvelope) -> Result<T> {
|
||||
self.require_entry_type::<T>()?;
|
||||
if entry.r#type != T::TYPE {
|
||||
return Err(anyhow!(
|
||||
"CultCache envelope type {:?} does not match registered Rust type {:?}",
|
||||
entry.r#type,
|
||||
T::TYPE
|
||||
));
|
||||
}
|
||||
if entry.key.trim().is_empty() {
|
||||
return Err(anyhow!(
|
||||
"CultCache envelope keys for type {:?} must be non-empty",
|
||||
T::TYPE
|
||||
));
|
||||
}
|
||||
if entry.stored_at.trim().is_empty() {
|
||||
return Err(anyhow!(
|
||||
"CultCache envelope stored_at for type {:?} must be non-empty",
|
||||
T::TYPE
|
||||
));
|
||||
}
|
||||
|
||||
let parsed: T = rmp_serde::from_slice(&entry.payload).with_context(|| {
|
||||
format!(
|
||||
"failed to validate CultCache envelope {:?} at key {:?} as {}",
|
||||
T::TYPE,
|
||||
entry.key,
|
||||
T::SCHEMA_NAME
|
||||
)
|
||||
})?;
|
||||
let route = self.resolve_route_indices(T::TYPE);
|
||||
let Some(primary_index) = route.first().copied() else {
|
||||
return Err(anyhow!(
|
||||
"No backing store is registered for entry type {:?}",
|
||||
T::TYPE
|
||||
));
|
||||
};
|
||||
self.stores[primary_index].store.push(&entry)?;
|
||||
for mirror_index in route.iter().skip(1).copied() {
|
||||
self.stores[mirror_index].store.push(&entry)?;
|
||||
}
|
||||
self.entries.insert(entry_id(&entry), entry);
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
pub fn update<T, F>(&mut self, key: &str, updater: F) -> Result<T>
|
||||
where
|
||||
T: DatabaseEntry,
|
||||
F: FnOnce(Option<T>) -> T,
|
||||
{
|
||||
let current = self.get::<T>(key)?;
|
||||
self.put::<T>(key.to_string(), &updater(current))
|
||||
}
|
||||
|
||||
pub fn delete<T: DatabaseEntry>(&mut self, key: &str) -> Result<bool> {
|
||||
self.require_entry_type::<T>()?;
|
||||
let id = entry_id_parts(T::TYPE, key);
|
||||
let Some(entry) = self.entries.get(&id).cloned() else {
|
||||
return Ok(false);
|
||||
};
|
||||
let route = self.resolve_route_indices(T::TYPE);
|
||||
let Some(primary_index) = route.first().copied() else {
|
||||
return Err(anyhow!(
|
||||
"No backing store is registered for entry type {:?}",
|
||||
T::TYPE
|
||||
));
|
||||
};
|
||||
self.stores[primary_index].store.delete(&entry)?;
|
||||
for mirror_index in route.iter().skip(1).copied() {
|
||||
self.stores[mirror_index].store.delete(&entry)?;
|
||||
}
|
||||
self.entries.remove(&id);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> Vec<CultCacheEnvelope> {
|
||||
self.entries.values().cloned().collect()
|
||||
}
|
||||
|
||||
fn require_entry_type<T: DatabaseEntry>(&self) -> Result<()> {
|
||||
match self.definitions.get(T::TYPE) {
|
||||
Some(schema_name) if *schema_name == T::SCHEMA_NAME => Ok(()),
|
||||
_ => Err(anyhow!(
|
||||
"CultCache entry type {:?} is not registered on this cache instance",
|
||||
T::TYPE
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_route_indices(&self, type_id: &str) -> Vec<usize> {
|
||||
let type_specific: Vec<usize> = self
|
||||
.stores
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, registration)| {
|
||||
registration.types.contains(type_id).then_some(index)
|
||||
})
|
||||
.collect();
|
||||
if !type_specific.is_empty() {
|
||||
return type_specific;
|
||||
}
|
||||
self.stores
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, registration)| registration.types.is_empty().then_some(index))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CultCache {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn entry_id(entry: &CultCacheEnvelope) -> String {
|
||||
entry_id_parts(&entry.r#type, &entry.key)
|
||||
}
|
||||
|
||||
fn entry_id_parts(r#type: &str, key: &str) -> String {
|
||||
format!("{type}::{key}", type = r#type)
|
||||
}
|
||||
|
||||
fn now_utc_second() -> String {
|
||||
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
|
||||
}
|
||||
|
||||
fn encode_store_snapshot(entries: &[CultCacheEnvelope]) -> PersistedStoreSnapshot {
|
||||
let mut schema_names = BTreeSet::<String>::new();
|
||||
for entry in entries {
|
||||
schema_names.insert(
|
||||
entry
|
||||
.schema_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| entry.r#type.clone()),
|
||||
);
|
||||
}
|
||||
|
||||
let catalog = schema_names
|
||||
.into_iter()
|
||||
.map(|schema_id| {
|
||||
PersistedSchemaCatalogEntry(
|
||||
schema_id.clone(),
|
||||
schema_id.clone(),
|
||||
format!("{schema_id}.v1"),
|
||||
schema_id.clone(),
|
||||
format!(
|
||||
"{{\"schemaName\":\"{}\",\"schemaVersion\":\"{}.v1\",\"members\":[]}}",
|
||||
escape_json_string(&schema_id),
|
||||
escape_json_string(&schema_id)
|
||||
),
|
||||
vec![schema_id],
|
||||
Vec::new(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let records = entries
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
PersistedRecord(
|
||||
entry.key.clone(),
|
||||
entry
|
||||
.schema_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| entry.r#type.clone()),
|
||||
entry.stored_at.clone(),
|
||||
entry.payload.clone(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
PersistedStoreSnapshot("cultcache.store.v1".to_string(), catalog, records)
|
||||
}
|
||||
|
||||
fn decode_store_snapshot(bytes: &[u8]) -> Result<Vec<CultCacheEnvelope>> {
|
||||
let snapshot: PersistedStoreSnapshot =
|
||||
rmp_serde::from_slice(bytes).context("failed to decode CultCache v1 snapshot")?;
|
||||
if snapshot.0 != "cultcache.store.v1" {
|
||||
return Err(anyhow!("unsupported CultCache snapshot {}", snapshot.0));
|
||||
}
|
||||
|
||||
let catalog = snapshot
|
||||
.1
|
||||
.into_iter()
|
||||
.map(|entry| (entry.0, entry.1))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
snapshot
|
||||
.2
|
||||
.into_iter()
|
||||
.map(|record| {
|
||||
let r#type = catalog.get(&record.1).cloned().ok_or_else(|| {
|
||||
anyhow!(
|
||||
"CultCache record {:?} references missing schema {:?}",
|
||||
record.0,
|
||||
record.1
|
||||
)
|
||||
})?;
|
||||
Ok(CultCacheEnvelope {
|
||||
key: record.0,
|
||||
r#type,
|
||||
stored_at: record.2,
|
||||
payload: record.3,
|
||||
schema_id: Some(record.1),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn escape_json_string(value: &str) -> String {
|
||||
value.replace('\\', "\\\\").replace('"', "\\\"")
|
||||
}
|
||||
|
||||
fn temporary_path_for(path: &Path) -> PathBuf {
|
||||
let mut file_name = path
|
||||
.file_name()
|
||||
.map(|value| value.to_os_string())
|
||||
.unwrap_or_else(|| "cultcache.msgpack".into());
|
||||
file_name.push(format!(".{}.tmp", uuid::Uuid::new_v4()));
|
||||
path.with_file_name(file_name)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "settings")]
|
||||
struct Settings {
|
||||
#[cultcache(key = 0)]
|
||||
theme: String,
|
||||
#[cultcache(key = 1, default)]
|
||||
retries: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "note")]
|
||||
struct Note {
|
||||
#[cultcache(key = 0)]
|
||||
title: String,
|
||||
#[cultcache(key = 1)]
|
||||
body: String,
|
||||
}
|
||||
|
||||
cultcache_registry!(TestEntries { Settings, Note });
|
||||
|
||||
#[test]
|
||||
fn familiar_cultcache_flow_persists_and_reloads_typed_documents() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let store_path = temp.path().join("cache.msgpack");
|
||||
let settings = Settings {
|
||||
theme: "ash".to_string(),
|
||||
retries: 3,
|
||||
};
|
||||
|
||||
let mut cache = CultCache::new();
|
||||
cache.register_entry_type::<Settings>()?;
|
||||
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path));
|
||||
cache.pull_all_backing_stores()?;
|
||||
cache.put("app", &settings)?;
|
||||
assert_eq!(cache.get_required::<Settings>("app")?, settings);
|
||||
|
||||
let mut reloaded = CultCache::new();
|
||||
reloaded.register_entry_type::<Settings>()?;
|
||||
reloaded.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path));
|
||||
reloaded.pull_all_backing_stores()?;
|
||||
assert_eq!(reloaded.get_required::<Settings>("app")?, settings);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_identity_is_polymorphic_by_type_and_key() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let store_path = temp.path().join("cache.msgpack");
|
||||
let mut cache = CultCache::new();
|
||||
cache.register_registry(TestEntries)?;
|
||||
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path));
|
||||
|
||||
cache.put(
|
||||
"shared",
|
||||
&Settings {
|
||||
theme: "green".to_string(),
|
||||
retries: 1,
|
||||
},
|
||||
)?;
|
||||
cache.put(
|
||||
"shared",
|
||||
&Note {
|
||||
title: "same key".to_string(),
|
||||
body: "different type".to_string(),
|
||||
},
|
||||
)?;
|
||||
|
||||
assert_eq!(cache.snapshot().len(), 2);
|
||||
assert_eq!(cache.get_required::<Note>("shared")?.title, "same key");
|
||||
assert_eq!(cache.get_required::<Settings>("shared")?.theme, "green");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_specific_store_routes_before_generic_store() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let generic_path = temp.path().join("generic.msgpack");
|
||||
let settings_path = temp.path().join("settings.msgpack");
|
||||
let mut cache = CultCache::new();
|
||||
cache.register_entry_type::<Settings>()?;
|
||||
cache.register_entry_type::<Note>()?;
|
||||
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&generic_path));
|
||||
cache.add_backing_store(
|
||||
SingleFileMessagePackBackingStore::new(&settings_path),
|
||||
["settings"],
|
||||
);
|
||||
|
||||
cache.put(
|
||||
"app",
|
||||
&Settings {
|
||||
theme: "ash".to_string(),
|
||||
retries: 3,
|
||||
},
|
||||
)?;
|
||||
cache.put(
|
||||
"memo",
|
||||
&Note {
|
||||
title: "hello".to_string(),
|
||||
body: "world".to_string(),
|
||||
},
|
||||
)?;
|
||||
|
||||
let generic_entries = SingleFileMessagePackBackingStore::new(&generic_path).pull_all()?;
|
||||
let settings_entries = SingleFileMessagePackBackingStore::new(&settings_path).pull_all()?;
|
||||
assert_eq!(generic_entries[0].r#type, "note");
|
||||
assert_eq!(settings_entries[0].r#type, "settings");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_and_delete_follow_the_cache_api() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let store_path = temp.path().join("cache.msgpack");
|
||||
let mut cache = CultCache::new();
|
||||
cache.register_entry_type::<Settings>()?;
|
||||
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path));
|
||||
|
||||
let updated = cache.update::<Settings, _>("app", |current| {
|
||||
let mut current = current.unwrap_or(Settings {
|
||||
theme: "ash".to_string(),
|
||||
retries: 0,
|
||||
});
|
||||
current.retries += 1;
|
||||
current
|
||||
})?;
|
||||
assert_eq!(updated.retries, 1);
|
||||
assert!(cache.delete::<Settings>("app")?);
|
||||
assert!(cache.get::<Settings>("app")?.is_none());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_rejects_unregistered_persisted_entry_type() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let store_path = temp.path().join("cache.msgpack");
|
||||
let mut store = SingleFileMessagePackBackingStore::new(&store_path);
|
||||
store.push(&CultCacheEnvelope {
|
||||
key: "unknown".to_string(),
|
||||
r#type: "unregistered".to_string(),
|
||||
payload: rmp_serde::to_vec(&1_u8)?,
|
||||
stored_at: now_utc_second(),
|
||||
schema_id: Some("unregistered".to_string()),
|
||||
})?;
|
||||
|
||||
let mut cache = CultCache::new();
|
||||
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path));
|
||||
let error = cache.pull_all_backing_stores().unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("No schema is registered for persisted entry type")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_is_binary_messagepack_not_json_value() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let store_path = temp.path().join("cache.msgpack");
|
||||
let mut cache = CultCache::new();
|
||||
cache.register_entry_type::<Settings>()?;
|
||||
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path));
|
||||
cache.put(
|
||||
"app",
|
||||
&Settings {
|
||||
theme: "ash".to_string(),
|
||||
retries: 3,
|
||||
},
|
||||
)?;
|
||||
|
||||
let entry = cache.snapshot().remove(0);
|
||||
let decoded: Settings = rmp_serde::from_slice(&entry.payload)?;
|
||||
assert_eq!(decoded.theme, "ash");
|
||||
assert!(!entry.payload.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupted_payload_fails_during_typed_retrieval() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let store_path = temp.path().join("cache.msgpack");
|
||||
let mut store = SingleFileMessagePackBackingStore::new(&store_path);
|
||||
store.push(&CultCacheEnvelope {
|
||||
key: "app".to_string(),
|
||||
r#type: "settings".to_string(),
|
||||
payload: vec![0xc1],
|
||||
stored_at: now_utc_second(),
|
||||
schema_id: Some("settings".to_string()),
|
||||
})?;
|
||||
|
||||
let mut cache = CultCache::new();
|
||||
cache.register_entry_type::<Settings>()?;
|
||||
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&store_path));
|
||||
cache.pull_all_backing_stores()?;
|
||||
let error = cache.get_required::<Settings>("app").unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("failed to decode CultCache entry")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn put_envelope_reuses_existing_messagepack_payload() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let origin_store = temp.path().join("origin.msgpack");
|
||||
let target_store = temp.path().join("target.msgpack");
|
||||
|
||||
let mut origin = CultCache::new();
|
||||
origin.register_entry_type::<Settings>()?;
|
||||
origin.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&origin_store));
|
||||
origin.put(
|
||||
"app",
|
||||
&Settings {
|
||||
theme: "ash".to_string(),
|
||||
retries: 3,
|
||||
},
|
||||
)?;
|
||||
|
||||
let envelope = origin.get_required_envelope::<Settings>("app")?;
|
||||
|
||||
let mut target = CultCache::new();
|
||||
target.register_entry_type::<Settings>()?;
|
||||
target.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&target_store));
|
||||
let applied = target.put_envelope::<Settings>(envelope.clone())?;
|
||||
|
||||
assert_eq!(
|
||||
applied,
|
||||
Settings {
|
||||
theme: "ash".to_string(),
|
||||
retries: 3,
|
||||
}
|
||||
);
|
||||
assert_eq!(target.get_required::<Settings>("app")?, applied);
|
||||
assert_eq!(
|
||||
target.get_required_envelope::<Settings>("app")?.payload,
|
||||
envelope.payload
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
5
packages/cultcache-ts/.gitignore
vendored
Normal file
5
packages/cultcache-ts/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules/
|
||||
dist-test/
|
||||
dist-inspector/
|
||||
release/
|
||||
release-inspector/
|
||||
290
packages/cultcache-ts/README.md
Normal file
290
packages/cultcache-ts/README.md
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
# CultCacheTS
|
||||
|
||||
`CultCacheTS` is the TypeScript port of the useful part of GameCult's `CultCache` idea: consumer code talks to typed domain documents, while a closed-world persistence layer deals with schemas, routing, name lookups, indexes, globals, and raw MessagePack bytes.
|
||||
|
||||
This is not an ORM in a fake mustache. It is for cases where you want:
|
||||
|
||||
- one cache API for heterogeneous domain documents
|
||||
- typed reads and writes instead of every script massaging anonymous blobs
|
||||
- lookup by type, logical key, name, and indexed property
|
||||
- singleton-style global documents
|
||||
- backing-store routing instead of every caller pawing raw files directly
|
||||
- a persistence boundary that refuses unregistered polymorphic junk
|
||||
|
||||
## Current Shape
|
||||
|
||||
- document types are registered explicitly
|
||||
- a document schema only needs a `parse(...)` function, so generated JSON-schema
|
||||
contracts can plug in directly without growing a second hand-written Zod body
|
||||
- each persisted record stores:
|
||||
- `key`
|
||||
- `schemaId`
|
||||
- raw MessagePack `payload` bytes
|
||||
- `storedAt`
|
||||
- the single-file store writes the CultCache v1 snapshot shape:
|
||||
- `formatVersion`
|
||||
- embedded schema catalog
|
||||
- record array
|
||||
- payload bytes only decode through the registered document definition for that schema
|
||||
- name and index maps live in the cache, not in the backing store
|
||||
- global documents are treated as a singleton per type
|
||||
- backing stores are adapters, not the public data model
|
||||
|
||||
That keeps the polymorphic boundary tight without making callers write boilerplate by candlelight.
|
||||
|
||||
## Huginn Inspector
|
||||
|
||||
CultCache files may use the `.cc` extension. The bytes are still the canonical
|
||||
`cultcache.store.v1` MessagePack snapshot; the extension is the human handle, not
|
||||
a second format.
|
||||
|
||||
Run the local inspector during development:
|
||||
|
||||
```sh
|
||||
npm run dev:inspector
|
||||
```
|
||||
|
||||
Build the Vite inspector bundle:
|
||||
|
||||
```sh
|
||||
npm run build:inspector
|
||||
```
|
||||
|
||||
Build the desktop package:
|
||||
|
||||
```sh
|
||||
npm run dist:inspector
|
||||
```
|
||||
|
||||
Release builds must carry a new semantic version before any deployable artifact
|
||||
is produced. While the package is pre-1.0, breaking public behavior increments
|
||||
the minor version, compatible fixes increment the patch version, and the
|
||||
generated Huginn executable must use that package version in its filename.
|
||||
|
||||
Huginn is read-only. Drop a `.cc`, `.msgpack`, or `.mpack` file onto the window to
|
||||
inspect the snapshot header, schema catalog, records, decoded MessagePack
|
||||
payload previews, and an Norn graph cloud of the file's structured payload data
|
||||
without registering application schemas.
|
||||
|
||||
Huginn's background field uses the graph node AABB as its single world-space
|
||||
authority. The WebGPU particle pass keeps a million particle slots available,
|
||||
intersects the current screen bounds with the artwork bounds, and assigns only
|
||||
visible quadtree cells to those slots. Each visible cell is seeded with the
|
||||
Aetheria Stardust `RandomFirst` hash and xorshift follow-up sequence, then each
|
||||
particle pair trades opacity with sine/cosine phase while tracing a short
|
||||
segment through the packed flow texture. The packed map uses `RG` for flow, `B`
|
||||
for structure/curvature/detail pressure, and `A` for emission/field strength;
|
||||
particle color comes from the source Huginn albedo at the starting UV so the
|
||||
flow carries brushstrokes instead of repainting them at the final sample.
|
||||
Zooming raises the live quadtree level; coarse cells fade down while finer cells
|
||||
seeded from the same spatial lattice fade in around them, so detail density
|
||||
rises without changing the field's identity.
|
||||
|
||||
## Example
|
||||
|
||||
```ts
|
||||
import { z } from "zod";
|
||||
import {
|
||||
CultCache,
|
||||
SingleFileMessagePackBackingStore,
|
||||
defineDocumentRegistry,
|
||||
defineDocumentType,
|
||||
} from "cultcache-ts";
|
||||
|
||||
const itemDocument = defineDocumentType({
|
||||
type: "item",
|
||||
schemaId: "gamecult.item.v1",
|
||||
schemaName: "item",
|
||||
schemaVersion: "item.v1",
|
||||
schema: z.object({
|
||||
name: z.string(),
|
||||
category: z.string(),
|
||||
value: z.number().int(),
|
||||
}),
|
||||
name: "name",
|
||||
indexes: {
|
||||
category: "category",
|
||||
},
|
||||
});
|
||||
|
||||
const settingsDocument = defineDocumentType({
|
||||
type: "settings",
|
||||
schema: z.object({
|
||||
theme: z.string(),
|
||||
retries: z.number().int().nonnegative(),
|
||||
}),
|
||||
global: true,
|
||||
});
|
||||
|
||||
const registry = defineDocumentRegistry(itemDocument, settingsDocument);
|
||||
|
||||
const cache = CultCache.builder()
|
||||
.withRegistry(registry)
|
||||
.withGenericStore(new SingleFileMessagePackBackingStore("cache.msgpack"))
|
||||
.build();
|
||||
|
||||
await cache.pullAllBackingStores();
|
||||
|
||||
await cache.put(itemDocument, "item:potion", {
|
||||
name: "Potion",
|
||||
category: "Consumable",
|
||||
value: 50,
|
||||
});
|
||||
|
||||
await cache.putGlobal(settingsDocument, {
|
||||
theme: "ash",
|
||||
retries: 3,
|
||||
});
|
||||
|
||||
const potion = cache.getByName(itemDocument, "Potion");
|
||||
const consumable = cache.getByIndex(itemDocument, "category", "Consumable");
|
||||
const settings = cache.getRequiredGlobal(settingsDocument);
|
||||
```
|
||||
|
||||
## Public Surface
|
||||
|
||||
- `defineDocumentType(...)`
|
||||
- `defineDocumentRegistry(...)`
|
||||
- `CultCache.builder()`
|
||||
- `registerDocumentType(...)`
|
||||
- `registerRegistry(...)`
|
||||
- `registerNameLookup(...)`
|
||||
- `registerIndex(...)`
|
||||
- `addBackingStore(...)`
|
||||
- `addGenericBackingStore(...)`
|
||||
- `pullAllBackingStores()`
|
||||
- `get(...)`
|
||||
- `getRequired(...)`
|
||||
- `getAll(...)`
|
||||
- `getKeyByName(...)`
|
||||
- `getByName(...)`
|
||||
- `getKeyByIndex(...)`
|
||||
- `getByIndex(...)`
|
||||
- `getGlobal(...)`
|
||||
- `getRequiredGlobal(...)`
|
||||
- `put(...)`
|
||||
- `putGlobal(...)`
|
||||
- `update(...)`
|
||||
- `updateGlobal(...)`
|
||||
- `delete(...)`
|
||||
- `deleteGlobal(...)`
|
||||
- `snapshot()`
|
||||
- `getEnvelope(...)`
|
||||
- `getRequiredEnvelope(...)`
|
||||
- `getGlobalEnvelope(...)`
|
||||
- `putEnvelope(...)`
|
||||
|
||||
Document definitions may also carry persistence metadata for cross-language
|
||||
stores: `schemaId`, `schemaName`, `schemaVersion`, `contentHash`,
|
||||
`canonicalSchemaJson`, `compatibleSchemaIds`, and slot `members`.
|
||||
|
||||
## Name, Index, and Global Semantics
|
||||
|
||||
`name` and `indexes` can be declared on the document definition:
|
||||
|
||||
```ts
|
||||
const playerDocument = defineDocumentType({
|
||||
type: "player",
|
||||
schema: playerSchema,
|
||||
name: "displayName",
|
||||
indexes: {
|
||||
faction: "faction",
|
||||
email: "email",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
You can also register them later if you feel like changing the machine after boot:
|
||||
|
||||
```ts
|
||||
cache.registerNameLookup(playerDocument, "displayName");
|
||||
cache.registerIndex(playerDocument, "faction", "faction");
|
||||
```
|
||||
|
||||
Global documents are singleton-style per type:
|
||||
|
||||
```ts
|
||||
await cache.putGlobal(settingsDocument, { theme: "ash", retries: 3 });
|
||||
const settings = cache.getRequiredGlobal(settingsDocument);
|
||||
```
|
||||
|
||||
If persisted state contains two globals for the same type, `pullAllBackingStores()` fails closed instead of pretending the contradiction is charming.
|
||||
|
||||
## Typed Formatters
|
||||
|
||||
Each document definition can provide a custom formatter:
|
||||
|
||||
```ts
|
||||
const combatLogDocument = defineDocumentType({
|
||||
type: "combat-log",
|
||||
schema: combatLogSchema,
|
||||
formatter: {
|
||||
encode(value) {
|
||||
return myFastBinaryEncoder(value);
|
||||
},
|
||||
decode(payload) {
|
||||
return myFastBinaryDecoder(payload);
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
If you do not supply one, `CultCacheTS` uses MessagePack plus Zod validation by default.
|
||||
|
||||
Zod is no longer mandatory. Any schema object with a `parse(...)` function will
|
||||
do, which means generated Ajv-backed JSON-schema contracts can feed the cache
|
||||
directly as long as they expose the same parse surface.
|
||||
|
||||
The important boundary is this:
|
||||
|
||||
- payload bytes are persisted as bytes
|
||||
- decoding goes through the registered formatter for that document type
|
||||
- the decoded value is still validated against the declared schema
|
||||
|
||||
So callers get typed values, and the persistence layer does not become an open polymorphic sewer.
|
||||
|
||||
## Raw Envelope Fast Path
|
||||
|
||||
For bit-compatible neighbors such as CultNet peers that already share the same
|
||||
formatter contract, `CultCacheTS` can now move the persisted envelope directly:
|
||||
|
||||
- `getEnvelope(...)` / `getRequiredEnvelope(...)` export the canonical
|
||||
MessagePack payload bytes for a typed document
|
||||
- `putEnvelope(...)` ingests that envelope into another cache instance without
|
||||
re-encoding the payload first
|
||||
|
||||
Raw envelopes may carry `schemaId` and catalog metadata. Domain callers should
|
||||
not need that sludge. Replication, migration tooling, and wire-compatibility
|
||||
tests do.
|
||||
|
||||
That still is not magic shared memory. The cache decodes once so lookups and
|
||||
typed reads stay honest. But it stops doing the stupid part where identical
|
||||
MessagePack payload bytes get decoded into a generic value, then re-encoded
|
||||
into the same bytes again just to cross the room.
|
||||
|
||||
## Security Model
|
||||
|
||||
`CultCacheTS` should never deserialize arbitrary persisted payloads into arbitrary runtime shapes.
|
||||
|
||||
It only accepts persisted records whose `schemaId` or legacy `type` discriminator resolves to a registered document definition on the cache instance. Unknown schemas fail immediately. Known schemas decode through the registered formatter and schema only.
|
||||
|
||||
That is the TypeScript version of the original CultCache trick:
|
||||
|
||||
- closed discriminator set
|
||||
- explicit registration
|
||||
- typed formatter path
|
||||
- no runtime type-name roulette
|
||||
|
||||
## Persistence Model
|
||||
|
||||
`SingleFileMessagePackBackingStore` is the first concrete store.
|
||||
|
||||
- it writes atomic CultCache v1 MessagePack snapshots
|
||||
- the top-level MessagePack value is `[formatVersion, schemaCatalog, records]`
|
||||
- each record is `[key, schemaId, storedAt, payload]`
|
||||
- each catalog entry is `[schemaId, schemaName, schemaVersion, contentHash, canonicalSchemaJson, compatibleSchemaIds, members]`
|
||||
- it still reads the older TS/Rust-style envelope array and rewrites repaired legacy payloads as v1 snapshots
|
||||
- it queues local writes so one process does not step on its own shoelaces
|
||||
- it persists raw MessagePack payload bytes inside schema-addressed records
|
||||
|
||||
It is a control-plane store, not a tiny god. If multiple processes hammer the same file, you still need a coordinator or an external lock instead of prayer.
|
||||
28
packages/cultcache-ts/electron-builder.json
Normal file
28
packages/cultcache-ts/electron-builder.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"appId": "com.gamecult.cultcache.inspector",
|
||||
"productName": "Huginn",
|
||||
"executableName": "Huginn",
|
||||
"asar": false,
|
||||
"icon": "inspector/public/hugin.ico",
|
||||
"extraMetadata": {
|
||||
"main": "electron/main.cjs"
|
||||
},
|
||||
"files": [
|
||||
"electron/main.cjs",
|
||||
"dist-inspector/**/*"
|
||||
],
|
||||
"directories": {
|
||||
"output": "release-inspector"
|
||||
},
|
||||
"win": {
|
||||
"signAndEditExecutable": false,
|
||||
"target": ["portable"],
|
||||
"artifactName": "Huginn-${version}.${ext}"
|
||||
},
|
||||
"mac": {
|
||||
"target": ["dir"]
|
||||
},
|
||||
"linux": {
|
||||
"target": ["dir"]
|
||||
}
|
||||
}
|
||||
72
packages/cultcache-ts/electron/main.cjs
Normal file
72
packages/cultcache-ts/electron/main.cjs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
const { app, BrowserWindow } = require("electron");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const devServer = process.env.CULTCACHE_INSPECTOR_DEV_SERVER;
|
||||
app.setName("Huginn");
|
||||
const logPath = path.join(app.getPath("userData"), "huginn-renderer.log");
|
||||
|
||||
function writeLog(message) {
|
||||
const line = `[${new Date().toISOString()}] ${message}${os.EOL}`;
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
||||
fs.appendFileSync(logPath, line);
|
||||
} catch {
|
||||
// Logging must never be the reason the inspector refuses to open.
|
||||
}
|
||||
console.log(line.trimEnd());
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
const window = new BrowserWindow({
|
||||
width: 1180,
|
||||
height: 780,
|
||||
minWidth: 820,
|
||||
minHeight: 560,
|
||||
title: "Huginn",
|
||||
icon: path.join(__dirname, "..", "dist-inspector", "hugin-64.png"),
|
||||
backgroundColor: "#05090d",
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
},
|
||||
});
|
||||
|
||||
window.webContents.on("console-message", (_event, level, message, line, sourceId) => {
|
||||
writeLog(`renderer console[${level}] ${sourceId}:${line} ${message}`);
|
||||
});
|
||||
window.webContents.on("did-fail-load", (_event, code, description, validatedUrl) => {
|
||||
writeLog(`renderer failed load ${code} ${description} ${validatedUrl}`);
|
||||
});
|
||||
window.webContents.on("render-process-gone", (_event, details) => {
|
||||
writeLog(`renderer process gone ${details.reason} exitCode=${details.exitCode}`);
|
||||
});
|
||||
window.webContents.on("did-finish-load", () => {
|
||||
writeLog(`renderer loaded ${window.webContents.getURL()}`);
|
||||
});
|
||||
|
||||
if (devServer) {
|
||||
void window.loadURL(devServer);
|
||||
} else {
|
||||
void window.loadFile(path.join(__dirname, "..", "dist-inspector", "index.html"));
|
||||
}
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
createWindow();
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
12
packages/cultcache-ts/inspector/index.html
Normal file
12
packages/cultcache-ts/inspector/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Huginn</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
packages/cultcache-ts/inspector/public/hugin-256.png
Normal file
BIN
packages/cultcache-ts/inspector/public/hugin-256.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
BIN
packages/cultcache-ts/inspector/public/hugin-64.png
Normal file
BIN
packages/cultcache-ts/inspector/public/hugin-64.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
BIN
packages/cultcache-ts/inspector/public/hugin.ico
Normal file
BIN
packages/cultcache-ts/inspector/public/hugin.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
BIN
packages/cultcache-ts/inspector/public/hugin.png
Normal file
BIN
packages/cultcache-ts/inspector/public/hugin.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
packages/cultcache-ts/inspector/public/huginn-fieldpack.png
Normal file
BIN
packages/cultcache-ts/inspector/public/huginn-fieldpack.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
BIN
packages/cultcache-ts/inspector/public/huginn-surface.png
Normal file
BIN
packages/cultcache-ts/inspector/public/huginn-surface.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
1380
packages/cultcache-ts/inspector/src/HuginnFieldCanvas.tsx
Normal file
1380
packages/cultcache-ts/inspector/src/HuginnFieldCanvas.tsx
Normal file
File diff suppressed because it is too large
Load diff
856
packages/cultcache-ts/inspector/src/main.tsx
Normal file
856
packages/cultcache-ts/inspector/src/main.tsx
Normal file
|
|
@ -0,0 +1,856 @@
|
|||
import { NornViewer, type NornGraphEdge, type NornGraphNode, type NornGraphsState, type ViewerSelection } from "@gamecult/norn-viewer";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Component, useMemo, useState, type DragEvent, type ReactNode } from "react";
|
||||
|
||||
import { inspectCultCacheBytes, type CultCacheInspection, type InspectedCatalogEntry, type InspectedRecord } from "../../src/cult-cache-inspector";
|
||||
import { HuginnFieldCanvas } from "./HuginnFieldCanvas";
|
||||
import "./styles.css";
|
||||
|
||||
type Selection = {
|
||||
record: number;
|
||||
};
|
||||
|
||||
type NodeArticle = NonNullable<Parameters<typeof NornViewer>[0]["nodeArticle"]>;
|
||||
|
||||
type RawSelection =
|
||||
| { kind: "record"; index: number }
|
||||
| { kind: "value"; recordIndex: number; path: string; value: unknown };
|
||||
|
||||
type GraphProjection = {
|
||||
state: NornGraphsState;
|
||||
recordNodeIds: string[];
|
||||
nodeSelections: Map<string, RawSelection>;
|
||||
edgeSelections: Map<string, RawSelection>;
|
||||
collapsedPayloadValues: number;
|
||||
};
|
||||
|
||||
const MAX_PAYLOAD_REGION_NODES = 160;
|
||||
const MAX_RECORD_REGION_NODES = 32;
|
||||
const MAX_PAYLOAD_REGION_DEPTH = 3;
|
||||
const MAX_PAYLOAD_REGION_CHILDREN = 10;
|
||||
const MAX_NESTED_PAYLOAD_REGION_CHILDREN = 6;
|
||||
const assetPath = (name: string) => `${import.meta.env.BASE_URL}${name}`;
|
||||
const HUGINN_ART = {
|
||||
surface: assetPath("huginn-surface.png"),
|
||||
field: assetPath("huginn-fieldpack.png"),
|
||||
};
|
||||
|
||||
function HuginnField() {
|
||||
return (
|
||||
<HuginnFieldCanvas
|
||||
imageUrl={HUGINN_ART.surface}
|
||||
fieldUrl={HUGINN_ART.field}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const app = document.querySelector<HTMLDivElement>("#app");
|
||||
if (!app) {
|
||||
throw new Error("Missing inspector app root.");
|
||||
}
|
||||
|
||||
class RenderCrashBoundary extends Component<{ children: ReactNode }, { error: string | null }> {
|
||||
state = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: unknown): { error: string } {
|
||||
return { error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<main className="workspace">
|
||||
<section className="sidebar">
|
||||
<div className="brand">
|
||||
<img src={assetPath("hugin-64.png")} alt="" width="64" height="64" />
|
||||
<div>
|
||||
<h1>Huginn</h1>
|
||||
<p>CultCache state inspection for <code>.cc</code> files.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="content">
|
||||
<div className="error fatal-error">
|
||||
<strong>Renderer error</strong>
|
||||
<span>{this.state.error}</span>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
createRoot(app).render(
|
||||
<RenderCrashBoundary>
|
||||
<HuginnApp />
|
||||
</RenderCrashBoundary>,
|
||||
);
|
||||
|
||||
function HuginnApp() {
|
||||
const [inspection, setInspection] = useState<CultCacheInspection | undefined>();
|
||||
const [selection, setSelection] = useState<Selection>({ record: 0 });
|
||||
const [graphSelection, setGraphSelection] = useState<ViewerSelection | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
const graphProjection = useMemo(
|
||||
() => inspection ? buildGraphProjection(inspection) : emptyGraphProjection(),
|
||||
[inspection],
|
||||
);
|
||||
|
||||
async function inspectFile(file: File): Promise<void> {
|
||||
try {
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
const nextInspection = inspectCultCacheBytes(file.name, bytes, file.size);
|
||||
setInspection(nextInspection);
|
||||
setSelection({ record: 0 });
|
||||
setGraphSelection({
|
||||
kind: "node",
|
||||
graphKey: "dataflow",
|
||||
nodeId: nextInspection.records[0]
|
||||
? recordNodeId(nextInspection.records[0], 0)
|
||||
: "store",
|
||||
});
|
||||
setErrorMessage("");
|
||||
} catch (error) {
|
||||
setInspection(undefined);
|
||||
setErrorMessage(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
function handleDrop(event: DragEvent<HTMLElement>): void {
|
||||
event.preventDefault();
|
||||
setDragging(false);
|
||||
const file = event.dataTransfer.files.item(0);
|
||||
if (file) {
|
||||
void inspectFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main
|
||||
className={`workspace ${dragging ? "is-dragging" : ""}`}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragLeave={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
setDragging(false);
|
||||
}
|
||||
}}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{inspection
|
||||
? (
|
||||
<InspectionView
|
||||
errorMessage={errorMessage}
|
||||
inspection={inspection}
|
||||
graphProjection={graphProjection}
|
||||
graphSelection={graphSelection}
|
||||
setGraphSelection={setGraphSelection}
|
||||
selection={selection}
|
||||
setSelection={setSelection}
|
||||
onInspectFile={inspectFile}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<EmptyWorkspace
|
||||
errorMessage={errorMessage}
|
||||
onInspectFile={inspectFile}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyWorkspace({
|
||||
errorMessage,
|
||||
onInspectFile,
|
||||
}: {
|
||||
errorMessage: string;
|
||||
onInspectFile: (file: File) => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<section className="empty-workspace">
|
||||
<HuginnField />
|
||||
<section className="floating-panel brand-panel is-primary">
|
||||
<BrandBlock />
|
||||
<FilePicker onInspectFile={onInspectFile} />
|
||||
{errorMessage ? <div className="error">{errorMessage}</div> : null}
|
||||
<EmptySummary />
|
||||
</section>
|
||||
<NoFile />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function BrandBlock() {
|
||||
return (
|
||||
<div className="brand">
|
||||
<img src={assetPath("hugin-64.png")} alt="" width="64" height="64" />
|
||||
<div>
|
||||
<h1>Huginn</h1>
|
||||
<p>CultCache state inspection for <code>.cc</code> files.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilePicker({ onInspectFile }: { onInspectFile: (file: File) => Promise<void> }) {
|
||||
return (
|
||||
<label className="dropzone">
|
||||
<input
|
||||
type="file"
|
||||
accept=".cc,.msgpack,.mpack,application/octet-stream"
|
||||
onChange={(event) => {
|
||||
const file = event.currentTarget.files?.item(0);
|
||||
if (file) {
|
||||
void onInspectFile(file);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="drop-title">Drop CultCache file</span>
|
||||
<span className="drop-copy">or choose one from disk</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function Summary({ inspection }: { inspection: CultCacheInspection }) {
|
||||
return (
|
||||
<div className="summary">
|
||||
<Metric label="Format" value={inspection.format} />
|
||||
<Metric label="Records" value={inspection.records.length.toString()} />
|
||||
<Metric label="Schemas" value={inspection.catalog.length.toString()} />
|
||||
<Metric label="Bytes" value={inspection.fileSizeBytes.toString()} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptySummary() {
|
||||
return (
|
||||
<div className="summary">
|
||||
<Metric label="Format" value=".cc" />
|
||||
<Metric label="Mode" value="Read-only" />
|
||||
<Metric label="Payload" value="MessagePack" />
|
||||
<Metric label="Runtime" value="Vite" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="metric">
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NoFile() {
|
||||
return (
|
||||
<div className="hero">
|
||||
<img src={HUGINN_ART.surface} alt="" />
|
||||
<h2>No state loaded</h2>
|
||||
<p>Drag a CultCache <code>.cc</code> store into the window to inspect the snapshot header, schema catalog, records, and decoded MessagePack payloads.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InspectionView({
|
||||
errorMessage,
|
||||
inspection,
|
||||
graphProjection,
|
||||
graphSelection,
|
||||
setGraphSelection,
|
||||
selection,
|
||||
setSelection,
|
||||
onInspectFile,
|
||||
}: {
|
||||
errorMessage: string;
|
||||
inspection: CultCacheInspection;
|
||||
graphProjection: GraphProjection;
|
||||
graphSelection: ViewerSelection | null;
|
||||
setGraphSelection: (selection: ViewerSelection | null) => void;
|
||||
selection: Selection;
|
||||
setSelection: (selection: Selection) => void;
|
||||
onInspectFile: (file: File) => Promise<void>;
|
||||
}) {
|
||||
const selectRaw = (next: Extract<RawSelection, { kind: "record" }>) => {
|
||||
setSelection({ record: next.index });
|
||||
setGraphSelection({
|
||||
kind: "node",
|
||||
graphKey: "dataflow",
|
||||
nodeId: graphProjection.recordNodeIds[next.index],
|
||||
});
|
||||
};
|
||||
const selectGraph = (next: ViewerSelection | null) => {
|
||||
setGraphSelection(next);
|
||||
if (!next) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rawSelection = next.kind === "node"
|
||||
? graphProjection.nodeSelections.get(next.nodeId)
|
||||
: graphProjection.edgeSelections.get(next.edgeId);
|
||||
if (!rawSelection) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (rawSelection.kind === "record") {
|
||||
setSelection({ record: rawSelection.index });
|
||||
return;
|
||||
}
|
||||
if (rawSelection.kind === "value") {
|
||||
setSelection({ record: rawSelection.recordIndex });
|
||||
return;
|
||||
}
|
||||
};
|
||||
const nodeArticle: NodeArticle = {
|
||||
className: "huginn-expanded-node",
|
||||
ariaLabel: (node) => `${node.title} CultCache detail`,
|
||||
content: (node) => {
|
||||
const rawSelection = graphProjection.nodeSelections.get(node.id);
|
||||
const record = rawSelection?.kind === "record"
|
||||
? inspection.records[rawSelection.index]
|
||||
: rawSelection?.kind === "value"
|
||||
? inspection.records[rawSelection.recordIndex]
|
||||
: undefined;
|
||||
const expandedValue = rawSelection?.kind === "value"
|
||||
? {
|
||||
path: rawSelection.path,
|
||||
value: rawSelection.value,
|
||||
}
|
||||
: undefined;
|
||||
const catalogEntry = record
|
||||
? inspection.catalog.find((entry) => entry.schemaId === record.schemaId)
|
||||
: undefined;
|
||||
return (
|
||||
<ExpandedNodePanel
|
||||
catalogEntry={catalogEntry}
|
||||
expandedValue={expandedValue}
|
||||
inspection={inspection}
|
||||
record={record}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="inspector-stage">
|
||||
<section className="graph-panel">
|
||||
<NornViewer
|
||||
className="huginn-graph-shell"
|
||||
state={graphProjection.state}
|
||||
initialGraph="dataflow"
|
||||
selection={graphSelection}
|
||||
onSelectionChange={selectGraph}
|
||||
title="CultCache Structure"
|
||||
viewportBackdrop={<HuginnField />}
|
||||
viewportBackground="#03070a"
|
||||
layoutMode={{ architecture: "force", dataflow: "force" }}
|
||||
motion={{
|
||||
strength: 1.24,
|
||||
damping: 0.88,
|
||||
flow: 1.2,
|
||||
orbit: 0.84,
|
||||
lift: 0.9,
|
||||
pulse: 1,
|
||||
emitNodeEnvelopes: true,
|
||||
}}
|
||||
overlayPanels
|
||||
showSidebar={false}
|
||||
nodeArticle={nodeArticle}
|
||||
graphLabels={{ architecture: "File", dataflow: "Payload Cloud" }}
|
||||
style={{ minHeight: "100vh" }}
|
||||
/>
|
||||
{graphProjection.collapsedPayloadValues > 0 ? (
|
||||
<div className="graph-warning">
|
||||
Payload cloud collapsed {graphProjection.collapsedPayloadValues} leaf/deep value(s) into article detail. The graph shows records and inspectable regions, not every scalar slot.
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
<div className="overlay-layer">
|
||||
<section className="floating-panel brand-panel">
|
||||
<BrandBlock />
|
||||
<FilePicker onInspectFile={onInspectFile} />
|
||||
{errorMessage ? <div className="error">{errorMessage}</div> : null}
|
||||
<Summary inspection={inspection} />
|
||||
</section>
|
||||
<section className="floating-panel data-panel records-panel">
|
||||
<PanelHeader title="Records" count={inspection.records.length.toString()} />
|
||||
<div className="list">
|
||||
{inspection.records.length
|
||||
? inspection.records.map((entry, index) => (
|
||||
<RecordButton
|
||||
key={`${entry.schemaId}:${entry.key}:${index}`}
|
||||
record={entry}
|
||||
index={index}
|
||||
selected={index === selection.record}
|
||||
onSelect={() => selectRaw({ kind: "record", index })}
|
||||
/>
|
||||
))
|
||||
: <div className="empty">No records</div>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelHeader({ title, count }: { title: string; count: string }) {
|
||||
return (
|
||||
<header>
|
||||
<h2>{title}</h2>
|
||||
<span>{count}</span>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function ExpandedNodePanel({
|
||||
catalogEntry,
|
||||
expandedValue,
|
||||
inspection,
|
||||
record,
|
||||
}: {
|
||||
catalogEntry: InspectedCatalogEntry | undefined;
|
||||
expandedValue: { path: string; value: unknown } | undefined;
|
||||
inspection: CultCacheInspection;
|
||||
record: InspectedRecord | undefined;
|
||||
}) {
|
||||
const title = expandedValue?.path ?? record?.key ?? catalogEntry?.schemaName ?? inspection.filePath;
|
||||
const payloadValue = expandedValue?.value ?? record?.payloadPreview;
|
||||
return (
|
||||
<article className="expanded-content">
|
||||
<div className="expanded-kicker">{expandedValue ? "Payload Value" : "CultCache Node"}</div>
|
||||
<header className="expanded-header">
|
||||
<h1>{title}</h1>
|
||||
<div className="expanded-chips">
|
||||
<span>{inspection.format}</span>
|
||||
{record ? <span>{record.schemaName}</span> : <span>{inspection.records.length} records</span>}
|
||||
{expandedValue
|
||||
? <span>{valueStatus(expandedValue.value)}</span>
|
||||
: record ? <span>{record.payloadBytes} bytes</span> : <span>{inspection.catalog.length} schemas</span>}
|
||||
</div>
|
||||
</header>
|
||||
<section className="expanded-summary">
|
||||
{expandedValue && record ? (
|
||||
<p>{expandedValue.path} inside {record.key}.</p>
|
||||
) : record ? (
|
||||
<>
|
||||
<p>{record.schemaName} record stored at {record.storedAt}.</p>
|
||||
{record.payloadDecodeError ? <div className="error">{record.payloadDecodeError}</div> : null}
|
||||
</>
|
||||
) : (
|
||||
<p>{inspection.filePath} contains a schema catalog and persisted MessagePack record set.</p>
|
||||
)}
|
||||
</section>
|
||||
{payloadValue !== undefined ? (
|
||||
<section className="expanded-payload">
|
||||
<h2>Payload</h2>
|
||||
<PayloadCode value={payloadValue} />
|
||||
</section>
|
||||
) : null}
|
||||
<section className="expanded-article">
|
||||
<div>
|
||||
<h2>Record Metadata</h2>
|
||||
{record ? <RecordFacts record={record} /> : <div className="empty">Select a record node to inspect payload data.</div>}
|
||||
</div>
|
||||
<div>
|
||||
<h2>Schema Entry</h2>
|
||||
{catalogEntry ? <CatalogDetail entry={catalogEntry} /> : <div className="empty">Select a schema node to inspect catalog data.</div>}
|
||||
</div>
|
||||
</section>
|
||||
<footer className="expanded-footer">
|
||||
Record {record ? inspection.records.indexOf(record) + 1 : "-"} of {inspection.records.length}
|
||||
</footer>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function RecordButton({
|
||||
record,
|
||||
index,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
record: InspectedRecord;
|
||||
index: number;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button className="row" type="button" data-record={index} aria-selected={selected} onClick={onSelect}>
|
||||
<strong>{record.key}</strong>
|
||||
<span>{record.schemaName} - {record.payloadBytes} bytes</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function RecordFacts({ record }: { record: InspectedRecord }) {
|
||||
return (
|
||||
<Facts rows={[
|
||||
["Key", record.key],
|
||||
["Schema", record.schemaName],
|
||||
["Schema ID", record.schemaId],
|
||||
["Stored", record.storedAt],
|
||||
["Payload", `${record.payloadBytes} bytes`],
|
||||
]} />
|
||||
);
|
||||
}
|
||||
|
||||
function PayloadCode({ value }: { value: unknown }) {
|
||||
return (
|
||||
<pre className="payload-code">{JSON.stringify(value, null, 2)}</pre>
|
||||
);
|
||||
}
|
||||
|
||||
function CatalogDetail({ entry }: { entry: InspectedCatalogEntry }) {
|
||||
return (
|
||||
<>
|
||||
<Facts rows={[
|
||||
["Schema", entry.schemaName],
|
||||
["Version", entry.schemaVersion],
|
||||
["Schema ID", entry.schemaId],
|
||||
["Hash", entry.contentHash],
|
||||
["Compatible", entry.compatibleSchemaIds.join(", ")],
|
||||
]} />
|
||||
<pre>{JSON.stringify({
|
||||
members: entry.members,
|
||||
canonicalSchemaJson: parseJson(entry.canonicalSchemaJson),
|
||||
}, null, 2)}</pre>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Facts({ rows }: { rows: Array<[string, string]> }) {
|
||||
return (
|
||||
<dl>
|
||||
{rows.map(([name, value]) => (
|
||||
<div className="fact-row" key={name}>
|
||||
<dt>{name}</dt>
|
||||
<dd>{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
function buildGraphProjection(inspection: CultCacheInspection): GraphProjection {
|
||||
const dataflowNodes: NornGraphNode[] = [];
|
||||
const dataflowEdges: NornGraphEdge[] = [];
|
||||
const recordNodeIds: string[] = [];
|
||||
const nodeSelections = new Map<string, RawSelection>();
|
||||
const edgeSelections = new Map<string, RawSelection>();
|
||||
let collapsedPayloadValues = 0;
|
||||
const architectureNodes: NornGraphNode[] = [
|
||||
{
|
||||
id: "store",
|
||||
title: ".cc Store",
|
||||
purpose: `${inspection.format} file with ${inspection.records.length} record(s) and ${inspection.catalog.length} schema catalog entr${inspection.catalog.length === 1 ? "y" : "ies"}.`,
|
||||
mechanism: inspection.filePath,
|
||||
status: "source",
|
||||
},
|
||||
{
|
||||
id: "catalog",
|
||||
title: "Schema Catalog",
|
||||
purpose: "Maps persisted schema ids to schema names, versions, compatible ids, and slot metadata.",
|
||||
status: `${inspection.catalog.length} schemas`,
|
||||
},
|
||||
{
|
||||
id: "records",
|
||||
title: "Records",
|
||||
purpose: "Persisted document entries carrying key, schema id, timestamp, and raw MessagePack payload bytes.",
|
||||
status: `${inspection.records.length} records`,
|
||||
},
|
||||
];
|
||||
const architectureEdges: NornGraphEdge[] = [
|
||||
{
|
||||
id: "store-catalog",
|
||||
source_id: "store",
|
||||
target_id: "catalog",
|
||||
kind: "contains",
|
||||
label: "schema catalog",
|
||||
},
|
||||
{
|
||||
id: "store-records",
|
||||
source_id: "store",
|
||||
target_id: "records",
|
||||
kind: "contains",
|
||||
label: "record set",
|
||||
},
|
||||
];
|
||||
|
||||
dataflowNodes.push({
|
||||
id: "store",
|
||||
title: ".cc Store",
|
||||
purpose: `Payload regions for ${inspection.format} snapshot.`,
|
||||
mechanism: inspection.filePath,
|
||||
status: `${inspection.fileSizeBytes} bytes`,
|
||||
});
|
||||
inspection.records.forEach((record, recordIndex) => {
|
||||
const currentRecordNodeId = recordNodeId(record, recordIndex);
|
||||
const recordSelection: RawSelection = { kind: "record", index: recordIndex };
|
||||
const perRecordBudget = {
|
||||
remainingRegionNodes: Math.min(
|
||||
MAX_RECORD_REGION_NODES,
|
||||
Math.floor(MAX_PAYLOAD_REGION_NODES / Math.max(1, inspection.records.length)),
|
||||
),
|
||||
collapsedPayloadValues: 0,
|
||||
};
|
||||
recordNodeIds[recordIndex] = currentRecordNodeId;
|
||||
nodeSelections.set(currentRecordNodeId, recordSelection);
|
||||
dataflowNodes.push({
|
||||
id: currentRecordNodeId,
|
||||
title: record.key,
|
||||
purpose: valuePurpose(record.payloadPreview),
|
||||
mechanism: record.schemaName,
|
||||
status: `${record.payloadBytes} bytes`,
|
||||
});
|
||||
const recordEdgeId = edgeId("store", currentRecordNodeId, "contains");
|
||||
edgeSelections.set(recordEdgeId, recordSelection);
|
||||
dataflowEdges.push({
|
||||
id: recordEdgeId,
|
||||
source_id: "store",
|
||||
target_id: currentRecordNodeId,
|
||||
kind: "contains",
|
||||
label: "record",
|
||||
});
|
||||
appendPayloadRegions({
|
||||
nodes: dataflowNodes,
|
||||
edges: dataflowEdges,
|
||||
nodeSelections,
|
||||
edgeSelections,
|
||||
recordIndex,
|
||||
parentId: currentRecordNodeId,
|
||||
value: record.payloadPreview,
|
||||
path: "payload",
|
||||
depth: 0,
|
||||
budget: perRecordBudget,
|
||||
});
|
||||
collapsedPayloadValues += perRecordBudget.collapsedPayloadValues;
|
||||
});
|
||||
|
||||
return {
|
||||
state: {
|
||||
architecture: { nodes: architectureNodes, edges: architectureEdges },
|
||||
dataflow: { nodes: dataflowNodes, edges: dataflowEdges },
|
||||
links: [],
|
||||
},
|
||||
recordNodeIds,
|
||||
nodeSelections,
|
||||
edgeSelections,
|
||||
collapsedPayloadValues,
|
||||
};
|
||||
}
|
||||
|
||||
function appendPayloadRegions({
|
||||
nodes,
|
||||
edges,
|
||||
nodeSelections,
|
||||
edgeSelections,
|
||||
recordIndex,
|
||||
parentId,
|
||||
value,
|
||||
path,
|
||||
depth,
|
||||
budget,
|
||||
}: {
|
||||
nodes: NornGraphNode[];
|
||||
edges: NornGraphEdge[];
|
||||
nodeSelections: Map<string, RawSelection>;
|
||||
edgeSelections: Map<string, RawSelection>;
|
||||
recordIndex: number;
|
||||
parentId: string;
|
||||
value: unknown;
|
||||
path: string;
|
||||
depth: number;
|
||||
budget: { remainingRegionNodes: number; collapsedPayloadValues: number };
|
||||
}): void {
|
||||
if (depth >= MAX_PAYLOAD_REGION_DEPTH) {
|
||||
budget.collapsedPayloadValues += countPayloadDescendants(value);
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = payloadRegionEntries(value, path);
|
||||
if (!entries.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
let emittedChildren = 0;
|
||||
const childLimit = depth === 0 ? MAX_PAYLOAD_REGION_CHILDREN : MAX_NESTED_PAYLOAD_REGION_CHILDREN;
|
||||
for (const entry of entries) {
|
||||
if (!isGraphworthyRegion(entry.value, depth)) {
|
||||
budget.collapsedPayloadValues += 1 + countPayloadDescendants(entry.value);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (budget.remainingRegionNodes <= 0 || emittedChildren >= childLimit) {
|
||||
budget.collapsedPayloadValues += 1 + countPayloadDescendants(entry.value);
|
||||
continue;
|
||||
}
|
||||
|
||||
budget.remainingRegionNodes -= 1;
|
||||
emittedChildren += 1;
|
||||
const childId = nodeId("region", `${parentId}:${entry.path}`);
|
||||
const rawSelection: RawSelection = { kind: "value", recordIndex, path: entry.path, value: entry.value };
|
||||
nodeSelections.set(childId, rawSelection);
|
||||
nodes.push(regionNode(childId, entry.label, entry.value, entry.path));
|
||||
const edge = valueEdge(parentId, childId, entry.kind, entry.label);
|
||||
edgeSelections.set(edge.id!, rawSelection);
|
||||
edges.push(edge);
|
||||
appendPayloadRegions({
|
||||
nodes,
|
||||
edges,
|
||||
nodeSelections,
|
||||
edgeSelections,
|
||||
recordIndex,
|
||||
parentId: childId,
|
||||
value: entry.value,
|
||||
path: entry.path,
|
||||
depth: depth + 1,
|
||||
budget,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function payloadRegionEntries(
|
||||
value: unknown,
|
||||
path: string,
|
||||
): Array<{ label: string; value: unknown; path: string; kind: "field" | "slot" }> {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item, index) => ({
|
||||
label: `[${index}]`,
|
||||
value: item,
|
||||
path: `${path}[${index}]`,
|
||||
kind: "slot",
|
||||
}));
|
||||
}
|
||||
if (isPlainObject(value)) {
|
||||
return Object.entries(value).map(([key, item]) => ({
|
||||
label: key,
|
||||
value: item,
|
||||
path: `${path}.${key}`,
|
||||
kind: "field",
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function isGraphworthyRegion(value: unknown, depth: number): boolean {
|
||||
if (!Array.isArray(value) && !isPlainObject(value)) {
|
||||
return false;
|
||||
}
|
||||
const directChildren = payloadRegionEntries(value, "payload").length;
|
||||
if (directChildren >= (depth === 0 ? 2 : 4)) {
|
||||
return true;
|
||||
}
|
||||
return countPayloadDescendants(value, 64) >= 10;
|
||||
}
|
||||
|
||||
function countPayloadDescendants(value: unknown, limit = 20_000): number {
|
||||
let count = 0;
|
||||
const stack: unknown[] = [value];
|
||||
while (stack.length && count < limit) {
|
||||
const current = stack.pop();
|
||||
const children = Array.isArray(current)
|
||||
? current
|
||||
: isPlainObject(current)
|
||||
? Object.values(current)
|
||||
: [];
|
||||
for (const child of children) {
|
||||
count += 1;
|
||||
if (Array.isArray(child) || isPlainObject(child)) {
|
||||
stack.push(child);
|
||||
}
|
||||
if (count >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function regionNode(id: string, title: string, value: unknown, path: string): NornGraphNode {
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
purpose: valuePurpose(value),
|
||||
mechanism: path,
|
||||
status: `${valueStatus(value)} / ${countPayloadDescendants(value)} values`,
|
||||
};
|
||||
}
|
||||
|
||||
function recordNodeId(record: InspectedRecord, index: number): string {
|
||||
return nodeId("record", `${record.schemaId}:${record.key}:${index}`);
|
||||
}
|
||||
|
||||
function valueEdge(sourceId: string, targetId: string, kind: string, label: string): NornGraphEdge {
|
||||
return {
|
||||
id: edgeId(sourceId, targetId, kind),
|
||||
source_id: sourceId,
|
||||
target_id: targetId,
|
||||
kind,
|
||||
label,
|
||||
};
|
||||
}
|
||||
|
||||
function valuePurpose(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return `Array with ${value.length} item(s).`;
|
||||
}
|
||||
if (isPlainObject(value)) {
|
||||
return `Object with ${Object.keys(value).length} field(s).`;
|
||||
}
|
||||
if (value === null) {
|
||||
return "Null payload value.";
|
||||
}
|
||||
return JSON.stringify(value) ?? String(value);
|
||||
}
|
||||
|
||||
function valueStatus(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return "array";
|
||||
}
|
||||
if (isPlainObject(value)) {
|
||||
return "object";
|
||||
}
|
||||
return typeof value;
|
||||
}
|
||||
|
||||
function emptyGraphProjection(): GraphProjection {
|
||||
return {
|
||||
state: {
|
||||
architecture: { nodes: [], edges: [] },
|
||||
dataflow: { nodes: [], edges: [] },
|
||||
links: [],
|
||||
},
|
||||
recordNodeIds: [],
|
||||
nodeSelections: new Map(),
|
||||
edgeSelections: new Map(),
|
||||
collapsedPayloadValues: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function parseJson(value: string): unknown {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function nodeId(prefix: string, value: string): string {
|
||||
return `${prefix}:${value.replace(/[^a-zA-Z0-9:_-]/g, "_")}`;
|
||||
}
|
||||
|
||||
function edgeId(sourceId: string, targetId: string, kind: string): string {
|
||||
return `${sourceId}->${targetId}:${kind}`;
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
726
packages/cultcache-ts/inspector/src/styles.css
Normal file
726
packages/cultcache-ts/inspector/src/styles.css
Normal file
|
|
@ -0,0 +1,726 @@
|
|||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: #dce9ee;
|
||||
background: #05090d;
|
||||
--bg: #05090d;
|
||||
--panel: #0b131b;
|
||||
--panel-strong: #101c27;
|
||||
--ink: #dce9ee;
|
||||
--muted: #8296a3;
|
||||
--line: #1e3444;
|
||||
--line-bright: #1e7fa4;
|
||||
--cyan: #33e6f3;
|
||||
--cyan-soft: rgba(51, 230, 243, 0.16);
|
||||
--cyan-glow: rgba(51, 230, 243, 0.36);
|
||||
--brass: #d49a3d;
|
||||
--danger: #ff897d;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 760px;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: "Cascadia Code", "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
position: relative;
|
||||
display: block;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at 78% 18%, rgba(39, 202, 239, 0.12), transparent 30%),
|
||||
linear-gradient(135deg, #05090d 0%, #07111a 48%, #030507 100%);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
border-right: 1px solid var(--line);
|
||||
background: linear-gradient(180deg, rgba(14, 27, 38, 0.98), rgba(5, 9, 13, 0.98));
|
||||
padding: 22px;
|
||||
box-shadow: 8px 0 36px rgba(0, 0, 0, 0.32);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: grid;
|
||||
grid-template-columns: 64px minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.brand img {
|
||||
border-radius: 8px;
|
||||
image-rendering: pixelated;
|
||||
filter: drop-shadow(0 0 14px var(--cyan-glow));
|
||||
}
|
||||
|
||||
.brand h1 {
|
||||
margin: 0;
|
||||
color: #f0fbff;
|
||||
font-size: 30px;
|
||||
line-height: 1.05;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.brand p {
|
||||
margin: 7px 0 18px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.dropzone {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
place-items: center;
|
||||
min-height: 132px;
|
||||
border: 1px dashed #276982;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(180deg, rgba(13, 38, 50, 0.72), rgba(6, 15, 22, 0.88));
|
||||
color: var(--cyan);
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
box-shadow: inset 0 0 18px rgba(51, 230, 243, 0.06);
|
||||
}
|
||||
|
||||
.is-dragging .dropzone {
|
||||
border-color: var(--cyan);
|
||||
background: rgba(51, 230, 243, 0.12);
|
||||
box-shadow: 0 0 22px var(--cyan-glow), inset 0 0 22px rgba(51, 230, 243, 0.1);
|
||||
}
|
||||
|
||||
.dropzone input {
|
||||
position: absolute;
|
||||
inline-size: 1px;
|
||||
block-size: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.drop-title {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.drop-copy {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: rgba(12, 23, 32, 0.92);
|
||||
padding: 11px;
|
||||
}
|
||||
|
||||
.metric span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.metric strong {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 18px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.empty-workspace,
|
||||
.inspector-stage {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
background: #03070a;
|
||||
}
|
||||
|
||||
.empty-workspace {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: clamp(18px, 4vw, 56px);
|
||||
}
|
||||
|
||||
.empty-workspace .huginn-field-canvas {
|
||||
opacity: 0.46;
|
||||
}
|
||||
|
||||
.overlay-layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 12;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.graph-panel {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
overflow: hidden;
|
||||
background: #03070a;
|
||||
}
|
||||
|
||||
.graph-panel > section {
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
min-height: 100vh !important;
|
||||
}
|
||||
|
||||
.huginn-field-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
image-rendering: auto;
|
||||
opacity: 0.96;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.norn-overlay-panel {
|
||||
background: rgba(4, 11, 16, 0.72) !important;
|
||||
border-color: rgba(51, 230, 243, 0.22) !important;
|
||||
box-shadow: 0 0 28px rgba(51, 230, 243, 0.08), 0 18px 48px rgba(0, 0, 0, 0.28) !important;
|
||||
}
|
||||
|
||||
.huginn-graph-shell {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.huginn-graph-shell svg {
|
||||
filter: drop-shadow(0 0 12px rgba(51, 230, 243, 0.28));
|
||||
}
|
||||
|
||||
.huginn-graph-shell svg path[stroke]:not([stroke="transparent"]) {
|
||||
opacity: 1;
|
||||
stroke-width: 2.4px;
|
||||
}
|
||||
|
||||
.huginn-graph-shell svg rect[filter] {
|
||||
fill-opacity: 0.94;
|
||||
stroke-opacity: 1;
|
||||
}
|
||||
|
||||
.huginn-graph-shell svg text {
|
||||
fill-opacity: 1;
|
||||
paint-order: stroke;
|
||||
stroke: rgba(1, 6, 10, 0.72);
|
||||
stroke-width: 2px;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.huginn-graph-shell .norn-overlay-panel,
|
||||
.floating-panel {
|
||||
opacity: 0.28;
|
||||
transition:
|
||||
opacity 160ms ease,
|
||||
transform 160ms ease,
|
||||
box-shadow 160ms ease,
|
||||
background-color 160ms ease;
|
||||
}
|
||||
|
||||
.huginn-graph-shell .norn-overlay-panel:hover,
|
||||
.huginn-graph-shell .norn-overlay-panel:focus-within,
|
||||
.floating-panel:hover,
|
||||
.floating-panel:focus-within,
|
||||
.floating-panel.is-primary {
|
||||
opacity: 1;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.floating-panel {
|
||||
position: absolute;
|
||||
pointer-events: auto;
|
||||
container-type: inline-size;
|
||||
border: 1px solid rgba(51, 230, 243, 0.22);
|
||||
border-radius: 24px;
|
||||
background: rgba(4, 11, 16, 0.72);
|
||||
padding: clamp(10px, 1.4vw, 18px);
|
||||
box-shadow: 0 20px 54px rgba(0, 0, 0, 0.34), 0 0 28px rgba(51, 230, 243, 0.08);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.brand-panel {
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
width: min(330px, calc(100vw - 32px));
|
||||
transform: translateX(-242px);
|
||||
}
|
||||
|
||||
.brand-panel:hover,
|
||||
.brand-panel:focus-within,
|
||||
.brand-panel.is-primary {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.records-panel {
|
||||
left: 16px;
|
||||
bottom: 16px;
|
||||
width: min(360px, calc(50vw - 24px));
|
||||
max-height: min(44vh, 420px);
|
||||
transform: translateY(calc(100% - 48px));
|
||||
}
|
||||
|
||||
.records-panel:hover,
|
||||
.records-panel:focus-within {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
|
||||
.graph-warning {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 18px;
|
||||
z-index: 14;
|
||||
max-width: min(720px, calc(100vw - 36px));
|
||||
transform: translateX(-50%);
|
||||
border-top: 1px solid rgba(212, 154, 61, 0.3);
|
||||
border: 1px solid rgba(212, 154, 61, 0.3);
|
||||
border-radius: 18px;
|
||||
background: rgba(72, 45, 12, 0.34);
|
||||
color: #ffd38a;
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
align-content: center;
|
||||
justify-items: center;
|
||||
min-height: calc(100vh - 36px);
|
||||
max-width: 680px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero img {
|
||||
width: 168px;
|
||||
height: 168px;
|
||||
margin-bottom: 18px;
|
||||
image-rendering: auto;
|
||||
filter: drop-shadow(0 0 28px var(--cyan-glow));
|
||||
}
|
||||
|
||||
.hero h2 {
|
||||
margin: 0;
|
||||
color: #f0fbff;
|
||||
font-size: 42px;
|
||||
line-height: 1;
|
||||
text-shadow: 0 0 18px rgba(3, 7, 10, 0.92), 0 0 34px rgba(51, 230, 243, 0.28);
|
||||
}
|
||||
|
||||
.hero p {
|
||||
margin: 14px 0 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.55;
|
||||
text-shadow: 0 0 14px rgba(3, 7, 10, 0.96);
|
||||
}
|
||||
|
||||
.panel {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: rgba(8, 16, 23, 0.94);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 18px 38px rgba(0, 0, 0, 0.24);
|
||||
}
|
||||
|
||||
.panel header,
|
||||
.floating-panel header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 11px 12px;
|
||||
background: rgba(16, 28, 39, 0.92);
|
||||
}
|
||||
|
||||
.panel h2,
|
||||
.floating-panel h2 {
|
||||
margin: 0;
|
||||
color: #f0fbff;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.panel header span,
|
||||
.floating-panel header span {
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-height: inherit;
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.row {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: var(--panel-strong);
|
||||
color: inherit;
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.row[aria-selected="true"] {
|
||||
border-color: var(--cyan);
|
||||
box-shadow: inset 3px 0 0 var(--cyan), 0 0 18px rgba(51, 230, 243, 0.12);
|
||||
}
|
||||
|
||||
.row strong,
|
||||
.row span {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.row span {
|
||||
margin-top: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: 120px minmax(0, 1fr);
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
dt {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
pre {
|
||||
max-height: min(440px, 42vh);
|
||||
overflow: auto;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #142b39;
|
||||
background: #03080c;
|
||||
color: #dffbff;
|
||||
padding: 12px;
|
||||
font-family: "Cascadia Code", "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.error {
|
||||
border: 1px solid rgba(255, 137, 125, 0.42);
|
||||
border-radius: 6px;
|
||||
background: rgba(94, 27, 24, 0.28);
|
||||
color: var(--danger);
|
||||
padding: 10px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.fatal-error {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-width: 720px;
|
||||
margin: 32px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 18px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.huginn-expanded-node {
|
||||
scrollbar-color: rgba(51, 230, 243, 0.58) rgba(3, 7, 10, 0.32);
|
||||
transition:
|
||||
border-radius 180ms ease,
|
||||
box-shadow 180ms ease,
|
||||
padding 180ms ease;
|
||||
}
|
||||
|
||||
.expanded-content {
|
||||
display: grid;
|
||||
gap: clamp(10px, 1.4vw, 18px);
|
||||
min-height: 100%;
|
||||
padding: clamp(14px, 2.2vw, 30px);
|
||||
color: #e7fbff;
|
||||
}
|
||||
|
||||
.expanded-kicker {
|
||||
color: var(--cyan);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.expanded-header {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.expanded-header h1 {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: #f8fdff;
|
||||
font-size: clamp(1.45rem, 3.4vw, 3rem);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.expanded-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.expanded-chips span,
|
||||
.expanded-footer {
|
||||
border: 1px solid rgba(51, 230, 243, 0.22);
|
||||
border-radius: 999px;
|
||||
background: rgba(5, 18, 26, 0.68);
|
||||
color: #aeeef5;
|
||||
padding: 6px 9px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.expanded-summary {
|
||||
color: rgba(231, 251, 255, 0.78);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.expanded-payload {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.expanded-payload h2 {
|
||||
margin: 0;
|
||||
color: #f8fdff;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.expanded-article {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.expanded-article h2 {
|
||||
margin: 0 0 8px;
|
||||
color: #f8fdff;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.expanded-footer {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.huginn-expanded-node[data-node-stage="summary"] {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.huginn-expanded-node[data-node-stage="summary"] .expanded-content {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.huginn-expanded-node[data-node-stage="summary"] .expanded-kicker,
|
||||
.huginn-expanded-node[data-node-stage="summary"] .expanded-summary,
|
||||
.huginn-expanded-node[data-node-stage="summary"] .expanded-article,
|
||||
.huginn-expanded-node[data-node-stage="summary"] .expanded-footer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.huginn-expanded-node[data-node-stage="summary"] .expanded-header {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.huginn-expanded-node[data-node-stage="summary"] .expanded-header h1 {
|
||||
max-width: 100%;
|
||||
font-size: 1.02rem;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.huginn-expanded-node[data-node-stage="summary"] .expanded-chips {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.huginn-expanded-node[data-node-stage="summary"] .expanded-payload {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.huginn-expanded-node[data-node-stage="preview"] {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.huginn-expanded-node[data-node-stage="preview"] .expanded-content {
|
||||
align-content: center;
|
||||
justify-items: center;
|
||||
gap: 10px;
|
||||
padding: 18px 22px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.huginn-expanded-node[data-node-stage="preview"] .expanded-header {
|
||||
justify-items: center;
|
||||
max-width: min(640px, 100%);
|
||||
}
|
||||
|
||||
.huginn-expanded-node[data-node-stage="preview"] .expanded-header h1 {
|
||||
max-width: 100%;
|
||||
font-size: clamp(1.05rem, 2.1vw, 1.7rem);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.huginn-expanded-node[data-node-stage="preview"] .expanded-chips {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.huginn-expanded-node[data-node-stage="preview"] .expanded-summary,
|
||||
.huginn-expanded-node[data-node-stage="preview"] .expanded-article,
|
||||
.huginn-expanded-node[data-node-stage="preview"] .expanded-footer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.huginn-expanded-node[data-node-stage="preview"] .expanded-payload {
|
||||
width: min(680px, 100%);
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.huginn-expanded-node[data-node-stage="preview"] .expanded-payload h2 {
|
||||
color: var(--cyan);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.huginn-expanded-node[data-node-stage="preview"] .payload-code {
|
||||
width: 100%;
|
||||
max-height: min(280px, 44vh);
|
||||
margin: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.huginn-expanded-node[data-node-stage="preview"] dl {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.huginn-expanded-node[data-node-stage="article"] {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@container (max-width: 260px) {
|
||||
.brand p,
|
||||
.drop-copy,
|
||||
.metric span,
|
||||
.row span,
|
||||
.floating-panel header span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.brand {
|
||||
grid-template-columns: 42px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.brand img {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
.brand h1 {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.dropzone {
|
||||
min-height: 54px;
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 180px) {
|
||||
.summary,
|
||||
.dropzone,
|
||||
.list {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.floating-panel header {
|
||||
border-bottom: 0;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
body {
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
.brand-panel {
|
||||
width: min(300px, calc(100vw - 24px));
|
||||
transform: translateX(calc(-100% + 58px));
|
||||
}
|
||||
|
||||
.records-panel {
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
width: auto;
|
||||
max-height: 32vh;
|
||||
}
|
||||
|
||||
.records-panel {
|
||||
bottom: 12px;
|
||||
}
|
||||
|
||||
.expanded-article {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
4911
packages/cultcache-ts/package-lock.json
generated
Normal file
4911
packages/cultcache-ts/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
49
packages/cultcache-ts/package.json
Normal file
49
packages/cultcache-ts/package.json
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
{
|
||||
"name": "cultcache-ts",
|
||||
"version": "0.13.5",
|
||||
"description": "CultCache-style typed persistence for TypeScript with schema-backed polymorphic MessagePack storage.",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"build:inspector": "tsc -p tsconfig.inspector.json && vite build --config vite.inspector.config.ts",
|
||||
"dev:inspector": "concurrently -k \"vite --host 127.0.0.1 --config vite.inspector.config.ts\" \"wait-on http://127.0.0.1:5173 && cross-env CULTCACHE_INSPECTOR_DEV_SERVER=http://127.0.0.1:5173 electron electron/main.cjs\"",
|
||||
"dist:inspector": "npm run build:inspector && electron-builder --config electron-builder.json",
|
||||
"pack:inspector": "npm run build:inspector && electron-builder --config electron-builder.json --dir",
|
||||
"typecheck": "tsc -p tsconfig.json --pretty false",
|
||||
"test": "npm run build && tsc -p tsconfig.test.json && node --test dist-test/test/cult-cache.test.js"
|
||||
},
|
||||
"keywords": [
|
||||
"messagepack",
|
||||
"schema",
|
||||
"cache",
|
||||
"persistence",
|
||||
"zod",
|
||||
"typescript"
|
||||
],
|
||||
"author": "metacratic",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@msgpack/msgpack": "^3.1.3",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@gamecult/norn-viewer": "file:../Norn/web/norn-viewer",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "^42.1.0",
|
||||
"electron-builder": "^26.8.1",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"typescript": "^5.9.0",
|
||||
"vite": "^8.0.13",
|
||||
"wait-on": "^9.0.10"
|
||||
}
|
||||
}
|
||||
305
packages/cultcache-ts/src/cult-cache-inspector.ts
Normal file
305
packages/cultcache-ts/src/cult-cache-inspector.ts
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
import { decode } from "@msgpack/msgpack";
|
||||
|
||||
export interface CultCacheInspection {
|
||||
filePath: string;
|
||||
fileSizeBytes: number;
|
||||
format: "cultcache.store.v1" | "legacy.envelope-array";
|
||||
catalog: InspectedCatalogEntry[];
|
||||
records: InspectedRecord[];
|
||||
}
|
||||
|
||||
export interface InspectedCatalogEntry {
|
||||
schemaId: string;
|
||||
schemaName: string;
|
||||
schemaVersion: string;
|
||||
contentHash: string;
|
||||
canonicalSchemaJson: string;
|
||||
compatibleSchemaIds: string[];
|
||||
members: InspectedCatalogMember[];
|
||||
}
|
||||
|
||||
export interface InspectedCatalogMember {
|
||||
slot: number;
|
||||
memberName: string;
|
||||
typeName: string;
|
||||
isReference: boolean;
|
||||
isMany: boolean;
|
||||
targetSchemaName: string | null;
|
||||
isName: boolean;
|
||||
indexAlias: string | null;
|
||||
}
|
||||
|
||||
export interface InspectedRecord {
|
||||
key: string;
|
||||
schemaId: string;
|
||||
schemaName: string;
|
||||
storedAt: string;
|
||||
payloadBytes: number;
|
||||
payloadPreview: unknown;
|
||||
payloadDecodeError?: string;
|
||||
}
|
||||
|
||||
const STORE_FORMAT_VERSION = "cultcache.store.v1";
|
||||
|
||||
export function inspectCultCacheBytes(
|
||||
filePath: string,
|
||||
bytes: Uint8Array,
|
||||
fileSizeBytes = bytes.length,
|
||||
): CultCacheInspection {
|
||||
const decoded = decode(bytes);
|
||||
|
||||
if (isV1Snapshot(decoded)) {
|
||||
return inspectV1Snapshot(filePath, fileSizeBytes, decoded);
|
||||
}
|
||||
|
||||
if (Array.isArray(decoded)) {
|
||||
return inspectLegacyEnvelopeArray(filePath, fileSizeBytes, decoded);
|
||||
}
|
||||
|
||||
throw new Error("CultCache file is not a recognized v1 snapshot or legacy envelope array.");
|
||||
}
|
||||
|
||||
function inspectV1Snapshot(filePath: string, fileSizeBytes: number, decoded: unknown[]): CultCacheInspection {
|
||||
const catalogRaw = decoded[1];
|
||||
const recordsRaw = decoded[2];
|
||||
if (!Array.isArray(catalogRaw) || !Array.isArray(recordsRaw)) {
|
||||
throw new Error("CultCache v1 snapshot must contain a schema catalog and record array.");
|
||||
}
|
||||
|
||||
const catalog = catalogRaw.map(decodeCatalogEntry);
|
||||
const catalogBySchemaId = new Map<string, InspectedCatalogEntry>();
|
||||
for (const entry of catalog) {
|
||||
catalogBySchemaId.set(entry.schemaId, entry);
|
||||
for (const compatibleSchemaId of entry.compatibleSchemaIds) {
|
||||
catalogBySchemaId.set(compatibleSchemaId, entry);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
filePath,
|
||||
fileSizeBytes,
|
||||
format: STORE_FORMAT_VERSION,
|
||||
catalog,
|
||||
records: recordsRaw.map((record) => decodeV1Record(record, catalogBySchemaId)),
|
||||
};
|
||||
}
|
||||
|
||||
function inspectLegacyEnvelopeArray(filePath: string, fileSizeBytes: number, decoded: unknown[]): CultCacheInspection {
|
||||
const records = decoded.map((entry) => decodeLegacyEnvelope(entry));
|
||||
const schemaNames = new Set(records.map((record) => record.schemaName));
|
||||
const catalog = [...schemaNames].sort(compareOrdinal).map((schemaName) => ({
|
||||
schemaId: schemaName,
|
||||
schemaName,
|
||||
schemaVersion: `${schemaName}.v1`,
|
||||
contentHash: schemaName,
|
||||
canonicalSchemaJson: JSON.stringify({
|
||||
schemaName,
|
||||
schemaVersion: `${schemaName}.v1`,
|
||||
members: [],
|
||||
}),
|
||||
compatibleSchemaIds: [schemaName],
|
||||
members: [],
|
||||
}));
|
||||
|
||||
return {
|
||||
filePath,
|
||||
fileSizeBytes,
|
||||
format: "legacy.envelope-array",
|
||||
catalog,
|
||||
records,
|
||||
};
|
||||
}
|
||||
|
||||
function decodeCatalogEntry(value: unknown): InspectedCatalogEntry {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error("CultCache schema catalog entries must be MessagePack arrays.");
|
||||
}
|
||||
|
||||
const [
|
||||
schemaId = "",
|
||||
schemaName = "",
|
||||
schemaVersion = "",
|
||||
contentHash = "",
|
||||
canonicalSchemaJson = "",
|
||||
compatibleSchemaIds = [],
|
||||
members = [],
|
||||
] = value;
|
||||
|
||||
if (!isNonEmptyString(schemaId) || !isNonEmptyString(schemaName)) {
|
||||
throw new Error("CultCache schema catalog entries must declare schemaId and schemaName.");
|
||||
}
|
||||
|
||||
return {
|
||||
schemaId,
|
||||
schemaName,
|
||||
schemaVersion: isNonEmptyString(schemaVersion) ? schemaVersion : `${schemaName}.v1`,
|
||||
contentHash: isNonEmptyString(contentHash) ? contentHash : schemaId,
|
||||
canonicalSchemaJson: typeof canonicalSchemaJson === "string" ? canonicalSchemaJson : "",
|
||||
compatibleSchemaIds: Array.isArray(compatibleSchemaIds)
|
||||
? compatibleSchemaIds.filter(isNonEmptyString)
|
||||
: [],
|
||||
members: Array.isArray(members) ? members.map(decodeCatalogMember) : [],
|
||||
};
|
||||
}
|
||||
|
||||
function decodeCatalogMember(value: unknown): InspectedCatalogMember {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error("CultCache schema catalog members must be MessagePack arrays.");
|
||||
}
|
||||
|
||||
const [
|
||||
slot = -1,
|
||||
memberName = "",
|
||||
typeName = "",
|
||||
isReference = false,
|
||||
isMany = false,
|
||||
targetSchemaName = null,
|
||||
isName = false,
|
||||
indexAlias = null,
|
||||
] = value;
|
||||
|
||||
if (!Number.isInteger(slot) || !isNonEmptyString(memberName) || !isNonEmptyString(typeName)) {
|
||||
throw new Error("CultCache schema catalog member has invalid slot, name, or type.");
|
||||
}
|
||||
|
||||
return {
|
||||
slot,
|
||||
memberName,
|
||||
typeName,
|
||||
isReference: isReference === true,
|
||||
isMany: isMany === true,
|
||||
targetSchemaName: typeof targetSchemaName === "string" ? targetSchemaName : null,
|
||||
isName: isName === true,
|
||||
indexAlias: typeof indexAlias === "string" ? indexAlias : null,
|
||||
};
|
||||
}
|
||||
|
||||
function decodeV1Record(value: unknown, catalogBySchemaId: Map<string, InspectedCatalogEntry>): InspectedRecord {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error("CultCache persisted records must be MessagePack arrays.");
|
||||
}
|
||||
|
||||
const [key = "", schemaId = "", storedAt = "", payload = new Uint8Array()] = value;
|
||||
if (!isNonEmptyString(key) || !isNonEmptyString(schemaId) || !isNonEmptyString(storedAt)) {
|
||||
throw new Error("CultCache persisted records must declare key, schemaId, and storedAt.");
|
||||
}
|
||||
|
||||
const catalogEntry = catalogBySchemaId.get(schemaId);
|
||||
const payloadBytes = normalizePayloadBytes(payload);
|
||||
return {
|
||||
key,
|
||||
schemaId,
|
||||
schemaName: catalogEntry?.schemaName ?? "<missing catalog entry>",
|
||||
storedAt,
|
||||
payloadBytes: payloadBytes.length,
|
||||
...previewPayload(payloadBytes),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeLegacyEnvelope(value: unknown): InspectedRecord {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error("CultCache legacy envelopes must be MessagePack maps.");
|
||||
}
|
||||
|
||||
const key = value.key;
|
||||
const schemaName = value.type;
|
||||
const storedAt = value.storedAt;
|
||||
if (!isNonEmptyString(key) || !isNonEmptyString(schemaName) || !isNonEmptyString(storedAt)) {
|
||||
throw new Error("CultCache legacy envelopes must declare key, type, and storedAt.");
|
||||
}
|
||||
|
||||
const schemaId = isNonEmptyString(value.schemaId) ? value.schemaId : schemaName;
|
||||
const payloadBytes = normalizePayloadBytes(value.payload);
|
||||
return {
|
||||
key,
|
||||
schemaId,
|
||||
schemaName,
|
||||
storedAt,
|
||||
payloadBytes: payloadBytes.length,
|
||||
...previewPayload(payloadBytes),
|
||||
};
|
||||
}
|
||||
|
||||
function previewPayload(payload: Uint8Array): Pick<InspectedRecord, "payloadPreview" | "payloadDecodeError"> {
|
||||
try {
|
||||
return { payloadPreview: toJsonSafe(decode(payload)) };
|
||||
} catch (error) {
|
||||
return {
|
||||
payloadPreview: {
|
||||
bytes: payload.length,
|
||||
hexPrefix: [...payload.slice(0, 32)]
|
||||
.map((value) => value.toString(16).padStart(2, "0"))
|
||||
.join(" "),
|
||||
},
|
||||
payloadDecodeError: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePayloadBytes(value: unknown): Uint8Array {
|
||||
if (value instanceof Uint8Array) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (
|
||||
isRecord(value) &&
|
||||
value.type === "Buffer" &&
|
||||
Array.isArray(value.data) &&
|
||||
value.data.every(isByte)
|
||||
) {
|
||||
return Uint8Array.from(value.data);
|
||||
}
|
||||
|
||||
if (Array.isArray(value) && value.every(isByte)) {
|
||||
return Uint8Array.from(value);
|
||||
}
|
||||
|
||||
throw new Error("CultCache record payload must be binary MessagePack bytes.");
|
||||
}
|
||||
|
||||
function toJsonSafe(value: unknown): unknown {
|
||||
if (typeof value === "bigint") {
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
if (value instanceof Uint8Array) {
|
||||
return {
|
||||
bytes: value.length,
|
||||
hexPrefix: [...value.slice(0, 32)]
|
||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||
.join(" "),
|
||||
};
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(toJsonSafe);
|
||||
}
|
||||
|
||||
if (isRecord(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>).map(([key, entryValue]) => [key, toJsonSafe(entryValue)]),
|
||||
);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function isV1Snapshot(value: unknown): value is unknown[] {
|
||||
return Array.isArray(value) && value[0] === STORE_FORMAT_VERSION;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.length > 0;
|
||||
}
|
||||
|
||||
function isByte(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 255;
|
||||
}
|
||||
|
||||
function compareOrdinal(left: string, right: string): number {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
871
packages/cultcache-ts/src/cult-cache.ts
Normal file
871
packages/cultcache-ts/src/cult-cache.ts
Normal file
|
|
@ -0,0 +1,871 @@
|
|||
import { decode, encode } from "@msgpack/msgpack";
|
||||
|
||||
import type {
|
||||
AnyCultCacheDocumentDefinition,
|
||||
CacheBackingStore,
|
||||
CultCacheDocumentAccessor,
|
||||
CultCacheDocumentDefinition,
|
||||
CultCacheDocumentFormatter,
|
||||
CultCacheDocumentRegistry,
|
||||
CultCacheDocumentValue,
|
||||
CultCacheEnvelope,
|
||||
CultCacheSchemaCatalogEntry,
|
||||
CultCacheStoreRegistration,
|
||||
} from "./types";
|
||||
|
||||
type StoreRoute = {
|
||||
primary?: CacheBackingStore;
|
||||
mirrors: CacheBackingStore[];
|
||||
};
|
||||
|
||||
type RegisteredDefinition = {
|
||||
readonly definition: AnyCultCacheDocumentDefinition;
|
||||
readonly global: boolean;
|
||||
readonly formatter: CultCacheDocumentFormatter<unknown>;
|
||||
readonly catalogEntry: CultCacheSchemaCatalogEntry;
|
||||
nameAccessor?: (value: unknown) => string | undefined;
|
||||
readonly indexAccessors: Map<string, (value: unknown) => string | undefined>;
|
||||
};
|
||||
|
||||
type HydratedEntry = CultCacheEnvelope & {
|
||||
value: unknown;
|
||||
};
|
||||
|
||||
type BackingStoreTypeReference = string | AnyCultCacheDocumentDefinition;
|
||||
type AccessorInput = string | ((value: any) => unknown);
|
||||
|
||||
export class CultCacheBuilder {
|
||||
readonly #cache = new CultCache();
|
||||
|
||||
withDocumentType<TDefinition extends AnyCultCacheDocumentDefinition>(definition: TDefinition): this {
|
||||
this.#cache.registerDocumentType(definition);
|
||||
return this;
|
||||
}
|
||||
|
||||
withRegistry(
|
||||
registry:
|
||||
| CultCacheDocumentRegistry
|
||||
| Iterable<AnyCultCacheDocumentDefinition>,
|
||||
): this {
|
||||
this.#cache.registerRegistry(registry);
|
||||
return this;
|
||||
}
|
||||
|
||||
withBackingStore(
|
||||
store: CacheBackingStore,
|
||||
...types: BackingStoreTypeReference[]
|
||||
): this {
|
||||
this.#cache.addBackingStore(store, ...types);
|
||||
return this;
|
||||
}
|
||||
|
||||
withGenericStore(store: CacheBackingStore): this {
|
||||
this.#cache.addGenericBackingStore(store);
|
||||
return this;
|
||||
}
|
||||
|
||||
build(): CultCache {
|
||||
return this.#cache;
|
||||
}
|
||||
}
|
||||
|
||||
export class CultCache {
|
||||
static readonly GLOBAL_KEY = "__global__";
|
||||
|
||||
readonly #definitions = new Map<string, RegisteredDefinition>();
|
||||
readonly #schemaIdDefinitions = new Map<string, RegisteredDefinition>();
|
||||
readonly #entries = new Map<string, HydratedEntry>();
|
||||
readonly #typeEntryIds = new Map<string, Set<string>>();
|
||||
readonly #nameToKeyMaps = new Map<string, Map<string, string>>();
|
||||
readonly #indexToKeyMaps = new Map<string, Map<string, string>>();
|
||||
readonly #globalKeys = new Map<string, string>();
|
||||
readonly #stores: CultCacheStoreRegistration[] = [];
|
||||
|
||||
static builder(): CultCacheBuilder {
|
||||
return new CultCacheBuilder();
|
||||
}
|
||||
|
||||
registerDocumentType<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
): TDefinition {
|
||||
const existing = this.#definitions.get(definition.type);
|
||||
if (existing && existing.definition !== definition) {
|
||||
throw new Error(`CultCache already has a different definition registered for type "${definition.type}".`);
|
||||
}
|
||||
|
||||
const registered = existing ?? {
|
||||
definition,
|
||||
global: definition.global === true,
|
||||
formatter: this.#createFormatter(definition),
|
||||
catalogEntry: this.#createCatalogEntry(definition),
|
||||
nameAccessor: undefined,
|
||||
indexAccessors: new Map<string, (value: unknown) => string | undefined>(),
|
||||
};
|
||||
|
||||
registered.nameAccessor = definition.name
|
||||
? this.#compileAccessor(definition.type, "name", definition.name)
|
||||
: undefined;
|
||||
registered.indexAccessors.clear();
|
||||
for (const [indexName, accessor] of this.#enumerateDefinitionIndexes(definition)) {
|
||||
registered.indexAccessors.set(
|
||||
indexName,
|
||||
this.#compileAccessor(definition.type, `index "${indexName}"`, accessor),
|
||||
);
|
||||
}
|
||||
|
||||
this.#definitions.set(definition.type, registered);
|
||||
for (const schemaId of registered.catalogEntry.compatibleSchemaIds ?? [registered.catalogEntry.schemaId]) {
|
||||
const existingBySchema = this.#schemaIdDefinitions.get(schemaId);
|
||||
if (existingBySchema && existingBySchema !== registered) {
|
||||
throw new Error(`CultCache schema id "${schemaId}" is already registered for type "${existingBySchema.definition.type}".`);
|
||||
}
|
||||
this.#schemaIdDefinitions.set(schemaId, registered);
|
||||
}
|
||||
this.#rebuildDefinitionLookups(registered);
|
||||
return definition;
|
||||
}
|
||||
|
||||
registerRegistry(
|
||||
registry:
|
||||
| CultCacheDocumentRegistry
|
||||
| Iterable<AnyCultCacheDocumentDefinition>,
|
||||
): this {
|
||||
const definitions = Symbol.iterator in Object(registry) && !("definitions" in Object(registry))
|
||||
? registry as Iterable<AnyCultCacheDocumentDefinition>
|
||||
: (registry as CultCacheDocumentRegistry).definitions;
|
||||
|
||||
for (const definition of definitions) {
|
||||
this.registerDocumentType(definition);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
registerNameLookup<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
accessor: CultCacheDocumentAccessor<CultCacheDocumentValue<TDefinition>>,
|
||||
): TDefinition {
|
||||
const registered = this.#requireDefinition(definition);
|
||||
registered.nameAccessor = this.#compileAccessor(definition.type, "name", accessor);
|
||||
this.#rebuildNameLookup(registered);
|
||||
return definition;
|
||||
}
|
||||
|
||||
registerIndex<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
indexName: string,
|
||||
accessor: CultCacheDocumentAccessor<CultCacheDocumentValue<TDefinition>>,
|
||||
): TDefinition {
|
||||
if (!indexName || indexName.trim().length === 0) {
|
||||
throw new Error(`CultCache index names for type "${definition.type}" must be non-empty.`);
|
||||
}
|
||||
|
||||
const registered = this.#requireDefinition(definition);
|
||||
registered.indexAccessors.set(
|
||||
indexName,
|
||||
this.#compileAccessor(definition.type, `index "${indexName}"`, accessor),
|
||||
);
|
||||
this.#rebuildIndexLookup(registered, indexName);
|
||||
return definition;
|
||||
}
|
||||
|
||||
addBackingStore(
|
||||
store: CacheBackingStore,
|
||||
...types: BackingStoreTypeReference[]
|
||||
): void {
|
||||
this.#stores.push({
|
||||
store,
|
||||
types: [...new Set(types.map((value) => (typeof value === "string" ? value : value.type)))],
|
||||
});
|
||||
}
|
||||
|
||||
addGenericBackingStore(store: CacheBackingStore): void {
|
||||
this.addBackingStore(store);
|
||||
}
|
||||
|
||||
async pullAllBackingStores(): Promise<void> {
|
||||
this.#resetHydratedState();
|
||||
|
||||
for (const registration of this.#stores) {
|
||||
const entries = await registration.store.pullAll();
|
||||
|
||||
for (const entry of entries) {
|
||||
const registered = this.#resolveDefinitionForEnvelope(entry);
|
||||
if (!registered) {
|
||||
throw new Error(
|
||||
entry.schemaId
|
||||
? `No schema is registered for persisted schema id "${entry.schemaId}".`
|
||||
: `No schema is registered for persisted document type "${entry.type}".`,
|
||||
);
|
||||
}
|
||||
|
||||
const payload = this.#cloneBytes(entry.payload);
|
||||
const value = registered.formatter.decode(payload);
|
||||
this.#applyHydratedEntry(registered, {
|
||||
...entry,
|
||||
payload,
|
||||
}, value, "pull");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
key: string,
|
||||
): CultCacheDocumentValue<TDefinition> | undefined {
|
||||
this.#requireDefinition(definition);
|
||||
const entry = this.#entries.get(this.#entryId(definition.type, key));
|
||||
return entry ? (entry.value as CultCacheDocumentValue<TDefinition>) : undefined;
|
||||
}
|
||||
|
||||
getRequired<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
key: string,
|
||||
): CultCacheDocumentValue<TDefinition> {
|
||||
const value = this.get(definition, key);
|
||||
if (value === undefined) {
|
||||
throw new Error(`CultCache has no "${definition.type}" document at key "${key}".`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
getEnvelope<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
key: string,
|
||||
): CultCacheEnvelope | undefined {
|
||||
this.#requireDefinition(definition);
|
||||
const entry = this.#entries.get(this.#entryId(definition.type, key));
|
||||
return entry ? this.#toEnvelope(entry) : undefined;
|
||||
}
|
||||
|
||||
getRequiredEnvelope<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
key: string,
|
||||
): CultCacheEnvelope {
|
||||
const entry = this.getEnvelope(definition, key);
|
||||
if (!entry) {
|
||||
throw new Error(`CultCache has no "${definition.type}" envelope at key "${key}".`);
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
getGlobal<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
): CultCacheDocumentValue<TDefinition> | undefined {
|
||||
const registered = this.#requireDefinition(definition);
|
||||
if (!registered.global) {
|
||||
throw new Error(`CultCache document type "${definition.type}" is not marked as global.`);
|
||||
}
|
||||
|
||||
const globalKey = this.#globalKeys.get(definition.type);
|
||||
return globalKey ? this.get(definition, globalKey) : undefined;
|
||||
}
|
||||
|
||||
getRequiredGlobal<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
): CultCacheDocumentValue<TDefinition> {
|
||||
const value = this.getGlobal(definition);
|
||||
if (value === undefined) {
|
||||
throw new Error(`CultCache has no global "${definition.type}" document.`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
getGlobalEnvelope<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
): CultCacheEnvelope | undefined {
|
||||
const registered = this.#requireDefinition(definition);
|
||||
if (!registered.global) {
|
||||
throw new Error(`CultCache document type "${definition.type}" is not marked as global.`);
|
||||
}
|
||||
|
||||
const globalKey = this.#globalKeys.get(definition.type);
|
||||
return globalKey ? this.getEnvelope(definition, globalKey) : undefined;
|
||||
}
|
||||
|
||||
getAll<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
): CultCacheDocumentValue<TDefinition>[] {
|
||||
this.#requireDefinition(definition);
|
||||
const values: CultCacheDocumentValue<TDefinition>[] = [];
|
||||
const entryIds = this.#typeEntryIds.get(definition.type);
|
||||
|
||||
if (!entryIds) {
|
||||
return values;
|
||||
}
|
||||
|
||||
for (const entryId of entryIds) {
|
||||
const entry = this.#entries.get(entryId);
|
||||
if (entry) {
|
||||
values.push(entry.value as CultCacheDocumentValue<TDefinition>);
|
||||
}
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
getKeyByName<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
name: string,
|
||||
): string | undefined {
|
||||
const registered = this.#requireDefinition(definition);
|
||||
if (!registered.nameAccessor) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return this.#nameToKeyMaps.get(definition.type)?.get(name);
|
||||
}
|
||||
|
||||
getIdByName<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
name: string,
|
||||
): string | undefined {
|
||||
return this.getKeyByName(definition, name);
|
||||
}
|
||||
|
||||
getByName<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
name: string,
|
||||
): CultCacheDocumentValue<TDefinition> | undefined {
|
||||
const key = this.getKeyByName(definition, name);
|
||||
return key ? this.get(definition, key) : undefined;
|
||||
}
|
||||
|
||||
getKeyByIndex<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
indexName: string,
|
||||
value: string,
|
||||
): string | undefined {
|
||||
this.#requireDefinition(definition);
|
||||
return this.#indexToKeyMaps.get(this.#indexId(definition.type, indexName))?.get(value);
|
||||
}
|
||||
|
||||
getIdByIndex<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
indexName: string,
|
||||
value: string,
|
||||
): string | undefined {
|
||||
return this.getKeyByIndex(definition, indexName, value);
|
||||
}
|
||||
|
||||
getByIndex<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
indexName: string,
|
||||
value: string,
|
||||
): CultCacheDocumentValue<TDefinition> | undefined {
|
||||
const key = this.getKeyByIndex(definition, indexName, value);
|
||||
return key ? this.get(definition, key) : undefined;
|
||||
}
|
||||
|
||||
async put<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
key: string,
|
||||
value: CultCacheDocumentValue<TDefinition>,
|
||||
): Promise<CultCacheDocumentValue<TDefinition>> {
|
||||
const registered = this.#requireDefinition(definition);
|
||||
const payload = registered.formatter.encode(value);
|
||||
const parsed = registered.formatter.decode(payload) as CultCacheDocumentValue<TDefinition>;
|
||||
const entry: CultCacheEnvelope = {
|
||||
key,
|
||||
type: definition.type,
|
||||
payload: this.#cloneBytes(payload),
|
||||
storedAt: new Date().toISOString(),
|
||||
schemaId: registered.catalogEntry.schemaId,
|
||||
catalogEntry: registered.catalogEntry,
|
||||
};
|
||||
|
||||
const route = this.#resolveRoute(definition.type);
|
||||
if (!route.primary) {
|
||||
throw new Error(`No backing store is registered for document type "${definition.type}".`);
|
||||
}
|
||||
|
||||
if (registered.global) {
|
||||
const existingGlobalKey = this.#globalKeys.get(definition.type);
|
||||
if (existingGlobalKey && existingGlobalKey !== key) {
|
||||
await this.delete(definition, existingGlobalKey);
|
||||
}
|
||||
}
|
||||
|
||||
await route.primary.push(entry);
|
||||
await Promise.all(route.mirrors.map(async (mirror) => mirror.push(entry)));
|
||||
this.#applyHydratedEntry(registered, entry, parsed, "put");
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async putEnvelope<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
envelope: CultCacheEnvelope,
|
||||
): Promise<CultCacheDocumentValue<TDefinition>> {
|
||||
const registered = this.#requireDefinition(definition);
|
||||
if (envelope.type !== definition.type) {
|
||||
throw new Error(
|
||||
`CultCache envelope type "${envelope.type}" does not match definition "${definition.type}".`,
|
||||
);
|
||||
}
|
||||
if (!envelope.key || envelope.key.trim().length === 0) {
|
||||
throw new Error(`CultCache envelope key for type "${definition.type}" must be non-empty.`);
|
||||
}
|
||||
if (!envelope.storedAt || envelope.storedAt.trim().length === 0) {
|
||||
throw new Error(`CultCache envelope storedAt for type "${definition.type}" must be non-empty.`);
|
||||
}
|
||||
|
||||
const payload = this.#cloneBytes(envelope.payload);
|
||||
const parsed = registered.formatter.decode(payload) as CultCacheDocumentValue<TDefinition>;
|
||||
const entry: CultCacheEnvelope = {
|
||||
key: envelope.key,
|
||||
type: envelope.type,
|
||||
payload,
|
||||
storedAt: envelope.storedAt,
|
||||
schemaId: envelope.schemaId ?? registered.catalogEntry.schemaId,
|
||||
catalogEntry: envelope.catalogEntry ?? registered.catalogEntry,
|
||||
};
|
||||
|
||||
const route = this.#resolveRoute(definition.type);
|
||||
if (!route.primary) {
|
||||
throw new Error(`No backing store is registered for document type "${definition.type}".`);
|
||||
}
|
||||
|
||||
if (registered.global) {
|
||||
const existingGlobalKey = this.#globalKeys.get(definition.type);
|
||||
if (existingGlobalKey && existingGlobalKey !== entry.key) {
|
||||
await this.delete(definition, existingGlobalKey);
|
||||
}
|
||||
}
|
||||
|
||||
await route.primary.push(entry);
|
||||
await Promise.all(route.mirrors.map(async (mirror) => mirror.push(entry)));
|
||||
this.#applyHydratedEntry(registered, entry, parsed, "put");
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async putGlobal<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
value: CultCacheDocumentValue<TDefinition>,
|
||||
): Promise<CultCacheDocumentValue<TDefinition>> {
|
||||
const registered = this.#requireDefinition(definition);
|
||||
if (!registered.global) {
|
||||
throw new Error(`CultCache document type "${definition.type}" is not marked as global.`);
|
||||
}
|
||||
|
||||
return this.put(definition, CultCache.GLOBAL_KEY, value);
|
||||
}
|
||||
|
||||
async update<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
key: string,
|
||||
updater: (
|
||||
current: CultCacheDocumentValue<TDefinition> | undefined,
|
||||
) => CultCacheDocumentValue<TDefinition>,
|
||||
): Promise<CultCacheDocumentValue<TDefinition>> {
|
||||
const current = this.get(definition, key);
|
||||
return this.put(definition, key, updater(current));
|
||||
}
|
||||
|
||||
async updateGlobal<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
updater: (
|
||||
current: CultCacheDocumentValue<TDefinition> | undefined,
|
||||
) => CultCacheDocumentValue<TDefinition>,
|
||||
): Promise<CultCacheDocumentValue<TDefinition>> {
|
||||
const current = this.getGlobal(definition);
|
||||
return this.putGlobal(definition, updater(current));
|
||||
}
|
||||
|
||||
async delete<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
key: string,
|
||||
): Promise<boolean> {
|
||||
const registered = this.#requireDefinition(definition);
|
||||
const entry = this.#entries.get(this.#entryId(definition.type, key));
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const route = this.#resolveRoute(definition.type);
|
||||
if (!route.primary) {
|
||||
throw new Error(`No backing store is registered for document type "${definition.type}".`);
|
||||
}
|
||||
|
||||
const envelope = this.#toEnvelope(entry);
|
||||
await route.primary.delete(envelope);
|
||||
await Promise.all(route.mirrors.map(async (mirror) => mirror.delete(envelope)));
|
||||
this.#removeHydratedEntry(registered, entry);
|
||||
return true;
|
||||
}
|
||||
|
||||
async deleteGlobal<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
): Promise<boolean> {
|
||||
const registered = this.#requireDefinition(definition);
|
||||
if (!registered.global) {
|
||||
throw new Error(`CultCache document type "${definition.type}" is not marked as global.`);
|
||||
}
|
||||
|
||||
const globalKey = this.#globalKeys.get(definition.type);
|
||||
return globalKey ? this.delete(definition, globalKey) : false;
|
||||
}
|
||||
|
||||
snapshot(): CultCacheEnvelope[] {
|
||||
return [...this.#entries.values()].map((entry) => this.#toEnvelope(entry));
|
||||
}
|
||||
|
||||
#createFormatter(definition: AnyCultCacheDocumentDefinition): CultCacheDocumentFormatter<unknown> {
|
||||
const userFormatter = definition.formatter ?? {
|
||||
encode: (value: unknown) => encode(value),
|
||||
decode: (payload: Uint8Array) => decode(payload),
|
||||
};
|
||||
|
||||
return {
|
||||
encode: (value: unknown) => {
|
||||
const parsed = definition.schema.parse(value);
|
||||
return this.#cloneBytes(userFormatter.encode(parsed));
|
||||
},
|
||||
decode: (payload: Uint8Array) => {
|
||||
const decoded = userFormatter.decode(this.#cloneBytes(payload));
|
||||
return definition.schema.parse(decoded);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
#createCatalogEntry(definition: AnyCultCacheDocumentDefinition): CultCacheSchemaCatalogEntry {
|
||||
const schemaName = definition.schemaName ?? definition.type;
|
||||
const schemaVersion = definition.schemaVersion ?? `${schemaName}.v1`;
|
||||
const canonicalSchemaJson = definition.canonicalSchemaJson ?? JSON.stringify({
|
||||
schemaName,
|
||||
schemaVersion,
|
||||
members: [...(definition.members ?? [])]
|
||||
.sort((left, right) => left.slot - right.slot)
|
||||
.map((member) => ({
|
||||
slot: member.slot,
|
||||
name: member.memberName,
|
||||
type: member.typeName,
|
||||
isReference: member.isReference === true,
|
||||
many: member.isMany === true,
|
||||
targetSchemaName: member.targetSchemaName ?? null,
|
||||
indexAlias: member.indexAlias ?? null,
|
||||
isName: member.isName === true,
|
||||
})),
|
||||
});
|
||||
const schemaId = definition.schemaId ?? schemaName;
|
||||
const compatibleSchemaIds = [
|
||||
...new Set([schemaId, ...(definition.compatibleSchemaIds ?? [])]),
|
||||
];
|
||||
|
||||
return {
|
||||
schemaId,
|
||||
schemaName,
|
||||
schemaVersion,
|
||||
contentHash: definition.contentHash ?? schemaId,
|
||||
canonicalSchemaJson,
|
||||
compatibleSchemaIds,
|
||||
members: [...(definition.members ?? [])]
|
||||
.sort((left, right) => left.slot - right.slot)
|
||||
.map((member) => ({
|
||||
slot: member.slot,
|
||||
memberName: member.memberName,
|
||||
typeName: member.typeName,
|
||||
isReference: member.isReference === true,
|
||||
isMany: member.isMany === true,
|
||||
targetSchemaName: member.targetSchemaName ?? null,
|
||||
isName: member.isName === true,
|
||||
indexAlias: member.indexAlias ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
#enumerateDefinitionIndexes(
|
||||
definition: AnyCultCacheDocumentDefinition,
|
||||
): Array<[string, AccessorInput]> {
|
||||
if (!definition.indexes) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Array.isArray(definition.indexes)) {
|
||||
return definition.indexes.map((index) => [index.name, index.accessor as AccessorInput]);
|
||||
}
|
||||
|
||||
return Object.entries(definition.indexes) as Array<[string, AccessorInput]>;
|
||||
}
|
||||
|
||||
#compileAccessor(
|
||||
type: string,
|
||||
label: string,
|
||||
accessor: AccessorInput,
|
||||
): (value: unknown) => string | undefined {
|
||||
if (typeof accessor === "function") {
|
||||
return (value: unknown) => this.#normalizeIndexValue(type, label, accessor(value));
|
||||
}
|
||||
|
||||
return (value: unknown) => {
|
||||
if (value === null || typeof value !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const fieldValue = (value as Record<string, unknown>)[accessor];
|
||||
return this.#normalizeIndexValue(type, `${label} field "${accessor}"`, fieldValue);
|
||||
};
|
||||
}
|
||||
|
||||
#normalizeIndexValue(
|
||||
type: string,
|
||||
label: string,
|
||||
value: unknown,
|
||||
): string | undefined {
|
||||
if (value === undefined || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
switch (typeof value) {
|
||||
case "string":
|
||||
return value;
|
||||
case "number":
|
||||
case "boolean":
|
||||
case "bigint":
|
||||
return String(value);
|
||||
default:
|
||||
throw new Error(`CultCache ${label} accessor for type "${type}" produced a non-scalar value.`);
|
||||
}
|
||||
}
|
||||
|
||||
#requireDefinition<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
): RegisteredDefinition {
|
||||
const registered = this.#definitions.get(definition.type);
|
||||
if (!registered || registered.definition !== definition) {
|
||||
throw new Error(`CultCache document type "${definition.type}" is not registered on this cache instance.`);
|
||||
}
|
||||
|
||||
return registered;
|
||||
}
|
||||
|
||||
#resolveDefinitionForEnvelope(entry: CultCacheEnvelope): RegisteredDefinition | undefined {
|
||||
if (entry.schemaId) {
|
||||
return this.#schemaIdDefinitions.get(entry.schemaId) ?? this.#definitions.get(entry.type);
|
||||
}
|
||||
|
||||
return this.#definitions.get(entry.type);
|
||||
}
|
||||
|
||||
#resolveRoute(type: string): StoreRoute {
|
||||
const typeSpecific = this.#stores.filter((registration) => registration.types.includes(type));
|
||||
if (typeSpecific.length > 0) {
|
||||
return {
|
||||
primary: typeSpecific[0]?.store,
|
||||
mirrors: typeSpecific.slice(1).map((registration) => registration.store),
|
||||
};
|
||||
}
|
||||
|
||||
const generic = this.#stores.filter((registration) => registration.types.length === 0);
|
||||
return {
|
||||
primary: generic[0]?.store,
|
||||
mirrors: generic.slice(1).map((registration) => registration.store),
|
||||
};
|
||||
}
|
||||
|
||||
#applyHydratedEntry(
|
||||
registered: RegisteredDefinition,
|
||||
entry: CultCacheEnvelope,
|
||||
value: unknown,
|
||||
source: "pull" | "put",
|
||||
): void {
|
||||
const entryId = this.#entryId(entry.type, entry.key);
|
||||
const existing = this.#entries.get(entryId);
|
||||
if (existing) {
|
||||
this.#removeHydratedEntry(registered, existing);
|
||||
}
|
||||
|
||||
if (registered.global) {
|
||||
const currentGlobalKey = this.#globalKeys.get(entry.type);
|
||||
if (currentGlobalKey && currentGlobalKey !== entry.key) {
|
||||
throw new Error(
|
||||
source === "pull"
|
||||
? `CultCache global document type "${entry.type}" has multiple persisted entries.`
|
||||
: `CultCache global document type "${entry.type}" already has a different key "${currentGlobalKey}".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const hydrated: HydratedEntry = {
|
||||
...entry,
|
||||
payload: this.#cloneBytes(entry.payload),
|
||||
schemaId: entry.schemaId ?? registered.catalogEntry.schemaId,
|
||||
catalogEntry: entry.catalogEntry ?? registered.catalogEntry,
|
||||
value,
|
||||
};
|
||||
this.#entries.set(entryId, hydrated);
|
||||
this.#getOrCreateTypeEntrySet(entry.type).add(entryId);
|
||||
|
||||
if (registered.global) {
|
||||
this.#globalKeys.set(entry.type, entry.key);
|
||||
}
|
||||
|
||||
if (registered.nameAccessor) {
|
||||
const name = registered.nameAccessor(value);
|
||||
if (name !== undefined) {
|
||||
this.#getOrCreateNameMap(entry.type).set(name, entry.key);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [indexName, accessor] of registered.indexAccessors) {
|
||||
const indexValue = accessor(value);
|
||||
if (indexValue !== undefined) {
|
||||
this.#getOrCreateIndexMap(entry.type, indexName).set(indexValue, entry.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#removeHydratedEntry(
|
||||
registered: RegisteredDefinition,
|
||||
entry: HydratedEntry,
|
||||
): void {
|
||||
const entryId = this.#entryId(entry.type, entry.key);
|
||||
this.#entries.delete(entryId);
|
||||
|
||||
const typeEntryIds = this.#typeEntryIds.get(entry.type);
|
||||
if (typeEntryIds) {
|
||||
typeEntryIds.delete(entryId);
|
||||
if (typeEntryIds.size === 0) {
|
||||
this.#typeEntryIds.delete(entry.type);
|
||||
}
|
||||
}
|
||||
|
||||
if (registered.global && this.#globalKeys.get(entry.type) === entry.key) {
|
||||
this.#globalKeys.delete(entry.type);
|
||||
}
|
||||
|
||||
if (registered.nameAccessor) {
|
||||
const name = registered.nameAccessor(entry.value);
|
||||
if (name !== undefined) {
|
||||
this.#nameToKeyMaps.get(entry.type)?.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [indexName, accessor] of registered.indexAccessors) {
|
||||
const value = accessor(entry.value);
|
||||
if (value !== undefined) {
|
||||
this.#indexToKeyMaps.get(this.#indexId(entry.type, indexName))?.delete(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#rebuildDefinitionLookups(registered: RegisteredDefinition): void {
|
||||
this.#rebuildNameLookup(registered);
|
||||
|
||||
for (const indexName of registered.indexAccessors.keys()) {
|
||||
this.#rebuildIndexLookup(registered, indexName);
|
||||
}
|
||||
}
|
||||
|
||||
#rebuildNameLookup(registered: RegisteredDefinition): void {
|
||||
this.#nameToKeyMaps.delete(registered.definition.type);
|
||||
|
||||
if (!registered.nameAccessor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const map = this.#getOrCreateNameMap(registered.definition.type);
|
||||
for (const entry of this.#entriesForType(registered.definition.type)) {
|
||||
const name = registered.nameAccessor(entry.value);
|
||||
if (name !== undefined) {
|
||||
map.set(name, entry.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#rebuildIndexLookup(registered: RegisteredDefinition, indexName: string): void {
|
||||
const lookupId = this.#indexId(registered.definition.type, indexName);
|
||||
this.#indexToKeyMaps.delete(lookupId);
|
||||
|
||||
const accessor = registered.indexAccessors.get(indexName);
|
||||
if (!accessor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const map = this.#getOrCreateIndexMap(registered.definition.type, indexName);
|
||||
for (const entry of this.#entriesForType(registered.definition.type)) {
|
||||
const value = accessor(entry.value);
|
||||
if (value !== undefined) {
|
||||
map.set(value, entry.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#entriesForType(type: string): HydratedEntry[] {
|
||||
const entryIds = this.#typeEntryIds.get(type);
|
||||
if (!entryIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries: HydratedEntry[] = [];
|
||||
for (const entryId of entryIds) {
|
||||
const entry = this.#entries.get(entryId);
|
||||
if (entry) {
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
#getOrCreateTypeEntrySet(type: string): Set<string> {
|
||||
let set = this.#typeEntryIds.get(type);
|
||||
if (!set) {
|
||||
set = new Set<string>();
|
||||
this.#typeEntryIds.set(type, set);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
#getOrCreateNameMap(type: string): Map<string, string> {
|
||||
let map = this.#nameToKeyMaps.get(type);
|
||||
if (!map) {
|
||||
map = new Map<string, string>();
|
||||
this.#nameToKeyMaps.set(type, map);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
#getOrCreateIndexMap(type: string, indexName: string): Map<string, string> {
|
||||
const lookupId = this.#indexId(type, indexName);
|
||||
let map = this.#indexToKeyMaps.get(lookupId);
|
||||
if (!map) {
|
||||
map = new Map<string, string>();
|
||||
this.#indexToKeyMaps.set(lookupId, map);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
#toEnvelope(entry: HydratedEntry): CultCacheEnvelope {
|
||||
return {
|
||||
key: entry.key,
|
||||
type: entry.type,
|
||||
payload: this.#cloneBytes(entry.payload),
|
||||
storedAt: entry.storedAt,
|
||||
schemaId: entry.schemaId,
|
||||
catalogEntry: entry.catalogEntry,
|
||||
};
|
||||
}
|
||||
|
||||
#resetHydratedState(): void {
|
||||
this.#entries.clear();
|
||||
this.#typeEntryIds.clear();
|
||||
this.#nameToKeyMaps.clear();
|
||||
this.#indexToKeyMaps.clear();
|
||||
this.#globalKeys.clear();
|
||||
}
|
||||
|
||||
#entryId(type: string, key: string): string {
|
||||
return `${type}::${key}`;
|
||||
}
|
||||
|
||||
#indexId(type: string, indexName: string): string {
|
||||
return `${type}::${indexName}`;
|
||||
}
|
||||
|
||||
#cloneBytes(payload: Uint8Array): Uint8Array {
|
||||
return new Uint8Array(payload);
|
||||
}
|
||||
}
|
||||
78
packages/cultcache-ts/src/document.ts
Normal file
78
packages/cultcache-ts/src/document.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import type {
|
||||
AnyCultCacheDocumentDefinition,
|
||||
CultCacheDocumentDefinition,
|
||||
CultCacheDocumentRegistry,
|
||||
CultCacheSchema,
|
||||
} from "./types";
|
||||
|
||||
export function defineDocumentType<TSchema extends CultCacheSchema>(
|
||||
definition: CultCacheDocumentDefinition<TSchema>,
|
||||
): CultCacheDocumentDefinition<TSchema> {
|
||||
if (!definition.type || definition.type.trim().length === 0) {
|
||||
throw new Error("CultCache document types must declare a non-empty type.");
|
||||
}
|
||||
|
||||
for (const [fieldName, fieldValue] of [
|
||||
["schemaId", definition.schemaId],
|
||||
["schemaName", definition.schemaName],
|
||||
["schemaVersion", definition.schemaVersion],
|
||||
["contentHash", definition.contentHash],
|
||||
["canonicalSchemaJson", definition.canonicalSchemaJson],
|
||||
] as const) {
|
||||
if (fieldValue !== undefined && fieldValue.trim().length === 0) {
|
||||
throw new Error(`CultCache document type "${definition.type}" declares an empty ${fieldName}.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (definition.name !== undefined && typeof definition.name !== "string" && typeof definition.name !== "function") {
|
||||
throw new Error(`CultCache document type "${definition.type}" declares an invalid name accessor.`);
|
||||
}
|
||||
|
||||
if (Array.isArray(definition.indexes)) {
|
||||
const seen = new Set<string>();
|
||||
for (const index of definition.indexes) {
|
||||
if (!index.name || index.name.trim().length === 0) {
|
||||
throw new Error(`CultCache document type "${definition.type}" declares an index with an empty name.`);
|
||||
}
|
||||
if (seen.has(index.name)) {
|
||||
throw new Error(`CultCache document type "${definition.type}" declares duplicate index "${index.name}".`);
|
||||
}
|
||||
seen.add(index.name);
|
||||
}
|
||||
} else if (definition.indexes) {
|
||||
for (const name of Object.keys(definition.indexes)) {
|
||||
if (!name || name.trim().length === 0) {
|
||||
throw new Error(`CultCache document type "${definition.type}" declares an index with an empty name.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (definition.members) {
|
||||
const seenSlots = new Set<number>();
|
||||
for (const member of definition.members) {
|
||||
if (!Number.isInteger(member.slot) || member.slot < 0) {
|
||||
throw new Error(`CultCache document type "${definition.type}" declares an invalid schema member slot.`);
|
||||
}
|
||||
if (seenSlots.has(member.slot)) {
|
||||
throw new Error(`CultCache document type "${definition.type}" declares duplicate schema member slot ${member.slot}.`);
|
||||
}
|
||||
seenSlots.add(member.slot);
|
||||
if (!member.memberName || member.memberName.trim().length === 0) {
|
||||
throw new Error(`CultCache document type "${definition.type}" declares a schema member with an empty name.`);
|
||||
}
|
||||
if (!member.typeName || member.typeName.trim().length === 0) {
|
||||
throw new Error(`CultCache document type "${definition.type}" declares schema member "${member.memberName}" with an empty type.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({ ...definition });
|
||||
}
|
||||
|
||||
export function defineDocumentRegistry<
|
||||
TDefinitions extends readonly AnyCultCacheDocumentDefinition[],
|
||||
>(...definitions: TDefinitions): CultCacheDocumentRegistry<TDefinitions> {
|
||||
return Object.freeze({
|
||||
definitions: Object.freeze([...definitions]) as TDefinitions,
|
||||
});
|
||||
}
|
||||
29
packages/cultcache-ts/src/index.ts
Normal file
29
packages/cultcache-ts/src/index.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
export { CultCache, CultCacheBuilder } from "./cult-cache";
|
||||
export { inspectCultCacheBytes } from "./cult-cache-inspector";
|
||||
export { defineDocumentRegistry, defineDocumentType } from "./document";
|
||||
export { SingleFileMessagePackBackingStore } from "./single-file-messagepack-backing-store";
|
||||
export type {
|
||||
AnyCultCacheDocumentDefinition,
|
||||
CacheBackingStore,
|
||||
CultCacheDocumentAccessor,
|
||||
CultCacheDocumentDefinition,
|
||||
CultCacheDocumentFieldName,
|
||||
CultCacheDocumentFormatter,
|
||||
CultCacheDocumentIndexDefinition,
|
||||
CultCacheDocumentRegistry,
|
||||
CultCacheSchemaCatalogEntry,
|
||||
CultCacheSchemaCatalogMember,
|
||||
CultCacheSchema,
|
||||
CultCacheDocumentValue,
|
||||
CultCacheEnvelope,
|
||||
CultCacheIndexScalar,
|
||||
InferCultCacheSchemaValue,
|
||||
CultCacheStoreRegistration,
|
||||
PushAllOptions,
|
||||
} from "./types";
|
||||
export type {
|
||||
CultCacheInspection,
|
||||
InspectedCatalogEntry,
|
||||
InspectedCatalogMember,
|
||||
InspectedRecord,
|
||||
} from "./cult-cache-inspector";
|
||||
|
|
@ -0,0 +1,424 @@
|
|||
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
import { decode, encode } from "@msgpack/msgpack";
|
||||
import { z } from "zod";
|
||||
|
||||
import type {
|
||||
CacheBackingStore,
|
||||
CultCacheEnvelope,
|
||||
CultCacheSchemaCatalogEntry,
|
||||
CultCacheSchemaCatalogMember,
|
||||
PushAllOptions,
|
||||
} from "./types";
|
||||
|
||||
const STORE_FORMAT_VERSION = "cultcache.store.v1";
|
||||
|
||||
const envelopeSchema = z.object({
|
||||
key: z.string().min(1),
|
||||
type: z.string().min(1),
|
||||
payload: z.instanceof(Uint8Array),
|
||||
storedAt: z.string().min(1),
|
||||
schemaId: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
const envelopeArraySchema = z.array(envelopeSchema);
|
||||
const legacyEnvelopeArraySchema = z.array(
|
||||
z.object({
|
||||
key: z.string().min(1),
|
||||
type: z.string().min(1),
|
||||
payload: z.unknown(),
|
||||
storedAt: z.string().min(1),
|
||||
}),
|
||||
);
|
||||
|
||||
export class SingleFileMessagePackBackingStore implements CacheBackingStore {
|
||||
readonly filePath: string;
|
||||
|
||||
#writeQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(filePath: string) {
|
||||
this.filePath = resolve(filePath);
|
||||
}
|
||||
|
||||
async pullAll(): Promise<CultCacheEnvelope[]> {
|
||||
try {
|
||||
const data = await readFile(this.filePath);
|
||||
if (data.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const decoded = decode(data);
|
||||
const snapshot = decodeSnapshot(decoded);
|
||||
if (snapshot) {
|
||||
return snapshot.records.map((record) => {
|
||||
const catalogEntry = snapshot.catalogBySchemaId.get(record.schemaId);
|
||||
if (!catalogEntry) {
|
||||
throw new Error(`CultCache persisted record "${record.key}" references missing schema id "${record.schemaId}".`);
|
||||
}
|
||||
|
||||
return {
|
||||
key: record.key,
|
||||
type: catalogEntry.schemaName,
|
||||
schemaId: record.schemaId,
|
||||
catalogEntry,
|
||||
payload: record.payload,
|
||||
storedAt: record.storedAt,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const legacy = decodeLegacyEnvelopeArray(decoded);
|
||||
if (legacy) {
|
||||
let repairedLegacyPayload = false;
|
||||
const normalized = legacy.map((entry) => {
|
||||
const payload = normalizePayload(entry.payload);
|
||||
if (payload !== entry.payload) {
|
||||
repairedLegacyPayload = true;
|
||||
}
|
||||
|
||||
return {
|
||||
...entry,
|
||||
payload,
|
||||
};
|
||||
});
|
||||
const parsed = envelopeArraySchema.parse(normalized) as CultCacheEnvelope[];
|
||||
|
||||
if (repairedLegacyPayload) {
|
||||
await this.#writeAll(parsed);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
throw new Error(`CultCache file ${this.filePath} is not a recognized CultCache MessagePack store.`);
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === "ENOENT") {
|
||||
return [];
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async push(entry: CultCacheEnvelope): Promise<void> {
|
||||
await this.#enqueue(async () => {
|
||||
const existing = await this.pullAll();
|
||||
const filtered = existing.filter(
|
||||
(candidate) => !(candidate.type === entry.type && candidate.key === entry.key),
|
||||
);
|
||||
filtered.push(entry);
|
||||
await this.#writeAll(filtered);
|
||||
});
|
||||
}
|
||||
|
||||
async delete(entry: CultCacheEnvelope): Promise<void> {
|
||||
await this.#enqueue(async () => {
|
||||
const existing = await this.pullAll();
|
||||
const filtered = existing.filter(
|
||||
(candidate) => !(candidate.type === entry.type && candidate.key === entry.key),
|
||||
);
|
||||
await this.#writeAll(filtered);
|
||||
});
|
||||
}
|
||||
|
||||
async pushAll(entries: CultCacheEnvelope[], options: PushAllOptions = {}): Promise<void> {
|
||||
await this.#enqueue(async () => {
|
||||
if (options.soft) {
|
||||
try {
|
||||
await readFile(this.filePath);
|
||||
return;
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.#writeAll(entries);
|
||||
});
|
||||
}
|
||||
|
||||
async #enqueue<T>(operation: () => Promise<T>): Promise<T> {
|
||||
let result!: T;
|
||||
|
||||
const next = this.#writeQueue.then(async () => {
|
||||
result = await operation();
|
||||
});
|
||||
|
||||
this.#writeQueue = next.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
await next;
|
||||
return result;
|
||||
}
|
||||
|
||||
async #writeAll(entries: CultCacheEnvelope[]): Promise<void> {
|
||||
await mkdir(dirname(this.filePath), { recursive: true });
|
||||
|
||||
const tempPath = `${this.filePath}.tmp-${process.pid}-${Date.now()}-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2)}`;
|
||||
|
||||
try {
|
||||
await writeFile(tempPath, encode(encodeSnapshot(entries)));
|
||||
await rename(tempPath, this.filePath);
|
||||
} catch (error) {
|
||||
await rm(tempPath, { force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type PersistedRecord = {
|
||||
key: string;
|
||||
schemaId: string;
|
||||
storedAt: string;
|
||||
payload: Uint8Array;
|
||||
};
|
||||
|
||||
type DecodedSnapshot = {
|
||||
catalogBySchemaId: Map<string, CultCacheSchemaCatalogEntry>;
|
||||
records: PersistedRecord[];
|
||||
};
|
||||
|
||||
function encodeSnapshot(entries: CultCacheEnvelope[]): unknown[] {
|
||||
const catalog = [...catalogEntriesFor(entries).values()]
|
||||
.sort((left, right) => compareOrdinal(left.schemaName, right.schemaName));
|
||||
const records = [...entries]
|
||||
.sort((left, right) => compareOrdinal(left.key, right.key))
|
||||
.map((entry) => [
|
||||
entry.key,
|
||||
schemaIdForEnvelope(entry),
|
||||
entry.storedAt,
|
||||
entry.payload,
|
||||
]);
|
||||
|
||||
return [
|
||||
STORE_FORMAT_VERSION,
|
||||
catalog.map(encodeCatalogEntry),
|
||||
records,
|
||||
];
|
||||
}
|
||||
|
||||
function catalogEntriesFor(entries: CultCacheEnvelope[]): Map<string, CultCacheSchemaCatalogEntry> {
|
||||
const catalog = new Map<string, CultCacheSchemaCatalogEntry>();
|
||||
for (const entry of entries) {
|
||||
const schemaId = schemaIdForEnvelope(entry);
|
||||
if (!catalog.has(schemaId)) {
|
||||
catalog.set(schemaId, entry.catalogEntry ?? {
|
||||
schemaId,
|
||||
schemaName: entry.type,
|
||||
schemaVersion: `${entry.type}.v1`,
|
||||
contentHash: schemaId,
|
||||
canonicalSchemaJson: JSON.stringify({
|
||||
schemaName: entry.type,
|
||||
schemaVersion: `${entry.type}.v1`,
|
||||
members: [],
|
||||
}),
|
||||
compatibleSchemaIds: [schemaId],
|
||||
members: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return catalog;
|
||||
}
|
||||
|
||||
function schemaIdForEnvelope(entry: CultCacheEnvelope): string {
|
||||
return entry.schemaId ?? entry.type;
|
||||
}
|
||||
|
||||
function encodeCatalogEntry(entry: CultCacheSchemaCatalogEntry): unknown[] {
|
||||
return [
|
||||
entry.schemaId,
|
||||
entry.schemaName,
|
||||
entry.schemaVersion,
|
||||
entry.contentHash,
|
||||
entry.canonicalSchemaJson,
|
||||
[...(entry.compatibleSchemaIds ?? [entry.schemaId])],
|
||||
[...(entry.members ?? [])].map(encodeCatalogMember),
|
||||
];
|
||||
}
|
||||
|
||||
function encodeCatalogMember(member: CultCacheSchemaCatalogMember): unknown[] {
|
||||
return [
|
||||
member.slot,
|
||||
member.memberName,
|
||||
member.typeName,
|
||||
member.isReference === true,
|
||||
member.isMany === true,
|
||||
member.targetSchemaName ?? null,
|
||||
member.isName === true,
|
||||
member.indexAlias ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
function decodeSnapshot(decoded: unknown): DecodedSnapshot | undefined {
|
||||
if (!Array.isArray(decoded) || decoded.length === 0 || decoded[0] !== STORE_FORMAT_VERSION) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const catalogRaw = decoded[1];
|
||||
const recordsRaw = decoded[2];
|
||||
if (!Array.isArray(catalogRaw) || !Array.isArray(recordsRaw)) {
|
||||
throw new Error("CultCache v1 snapshot must contain a schema catalog and record array.");
|
||||
}
|
||||
|
||||
const catalogBySchemaId = new Map<string, CultCacheSchemaCatalogEntry>();
|
||||
for (const entry of catalogRaw) {
|
||||
const catalogEntry = decodeCatalogEntry(entry);
|
||||
catalogBySchemaId.set(catalogEntry.schemaId, catalogEntry);
|
||||
for (const compatibleSchemaId of catalogEntry.compatibleSchemaIds ?? []) {
|
||||
catalogBySchemaId.set(compatibleSchemaId, catalogEntry);
|
||||
}
|
||||
}
|
||||
|
||||
const records = recordsRaw.map(decodeRecord);
|
||||
return { catalogBySchemaId, records };
|
||||
}
|
||||
|
||||
function decodeCatalogEntry(value: unknown): CultCacheSchemaCatalogEntry {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error("CultCache schema catalog entries must be MessagePack arrays.");
|
||||
}
|
||||
|
||||
const [
|
||||
schemaId = "",
|
||||
schemaName = "",
|
||||
schemaVersion = "",
|
||||
contentHash = "",
|
||||
canonicalSchemaJson = "",
|
||||
compatibleSchemaIds = [],
|
||||
members = [],
|
||||
] = value;
|
||||
|
||||
if (!isNonEmptyString(schemaId) || !isNonEmptyString(schemaName)) {
|
||||
throw new Error("CultCache schema catalog entries must declare schemaId and schemaName.");
|
||||
}
|
||||
|
||||
if (!Array.isArray(compatibleSchemaIds) || !compatibleSchemaIds.every(isNonEmptyString)) {
|
||||
throw new Error(`CultCache schema catalog entry "${schemaId}" has invalid compatible schema ids.`);
|
||||
}
|
||||
|
||||
if (!Array.isArray(members)) {
|
||||
throw new Error(`CultCache schema catalog entry "${schemaId}" has invalid members.`);
|
||||
}
|
||||
|
||||
return {
|
||||
schemaId,
|
||||
schemaName,
|
||||
schemaVersion: isNonEmptyString(schemaVersion) ? schemaVersion : `${schemaName}.v1`,
|
||||
contentHash: isNonEmptyString(contentHash) ? contentHash : schemaId,
|
||||
canonicalSchemaJson: typeof canonicalSchemaJson === "string" ? canonicalSchemaJson : "",
|
||||
compatibleSchemaIds,
|
||||
members: members.map(decodeCatalogMember),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeCatalogMember(value: unknown): CultCacheSchemaCatalogMember {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error("CultCache schema catalog members must be MessagePack arrays.");
|
||||
}
|
||||
|
||||
const [
|
||||
slot = -1,
|
||||
memberName = "",
|
||||
typeName = "",
|
||||
isReference = false,
|
||||
isMany = false,
|
||||
targetSchemaName = null,
|
||||
isName = false,
|
||||
indexAlias = null,
|
||||
] = value;
|
||||
|
||||
if (!Number.isInteger(slot) || slot < 0 || !isNonEmptyString(memberName) || !isNonEmptyString(typeName)) {
|
||||
throw new Error("CultCache schema catalog member has invalid slot, name, or type.");
|
||||
}
|
||||
|
||||
return {
|
||||
slot,
|
||||
memberName,
|
||||
typeName,
|
||||
isReference: isReference === true,
|
||||
isMany: isMany === true,
|
||||
targetSchemaName: typeof targetSchemaName === "string" ? targetSchemaName : null,
|
||||
isName: isName === true,
|
||||
indexAlias: typeof indexAlias === "string" ? indexAlias : null,
|
||||
};
|
||||
}
|
||||
|
||||
function decodeRecord(value: unknown): PersistedRecord {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error("CultCache persisted records must be MessagePack arrays.");
|
||||
}
|
||||
|
||||
const [key = "", schemaId = "", storedAt = "", payload = new Uint8Array()] = value;
|
||||
if (!isNonEmptyString(key) || !isNonEmptyString(schemaId) || !isNonEmptyString(storedAt)) {
|
||||
throw new Error("CultCache persisted records must declare key, schemaId, and storedAt.");
|
||||
}
|
||||
|
||||
return {
|
||||
key,
|
||||
schemaId,
|
||||
storedAt,
|
||||
payload: normalizePayload(payload),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeLegacyEnvelopeArray(decoded: unknown): Array<{
|
||||
key: string;
|
||||
type: string;
|
||||
payload: unknown;
|
||||
storedAt: string;
|
||||
schemaId?: string;
|
||||
}> | undefined {
|
||||
if (!Array.isArray(decoded)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return legacyEnvelopeArraySchema.parse(decoded) as Array<{
|
||||
key: string;
|
||||
type: string;
|
||||
payload: unknown;
|
||||
storedAt: string;
|
||||
schemaId?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
function normalizePayload(payload: unknown): Uint8Array {
|
||||
if (payload instanceof Uint8Array) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
if (
|
||||
isObject(payload) &&
|
||||
payload.type === "Buffer" &&
|
||||
Array.isArray(payload.data) &&
|
||||
payload.data.every((value) => Number.isInteger(value) && value >= 0 && value <= 255)
|
||||
) {
|
||||
return Uint8Array.from(payload.data);
|
||||
}
|
||||
|
||||
if (Array.isArray(payload) && payload.every((value) => Number.isInteger(value) && value >= 0 && value <= 255)) {
|
||||
return Uint8Array.from(payload);
|
||||
}
|
||||
|
||||
return encode(payload);
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.length > 0;
|
||||
}
|
||||
|
||||
function compareOrdinal(left: string, right: string): number {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
100
packages/cultcache-ts/src/types.ts
Normal file
100
packages/cultcache-ts/src/types.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
export type CultCacheIndexScalar = string | number | boolean | bigint;
|
||||
|
||||
export interface CultCacheSchema<TValue = unknown> {
|
||||
parse(input: unknown): TValue;
|
||||
}
|
||||
|
||||
export type InferCultCacheSchemaValue<TSchema extends CultCacheSchema> =
|
||||
TSchema extends CultCacheSchema<infer TValue> ? TValue : never;
|
||||
|
||||
export type CultCacheDocumentFieldName<TValue> = TValue extends object ? Extract<keyof TValue, string> : never;
|
||||
|
||||
export type CultCacheDocumentAccessor<TValue> =
|
||||
| CultCacheDocumentFieldName<TValue>
|
||||
| ((value: TValue) => CultCacheIndexScalar | null | undefined);
|
||||
|
||||
export interface CultCacheDocumentFormatter<TValue> {
|
||||
encode(value: TValue): Uint8Array;
|
||||
decode(payload: Uint8Array): TValue;
|
||||
}
|
||||
|
||||
export interface CultCacheSchemaCatalogMember {
|
||||
slot: number;
|
||||
memberName: string;
|
||||
typeName: string;
|
||||
isReference?: boolean;
|
||||
isMany?: boolean;
|
||||
targetSchemaName?: string | null;
|
||||
isName?: boolean;
|
||||
indexAlias?: string | null;
|
||||
}
|
||||
|
||||
export interface CultCacheSchemaCatalogEntry {
|
||||
schemaId: string;
|
||||
schemaName: string;
|
||||
schemaVersion: string;
|
||||
contentHash: string;
|
||||
canonicalSchemaJson: string;
|
||||
compatibleSchemaIds?: readonly string[];
|
||||
members?: readonly CultCacheSchemaCatalogMember[];
|
||||
}
|
||||
|
||||
export interface CultCacheDocumentIndexDefinition<TValue> {
|
||||
name: string;
|
||||
accessor: CultCacheDocumentAccessor<TValue>;
|
||||
}
|
||||
|
||||
export interface CultCacheDocumentDefinition<TSchema extends CultCacheSchema = CultCacheSchema> {
|
||||
type: string;
|
||||
schema: TSchema;
|
||||
schemaId?: string;
|
||||
schemaName?: string;
|
||||
schemaVersion?: string;
|
||||
contentHash?: string;
|
||||
canonicalSchemaJson?: string;
|
||||
compatibleSchemaIds?: readonly string[];
|
||||
members?: readonly CultCacheSchemaCatalogMember[];
|
||||
global?: boolean;
|
||||
name?: CultCacheDocumentAccessor<InferCultCacheSchemaValue<TSchema>>;
|
||||
indexes?:
|
||||
| Readonly<Record<string, CultCacheDocumentAccessor<InferCultCacheSchemaValue<TSchema>>>>
|
||||
| readonly CultCacheDocumentIndexDefinition<InferCultCacheSchemaValue<TSchema>>[];
|
||||
formatter?: CultCacheDocumentFormatter<InferCultCacheSchemaValue<TSchema>>;
|
||||
}
|
||||
|
||||
export type CultCacheDocumentValue<TDefinition extends CultCacheDocumentDefinition> = InferCultCacheSchemaValue<
|
||||
TDefinition["schema"]
|
||||
>;
|
||||
|
||||
export type AnyCultCacheDocumentDefinition = CultCacheDocumentDefinition<any>;
|
||||
|
||||
export interface CultCacheDocumentRegistry<
|
||||
TDefinitions extends readonly AnyCultCacheDocumentDefinition[] = readonly AnyCultCacheDocumentDefinition[],
|
||||
> {
|
||||
readonly definitions: TDefinitions;
|
||||
}
|
||||
|
||||
export interface CultCacheEnvelope {
|
||||
key: string;
|
||||
type: string;
|
||||
payload: Uint8Array;
|
||||
storedAt: string;
|
||||
schemaId?: string;
|
||||
catalogEntry?: CultCacheSchemaCatalogEntry;
|
||||
}
|
||||
|
||||
export interface PushAllOptions {
|
||||
soft?: boolean;
|
||||
}
|
||||
|
||||
export interface CacheBackingStore {
|
||||
pullAll(): Promise<CultCacheEnvelope[]>;
|
||||
push(entry: CultCacheEnvelope): Promise<void>;
|
||||
delete(entry: CultCacheEnvelope): Promise<void>;
|
||||
pushAll?(entries: CultCacheEnvelope[], options?: PushAllOptions): Promise<void>;
|
||||
}
|
||||
|
||||
export interface CultCacheStoreRegistration {
|
||||
store: CacheBackingStore;
|
||||
types: string[];
|
||||
}
|
||||
743
packages/cultcache-ts/test/cult-cache.test.ts
Normal file
743
packages/cultcache-ts/test/cult-cache.test.ts
Normal file
|
|
@ -0,0 +1,743 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { exec, execFile } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { access, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { decode, encode } from "@msgpack/msgpack";
|
||||
import { z } from "zod";
|
||||
|
||||
import { CultCache } from "../src/cult-cache";
|
||||
import { inspectCultCacheBytes } from "../src/cult-cache-inspector";
|
||||
import { defineDocumentRegistry, defineDocumentType } from "../src/document";
|
||||
import { SingleFileMessagePackBackingStore } from "../src/single-file-messagepack-backing-store";
|
||||
import type { CacheBackingStore, CultCacheEnvelope, CultCacheSchema } from "../src/types";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const execAsync = promisify(exec);
|
||||
const cargoCommand = process.env.CARGO ?? (process.platform === "win32" ? join(homedir(), ".cargo", "bin", "cargo.exe") : "cargo");
|
||||
const dotnetCommand = process.env.DOTNET ?? (process.platform === "win32" ? join("C:", "Program Files", "dotnet", "dotnet.exe") : "dotnet");
|
||||
const cultCacheTsRoot = resolve(__dirname, "../..");
|
||||
const cultLibRoot = findAncestor(cultCacheTsRoot, "CultLib.sln") ?? resolve(cultCacheTsRoot, "..", "CultLib");
|
||||
const cultcacheRsRoot = existsSync(resolve(cultLibRoot, "packages", "cultcache-rs"))
|
||||
? resolve(cultLibRoot, "packages", "cultcache-rs")
|
||||
: resolve(cultCacheTsRoot, "..", "cultcache-rs");
|
||||
const rustInteropBinary = resolve(
|
||||
cultcacheRsRoot,
|
||||
"target",
|
||||
"debug",
|
||||
"examples",
|
||||
process.platform === "win32" ? "cultcache_interop.exe" : "cultcache_interop",
|
||||
);
|
||||
const csharpInteropProject = resolve(
|
||||
cultLibRoot,
|
||||
"tests",
|
||||
"GameCult.Caching.InteropPeer",
|
||||
"GameCult.Caching.InteropPeer.csproj",
|
||||
);
|
||||
const csharpInteropDll = resolve(
|
||||
cultLibRoot,
|
||||
"bin",
|
||||
"GameCult.Caching.InteropPeer",
|
||||
"Debug",
|
||||
"net10.0",
|
||||
"GameCult.Caching.InteropPeer.dll",
|
||||
);
|
||||
|
||||
function findAncestor(start: string, marker: string): string | undefined {
|
||||
let current = start;
|
||||
while (true) {
|
||||
if (existsSync(resolve(current, marker))) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const parent = dirname(current);
|
||||
if (parent === current) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
test("CultCache supports registry bootstrap, global documents, and lookup by name and index", async () => {
|
||||
const itemDocument = defineDocumentType({
|
||||
type: "item",
|
||||
schema: z.object({
|
||||
name: z.string(),
|
||||
category: z.string(),
|
||||
value: z.number().int(),
|
||||
}),
|
||||
name: "name",
|
||||
indexes: {
|
||||
category: "category",
|
||||
},
|
||||
});
|
||||
|
||||
const settingsDocument = defineDocumentType({
|
||||
type: "settings",
|
||||
schema: z.object({
|
||||
theme: z.string(),
|
||||
retries: z.number().int().nonnegative(),
|
||||
}),
|
||||
global: true,
|
||||
});
|
||||
|
||||
const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-ts-")), "cache.msgpack");
|
||||
const registry = defineDocumentRegistry(itemDocument, settingsDocument);
|
||||
|
||||
const cache = CultCache.builder()
|
||||
.withRegistry(registry)
|
||||
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
|
||||
.build();
|
||||
|
||||
await cache.put(itemDocument, "item:potion", {
|
||||
name: "Potion",
|
||||
category: "Consumable",
|
||||
value: 50,
|
||||
});
|
||||
await cache.putGlobal(settingsDocument, {
|
||||
theme: "ash",
|
||||
retries: 3,
|
||||
});
|
||||
|
||||
assert.deepEqual(cache.getByName(itemDocument, "Potion"), {
|
||||
name: "Potion",
|
||||
category: "Consumable",
|
||||
value: 50,
|
||||
});
|
||||
assert.equal(cache.getKeyByIndex(itemDocument, "category", "Consumable"), "item:potion");
|
||||
assert.deepEqual(cache.getGlobal(settingsDocument), {
|
||||
theme: "ash",
|
||||
retries: 3,
|
||||
});
|
||||
|
||||
const snapshot = cache.snapshot();
|
||||
assert.equal(snapshot.length, 2);
|
||||
assert.ok(snapshot.every((entry) => entry.payload instanceof Uint8Array && entry.payload.length > 0));
|
||||
|
||||
const reloaded = CultCache.builder()
|
||||
.withRegistry(registry)
|
||||
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
|
||||
.build();
|
||||
await reloaded.pullAllBackingStores();
|
||||
|
||||
assert.deepEqual(reloaded.getByName(itemDocument, "Potion"), {
|
||||
name: "Potion",
|
||||
category: "Consumable",
|
||||
value: 50,
|
||||
});
|
||||
assert.deepEqual(reloaded.getRequiredGlobal(settingsDocument), {
|
||||
theme: "ash",
|
||||
retries: 3,
|
||||
});
|
||||
});
|
||||
|
||||
test("CultCache can register name and index lookups after entries already exist", async () => {
|
||||
const noteDocument = defineDocumentType({
|
||||
type: "note",
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
author: z.string(),
|
||||
body: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-ts-")), "cache.msgpack");
|
||||
const cache = CultCache.builder()
|
||||
.withDocumentType(noteDocument)
|
||||
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
|
||||
.build();
|
||||
|
||||
await cache.put(noteDocument, "note:hello", {
|
||||
title: "Hello",
|
||||
author: "ari",
|
||||
body: "world",
|
||||
});
|
||||
|
||||
cache.registerNameLookup(noteDocument, "title");
|
||||
cache.registerIndex(noteDocument, "author", "author");
|
||||
|
||||
assert.equal(cache.getIdByName(noteDocument, "Hello"), "note:hello");
|
||||
assert.equal(cache.getIdByIndex(noteDocument, "author", "ari"), "note:hello");
|
||||
});
|
||||
|
||||
test("CultCache fails closed on unknown or illegal persisted polymorphic state", async () => {
|
||||
const settingsDocument = defineDocumentType({
|
||||
type: "settings",
|
||||
schema: z.object({
|
||||
theme: z.string(),
|
||||
}),
|
||||
global: true,
|
||||
});
|
||||
|
||||
class InMemoryStore implements CacheBackingStore {
|
||||
constructor(private readonly entries: CultCacheEnvelope[]) {}
|
||||
|
||||
async pullAll(): Promise<CultCacheEnvelope[]> {
|
||||
return this.entries;
|
||||
}
|
||||
|
||||
async push(): Promise<void> {
|
||||
throw new Error("not used");
|
||||
}
|
||||
|
||||
async delete(): Promise<void> {
|
||||
throw new Error("not used");
|
||||
}
|
||||
}
|
||||
|
||||
const cacheWithUnknownType = CultCache.builder()
|
||||
.withDocumentType(settingsDocument)
|
||||
.withGenericStore(new InMemoryStore([
|
||||
{
|
||||
key: "mystery",
|
||||
type: "forbidden",
|
||||
payload: encode({ theme: "ash" }),
|
||||
storedAt: new Date().toISOString(),
|
||||
},
|
||||
]))
|
||||
.build();
|
||||
|
||||
await assert.rejects(
|
||||
async () => cacheWithUnknownType.pullAllBackingStores(),
|
||||
/No schema is registered for persisted document type "forbidden"\./,
|
||||
);
|
||||
|
||||
const cacheWithDuplicateGlobal = CultCache.builder()
|
||||
.withDocumentType(settingsDocument)
|
||||
.withGenericStore(new InMemoryStore([
|
||||
{
|
||||
key: "one",
|
||||
type: "settings",
|
||||
payload: encode({ theme: "ash" }),
|
||||
storedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
key: "two",
|
||||
type: "settings",
|
||||
payload: encode({ theme: "ember" }),
|
||||
storedAt: new Date().toISOString(),
|
||||
},
|
||||
]))
|
||||
.build();
|
||||
|
||||
await assert.rejects(
|
||||
async () => cacheWithDuplicateGlobal.pullAllBackingStores(),
|
||||
/has multiple persisted entries/,
|
||||
);
|
||||
});
|
||||
|
||||
test("CultCache accepts generated parse-style schemas without a Zod mirror", async () => {
|
||||
type GeneratedSettings = {
|
||||
schema_version: "generated.settings.v0";
|
||||
theme: string;
|
||||
retries: number;
|
||||
};
|
||||
|
||||
const generatedSchema: CultCacheSchema<GeneratedSettings> = {
|
||||
parse(input: unknown): GeneratedSettings {
|
||||
if (!input || typeof input !== "object") {
|
||||
throw new Error("generated settings must be an object");
|
||||
}
|
||||
|
||||
const value = input as Record<string, unknown>;
|
||||
if (value.schema_version !== "generated.settings.v0") {
|
||||
throw new Error("generated settings schema_version mismatch");
|
||||
}
|
||||
if (typeof value.theme !== "string") {
|
||||
throw new Error("generated settings theme must be a string");
|
||||
}
|
||||
if (typeof value.retries !== "number") {
|
||||
throw new Error("generated settings retries must be a number");
|
||||
}
|
||||
|
||||
return {
|
||||
schema_version: "generated.settings.v0",
|
||||
theme: value.theme,
|
||||
retries: value.retries,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const generatedDocument = defineDocumentType({
|
||||
type: "generated-settings",
|
||||
schema: generatedSchema,
|
||||
global: true,
|
||||
});
|
||||
|
||||
const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-ts-")), "generated.msgpack");
|
||||
const cache = CultCache.builder()
|
||||
.withDocumentType(generatedDocument)
|
||||
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
|
||||
.build();
|
||||
|
||||
await cache.putGlobal(generatedDocument, {
|
||||
schema_version: "generated.settings.v0",
|
||||
theme: "ash",
|
||||
retries: 3,
|
||||
});
|
||||
|
||||
const settings = cache.getRequiredGlobal(generatedDocument);
|
||||
assert.equal(settings.schema_version, "generated.settings.v0");
|
||||
assert.equal(settings.theme, "ash");
|
||||
assert.equal(settings.retries, 3);
|
||||
});
|
||||
|
||||
test("CultCache can ingest raw envelopes without re-encoding the payload", async () => {
|
||||
const noteDocument = defineDocumentType({
|
||||
type: "note",
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
body: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-ts-")), "cache.msgpack");
|
||||
const origin = CultCache.builder()
|
||||
.withDocumentType(noteDocument)
|
||||
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
|
||||
.build();
|
||||
const target = CultCache.builder()
|
||||
.withDocumentType(noteDocument)
|
||||
.withGenericStore(new SingleFileMessagePackBackingStore(join(await mkdtemp(join(tmpdir(), "cultcache-ts-")), "target.msgpack")))
|
||||
.build();
|
||||
|
||||
await origin.put(noteDocument, "note:hello", {
|
||||
title: "Hello",
|
||||
body: "world",
|
||||
});
|
||||
|
||||
const envelope = origin.getRequiredEnvelope(noteDocument, "note:hello");
|
||||
const applied = await target.putEnvelope(noteDocument, envelope);
|
||||
|
||||
assert.deepEqual(applied, {
|
||||
title: "Hello",
|
||||
body: "world",
|
||||
});
|
||||
assert.deepEqual(target.getRequired(noteDocument, "note:hello"), applied);
|
||||
assert.deepEqual(target.getRequiredEnvelope(noteDocument, "note:hello").payload, envelope.payload);
|
||||
});
|
||||
|
||||
test("SingleFileMessagePackBackingStore writes the CultCache v1 snapshot shape", async () => {
|
||||
const namedDocument = defineDocumentType({
|
||||
type: "tests.named_entry",
|
||||
schema: z.object({
|
||||
Name: z.string(),
|
||||
Value: z.string(),
|
||||
}),
|
||||
schemaId: "sha256:e7b97801b94190f3159012ede45b0069bb09ebf7920f7432c971bc86a0e08de8",
|
||||
schemaName: "tests.named_entry",
|
||||
schemaVersion: "tests.named_entry.v1",
|
||||
contentHash: "sha256:23150930afcc1d84f0cb3012ccc2debcb9b4685f62083033bbaab0083f1e832e",
|
||||
canonicalSchemaJson: "{\"schemaName\":\"tests.named_entry\",\"schemaVersion\":\"tests.named_entry.v1\",\"members\":[{\"slot\":0,\"name\":\"Name\",\"type\":\"System.String\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":true},{\"slot\":1,\"name\":\"Value\",\"type\":\"System.String\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":false}]}",
|
||||
members: [
|
||||
{
|
||||
slot: 0,
|
||||
memberName: "Name",
|
||||
typeName: "System.String",
|
||||
isName: true,
|
||||
},
|
||||
{
|
||||
slot: 1,
|
||||
memberName: "Value",
|
||||
typeName: "System.String",
|
||||
},
|
||||
],
|
||||
name: "Name",
|
||||
});
|
||||
|
||||
const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-ts-")), "snapshot.msgpack");
|
||||
const cache = CultCache.builder()
|
||||
.withDocumentType(namedDocument)
|
||||
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
|
||||
.build();
|
||||
|
||||
await cache.put(namedDocument, "record-1", {
|
||||
Name: "Teeth",
|
||||
Value: "slot-array",
|
||||
});
|
||||
|
||||
const snapshot = decode(await readFile(storePath)) as unknown[];
|
||||
assert.equal(snapshot[0], "cultcache.store.v1");
|
||||
|
||||
const catalog = snapshot[1] as unknown[][];
|
||||
assert.equal(catalog.length, 1);
|
||||
assert.equal(catalog[0]?.[0], "sha256:e7b97801b94190f3159012ede45b0069bb09ebf7920f7432c971bc86a0e08de8");
|
||||
assert.equal(catalog[0]?.[1], "tests.named_entry");
|
||||
assert.equal(catalog[0]?.[2], "tests.named_entry.v1");
|
||||
|
||||
const records = snapshot[2] as unknown[][];
|
||||
assert.equal(records.length, 1);
|
||||
assert.equal(records[0]?.[0], "record-1");
|
||||
assert.equal(records[0]?.[1], "sha256:e7b97801b94190f3159012ede45b0069bb09ebf7920f7432c971bc86a0e08de8");
|
||||
assert.ok(records[0]?.[3] instanceof Uint8Array);
|
||||
});
|
||||
|
||||
test("SingleFileMessagePackBackingStore reads CultCache v1 snapshots by schema id", async () => {
|
||||
const noteDocument = defineDocumentType({
|
||||
type: "tests.named_entry",
|
||||
schema: z.object({
|
||||
Name: z.string(),
|
||||
Value: z.string(),
|
||||
}),
|
||||
schemaId: "schema-1",
|
||||
schemaName: "tests.named_entry",
|
||||
schemaVersion: "tests.named_entry.v1",
|
||||
contentHash: "hash-1",
|
||||
canonicalSchemaJson: "{\"fields\":2}",
|
||||
});
|
||||
|
||||
const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-ts-")), "csharp.msgpack");
|
||||
await writeFile(
|
||||
storePath,
|
||||
encode([
|
||||
"cultcache.store.v1",
|
||||
[
|
||||
[
|
||||
"schema-1",
|
||||
"tests.named_entry",
|
||||
"tests.named_entry.v1",
|
||||
"hash-1",
|
||||
"{\"fields\":2}",
|
||||
["schema-1"],
|
||||
[],
|
||||
],
|
||||
],
|
||||
[
|
||||
[
|
||||
"record-1",
|
||||
"schema-1",
|
||||
"2026-05-08T12:00:00Z",
|
||||
encode({
|
||||
Name: "Teeth",
|
||||
Value: "slot-array",
|
||||
}),
|
||||
],
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const cache = CultCache.builder()
|
||||
.withDocumentType(noteDocument)
|
||||
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
|
||||
.build();
|
||||
|
||||
await cache.pullAllBackingStores();
|
||||
assert.deepEqual(cache.getRequired(noteDocument, "record-1"), {
|
||||
Name: "Teeth",
|
||||
Value: "slot-array",
|
||||
});
|
||||
assert.equal(cache.getRequiredEnvelope(noteDocument, "record-1").schemaId, "schema-1");
|
||||
});
|
||||
|
||||
test("SingleFileMessagePackBackingStore heals legacy envelopes whose payload was persisted as an object", async () => {
|
||||
const noteDocument = defineDocumentType({
|
||||
type: "note",
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
body: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-ts-")), "legacy.msgpack");
|
||||
const envelopePayload = {
|
||||
title: "Hello",
|
||||
body: "world",
|
||||
};
|
||||
|
||||
await writeFile(
|
||||
storePath,
|
||||
encode([
|
||||
{
|
||||
key: "note:hello",
|
||||
type: "note",
|
||||
payload: envelopePayload,
|
||||
storedAt: new Date().toISOString(),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const cache = CultCache.builder()
|
||||
.withDocumentType(noteDocument)
|
||||
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
|
||||
.build();
|
||||
|
||||
await cache.pullAllBackingStores();
|
||||
assert.deepEqual(cache.getRequired(noteDocument, "note:hello"), envelopePayload);
|
||||
|
||||
const rewritten = decode(await readFile(storePath)) as unknown[];
|
||||
assert.equal(rewritten[0], "cultcache.store.v1");
|
||||
const records = rewritten[2] as unknown[][];
|
||||
assert.equal(records.length, 1);
|
||||
assert.ok(records[0]?.[3] instanceof Uint8Array);
|
||||
});
|
||||
|
||||
test("CultCache v1 MessagePack stores are readable across TS, Rust, and C#", async () => {
|
||||
await buildInteropPeers();
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "cultcache-interop-"));
|
||||
const writers = [
|
||||
{
|
||||
name: "ts",
|
||||
write: async (file: string) => writeTsInteropStore(file, "ts-writer"),
|
||||
},
|
||||
{
|
||||
name: "rust",
|
||||
write: async (file: string) => runJsonCommand("rust-write", rustInteropBinary, [
|
||||
"write",
|
||||
"--file", file,
|
||||
"--runtime-id", "rust-writer",
|
||||
], cultcacheRsRoot),
|
||||
},
|
||||
{
|
||||
name: "csharp",
|
||||
write: async (file: string) => runJsonCommand("csharp-write", dotnetCommand, [
|
||||
csharpInteropDll,
|
||||
"write",
|
||||
"--file", file,
|
||||
"--runtime-id", "csharp-writer",
|
||||
], cultLibRoot),
|
||||
},
|
||||
];
|
||||
const readers = [
|
||||
{
|
||||
name: "ts",
|
||||
read: async (file: string) => readTsInteropStore(file),
|
||||
},
|
||||
{
|
||||
name: "rust",
|
||||
read: async (file: string) => runJsonCommand("rust-read", rustInteropBinary, [
|
||||
"read",
|
||||
"--file", file,
|
||||
], cultcacheRsRoot),
|
||||
},
|
||||
{
|
||||
name: "csharp",
|
||||
read: async (file: string) => runJsonCommand("csharp-read", dotnetCommand, [
|
||||
csharpInteropDll,
|
||||
"read",
|
||||
"--file", file,
|
||||
], cultLibRoot),
|
||||
},
|
||||
];
|
||||
|
||||
for (const writer of writers) {
|
||||
const file = join(tempDir, `${writer.name}.msgpack`);
|
||||
const written = await writer.write(file);
|
||||
const decoded = decode(await readFile(file)) as unknown[];
|
||||
assert.equal(decoded[0], "cultcache.store.v1");
|
||||
assert.ok(Array.isArray(decoded[1]), `${writer.name} did not write a schema catalog`);
|
||||
assert.ok(Array.isArray(decoded[2]), `${writer.name} did not write records`);
|
||||
|
||||
for (const reader of readers) {
|
||||
const read = await reader.read(file);
|
||||
assert.equal(read.documentId, written.documentId, `${reader.name} failed to read ${writer.name}`);
|
||||
assert.equal(read.authorRuntimeId, written.authorRuntimeId);
|
||||
assert.equal(read.body, "The v1 store format is the contract.");
|
||||
assert.ok(read.tags.includes("interop"));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("CultCache interop reader accepts missing compatible trailing slots and rejects mismatched slots", async () => {
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "cultcache-interop-"));
|
||||
const compatible = join(tempDir, "compatible.msgpack");
|
||||
const mismatch = join(tempDir, "mismatch.msgpack");
|
||||
await writeTsInteropStore(compatible, "legacy-writer", { legacyPayload: true });
|
||||
assert.deepEqual(await readTsInteropStore(compatible), {
|
||||
schemaVersion: "cultcache.interop_note.v1",
|
||||
documentId: "note:legacy-writer",
|
||||
authorRuntimeId: "legacy-writer",
|
||||
title: "legacy-writer wrote a CultCache note",
|
||||
body: "The v1 store format is the contract.",
|
||||
tags: [],
|
||||
});
|
||||
|
||||
await writeTsInteropStore(mismatch, "bad-writer", { mismatchedPayload: true });
|
||||
await assert.rejects(
|
||||
() => readTsInteropStore(mismatch),
|
||||
/Expected string/u,
|
||||
);
|
||||
});
|
||||
|
||||
test("CultCache inspector decodes v1 store catalog and record payloads from bytes", async () => {
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "cultcache-inspector-"));
|
||||
const file = join(tempDir, "cache.cc");
|
||||
const written = await writeTsInteropStore(file, "inspector");
|
||||
|
||||
const inspection = inspectCultCacheBytes(file, await readFile(file));
|
||||
assert.equal(inspection.format, "cultcache.store.v1");
|
||||
assert.equal(inspection.catalog.length, 1);
|
||||
assert.equal(inspection.catalog[0]?.schemaName, "cultcache.interop-note");
|
||||
assert.equal(inspection.records.length, 1);
|
||||
assert.equal(inspection.records[0]?.key, written.documentId);
|
||||
assert.deepEqual(inspection.records[0]?.payloadPreview, [
|
||||
"cultcache.interop_note.v1",
|
||||
written.documentId,
|
||||
written.authorRuntimeId,
|
||||
written.title,
|
||||
written.body,
|
||||
written.tags,
|
||||
]);
|
||||
});
|
||||
|
||||
interface InteropNote {
|
||||
schemaVersion: "cultcache.interop_note.v1";
|
||||
documentId: string;
|
||||
authorRuntimeId: string;
|
||||
title: string;
|
||||
body: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
const interopNoteDocument = defineDocumentType({
|
||||
type: "cultcache.interop-note",
|
||||
schemaId: "cultcache.interop-note",
|
||||
schemaName: "cultcache.interop-note",
|
||||
schemaVersion: "cultcache.interop_note.v1",
|
||||
contentHash: "cultcache.interop-note",
|
||||
canonicalSchemaJson: "{\"schemaName\":\"cultcache.interop-note\",\"schemaVersion\":\"cultcache.interop_note.v1\",\"members\":[{\"slot\":0,\"name\":\"SchemaVersion\",\"type\":\"System.String\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":false},{\"slot\":1,\"name\":\"DocumentId\",\"type\":\"System.String\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":true},{\"slot\":2,\"name\":\"AuthorRuntimeId\",\"type\":\"System.String\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":false},{\"slot\":3,\"name\":\"Title\",\"type\":\"System.String\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":false},{\"slot\":4,\"name\":\"Body\",\"type\":\"System.String\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":false},{\"slot\":5,\"name\":\"Tags\",\"type\":\"System.String[]\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":false}]}",
|
||||
compatibleSchemaIds: ["cultcache.interop-note"],
|
||||
members: [
|
||||
{ slot: 0, memberName: "SchemaVersion", typeName: "System.String" },
|
||||
{ slot: 1, memberName: "DocumentId", typeName: "System.String", isName: true },
|
||||
{ slot: 2, memberName: "AuthorRuntimeId", typeName: "System.String" },
|
||||
{ slot: 3, memberName: "Title", typeName: "System.String" },
|
||||
{ slot: 4, memberName: "Body", typeName: "System.String" },
|
||||
{ slot: 5, memberName: "Tags", typeName: "System.String[]" },
|
||||
],
|
||||
schema: z.object({
|
||||
schemaVersion: z.literal("cultcache.interop_note.v1"),
|
||||
documentId: z.string().min(1),
|
||||
authorRuntimeId: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
body: z.string().min(1),
|
||||
tags: z.array(z.string().min(1)),
|
||||
}),
|
||||
formatter: {
|
||||
encode(value: InteropNote): Uint8Array {
|
||||
return encode([
|
||||
value.schemaVersion,
|
||||
value.documentId,
|
||||
value.authorRuntimeId,
|
||||
value.title,
|
||||
value.body,
|
||||
value.tags,
|
||||
]);
|
||||
},
|
||||
decode(payload: Uint8Array): InteropNote {
|
||||
const decoded = decode(payload);
|
||||
if (!Array.isArray(decoded) || decoded.length < 5) {
|
||||
throw new Error("CultCache interop note payload must be a MessagePack slot array.");
|
||||
}
|
||||
const [schemaVersion, documentId, authorRuntimeId, title, body, tags] = decoded;
|
||||
return interopNoteDocument.schema.parse({
|
||||
schemaVersion,
|
||||
documentId,
|
||||
authorRuntimeId,
|
||||
title,
|
||||
body,
|
||||
tags: Array.isArray(tags) ? tags : [],
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
async function writeTsInteropStore(
|
||||
file: string,
|
||||
runtimeId: string,
|
||||
options: { legacyPayload?: boolean; mismatchedPayload?: boolean } = {},
|
||||
): Promise<InteropNote> {
|
||||
const cache = CultCache.builder()
|
||||
.withDocumentType(interopNoteDocument)
|
||||
.withGenericStore(new SingleFileMessagePackBackingStore(file))
|
||||
.build();
|
||||
const note: InteropNote = {
|
||||
schemaVersion: "cultcache.interop_note.v1",
|
||||
documentId: `note:${runtimeId}`,
|
||||
authorRuntimeId: runtimeId,
|
||||
title: `${runtimeId} wrote a CultCache note`,
|
||||
body: "The v1 store format is the contract.",
|
||||
tags: [runtimeId, "ts", "interop"],
|
||||
};
|
||||
|
||||
if (options.legacyPayload || options.mismatchedPayload) {
|
||||
const payload = options.mismatchedPayload
|
||||
? encode([note.schemaVersion, note.documentId, 42, note.title, note.body, note.tags])
|
||||
: encode([note.schemaVersion, note.documentId, note.authorRuntimeId, note.title, note.body]);
|
||||
await new SingleFileMessagePackBackingStore(file).push({
|
||||
key: note.documentId,
|
||||
type: interopNoteDocument.type,
|
||||
schemaId: interopNoteDocument.schemaId,
|
||||
catalogEntry: {
|
||||
schemaId: interopNoteDocument.schemaId!,
|
||||
schemaName: interopNoteDocument.schemaName!,
|
||||
schemaVersion: interopNoteDocument.schemaVersion!,
|
||||
contentHash: interopNoteDocument.contentHash!,
|
||||
canonicalSchemaJson: interopNoteDocument.canonicalSchemaJson!,
|
||||
compatibleSchemaIds: [interopNoteDocument.schemaId!],
|
||||
members: interopNoteDocument.members,
|
||||
},
|
||||
storedAt: new Date().toISOString(),
|
||||
payload,
|
||||
});
|
||||
return note;
|
||||
}
|
||||
|
||||
await cache.put(interopNoteDocument, note.documentId, note);
|
||||
return note;
|
||||
}
|
||||
|
||||
async function readTsInteropStore(file: string): Promise<InteropNote> {
|
||||
const cache = CultCache.builder()
|
||||
.withDocumentType(interopNoteDocument)
|
||||
.withGenericStore(new SingleFileMessagePackBackingStore(file))
|
||||
.build();
|
||||
await cache.pullAllBackingStores();
|
||||
const notes = cache.getAll(interopNoteDocument);
|
||||
const note = notes[0];
|
||||
if (!note) {
|
||||
throw new Error("No cultcache.interop-note records found.");
|
||||
}
|
||||
return note;
|
||||
}
|
||||
|
||||
async function buildInteropPeers(): Promise<void> {
|
||||
if (!(await exists(rustInteropBinary))) {
|
||||
await execAsync(`"${cargoCommand}" build --quiet --example cultcache_interop`, {
|
||||
cwd: cultcacheRsRoot,
|
||||
});
|
||||
}
|
||||
if (!(await exists(csharpInteropDll))) {
|
||||
await execAsync(`"${dotnetCommand}" build "${csharpInteropProject}" -nologo`, {
|
||||
cwd: cultLibRoot,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function exists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runJsonCommand(
|
||||
name: string,
|
||||
command: string,
|
||||
args: string[],
|
||||
cwd: string,
|
||||
): Promise<any> {
|
||||
const { stdout, stderr } = await execFileAsync(command, args, { cwd, timeout: 30_000 });
|
||||
const trimmed = stdout.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error(`${name} produced no stdout.\n${stderr}`);
|
||||
}
|
||||
|
||||
return JSON.parse(trimmed.split(/\r?\n/).at(-1) as string);
|
||||
}
|
||||
17
packages/cultcache-ts/tsconfig.inspector.json
Normal file
17
packages/cultcache-ts/tsconfig.inspector.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"noEmit": true,
|
||||
"rootDir": ".",
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": [
|
||||
"inspector/src/**/*.ts",
|
||||
"inspector/src/**/*.tsx",
|
||||
"src/cult-cache-inspector.ts"
|
||||
]
|
||||
}
|
||||
24
packages/cultcache-ts/tsconfig.json
Normal file
24
packages/cultcache-ts/tsconfig.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "Node",
|
||||
"lib": [
|
||||
"ES2022"
|
||||
],
|
||||
"strict": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"test/**/*.ts"
|
||||
]
|
||||
}
|
||||
14
packages/cultcache-ts/tsconfig.test.json
Normal file
14
packages/cultcache-ts/tsconfig.test.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "dist-test",
|
||||
"declaration": false,
|
||||
"declarationMap": false
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"test/**/*.ts"
|
||||
],
|
||||
"exclude": []
|
||||
}
|
||||
24
packages/cultcache-ts/vite.inspector.config.ts
Normal file
24
packages/cultcache-ts/vite.inspector.config.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = fileURLToPath(new URL(".", import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
root: "inspector",
|
||||
base: "./",
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
react: path.resolve(root, "node_modules/react"),
|
||||
"react-dom": path.resolve(root, "node_modules/react-dom"),
|
||||
"react/jsx-runtime": path.resolve(root, "node_modules/react/jsx-runtime.js"),
|
||||
},
|
||||
dedupe: ["react", "react-dom"],
|
||||
},
|
||||
build: {
|
||||
outDir: "../dist-inspector",
|
||||
emptyOutDir: true
|
||||
}
|
||||
});
|
||||
33
packages/cultmesh-ts/package.json
Normal file
33
packages/cultmesh-ts/package.json
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"name": "cultmesh-ts",
|
||||
"version": "0.1.0",
|
||||
"description": "CultMesh local typed state and mesh catalog primitives for TypeScript runtimes.",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --pretty false",
|
||||
"test": "npm run build && tsc -p tsconfig.test.json && node --test dist-test/test/*.test.js"
|
||||
},
|
||||
"keywords": [
|
||||
"cultmesh",
|
||||
"cultcache",
|
||||
"cultnet",
|
||||
"messagepack",
|
||||
"typescript"
|
||||
],
|
||||
"author": "metacratic",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cultcache-ts": "file:../cultcache-ts",
|
||||
"cultnet-ts": "file:../cultnet-ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"typescript": "^5.9.0",
|
||||
"zod": "^3.25.76"
|
||||
}
|
||||
}
|
||||
218
packages/cultmesh-ts/src/index.ts
Normal file
218
packages/cultmesh-ts/src/index.ts
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
import {
|
||||
CultCache,
|
||||
SingleFileMessagePackBackingStore,
|
||||
type AnyCultCacheDocumentDefinition,
|
||||
type CacheBackingStore,
|
||||
type CultCacheDocumentValue,
|
||||
} from "cultcache-ts";
|
||||
import {
|
||||
CultNetDocumentRegistry,
|
||||
defineCultNetDocumentBinding,
|
||||
type CultNetDocumentBinding,
|
||||
} from "cultnet-ts";
|
||||
|
||||
export interface CultMeshNodeOptions {
|
||||
documents?: Iterable<AnyCultCacheDocumentDefinition>;
|
||||
bindings?: Iterable<CultNetDocumentBinding>;
|
||||
store?: CacheBackingStore;
|
||||
pullOnStart?: boolean;
|
||||
}
|
||||
|
||||
export class CultMeshNode {
|
||||
public constructor(
|
||||
public readonly cache: CultCache,
|
||||
public readonly store: CacheBackingStore,
|
||||
public readonly documents: CultNetDocumentRegistry,
|
||||
) {}
|
||||
|
||||
public get<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
key: string,
|
||||
): CultCacheDocumentValue<TDefinition> | undefined {
|
||||
return this.cache.get(definition, key);
|
||||
}
|
||||
|
||||
public getRequired<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
key: string,
|
||||
): CultCacheDocumentValue<TDefinition> {
|
||||
return this.cache.getRequired(definition, key);
|
||||
}
|
||||
|
||||
public put<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
key: string,
|
||||
value: CultCacheDocumentValue<TDefinition>,
|
||||
): Promise<CultCacheDocumentValue<TDefinition>> {
|
||||
return this.cache.put(definition, key, value);
|
||||
}
|
||||
|
||||
public delete<TDefinition extends AnyCultCacheDocumentDefinition>(
|
||||
definition: TDefinition,
|
||||
key: string,
|
||||
): Promise<boolean> {
|
||||
return this.cache.delete(definition, key);
|
||||
}
|
||||
|
||||
public async flush(soft = false): Promise<void> {
|
||||
await this.store.pushAll?.(this.cache.snapshot(), { soft });
|
||||
}
|
||||
}
|
||||
|
||||
export class CultMeshVerseCatalog<TVerse = unknown> {
|
||||
readonly #verses = new Map<string, TVerse>();
|
||||
|
||||
public get verses(): readonly TVerse[] {
|
||||
return [...this.#verses.values()];
|
||||
}
|
||||
|
||||
public upsert(verseId: string, verse: TVerse): void {
|
||||
requireNonEmpty(verseId, "verseId");
|
||||
this.#verses.set(verseId, verse);
|
||||
}
|
||||
|
||||
public get(verseId: string): TVerse | undefined {
|
||||
requireNonEmpty(verseId, "verseId");
|
||||
return this.#verses.get(verseId);
|
||||
}
|
||||
}
|
||||
|
||||
export interface CultMeshPeerCard {
|
||||
peerId: string;
|
||||
verseId: string;
|
||||
endpoints: readonly string[];
|
||||
roles?: readonly string[];
|
||||
shardIds?: readonly string[];
|
||||
authorityLeaseId?: string;
|
||||
}
|
||||
|
||||
export class CultMeshPeerCatalog {
|
||||
readonly #peers = new Map<string, CultMeshPeerCard>();
|
||||
|
||||
public get peers(): readonly CultMeshPeerCard[] {
|
||||
return [...this.#peers.values()].sort((left, right) =>
|
||||
left.peerId.localeCompare(right.peerId),
|
||||
);
|
||||
}
|
||||
|
||||
public upsert(peer: CultMeshPeerCard): void {
|
||||
requireNonEmpty(peer.peerId, "peer.peerId");
|
||||
requireNonEmpty(peer.verseId, "peer.verseId");
|
||||
this.#peers.set(peer.peerId, peer);
|
||||
}
|
||||
|
||||
public find(verseId: string, role?: string): readonly CultMeshPeerCard[] {
|
||||
requireNonEmpty(verseId, "verseId");
|
||||
return this.peers.filter(
|
||||
(peer) =>
|
||||
peer.verseId === verseId &&
|
||||
(!role || peer.roles?.includes(role) === true),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface CultMeshAuthorityLease {
|
||||
leaseId: string;
|
||||
verseId: string;
|
||||
peerId: string;
|
||||
roles: readonly string[];
|
||||
shardIds?: readonly string[];
|
||||
validFrom: Date;
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
export class CultMeshAuthorityLeaseCatalog {
|
||||
readonly #leases = new Map<string, CultMeshAuthorityLease>();
|
||||
|
||||
public upsert(lease: CultMeshAuthorityLease): void {
|
||||
requireNonEmpty(lease.leaseId, "lease.leaseId");
|
||||
requireNonEmpty(lease.verseId, "lease.verseId");
|
||||
requireNonEmpty(lease.peerId, "lease.peerId");
|
||||
if (lease.expiresAt <= lease.validFrom) {
|
||||
throw new Error("CultMesh authority lease expiry must be after validFrom.");
|
||||
}
|
||||
|
||||
this.#leases.set(lease.leaseId, lease);
|
||||
}
|
||||
|
||||
public isAuthorized(
|
||||
peer: CultMeshPeerCard,
|
||||
role: string,
|
||||
shardId?: string,
|
||||
at = new Date(),
|
||||
): boolean {
|
||||
requireNonEmpty(role, "role");
|
||||
if (!peer.authorityLeaseId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lease = this.#leases.get(peer.authorityLeaseId);
|
||||
if (!lease) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
at >= lease.validFrom &&
|
||||
at < lease.expiresAt &&
|
||||
lease.verseId === peer.verseId &&
|
||||
lease.peerId === peer.peerId &&
|
||||
lease.roles.includes(role) &&
|
||||
peer.roles?.includes(role) === true &&
|
||||
(!shardId ||
|
||||
!lease.shardIds ||
|
||||
lease.shardIds.length === 0 ||
|
||||
lease.shardIds.includes(shardId))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class CultMesh {
|
||||
public static async createNode(
|
||||
cachePath: string,
|
||||
options: CultMeshNodeOptions = {},
|
||||
): Promise<CultMeshNode> {
|
||||
requireNonEmpty(cachePath, "cachePath");
|
||||
const store =
|
||||
options.store ?? new SingleFileMessagePackBackingStore(cachePath);
|
||||
const cache = CultCache.builder().withGenericStore(store).build();
|
||||
const documents = new CultNetDocumentRegistry(options.bindings);
|
||||
|
||||
for (const definition of options.documents ?? []) {
|
||||
cache.registerDocumentType(definition);
|
||||
if (!documents.get(definition.type)) {
|
||||
documents.register(defineCultNetDocumentBinding({ definition }));
|
||||
}
|
||||
}
|
||||
|
||||
if (options.pullOnStart !== false) {
|
||||
await cache.pullAllBackingStores();
|
||||
}
|
||||
|
||||
return new CultMeshNode(cache, store, documents);
|
||||
}
|
||||
|
||||
public static startNode(
|
||||
cachePath: string,
|
||||
options: CultMeshNodeOptions = {},
|
||||
): Promise<CultMeshNode> {
|
||||
return CultMesh.createNode(cachePath, options);
|
||||
}
|
||||
|
||||
public static createVerseCatalog<TVerse = unknown>(): CultMeshVerseCatalog<TVerse> {
|
||||
return new CultMeshVerseCatalog<TVerse>();
|
||||
}
|
||||
|
||||
public static createPeerCatalog(): CultMeshPeerCatalog {
|
||||
return new CultMeshPeerCatalog();
|
||||
}
|
||||
|
||||
public static createAuthorityLeaseCatalog(): CultMeshAuthorityLeaseCatalog {
|
||||
return new CultMeshAuthorityLeaseCatalog();
|
||||
}
|
||||
}
|
||||
|
||||
function requireNonEmpty(value: string, name: string): void {
|
||||
if (!value || value.trim().length === 0) {
|
||||
throw new Error(`${name} must be non-empty.`);
|
||||
}
|
||||
}
|
||||
69
packages/cultmesh-ts/test/cultmesh.test.ts
Normal file
69
packages/cultmesh-ts/test/cultmesh.test.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { mkdtemp } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { z } from "zod";
|
||||
import { defineDocumentType } from "cultcache-ts";
|
||||
import { CultMesh } from "../src/index";
|
||||
|
||||
const noteDocument = defineDocumentType({
|
||||
type: "cultmesh.note",
|
||||
schemaId: "cultmesh.note.v0",
|
||||
schema: z.object({
|
||||
noteId: z.string(),
|
||||
body: z.string(),
|
||||
}),
|
||||
name: "noteId",
|
||||
});
|
||||
|
||||
test("CultMesh TS opens a durable local node and registers document bindings", async () => {
|
||||
const filePath = join(await mkdtemp(join(tmpdir(), "cultmesh-ts-")), "node.ccmp");
|
||||
const first = await CultMesh.startNode(filePath, {
|
||||
documents: [noteDocument],
|
||||
});
|
||||
|
||||
await first.put(noteDocument, "note:intro", {
|
||||
noteId: "note:intro",
|
||||
body: "hello from local CultMesh TS",
|
||||
});
|
||||
await first.flush();
|
||||
|
||||
const reopened = await CultMesh.startNode(filePath, {
|
||||
documents: [noteDocument],
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
reopened.getRequired(noteDocument, "note:intro").body,
|
||||
"hello from local CultMesh TS",
|
||||
);
|
||||
assert.ok(reopened.documents.get("cultmesh.note"));
|
||||
assert.ok(reopened.documents.getBySchemaId("cultmesh.note.v0"));
|
||||
});
|
||||
|
||||
test("CultMesh TS local authority leases do not trust peer cards by contact alone", () => {
|
||||
const peers = CultMesh.createPeerCatalog();
|
||||
const leases = CultMesh.createAuthorityLeaseCatalog();
|
||||
const peer = {
|
||||
peerId: "voidbot-local",
|
||||
verseId: "local",
|
||||
endpoints: ["cultmesh://localhost"],
|
||||
roles: ["shard-primary"],
|
||||
authorityLeaseId: "lease:voidbot-local",
|
||||
};
|
||||
|
||||
peers.upsert(peer);
|
||||
|
||||
assert.equal(leases.isAuthorized(peer, "shard-primary"), false);
|
||||
|
||||
leases.upsert({
|
||||
leaseId: "lease:voidbot-local",
|
||||
verseId: "local",
|
||||
peerId: "voidbot-local",
|
||||
roles: ["shard-primary"],
|
||||
validFrom: new Date(Date.now() - 1000),
|
||||
expiresAt: new Date(Date.now() + 1000),
|
||||
});
|
||||
|
||||
assert.equal(leases.isAuthorized(peer, "shard-primary"), true);
|
||||
});
|
||||
18
packages/cultmesh-ts/tsconfig.json
Normal file
18
packages/cultmesh-ts/tsconfig.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "Node",
|
||||
"strict": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
]
|
||||
}
|
||||
14
packages/cultmesh-ts/tsconfig.test.json
Normal file
14
packages/cultmesh-ts/tsconfig.test.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist-test",
|
||||
"rootDir": ".",
|
||||
"types": [
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"test/**/*.ts"
|
||||
]
|
||||
}
|
||||
2
packages/cultnet-rs/.gitignore
vendored
Normal file
2
packages/cultnet-rs/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/target
|
||||
Cargo.lock
|
||||
32
packages/cultnet-rs/Cargo.toml
Normal file
32
packages/cultnet-rs/Cargo.toml
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
[package]
|
||||
name = "cultnet-rs"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
license = "MIT"
|
||||
description = "CultNet-style typed MessagePack messaging, direct-pipe framing, security helpers, and CultCache replication for Rust runtimes."
|
||||
|
||||
[lib]
|
||||
name = "cultnet_rs"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
aes-gcm = "0.10"
|
||||
anyhow = "1"
|
||||
base64 = "0.22"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
cultcache-rs = { path = "../cultcache-rs" }
|
||||
hmac = "0.12"
|
||||
rand = "0.9"
|
||||
rmp-serde = "1.3"
|
||||
rmpv = { version = "1.3", features = ["with-serde"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_bytes = "0.11"
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
socket2 = "0.5"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = "1.4"
|
||||
tempfile = "3"
|
||||
79
packages/cultnet-rs/README.md
Normal file
79
packages/cultnet-rs/README.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# CultNet RS
|
||||
|
||||
`cultnet-rs` is the Rust sibling of `cultnet-ts`: typed MessagePack messages,
|
||||
4-byte length-prefixed direct-pipe framing, CultLib-shaped security helpers, and
|
||||
CultCache replication without making callers paw through raw envelopes like we
|
||||
lost a bet.
|
||||
|
||||
The contract is intentionally boring:
|
||||
|
||||
- `cultnet.schema.v0` sends schema-versioned MessagePack objects.
|
||||
- `gamecult.networking.v0` maps the legacy C# union shape explicitly.
|
||||
- schema discovery uses explicit catalog request/response messages; no inbound
|
||||
auto-detect sludge.
|
||||
- document put/delete/snapshot messages move typed CultCache entries.
|
||||
- raw document put/snapshot messages preserve canonical MessagePack payload
|
||||
bytes for bit-compatible neighbors.
|
||||
- hello messages advertise `supportedMutationContracts`, so callers discover
|
||||
which document types are read-only, which accept typed intents, which
|
||||
authority owns the mutation, and which receipt documents prove the outcome.
|
||||
- payloads are decoded through registered Rust types before entering the cache.
|
||||
|
||||
This crate is not an HTTP wrapper. It is the wire vocabulary Epiphany,
|
||||
Ghostlight, VoidBot, and the rest of the swarm can share.
|
||||
|
||||
The public API surface is the schema plus its mutation contracts. A runtime does
|
||||
not expose a pile of bespoke verbs and hope everyone remembers the ritual; it
|
||||
advertises typed documents, typed intents, and typed receipts. Polite machines
|
||||
knock on the contract before touching the furniture.
|
||||
|
||||
## Receipts
|
||||
|
||||
```powershell
|
||||
cargo test
|
||||
```
|
||||
|
||||
The initial tests prove:
|
||||
|
||||
- CultLib-compatible AES-GCM string encryption and HMAC session tokens
|
||||
- 4-byte big-endian MessagePack framing
|
||||
- schema-versioned message round trips
|
||||
- legacy `gamecult.networking.v0` login mapping
|
||||
- schema discovery catalog responses with canonical JSON schema hashes
|
||||
- CultCache snapshot replication through registered typed documents
|
||||
- raw snapshot replication that preserves the original payload bytes
|
||||
- document mutation contract advertisement through hello frames and registries
|
||||
|
||||
## Schema Discovery
|
||||
|
||||
`cultnet-rs` now ships a built-in schema registry for the shared swarm contract
|
||||
surface:
|
||||
|
||||
- core wire messages
|
||||
- legacy `gamecult.networking.v0` auth/sample payloads
|
||||
- schema catalog request/response messages
|
||||
- the canonical `ghostlight.agent-state` document payload schema
|
||||
|
||||
Use `builtin_schema_registry()` when you want the standard catalog, or register
|
||||
your own closed-world schema set with `CultNetSchemaRegistry`. Discovery stays
|
||||
explicit on purpose: peers advertise only the contracts they were compiled to
|
||||
understand, the same way CultCache only consumes the document types you
|
||||
registered instead of pretending polymorphism is a public park.
|
||||
|
||||
## Local Fast Lane
|
||||
|
||||
`cultnet-rs` now mirrors the raw replication seam from `cultnet-ts`:
|
||||
|
||||
- `cultnet.document_put_raw.v0`
|
||||
- `cultnet.snapshot_response_raw.v0`
|
||||
|
||||
Those messages carry the exact persisted MessagePack payload bytes from
|
||||
CultCache along with the typed envelope metadata. Combined with
|
||||
`CultCache::put_envelope::<T>()`, that lets a bit-compatible neighbor ingest the
|
||||
document without re-encoding the payload first.
|
||||
|
||||
That still is not zero-copy in the religious sense. Frames allocate, bytes move,
|
||||
and the receiving cache still decodes once to keep typed reads and validation
|
||||
honest. The win is narrower and realer: identical payload bytes stop getting
|
||||
decoded into generic sludge and then encoded right back into the same bytes for
|
||||
no reason.
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-delete.schema.json",
|
||||
"title": "CultNet Document Delete Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"messageId",
|
||||
"schemaId",
|
||||
"recordKey"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.document_delete.v0" },
|
||||
"messageId": { "type": "string", "minLength": 1 },
|
||||
"schemaId": { "type": "string", "minLength": 1 },
|
||||
"recordKey": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-mutation-contract.schema.json",
|
||||
"title": "CultNet Document Mutation Contract",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"documentType",
|
||||
"operations",
|
||||
"authority"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"documentType": { "type": "string", "minLength": 1 },
|
||||
"payloadSchemaVersion": { "type": "string", "minLength": 1 },
|
||||
"operations": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["snapshot", "documentPut", "documentDelete", "intentSubmit", "receiptWatch"]
|
||||
}
|
||||
},
|
||||
"authority": {
|
||||
"type": "string",
|
||||
"enum": ["readOnly", "localUser", "coordinator", "runtime", "denied"]
|
||||
},
|
||||
"intentDocumentTypes": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"receiptDocumentTypes": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"notes": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-put-raw.schema.json",
|
||||
"title": "CultNet Raw Document Put Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"messageId",
|
||||
"document"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.document_put_raw.v0" },
|
||||
"messageId": { "type": "string", "minLength": 1 },
|
||||
"document": {
|
||||
"$ref": "./cultnet.raw-document-record.schema.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-put.schema.json",
|
||||
"title": "CultNet Document Put Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"messageId",
|
||||
"document"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.document_put.v0" },
|
||||
"messageId": { "type": "string", "minLength": 1 },
|
||||
"document": {
|
||||
"$ref": "./cultnet.document-record.schema.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-record.schema.json",
|
||||
"title": "CultNet Document Record",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaId",
|
||||
"recordKey",
|
||||
"storedAt",
|
||||
"payload"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaId": { "type": "string", "minLength": 1 },
|
||||
"recordKey": { "type": "string", "minLength": 1 },
|
||||
"storedAt": { "type": "string", "minLength": 1 },
|
||||
"payload": {},
|
||||
"sourceRuntimeId": { "type": "string", "minLength": 1 },
|
||||
"sourceAgentId": { "type": "string", "minLength": 1 },
|
||||
"sourceRole": { "type": "string", "minLength": 1 },
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
15
packages/cultnet-rs/contracts/cultnet.error.schema.json
Normal file
15
packages/cultnet-rs/contracts/cultnet.error.schema.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.error.schema.json",
|
||||
"title": "CultNet Error Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"error"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.error.v0" },
|
||||
"error": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
35
packages/cultnet-rs/contracts/cultnet.hello.schema.json
Normal file
35
packages/cultnet-rs/contracts/cultnet.hello.schema.json
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.hello.schema.json",
|
||||
"title": "CultNet Hello Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"runtimeId",
|
||||
"runtimeKind"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.hello.v0" },
|
||||
"runtimeId": { "type": "string", "minLength": 1 },
|
||||
"runtimeKind": { "type": "string", "minLength": 1 },
|
||||
"agentId": { "type": "string", "minLength": 1 },
|
||||
"role": { "type": "string", "minLength": 1 },
|
||||
"displayName": { "type": "string", "minLength": 1 },
|
||||
"supportedDocumentTypes": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"supportedMutationContracts": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "./cultnet.document-mutation-contract.schema.json"
|
||||
}
|
||||
},
|
||||
"supportedMessageVersions": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"supportsSchemaCatalog": { "type": "boolean" }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.login-success.schema.json",
|
||||
"title": "CultNet Login Success Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"nonce",
|
||||
"session"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.login_success.v0" },
|
||||
"nonce": { "type": "string", "minLength": 1 },
|
||||
"session": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
19
packages/cultnet-rs/contracts/cultnet.login.schema.json
Normal file
19
packages/cultnet-rs/contracts/cultnet.login.schema.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.login.schema.json",
|
||||
"title": "CultNet Login Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"nonce",
|
||||
"auth",
|
||||
"password"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.login.v0" },
|
||||
"nonce": { "type": "string", "minLength": 1 },
|
||||
"auth": { "type": "string", "minLength": 1 },
|
||||
"password": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.raw-document-record.schema.json",
|
||||
"title": "CultNet Raw Document Record",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaId",
|
||||
"recordKey",
|
||||
"storedAt",
|
||||
"payloadEncoding",
|
||||
"payload"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaId": { "type": "string", "minLength": 1 },
|
||||
"recordKey": { "type": "string", "minLength": 1 },
|
||||
"storedAt": { "type": "string", "minLength": 1 },
|
||||
"payloadEncoding": { "const": "messagepack" },
|
||||
"payload": {
|
||||
"type": "string",
|
||||
"contentEncoding": "base64",
|
||||
"x-cult-runtime-type": "bytes"
|
||||
},
|
||||
"sourceRuntimeId": { "type": "string", "minLength": 1 },
|
||||
"sourceAgentId": { "type": "string", "minLength": 1 },
|
||||
"sourceRole": { "type": "string", "minLength": 1 },
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
21
packages/cultnet-rs/contracts/cultnet.register.schema.json
Normal file
21
packages/cultnet-rs/contracts/cultnet.register.schema.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.register.schema.json",
|
||||
"title": "CultNet Register Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"nonce",
|
||||
"email",
|
||||
"password",
|
||||
"name"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.register.v0" },
|
||||
"nonce": { "type": "string", "minLength": 1 },
|
||||
"email": { "type": "string", "minLength": 1 },
|
||||
"password": { "type": "string", "minLength": 1 },
|
||||
"name": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.sample-change-name.schema.json",
|
||||
"title": "CultNet Sample Change Name Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"name"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.sample.change_name.v0" },
|
||||
"name": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.sample-chat.schema.json",
|
||||
"title": "CultNet Sample Chat Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"text"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.sample.chat.v0" },
|
||||
"text": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.schema-catalog-request.schema.json",
|
||||
"title": "CultNet Schema Catalog Request Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"messageId"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.schema_catalog_request.v0" },
|
||||
"messageId": { "type": "string", "minLength": 1 },
|
||||
"includeSchemaJson": { "type": "boolean" },
|
||||
"schemaIds": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"kinds": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"wire_message",
|
||||
"document_payload",
|
||||
"shared_contract"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.schema-catalog-response.schema.json",
|
||||
"title": "CultNet Schema Catalog Response Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"messageId",
|
||||
"schemas"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.schema_catalog_response.v0" },
|
||||
"messageId": { "type": "string", "minLength": 1 },
|
||||
"schemas": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "./cultnet.schema-descriptor.schema.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.schema-descriptor.schema.json",
|
||||
"title": "CultNet Schema Descriptor",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaId",
|
||||
"kind",
|
||||
"wireContracts",
|
||||
"contentHash"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaId": { "type": "string", "minLength": 1 },
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"wire_message",
|
||||
"document_payload",
|
||||
"shared_contract"
|
||||
]
|
||||
},
|
||||
"schemaVersion": { "type": "string", "minLength": 1 },
|
||||
"documentType": { "type": "string", "minLength": 1 },
|
||||
"title": { "type": "string", "minLength": 1 },
|
||||
"wireContracts": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"cultnet.schema.v0",
|
||||
"gamecult.networking.v0"
|
||||
]
|
||||
}
|
||||
},
|
||||
"contentHash": { "type": "string", "minLength": 1 },
|
||||
"schemaJson": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.snapshot-request.schema.json",
|
||||
"title": "CultNet Snapshot Request Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"messageId"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.snapshot_request.v0" },
|
||||
"messageId": { "type": "string", "minLength": 1 },
|
||||
"schemaIds": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"recordKeys": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.snapshot-response-raw.schema.json",
|
||||
"title": "CultNet Raw Snapshot Response Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"messageId",
|
||||
"documents"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.snapshot_response_raw.v0" },
|
||||
"messageId": { "type": "string", "minLength": 1 },
|
||||
"documents": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "./cultnet.raw-document-record.schema.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.snapshot-response.schema.json",
|
||||
"title": "CultNet Snapshot Response Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"messageId",
|
||||
"documents"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.snapshot_response.v0" },
|
||||
"messageId": { "type": "string", "minLength": 1 },
|
||||
"documents": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "./cultnet.document-record.schema.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
17
packages/cultnet-rs/contracts/cultnet.verify.schema.json
Normal file
17
packages/cultnet-rs/contracts/cultnet.verify.schema.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.verify.schema.json",
|
||||
"title": "CultNet Verify Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"nonce",
|
||||
"session"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.verify.v0" },
|
||||
"nonce": { "type": "string", "minLength": 1 },
|
||||
"session": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
845
packages/cultnet-rs/contracts/ghostlight.agent-state.schema.json
Normal file
845
packages/cultnet-rs/contracts/ghostlight.agent-state.schema.json
Normal file
|
|
@ -0,0 +1,845 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/Ghostlight/schemas/agent-state.schema.json",
|
||||
"title": "Ghostlight Agent State Schema",
|
||||
"description": "First canonical contract for Ghostlight agent, perception, relationship, event, scene, and dialogue-context state.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schema_version",
|
||||
"world",
|
||||
"agents",
|
||||
"relationships",
|
||||
"events",
|
||||
"scenes"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schema_version": {
|
||||
"const": "ghostlight.agent_state.v0"
|
||||
},
|
||||
"world": {
|
||||
"$ref": "#/$defs/world_state"
|
||||
},
|
||||
"agents": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/agent"
|
||||
}
|
||||
},
|
||||
"relationships": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/relationship"
|
||||
}
|
||||
},
|
||||
"events": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/event"
|
||||
}
|
||||
},
|
||||
"scenes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/scene"
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"world_state": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"world_id",
|
||||
"setting",
|
||||
"time",
|
||||
"canon_context"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"world_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"setting": {
|
||||
"type": "string"
|
||||
},
|
||||
"time": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"label"
|
||||
],
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"canon_context": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"ambient_pressures": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/pressure"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"agent": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"agent_id",
|
||||
"identity",
|
||||
"canonical_state",
|
||||
"goals",
|
||||
"memories",
|
||||
"perceived_state_overlays"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"agent_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"identity": {
|
||||
"$ref": "#/$defs/identity"
|
||||
},
|
||||
"canonical_state": {
|
||||
"$ref": "#/$defs/canonical_state"
|
||||
},
|
||||
"goals": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/goal"
|
||||
}
|
||||
},
|
||||
"memories": {
|
||||
"$ref": "#/$defs/memory_bundle"
|
||||
},
|
||||
"perceived_state_overlays": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/perceived_state_overlay"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"identity": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"roles",
|
||||
"origin",
|
||||
"public_description"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"roles": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"origin": {
|
||||
"type": "string"
|
||||
},
|
||||
"public_description": {
|
||||
"type": "string"
|
||||
},
|
||||
"private_notes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"canonical_state": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"underlying_organization",
|
||||
"stable_dispositions",
|
||||
"behavioral_dimensions",
|
||||
"presentation_strategy",
|
||||
"voice_style",
|
||||
"situational_state",
|
||||
"values"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"underlying_organization": {
|
||||
"$ref": "#/$defs/variable_map"
|
||||
},
|
||||
"stable_dispositions": {
|
||||
"$ref": "#/$defs/variable_map"
|
||||
},
|
||||
"behavioral_dimensions": {
|
||||
"$ref": "#/$defs/variable_map"
|
||||
},
|
||||
"presentation_strategy": {
|
||||
"$ref": "#/$defs/variable_map"
|
||||
},
|
||||
"situational_state": {
|
||||
"$ref": "#/$defs/variable_map"
|
||||
},
|
||||
"values": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/value_commitment"
|
||||
}
|
||||
},
|
||||
"voice_style": {
|
||||
"$ref": "#/$defs/variable_map"
|
||||
}
|
||||
}
|
||||
},
|
||||
"variable_map": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/$defs/state_variable"
|
||||
}
|
||||
},
|
||||
"state_variable": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"mean",
|
||||
"plasticity",
|
||||
"current_activation"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"mean": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"plasticity": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"current_activation": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"evidence": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"value_commitment": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"value_id",
|
||||
"label",
|
||||
"priority",
|
||||
"unforgivable_if_betrayed"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"value_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"priority": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"unforgivable_if_betrayed": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"goal": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"goal_id",
|
||||
"description",
|
||||
"scope",
|
||||
"priority",
|
||||
"emotional_stake",
|
||||
"status"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"goal_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"scope": {
|
||||
"enum": [
|
||||
"immediate",
|
||||
"scene",
|
||||
"case",
|
||||
"arc",
|
||||
"life"
|
||||
]
|
||||
},
|
||||
"priority": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"emotional_stake": {
|
||||
"type": "string"
|
||||
},
|
||||
"blockers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"enum": [
|
||||
"active",
|
||||
"blocked",
|
||||
"dormant",
|
||||
"resolved",
|
||||
"abandoned"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"memory_bundle": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"episodic",
|
||||
"semantic",
|
||||
"relationship_summaries"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"episodic": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/memory"
|
||||
}
|
||||
},
|
||||
"semantic": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/memory"
|
||||
}
|
||||
},
|
||||
"relationship_summaries": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/memory"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"memory": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"memory_id",
|
||||
"summary",
|
||||
"salience",
|
||||
"confidence"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"memory_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"summary": {
|
||||
"type": "string"
|
||||
},
|
||||
"salience": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"confidence": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"linked_event_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"linked_relationship_id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"perceived_state_overlay": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"observer_agent_id",
|
||||
"target_agent_id",
|
||||
"perceived_dimensions",
|
||||
"beliefs",
|
||||
"distortions"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"observer_agent_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"target_agent_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"perceived_dimensions": {
|
||||
"$ref": "#/$defs/inferred_variable_map"
|
||||
},
|
||||
"beliefs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/belief"
|
||||
}
|
||||
},
|
||||
"distortions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"enum": [
|
||||
"hostile_attribution",
|
||||
"false_reassurance",
|
||||
"overread_warmth",
|
||||
"underread_threat",
|
||||
"false_mask_detection",
|
||||
"missed_mask_detection",
|
||||
"status_injury",
|
||||
"threat_amplification",
|
||||
"institutional_suspicion_transfer",
|
||||
"care_underread",
|
||||
"mask_overfocus"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"belief": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"belief_id",
|
||||
"claim",
|
||||
"confidence"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"belief_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"claim": {
|
||||
"type": "string"
|
||||
},
|
||||
"confidence": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"subject_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"claim_type": {
|
||||
"enum": [
|
||||
"fact",
|
||||
"identity_inference",
|
||||
"motive_inference",
|
||||
"prediction",
|
||||
"norm",
|
||||
"value_read",
|
||||
"relationship_read"
|
||||
]
|
||||
},
|
||||
"evidence_event_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"visibility": {
|
||||
"enum": [
|
||||
"private",
|
||||
"shared",
|
||||
"public",
|
||||
"rumor"
|
||||
]
|
||||
},
|
||||
"emotional_charge": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"relationship": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"relationship_id",
|
||||
"source_id",
|
||||
"target_id",
|
||||
"stance",
|
||||
"summary"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"relationship_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"source_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"target_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"stance": {
|
||||
"$ref": "#/$defs/variable_map"
|
||||
},
|
||||
"summary": {
|
||||
"type": "string"
|
||||
},
|
||||
"unresolved_incident_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"event": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"event_id",
|
||||
"kind",
|
||||
"summary",
|
||||
"participants",
|
||||
"pressure_tags"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"event_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"kind": {
|
||||
"enum": [
|
||||
"dialogue",
|
||||
"action",
|
||||
"memory",
|
||||
"relationship_shift",
|
||||
"scene_outcome",
|
||||
"author_note"
|
||||
]
|
||||
},
|
||||
"summary": {
|
||||
"type": "string"
|
||||
},
|
||||
"participants": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"pressure_tags": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"observed_exchange": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/event_exchange_turn"
|
||||
}
|
||||
},
|
||||
"private_interpretations": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/event_private_interpretation"
|
||||
}
|
||||
},
|
||||
"event_effects": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/event_effect"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"scene": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"scene_id",
|
||||
"location",
|
||||
"participants",
|
||||
"public_stakes",
|
||||
"hidden_stakes",
|
||||
"dialogue_context_packs"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"scene_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"location": {
|
||||
"type": "string"
|
||||
},
|
||||
"participants": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"public_stakes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"hidden_stakes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"dialogue_context_packs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/dialogue_context_pack"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"dialogue_context_pack": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"speaker_agent_id",
|
||||
"listener_ids",
|
||||
"speaker_local_truth",
|
||||
"speaker_beliefs",
|
||||
"active_memories",
|
||||
"active_goals",
|
||||
"presentation_constraints"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"speaker_agent_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"listener_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"speaker_local_truth": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"speaker_beliefs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"active_memories": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"active_goals": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"presentation_constraints": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"pressure": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"pressure_id",
|
||||
"label",
|
||||
"intensity"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"pressure_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"intensity": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"inferred_variable_map": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/$defs/inferred_state_variable"
|
||||
}
|
||||
},
|
||||
"inferred_state_variable": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"observed_activation",
|
||||
"attributed_disposition",
|
||||
"confidence"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"observed_activation": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"attributed_disposition": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"confidence": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"evidence": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"event_exchange_turn": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"speaker_id",
|
||||
"utterance_summary",
|
||||
"dialogue_function"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"speaker_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"utterance_summary": {
|
||||
"type": "string"
|
||||
},
|
||||
"dialogue_function": {
|
||||
"type": "string"
|
||||
},
|
||||
"observable_cues": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"event_private_interpretation": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"agent_id",
|
||||
"interpretation",
|
||||
"confidence"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"agent_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"interpretation": {
|
||||
"type": "string"
|
||||
},
|
||||
"attributed_motive": {
|
||||
"type": "string"
|
||||
},
|
||||
"appraisal_tags": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"confidence": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"event_effect": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"agent_id",
|
||||
"summary"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"agent_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"summary": {
|
||||
"type": "string"
|
||||
},
|
||||
"state_deltas": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/state_delta"
|
||||
}
|
||||
},
|
||||
"new_belief_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"memory_update_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"state_delta": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"path",
|
||||
"delta"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
"delta": {
|
||||
"type": "number",
|
||||
"minimum": -1,
|
||||
"maximum": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
967
packages/cultnet-rs/examples/cultnet_interop_peer.rs
Normal file
967
packages/cultnet-rs/examples/cultnet_interop_peer.rs
Normal file
|
|
@ -0,0 +1,967 @@
|
|||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use cultcache_rs::CultCache;
|
||||
use cultcache_rs::DatabaseEntry;
|
||||
use cultcache_rs::SingleFileMessagePackBackingStore;
|
||||
use cultnet_rs::CultNetDocumentBinding;
|
||||
use cultnet_rs::CultNetDocumentMutationContract;
|
||||
use cultnet_rs::CultNetDocumentOperation;
|
||||
use cultnet_rs::CultNetDocumentPutOptions;
|
||||
use cultnet_rs::CultNetDocumentRegistry;
|
||||
use cultnet_rs::CultNetMessage;
|
||||
use cultnet_rs::CultNetMutationAuthority;
|
||||
use cultnet_rs::CultNetSchemaKind;
|
||||
use cultnet_rs::CultNetSchemaRegistration;
|
||||
use cultnet_rs::CultNetSchemaRegistry;
|
||||
use cultnet_rs::CultNetWireContract;
|
||||
use cultnet_rs::builtin_schema_registry;
|
||||
use cultnet_rs::decode_cultnet_message_from_slice;
|
||||
use cultnet_rs::encode_cultnet_message_to_vec;
|
||||
use cultnet_rs::encode_frame;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use socket2::Domain;
|
||||
use socket2::Protocol;
|
||||
use socket2::Socket;
|
||||
use socket2::Type;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::io::Write;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::net::SocketAddr;
|
||||
use std::net::SocketAddrV4;
|
||||
use std::net::TcpListener;
|
||||
use std::net::TcpStream;
|
||||
use std::net::UdpSocket;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
const INTEROP_DOCUMENT_TYPE: &str = "cultnet.interop-note";
|
||||
const INTEROP_SCHEMA_VERSION: &str = "cultnet.interop_note.v0";
|
||||
const MUTATION_INTENT_TYPE: &str = "cultnet.interop-note-mutation-intent";
|
||||
const MUTATION_INTENT_SCHEMA_ID: &str = "https://github.com/GameCult/cultnet-ts/integration/contracts/cultnet.interop-note-mutation-intent.schema.json";
|
||||
const MUTATION_INTENT_SCHEMA_VERSION: &str = "cultnet.interop_note_mutation_intent.v0";
|
||||
const MUTATION_RECEIPT_TYPE: &str = "cultnet.interop-note-mutation-receipt";
|
||||
const MUTATION_RECEIPT_SCHEMA_ID: &str = "https://github.com/GameCult/cultnet-ts/integration/contracts/cultnet.interop-note-mutation-receipt.schema.json";
|
||||
const MUTATION_RECEIPT_SCHEMA_VERSION: &str = "cultnet.interop_note_mutation_receipt.v0";
|
||||
const FIRE_COMMAND_TYPE: &str = "cultnet.interop-fire-weapon-command";
|
||||
const FIRE_COMMAND_SCHEMA_ID: &str = "https://github.com/GameCult/cultnet-ts/integration/contracts/cultnet.interop-fire-weapon-command.schema.json";
|
||||
const FIRE_COMMAND_SCHEMA_VERSION: &str = "cultnet.interop_fire_weapon_command.v0";
|
||||
const FIRE_RECEIPT_TYPE: &str = "cultnet.interop-fire-weapon-receipt";
|
||||
const FIRE_RECEIPT_SCHEMA_ID: &str = "https://github.com/GameCult/cultnet-ts/integration/contracts/cultnet.interop-fire-weapon-receipt.schema.json";
|
||||
const FIRE_RECEIPT_SCHEMA_VERSION: &str = "cultnet.interop_fire_weapon_receipt.v0";
|
||||
const DISCOVERY_ANNOUNCE_SCHEMA_VERSION: &str = "cultnet.discovery_announce.v0";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "cultnet.interop-note", schema = "CultNetInteropNote")]
|
||||
struct CultNetInteropNote {
|
||||
#[cultcache(key = 0)]
|
||||
schema_version: String,
|
||||
#[cultcache(key = 1)]
|
||||
document_id: String,
|
||||
#[cultcache(key = 2)]
|
||||
author_runtime_id: String,
|
||||
#[cultcache(key = 3)]
|
||||
title: String,
|
||||
#[cultcache(key = 4)]
|
||||
body: String,
|
||||
#[cultcache(key = 5)]
|
||||
tags: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(
|
||||
type = "cultnet.interop-note-mutation-intent",
|
||||
schema = "CultNetInteropMutationIntent"
|
||||
)]
|
||||
struct CultNetInteropMutationIntent {
|
||||
#[cultcache(key = 0)]
|
||||
schema_version: String,
|
||||
#[cultcache(key = 1)]
|
||||
intent_id: String,
|
||||
#[cultcache(key = 2)]
|
||||
target_document_id: String,
|
||||
#[cultcache(key = 3)]
|
||||
append_body: String,
|
||||
#[cultcache(key = 4)]
|
||||
append_tag: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(
|
||||
type = "cultnet.interop-note-mutation-receipt",
|
||||
schema = "CultNetInteropMutationReceipt"
|
||||
)]
|
||||
struct CultNetInteropMutationReceipt {
|
||||
#[cultcache(key = 0)]
|
||||
schema_version: String,
|
||||
#[cultcache(key = 1)]
|
||||
intent_id: String,
|
||||
#[cultcache(key = 2)]
|
||||
accepted: bool,
|
||||
#[cultcache(key = 3)]
|
||||
document_id: String,
|
||||
#[cultcache(key = 4)]
|
||||
body: String,
|
||||
#[cultcache(key = 5)]
|
||||
tags: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(
|
||||
type = "cultnet.interop-fire-weapon-command",
|
||||
schema = "CultNetInteropFireCommand"
|
||||
)]
|
||||
struct CultNetInteropFireCommand {
|
||||
#[cultcache(key = 0)]
|
||||
schema_version: String,
|
||||
#[cultcache(key = 1)]
|
||||
command_id: String,
|
||||
#[cultcache(key = 2)]
|
||||
character_id: String,
|
||||
#[cultcache(key = 3)]
|
||||
weapon_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(
|
||||
type = "cultnet.interop-fire-weapon-receipt",
|
||||
schema = "CultNetInteropFireReceipt"
|
||||
)]
|
||||
struct CultNetInteropFireReceipt {
|
||||
#[cultcache(key = 0)]
|
||||
schema_version: String,
|
||||
#[cultcache(key = 1)]
|
||||
command_id: String,
|
||||
#[cultcache(key = 2)]
|
||||
accepted: bool,
|
||||
#[cultcache(key = 3)]
|
||||
character_id: String,
|
||||
#[cultcache(key = 4)]
|
||||
weapon_id: String,
|
||||
#[cultcache(key = 5)]
|
||||
shots_fired: u32,
|
||||
#[cultcache(key = 6)]
|
||||
ammo_remaining: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "schemaVersion", rename_all = "camelCase")]
|
||||
enum DiscoveryMessage {
|
||||
#[serde(rename = "cultnet.discovery_probe.v0", rename_all = "camelCase")]
|
||||
Probe {
|
||||
message_id: String,
|
||||
requester_runtime_id: String,
|
||||
},
|
||||
#[serde(rename = "cultnet.discovery_announce.v0", rename_all = "camelCase")]
|
||||
Announce {
|
||||
message_id: String,
|
||||
runtime_id: String,
|
||||
runtime_kind: String,
|
||||
display_name: String,
|
||||
agent_id: Option<String>,
|
||||
tcp_host: String,
|
||||
tcp_port: u16,
|
||||
wire_contract: String,
|
||||
supported_document_types: Vec<String>,
|
||||
supports_schema_catalog: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct PeerConfig {
|
||||
runtime_id: String,
|
||||
runtime_kind: String,
|
||||
display_name: String,
|
||||
agent_id: String,
|
||||
bind_host: String,
|
||||
advertise_host: String,
|
||||
tcp_port: u16,
|
||||
discovery_port: u16,
|
||||
discovery_group: Ipv4Addr,
|
||||
schema_path: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct DialConfig {
|
||||
runtime_id: String,
|
||||
runtime_kind: String,
|
||||
display_name: String,
|
||||
agent_id: String,
|
||||
target_host: String,
|
||||
target_port: u16,
|
||||
schema_path: String,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let mode = args
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("expected mode: serve | probe | dial"))?;
|
||||
let options = parse_args(args.collect());
|
||||
|
||||
match mode.as_str() {
|
||||
"serve" => serve(parse_peer_config(&options)?)?,
|
||||
"probe" => probe(&options)?,
|
||||
"dial" => dial(parse_dial_config(&options)?)?,
|
||||
_ => return Err(anyhow!("unknown mode {mode}")),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn serve(config: PeerConfig) -> Result<()> {
|
||||
let schema_registration = load_schema_registration(&config.schema_path)?;
|
||||
let mut schema_registry = builtin_schema_registry()?;
|
||||
schema_registry.register(schema_registration.clone())?;
|
||||
|
||||
let mut cache = CultCache::new();
|
||||
cache.register_entry_type::<CultNetInteropNote>()?;
|
||||
register_capability_entry_types(&mut cache)?;
|
||||
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(runtime_store_path(
|
||||
&config.runtime_id,
|
||||
)));
|
||||
cache.pull_all_backing_stores()?;
|
||||
let note = build_note(&config.runtime_id, &config.display_name);
|
||||
cache.put(¬e.document_id, ¬e)?;
|
||||
|
||||
let mut document_registry = CultNetDocumentRegistry::new();
|
||||
register_capability_bindings(&mut document_registry, &schema_registration.schema_id);
|
||||
|
||||
let cache = Arc::new(Mutex::new(cache));
|
||||
let document_registry = Arc::new(document_registry);
|
||||
let schema_registry = Arc::new(schema_registry);
|
||||
let config = Arc::new(config);
|
||||
|
||||
start_udp_discovery_server(config.clone())?;
|
||||
start_tcp_server(config.clone(), cache, document_registry, schema_registry)?;
|
||||
|
||||
print_json(&serde_json::json!({
|
||||
"status": "ready",
|
||||
"mode": "serve",
|
||||
"runtimeId": config.runtime_id,
|
||||
"runtimeKind": config.runtime_kind,
|
||||
"tcpPort": config.tcp_port,
|
||||
"discoveryPort": config.discovery_port,
|
||||
"discoveryGroup": config.discovery_group.to_string(),
|
||||
}))?;
|
||||
|
||||
loop {
|
||||
thread::sleep(Duration::from_secs(3600));
|
||||
}
|
||||
}
|
||||
|
||||
fn probe(options: &BTreeMap<String, String>) -> Result<()> {
|
||||
let runtime_id = require_arg(options, "runtime-id")?.to_string();
|
||||
let discovery_port = parse_u16_arg(options, "discovery-port")?;
|
||||
let discovery_group = parse_ipv4_arg(options, "discovery-group")?;
|
||||
let timeout_ms = parse_u64_arg(options, "timeout-ms").unwrap_or(1_500);
|
||||
let message_id = format!("{runtime_id}-{}", chrono::Utc::now().timestamp_millis());
|
||||
|
||||
let socket = create_discovery_socket(0, false)?;
|
||||
socket.set_read_timeout(Some(Duration::from_millis(timeout_ms)))?;
|
||||
|
||||
let probe_message = DiscoveryMessage::Probe {
|
||||
message_id: message_id.clone(),
|
||||
requester_runtime_id: runtime_id.clone(),
|
||||
};
|
||||
let payload = rmp_serde::to_vec_named(&probe_message)?;
|
||||
socket.send_to(
|
||||
&payload,
|
||||
SocketAddr::V4(SocketAddrV4::new(discovery_group, discovery_port)),
|
||||
)?;
|
||||
|
||||
let mut buffer = vec![0_u8; 4096];
|
||||
let mut found = BTreeMap::<String, serde_json::Value>::new();
|
||||
loop {
|
||||
match socket.recv_from(&mut buffer) {
|
||||
Ok((len, _)) => {
|
||||
if let Ok(DiscoveryMessage::Announce {
|
||||
message_id: response_message_id,
|
||||
runtime_id,
|
||||
runtime_kind,
|
||||
display_name,
|
||||
agent_id,
|
||||
tcp_host,
|
||||
tcp_port,
|
||||
wire_contract,
|
||||
supported_document_types,
|
||||
supports_schema_catalog,
|
||||
}) = rmp_serde::from_slice::<DiscoveryMessage>(&buffer[..len])
|
||||
{
|
||||
if response_message_id == message_id {
|
||||
found.insert(
|
||||
runtime_id.clone(),
|
||||
serde_json::json!({
|
||||
"schemaVersion": DISCOVERY_ANNOUNCE_SCHEMA_VERSION,
|
||||
"messageId": response_message_id,
|
||||
"runtimeId": runtime_id,
|
||||
"runtimeKind": runtime_kind,
|
||||
"displayName": display_name,
|
||||
"agentId": agent_id,
|
||||
"tcpHost": tcp_host,
|
||||
"tcpPort": tcp_port,
|
||||
"wireContract": wire_contract,
|
||||
"supportedDocumentTypes": supported_document_types,
|
||||
"supportsSchemaCatalog": supports_schema_catalog,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error)
|
||||
if error.kind() == std::io::ErrorKind::WouldBlock
|
||||
|| error.kind() == std::io::ErrorKind::TimedOut =>
|
||||
{
|
||||
break;
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
print_json(&serde_json::json!({
|
||||
"mode": "probe",
|
||||
"runtimeId": runtime_id,
|
||||
"peers": found.into_values().collect::<Vec<_>>(),
|
||||
}))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn dial(config: DialConfig) -> Result<()> {
|
||||
let schema_registration = load_schema_registration(&config.schema_path)?;
|
||||
|
||||
let mut cache = CultCache::new();
|
||||
cache.register_entry_type::<CultNetInteropNote>()?;
|
||||
register_capability_entry_types(&mut cache)?;
|
||||
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(runtime_store_path(
|
||||
&format!("{}-dial", config.runtime_id),
|
||||
)));
|
||||
cache.pull_all_backing_stores()?;
|
||||
|
||||
let mut document_registry = CultNetDocumentRegistry::new();
|
||||
register_capability_bindings(&mut document_registry, &schema_registration.schema_id);
|
||||
|
||||
let mut stream = TcpStream::connect((config.target_host.as_str(), config.target_port))
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to connect to {}:{}",
|
||||
config.target_host, config.target_port
|
||||
)
|
||||
})?;
|
||||
|
||||
send_message(
|
||||
&mut stream,
|
||||
&CultNetMessage::Hello {
|
||||
runtime_id: config.runtime_id.clone(),
|
||||
runtime_kind: config.runtime_kind.clone(),
|
||||
agent_id: Some(config.agent_id.clone()),
|
||||
role: None,
|
||||
display_name: Some(config.display_name.clone()),
|
||||
supported_document_types: Some(vec![INTEROP_DOCUMENT_TYPE.to_string()]),
|
||||
supported_mutation_contracts: Some(interaction_contracts()),
|
||||
supported_message_versions: Some(vec![INTEROP_SCHEMA_VERSION.to_string()]),
|
||||
supports_schema_catalog: Some(true),
|
||||
},
|
||||
)?;
|
||||
|
||||
let remote_hello = read_message(&mut stream)?;
|
||||
let remote_runtime_id = match &remote_hello {
|
||||
CultNetMessage::Hello { runtime_id, .. } => runtime_id.clone(),
|
||||
other => return Err(anyhow!("expected hello response, got {other:?}")),
|
||||
};
|
||||
|
||||
send_message(
|
||||
&mut stream,
|
||||
&CultNetMessage::SchemaCatalogRequest {
|
||||
message_id: format!("{}-catalog", config.runtime_id),
|
||||
include_schema_json: Some(true),
|
||||
schema_ids: None,
|
||||
kinds: None,
|
||||
},
|
||||
)?;
|
||||
let catalog_response = read_message(&mut stream)?;
|
||||
let has_interop_schema = match &catalog_response {
|
||||
CultNetMessage::SchemaCatalogResponse { schemas, .. } => schemas.iter().any(|schema| {
|
||||
schema.schema_id == schema_registration.schema_id
|
||||
&& schema.document_type.as_deref() == Some(INTEROP_DOCUMENT_TYPE)
|
||||
}),
|
||||
other => return Err(anyhow!("expected catalog response, got {other:?}")),
|
||||
};
|
||||
|
||||
send_message(
|
||||
&mut stream,
|
||||
&CultNetMessage::SnapshotRequest {
|
||||
message_id: format!("{}-snapshot", config.runtime_id),
|
||||
schema_ids: Some(vec![schema_registration.schema_id.clone()]),
|
||||
record_keys: None,
|
||||
},
|
||||
)?;
|
||||
let snapshot_response = read_message(&mut stream)?;
|
||||
let applied = document_registry
|
||||
.apply_raw_snapshot_response::<CultNetInteropNote>(&mut cache, &snapshot_response)?;
|
||||
let note = applied
|
||||
.into_iter()
|
||||
.find(|candidate| candidate.author_runtime_id == remote_runtime_id)
|
||||
.ok_or_else(|| anyhow!("did not receive an interop note from {remote_runtime_id}"))?;
|
||||
|
||||
print_json(&serde_json::json!({
|
||||
"mode": "dial",
|
||||
"runtimeId": config.runtime_id,
|
||||
"targetHost": config.target_host,
|
||||
"targetPort": config.target_port,
|
||||
"remoteHello": {
|
||||
"schemaVersion": "cultnet.hello.v0",
|
||||
"runtimeId": remote_runtime_id,
|
||||
},
|
||||
"hasInteropSchema": has_interop_schema,
|
||||
"retrievedNote": {
|
||||
"schemaVersion": note.schema_version,
|
||||
"documentId": note.document_id,
|
||||
"authorRuntimeId": note.author_runtime_id,
|
||||
"title": note.title,
|
||||
"body": note.body,
|
||||
"tags": note.tags,
|
||||
},
|
||||
"mutatedNote": mutate_remote_note(&mut stream, &mut cache, &document_registry, &schema_registration.schema_id, &config.runtime_id, ¬e)?,
|
||||
"fireReceipt": fire_remote_weapon(&mut stream, &mut cache, &document_registry, &config.runtime_id, &remote_runtime_id)?,
|
||||
}))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mutate_remote_note(
|
||||
stream: &mut TcpStream,
|
||||
cache: &mut CultCache,
|
||||
document_registry: &CultNetDocumentRegistry,
|
||||
note_schema_id: &str,
|
||||
runtime_id: &str,
|
||||
note: &CultNetInteropNote,
|
||||
) -> Result<serde_json::Value> {
|
||||
let intent = CultNetInteropMutationIntent {
|
||||
schema_version: MUTATION_INTENT_SCHEMA_VERSION.to_string(),
|
||||
intent_id: format!("{runtime_id}-decorate"),
|
||||
target_document_id: note.document_id.clone(),
|
||||
append_body: format!(" Decorated by {runtime_id}."),
|
||||
append_tag: format!("decorated:{runtime_id}"),
|
||||
};
|
||||
let message = document_registry.create_raw_document_put_message(
|
||||
format!("{runtime_id}-decorate-put"),
|
||||
intent.intent_id.clone(),
|
||||
&intent,
|
||||
CultNetDocumentPutOptions::default(),
|
||||
)?;
|
||||
send_message(stream, &message)?;
|
||||
|
||||
let receipt_message = read_message(stream)?;
|
||||
let _receipt = document_registry
|
||||
.apply_raw_document_put_message::<CultNetInteropMutationReceipt>(cache, &receipt_message)?;
|
||||
let mutated_message = read_message(stream)?;
|
||||
let mutated = document_registry
|
||||
.apply_raw_document_put_message::<CultNetInteropNote>(cache, &mutated_message)?;
|
||||
if mutated_message_schema_id(&mutated_message) != Some(note_schema_id) {
|
||||
return Err(anyhow!("mutation response used the wrong schema id"));
|
||||
}
|
||||
Ok(serde_json::json!({
|
||||
"schemaVersion": mutated.schema_version,
|
||||
"documentId": mutated.document_id,
|
||||
"authorRuntimeId": mutated.author_runtime_id,
|
||||
"title": mutated.title,
|
||||
"body": mutated.body,
|
||||
"tags": mutated.tags,
|
||||
}))
|
||||
}
|
||||
|
||||
fn fire_remote_weapon(
|
||||
stream: &mut TcpStream,
|
||||
cache: &mut CultCache,
|
||||
document_registry: &CultNetDocumentRegistry,
|
||||
runtime_id: &str,
|
||||
remote_runtime_id: &str,
|
||||
) -> Result<serde_json::Value> {
|
||||
let command = CultNetInteropFireCommand {
|
||||
schema_version: FIRE_COMMAND_SCHEMA_VERSION.to_string(),
|
||||
command_id: format!("{runtime_id}-fire"),
|
||||
character_id: remote_runtime_id.to_string(),
|
||||
weapon_id: "interop-rifle".to_string(),
|
||||
};
|
||||
let message = document_registry.create_raw_document_put_message(
|
||||
format!("{runtime_id}-fire-put"),
|
||||
command.command_id.clone(),
|
||||
&command,
|
||||
CultNetDocumentPutOptions::default(),
|
||||
)?;
|
||||
send_message(stream, &message)?;
|
||||
let receipt_message = read_message(stream)?;
|
||||
let receipt = document_registry
|
||||
.apply_raw_document_put_message::<CultNetInteropFireReceipt>(cache, &receipt_message)?;
|
||||
Ok(serde_json::json!({
|
||||
"schemaVersion": receipt.schema_version,
|
||||
"commandId": receipt.command_id,
|
||||
"accepted": receipt.accepted,
|
||||
"characterId": receipt.character_id,
|
||||
"weaponId": receipt.weapon_id,
|
||||
"shotsFired": receipt.shots_fired,
|
||||
"ammoRemaining": receipt.ammo_remaining,
|
||||
}))
|
||||
}
|
||||
|
||||
fn start_udp_discovery_server(config: Arc<PeerConfig>) -> Result<()> {
|
||||
let socket = create_discovery_socket(config.discovery_port, true)?;
|
||||
socket.join_multicast_v4(&config.discovery_group, &Ipv4Addr::UNSPECIFIED)?;
|
||||
socket.set_read_timeout(Some(Duration::from_millis(250)))?;
|
||||
|
||||
thread::spawn(move || {
|
||||
let mut buffer = vec![0_u8; 4096];
|
||||
loop {
|
||||
match socket.recv_from(&mut buffer) {
|
||||
Ok((len, remote)) => {
|
||||
if let Ok(DiscoveryMessage::Probe {
|
||||
message_id,
|
||||
requester_runtime_id: _,
|
||||
}) = rmp_serde::from_slice::<DiscoveryMessage>(&buffer[..len])
|
||||
{
|
||||
let announce = DiscoveryMessage::Announce {
|
||||
message_id,
|
||||
runtime_id: config.runtime_id.clone(),
|
||||
runtime_kind: config.runtime_kind.clone(),
|
||||
display_name: config.display_name.clone(),
|
||||
agent_id: Some(config.agent_id.clone()),
|
||||
tcp_host: config.advertise_host.clone(),
|
||||
tcp_port: config.tcp_port,
|
||||
wire_contract: "cultnet.schema.v0".to_string(),
|
||||
supported_document_types: vec![INTEROP_DOCUMENT_TYPE.to_string()],
|
||||
supports_schema_catalog: true,
|
||||
};
|
||||
if let Ok(payload) = rmp_serde::to_vec_named(&announce) {
|
||||
let _ = socket.send_to(&payload, remote);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error)
|
||||
if error.kind() == std::io::ErrorKind::WouldBlock
|
||||
|| error.kind() == std::io::ErrorKind::TimedOut => {}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn start_tcp_server(
|
||||
config: Arc<PeerConfig>,
|
||||
cache: Arc<Mutex<CultCache>>,
|
||||
document_registry: Arc<CultNetDocumentRegistry>,
|
||||
schema_registry: Arc<CultNetSchemaRegistry>,
|
||||
) -> Result<()> {
|
||||
let listener =
|
||||
TcpListener::bind((config.bind_host.as_str(), config.tcp_port)).with_context(|| {
|
||||
format!(
|
||||
"failed to bind TCP listener on {}:{}",
|
||||
config.bind_host, config.tcp_port
|
||||
)
|
||||
})?;
|
||||
listener.set_nonblocking(false)?;
|
||||
|
||||
thread::spawn(move || {
|
||||
for stream in listener.incoming() {
|
||||
let Ok(stream) = stream else {
|
||||
continue;
|
||||
};
|
||||
let config = config.clone();
|
||||
let cache = cache.clone();
|
||||
let document_registry = document_registry.clone();
|
||||
let schema_registry = schema_registry.clone();
|
||||
thread::spawn(move || {
|
||||
if let Err(error) =
|
||||
handle_connection(stream, config, cache, document_registry, schema_registry)
|
||||
{
|
||||
eprintln!("{error:#}");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_connection(
|
||||
mut stream: TcpStream,
|
||||
config: Arc<PeerConfig>,
|
||||
cache: Arc<Mutex<CultCache>>,
|
||||
document_registry: Arc<CultNetDocumentRegistry>,
|
||||
schema_registry: Arc<CultNetSchemaRegistry>,
|
||||
) -> Result<()> {
|
||||
loop {
|
||||
let message = match read_message(&mut stream) {
|
||||
Ok(message) => message,
|
||||
Err(error) if is_eof_like(&error) => break,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
|
||||
match message {
|
||||
CultNetMessage::Hello { .. } => {
|
||||
send_message(
|
||||
&mut stream,
|
||||
&CultNetMessage::Hello {
|
||||
runtime_id: config.runtime_id.clone(),
|
||||
runtime_kind: config.runtime_kind.clone(),
|
||||
agent_id: Some(config.agent_id.clone()),
|
||||
role: None,
|
||||
display_name: Some(config.display_name.clone()),
|
||||
supported_document_types: Some(vec![INTEROP_DOCUMENT_TYPE.to_string()]),
|
||||
supported_mutation_contracts: Some(interaction_contracts()),
|
||||
supported_message_versions: Some(vec![INTEROP_SCHEMA_VERSION.to_string()]),
|
||||
supports_schema_catalog: Some(true),
|
||||
},
|
||||
)?;
|
||||
}
|
||||
request @ CultNetMessage::SchemaCatalogRequest { .. } => {
|
||||
let response = schema_registry.create_catalog_response(&request)?;
|
||||
send_message(&mut stream, &response)?;
|
||||
}
|
||||
CultNetMessage::SnapshotRequest {
|
||||
message_id,
|
||||
schema_ids,
|
||||
record_keys,
|
||||
} => {
|
||||
let mut response = document_registry.create_raw_snapshot_response(
|
||||
&cache.lock().expect("cache poisoned"),
|
||||
message_id,
|
||||
schema_ids.as_deref(),
|
||||
record_keys.as_deref(),
|
||||
)?;
|
||||
if let CultNetMessage::SnapshotResponseRaw { documents, .. } = &mut response {
|
||||
for document in documents.iter_mut() {
|
||||
document.source_runtime_id = Some(config.runtime_id.clone());
|
||||
document.source_agent_id = Some(config.agent_id.clone());
|
||||
document.source_role = Some("peer".to_string());
|
||||
document.tags =
|
||||
Some(vec!["interop".to_string(), config.runtime_id.clone()]);
|
||||
}
|
||||
}
|
||||
send_message(&mut stream, &response)?;
|
||||
}
|
||||
message @ CultNetMessage::DocumentPutRaw { .. } => {
|
||||
handle_raw_put(&mut stream, &config, &cache, &document_registry, &message)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_raw_put(
|
||||
stream: &mut TcpStream,
|
||||
config: &PeerConfig,
|
||||
cache: &Arc<Mutex<CultCache>>,
|
||||
document_registry: &CultNetDocumentRegistry,
|
||||
message: &CultNetMessage,
|
||||
) -> Result<()> {
|
||||
let CultNetMessage::DocumentPutRaw { document, .. } = message else {
|
||||
return Ok(());
|
||||
};
|
||||
if document.schema_id == MUTATION_INTENT_SCHEMA_ID {
|
||||
let mut cache = cache.lock().expect("cache poisoned");
|
||||
let intent = document_registry
|
||||
.apply_raw_document_put_message::<CultNetInteropMutationIntent>(&mut cache, message)?;
|
||||
let mut note = cache.get_required::<CultNetInteropNote>(&intent.target_document_id)?;
|
||||
note.body = format!("{}{}", note.body, intent.append_body);
|
||||
note.tags.push(intent.append_tag);
|
||||
cache.put(¬e.document_id, ¬e)?;
|
||||
let receipt = CultNetInteropMutationReceipt {
|
||||
schema_version: MUTATION_RECEIPT_SCHEMA_VERSION.to_string(),
|
||||
intent_id: intent.intent_id.clone(),
|
||||
accepted: true,
|
||||
document_id: note.document_id.clone(),
|
||||
body: note.body.clone(),
|
||||
tags: note.tags.clone(),
|
||||
};
|
||||
let options = response_options(config, "mutation");
|
||||
let receipt_message = document_registry.create_raw_document_put_message(
|
||||
format!("{}-mutation-receipt", config.runtime_id),
|
||||
receipt.intent_id.clone(),
|
||||
&receipt,
|
||||
options.clone(),
|
||||
)?;
|
||||
let note_message = document_registry.create_raw_document_put_message(
|
||||
format!("{}-mutated-note", config.runtime_id),
|
||||
note.document_id.clone(),
|
||||
¬e,
|
||||
options,
|
||||
)?;
|
||||
send_message(stream, &receipt_message)?;
|
||||
send_message(stream, ¬e_message)?;
|
||||
} else if document.schema_id == FIRE_COMMAND_SCHEMA_ID {
|
||||
let mut cache = cache.lock().expect("cache poisoned");
|
||||
let command = document_registry
|
||||
.apply_raw_document_put_message::<CultNetInteropFireCommand>(&mut cache, message)?;
|
||||
let receipt = CultNetInteropFireReceipt {
|
||||
schema_version: FIRE_RECEIPT_SCHEMA_VERSION.to_string(),
|
||||
command_id: command.command_id,
|
||||
accepted: true,
|
||||
character_id: command.character_id,
|
||||
weapon_id: command.weapon_id,
|
||||
shots_fired: 1,
|
||||
ammo_remaining: 29,
|
||||
};
|
||||
let receipt_message = document_registry.create_raw_document_put_message(
|
||||
format!("{}-fire-receipt", config.runtime_id),
|
||||
receipt.command_id.clone(),
|
||||
&receipt,
|
||||
response_options(config, "side-effect"),
|
||||
)?;
|
||||
send_message(stream, &receipt_message)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_schema_registration(schema_path: &str) -> Result<CultNetSchemaRegistration> {
|
||||
let schema_json = fs::read_to_string(schema_path)
|
||||
.with_context(|| format!("failed to read schema {}", schema_path))?;
|
||||
let parsed: serde_json::Value = serde_json::from_str(&schema_json)
|
||||
.with_context(|| format!("failed to parse schema {}", schema_path))?;
|
||||
let schema_id = parsed
|
||||
.get("$id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.ok_or_else(|| anyhow!("schema {} is missing $id", schema_path))?
|
||||
.to_string();
|
||||
let title = parsed
|
||||
.get("title")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(ToString::to_string);
|
||||
|
||||
Ok(CultNetSchemaRegistration {
|
||||
schema_id,
|
||||
kind: CultNetSchemaKind::DocumentPayload,
|
||||
wire_contracts: vec![CultNetWireContract::CultNetSchemaV0],
|
||||
schema_version: Some(INTEROP_SCHEMA_VERSION.to_string()),
|
||||
document_type: Some(INTEROP_DOCUMENT_TYPE.to_string()),
|
||||
title,
|
||||
schema_json: Some(schema_json),
|
||||
})
|
||||
}
|
||||
|
||||
fn register_capability_entry_types(cache: &mut CultCache) -> Result<()> {
|
||||
cache.register_entry_type::<CultNetInteropMutationIntent>()?;
|
||||
cache.register_entry_type::<CultNetInteropMutationReceipt>()?;
|
||||
cache.register_entry_type::<CultNetInteropFireCommand>()?;
|
||||
cache.register_entry_type::<CultNetInteropFireReceipt>()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn register_capability_bindings(
|
||||
document_registry: &mut CultNetDocumentRegistry,
|
||||
note_schema_id: &str,
|
||||
) {
|
||||
document_registry
|
||||
.register(CultNetDocumentBinding::for_entry_with_schema_id::<
|
||||
CultNetInteropNote,
|
||||
>(
|
||||
note_schema_id.to_string(),
|
||||
INTEROP_SCHEMA_VERSION.to_string(),
|
||||
))
|
||||
.register(CultNetDocumentBinding::for_entry_with_schema_id::<
|
||||
CultNetInteropMutationIntent,
|
||||
>(
|
||||
MUTATION_INTENT_SCHEMA_ID.to_string(),
|
||||
MUTATION_INTENT_SCHEMA_VERSION.to_string(),
|
||||
))
|
||||
.register(CultNetDocumentBinding::for_entry_with_schema_id::<
|
||||
CultNetInteropMutationReceipt,
|
||||
>(
|
||||
MUTATION_RECEIPT_SCHEMA_ID.to_string(),
|
||||
MUTATION_RECEIPT_SCHEMA_VERSION.to_string(),
|
||||
))
|
||||
.register(CultNetDocumentBinding::for_entry_with_schema_id::<
|
||||
CultNetInteropFireCommand,
|
||||
>(
|
||||
FIRE_COMMAND_SCHEMA_ID.to_string(),
|
||||
FIRE_COMMAND_SCHEMA_VERSION.to_string(),
|
||||
))
|
||||
.register(CultNetDocumentBinding::for_entry_with_schema_id::<
|
||||
CultNetInteropFireReceipt,
|
||||
>(
|
||||
FIRE_RECEIPT_SCHEMA_ID.to_string(),
|
||||
FIRE_RECEIPT_SCHEMA_VERSION.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
fn interaction_contracts() -> Vec<CultNetDocumentMutationContract> {
|
||||
vec![CultNetDocumentMutationContract {
|
||||
document_type: INTEROP_DOCUMENT_TYPE.to_string(),
|
||||
payload_schema_version: Some(INTEROP_SCHEMA_VERSION.to_string()),
|
||||
operations: vec![
|
||||
CultNetDocumentOperation::Snapshot,
|
||||
CultNetDocumentOperation::DocumentPut,
|
||||
CultNetDocumentOperation::IntentSubmit,
|
||||
CultNetDocumentOperation::ReceiptWatch,
|
||||
],
|
||||
authority: CultNetMutationAuthority::Runtime,
|
||||
intent_document_types: Some(vec![
|
||||
MUTATION_INTENT_TYPE.to_string(),
|
||||
FIRE_COMMAND_TYPE.to_string(),
|
||||
]),
|
||||
receipt_document_types: Some(vec![
|
||||
MUTATION_RECEIPT_TYPE.to_string(),
|
||||
FIRE_RECEIPT_TYPE.to_string(),
|
||||
]),
|
||||
notes: None,
|
||||
}]
|
||||
}
|
||||
|
||||
fn response_options(config: &PeerConfig, tag: &str) -> CultNetDocumentPutOptions {
|
||||
CultNetDocumentPutOptions {
|
||||
source_runtime_id: Some(config.runtime_id.clone()),
|
||||
source_agent_id: Some(config.agent_id.clone()),
|
||||
source_role: Some("peer".to_string()),
|
||||
tags: Some(vec![tag.to_string(), config.runtime_id.clone()]),
|
||||
..CultNetDocumentPutOptions::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn mutated_message_schema_id(message: &CultNetMessage) -> Option<&str> {
|
||||
match message {
|
||||
CultNetMessage::DocumentPutRaw { document, .. } => Some(document.schema_id.as_str()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_note(runtime_id: &str, display_name: &str) -> CultNetInteropNote {
|
||||
CultNetInteropNote {
|
||||
schema_version: INTEROP_SCHEMA_VERSION.to_string(),
|
||||
document_id: format!("note:{runtime_id}"),
|
||||
author_runtime_id: runtime_id.to_string(),
|
||||
title: format!("{display_name} keeps a little note"),
|
||||
body: format!(
|
||||
"{runtime_id} can move CultNet state without begging the gods for translation."
|
||||
),
|
||||
tags: vec![
|
||||
runtime_id.to_string(),
|
||||
"interop".to_string(),
|
||||
"cultnet".to_string(),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn send_message(stream: &mut TcpStream, message: &CultNetMessage) -> Result<()> {
|
||||
let payload = encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0)?;
|
||||
let frame = encode_frame(&payload)?;
|
||||
stream.write_all(&frame)?;
|
||||
stream.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_message(stream: &mut TcpStream) -> Result<CultNetMessage> {
|
||||
let mut header = [0_u8; 4];
|
||||
stream.read_exact(&mut header)?;
|
||||
let payload_len = u32::from_be_bytes(header) as usize;
|
||||
let mut payload = vec![0_u8; payload_len];
|
||||
stream.read_exact(&mut payload)?;
|
||||
decode_cultnet_message_from_slice(&payload, CultNetWireContract::CultNetSchemaV0)
|
||||
}
|
||||
|
||||
fn is_eof_like(error: &anyhow::Error) -> bool {
|
||||
error
|
||||
.downcast_ref::<std::io::Error>()
|
||||
.is_some_and(|io| io.kind() == std::io::ErrorKind::UnexpectedEof)
|
||||
}
|
||||
|
||||
fn create_discovery_socket(port: u16, _join_group: bool) -> Result<UdpSocket> {
|
||||
let socket = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))?;
|
||||
socket.set_reuse_address(true)?;
|
||||
socket.bind(&SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, port).into())?;
|
||||
let socket = UdpSocket::from(socket);
|
||||
socket.set_multicast_loop_v4(true)?;
|
||||
socket.set_multicast_ttl_v4(1)?;
|
||||
socket.set_nonblocking(false)?;
|
||||
Ok(socket)
|
||||
}
|
||||
|
||||
fn parse_peer_config(options: &BTreeMap<String, String>) -> Result<PeerConfig> {
|
||||
Ok(PeerConfig {
|
||||
runtime_id: require_arg(options, "runtime-id")?.to_string(),
|
||||
runtime_kind: require_arg(options, "runtime-kind")?.to_string(),
|
||||
display_name: require_arg(options, "display-name")?.to_string(),
|
||||
agent_id: require_arg(options, "agent-id")?.to_string(),
|
||||
bind_host: options
|
||||
.get("bind-host")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "127.0.0.1".to_string()),
|
||||
advertise_host: require_arg(options, "advertise-host")?.to_string(),
|
||||
tcp_port: parse_u16_arg(options, "tcp-port")?,
|
||||
discovery_port: parse_u16_arg(options, "discovery-port")?,
|
||||
discovery_group: parse_ipv4_arg(options, "discovery-group")?,
|
||||
schema_path: require_arg(options, "schema-path")?.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_dial_config(options: &BTreeMap<String, String>) -> Result<DialConfig> {
|
||||
Ok(DialConfig {
|
||||
runtime_id: require_arg(options, "runtime-id")?.to_string(),
|
||||
runtime_kind: require_arg(options, "runtime-kind")?.to_string(),
|
||||
display_name: require_arg(options, "display-name")?.to_string(),
|
||||
agent_id: require_arg(options, "agent-id")?.to_string(),
|
||||
target_host: require_arg(options, "target-host")?.to_string(),
|
||||
target_port: parse_u16_arg(options, "target-port")?,
|
||||
schema_path: require_arg(options, "schema-path")?.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_args(args: Vec<String>) -> BTreeMap<String, String> {
|
||||
let mut parsed = BTreeMap::new();
|
||||
let mut index = 0;
|
||||
while index < args.len() {
|
||||
let token = &args[index];
|
||||
if !token.starts_with("--") {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
let name = token.trim_start_matches("--").to_string();
|
||||
let value = args
|
||||
.get(index + 1)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| panic!("missing value for --{name}"));
|
||||
parsed.insert(name, value);
|
||||
index += 2;
|
||||
}
|
||||
parsed
|
||||
}
|
||||
|
||||
fn require_arg<'a>(options: &'a BTreeMap<String, String>, name: &str) -> Result<&'a str> {
|
||||
options
|
||||
.get(name)
|
||||
.map(String::as_str)
|
||||
.ok_or_else(|| anyhow!("missing required argument --{name}"))
|
||||
}
|
||||
|
||||
fn parse_u16_arg(options: &BTreeMap<String, String>, name: &str) -> Result<u16> {
|
||||
require_arg(options, name)?
|
||||
.parse::<u16>()
|
||||
.with_context(|| format!("argument --{name} must be a u16"))
|
||||
}
|
||||
|
||||
fn parse_u64_arg(options: &BTreeMap<String, String>, name: &str) -> Option<u64> {
|
||||
options
|
||||
.get(name)
|
||||
.map(|value| value.parse::<u64>().expect("u64 arg"))
|
||||
}
|
||||
|
||||
fn parse_ipv4_arg(options: &BTreeMap<String, String>, name: &str) -> Result<Ipv4Addr> {
|
||||
require_arg(options, name)?
|
||||
.parse::<Ipv4Addr>()
|
||||
.with_context(|| format!("argument --{name} must be an IPv4 address"))
|
||||
}
|
||||
|
||||
fn runtime_store_path(runtime_id: &str) -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(format!("cultnet-rs-interop-{runtime_id}.msgpack"))
|
||||
}
|
||||
|
||||
fn print_json(value: &serde_json::Value) -> Result<()> {
|
||||
println!("{}", serde_json::to_string(value)?);
|
||||
Ok(())
|
||||
}
|
||||
1155
packages/cultnet-rs/src/contracts.rs
Normal file
1155
packages/cultnet-rs/src/contracts.rs
Normal file
File diff suppressed because it is too large
Load diff
49
packages/cultnet-rs/src/framing.rs
Normal file
49
packages/cultnet-rs/src/framing.rs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
|
||||
pub const FRAME_HEADER_BYTES: usize = 4;
|
||||
|
||||
pub fn encode_frame(payload: &[u8]) -> Result<Vec<u8>> {
|
||||
let len: u32 = payload
|
||||
.len()
|
||||
.try_into()
|
||||
.map_err(|_| anyhow!("CultNet frame payload is too large"))?;
|
||||
let mut frame = Vec::with_capacity(FRAME_HEADER_BYTES + payload.len());
|
||||
frame.extend_from_slice(&len.to_be_bytes());
|
||||
frame.extend_from_slice(payload);
|
||||
Ok(frame)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct LengthPrefixedMessageFramer {
|
||||
buffer: Vec<u8>,
|
||||
}
|
||||
|
||||
impl LengthPrefixedMessageFramer {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn push(&mut self, chunk: impl AsRef<[u8]>) -> Vec<Vec<u8>> {
|
||||
self.buffer.extend_from_slice(chunk.as_ref());
|
||||
let mut frames = Vec::new();
|
||||
loop {
|
||||
if self.buffer.len() < FRAME_HEADER_BYTES {
|
||||
break;
|
||||
}
|
||||
let payload_len = u32::from_be_bytes([
|
||||
self.buffer[0],
|
||||
self.buffer[1],
|
||||
self.buffer[2],
|
||||
self.buffer[3],
|
||||
]) as usize;
|
||||
let total_len = FRAME_HEADER_BYTES + payload_len;
|
||||
if self.buffer.len() < total_len {
|
||||
break;
|
||||
}
|
||||
frames.push(self.buffer[FRAME_HEADER_BYTES..total_len].to_vec());
|
||||
self.buffer.drain(0..total_len);
|
||||
}
|
||||
frames
|
||||
}
|
||||
}
|
||||
11
packages/cultnet-rs/src/lib.rs
Normal file
11
packages/cultnet-rs/src/lib.rs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
mod contracts;
|
||||
mod framing;
|
||||
mod replication;
|
||||
mod schema_discovery;
|
||||
mod security;
|
||||
|
||||
pub use contracts::*;
|
||||
pub use framing::*;
|
||||
pub use replication::*;
|
||||
pub use schema_discovery::*;
|
||||
pub use security::*;
|
||||
440
packages/cultnet-rs/src/replication.rs
Normal file
440
packages/cultnet-rs/src/replication.rs
Normal file
|
|
@ -0,0 +1,440 @@
|
|||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use cultcache_rs::CultCache;
|
||||
use cultcache_rs::CultCacheEnvelope;
|
||||
use cultcache_rs::DatabaseEntry;
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use crate::CultNetDocumentMutationContract;
|
||||
use crate::CultNetDocumentRecord;
|
||||
use crate::CultNetMessage;
|
||||
use crate::CultNetRawDocumentRecord;
|
||||
use crate::CultNetRawPayloadEncoding;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct CultNetDocumentPutOptions {
|
||||
pub stored_at: Option<String>,
|
||||
pub source_runtime_id: Option<String>,
|
||||
pub source_agent_id: Option<String>,
|
||||
pub source_role: Option<String>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CultNetDocumentBinding {
|
||||
pub schema_id: String,
|
||||
pub document_type: String,
|
||||
pub mutation_contract: Option<CultNetDocumentMutationContract>,
|
||||
pub payload_schema_version: Option<String>,
|
||||
}
|
||||
|
||||
impl CultNetDocumentBinding {
|
||||
pub fn for_entry<T: DatabaseEntry>(payload_schema_version: impl Into<Option<String>>) -> Self {
|
||||
Self {
|
||||
document_type: T::TYPE.to_string(),
|
||||
schema_id: T::TYPE.to_string(),
|
||||
mutation_contract: None,
|
||||
payload_schema_version: payload_schema_version.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn for_entry_with_schema_id<T: DatabaseEntry>(
|
||||
schema_id: impl Into<String>,
|
||||
payload_schema_version: impl Into<Option<String>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
document_type: T::TYPE.to_string(),
|
||||
schema_id: schema_id.into(),
|
||||
mutation_contract: None,
|
||||
payload_schema_version: payload_schema_version.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_mutation_contract(mut self, contract: CultNetDocumentMutationContract) -> Self {
|
||||
self.mutation_contract = Some(contract);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct CultNetDocumentRegistry {
|
||||
bindings_by_type: BTreeMap<String, CultNetDocumentBinding>,
|
||||
bindings_by_schema_id: BTreeMap<String, CultNetDocumentBinding>,
|
||||
}
|
||||
|
||||
impl CultNetDocumentRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn register(&mut self, binding: CultNetDocumentBinding) -> &mut Self {
|
||||
self.bindings_by_schema_id
|
||||
.insert(binding.schema_id.clone(), binding.clone());
|
||||
self.bindings_by_type
|
||||
.insert(binding.document_type.clone(), binding);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn binding(&self, document_type: &str) -> Option<&CultNetDocumentBinding> {
|
||||
self.bindings_by_type.get(document_type)
|
||||
}
|
||||
|
||||
pub fn binding_by_schema_id(&self, schema_id: &str) -> Option<&CultNetDocumentBinding> {
|
||||
self.bindings_by_schema_id.get(schema_id)
|
||||
}
|
||||
|
||||
pub fn mutation_contracts(&self) -> Vec<CultNetDocumentMutationContract> {
|
||||
self.bindings_by_type
|
||||
.values()
|
||||
.filter_map(|binding| binding.mutation_contract.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn create_document_put_message<T>(
|
||||
&self,
|
||||
message_id: impl Into<String>,
|
||||
record_key: impl Into<String>,
|
||||
value: &T,
|
||||
options: CultNetDocumentPutOptions,
|
||||
) -> Result<CultNetMessage>
|
||||
where
|
||||
T: DatabaseEntry + Serialize + DeserializeOwned,
|
||||
{
|
||||
let binding = self.require_binding(T::TYPE)?;
|
||||
let payload = serde_json::to_value(round_trip_typed(value)?)?;
|
||||
Ok(CultNetMessage::DocumentPut {
|
||||
message_id: message_id.into(),
|
||||
document: CultNetDocumentRecord {
|
||||
schema_id: binding.schema_id.clone(),
|
||||
record_key: record_key.into(),
|
||||
stored_at: options.stored_at.unwrap_or_else(now_utc_second),
|
||||
payload,
|
||||
source_runtime_id: options.source_runtime_id,
|
||||
source_agent_id: options.source_agent_id,
|
||||
source_role: options.source_role,
|
||||
tags: options.tags,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_document_delete_message(
|
||||
&self,
|
||||
message_id: impl Into<String>,
|
||||
schema_id: impl Into<String>,
|
||||
record_key: impl Into<String>,
|
||||
) -> CultNetMessage {
|
||||
CultNetMessage::DocumentDelete {
|
||||
message_id: message_id.into(),
|
||||
schema_id: schema_id.into(),
|
||||
record_key: record_key.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_raw_document_put_message_from_envelope(
|
||||
&self,
|
||||
message_id: impl Into<String>,
|
||||
envelope: &CultCacheEnvelope,
|
||||
) -> Result<CultNetMessage> {
|
||||
Ok(CultNetMessage::DocumentPutRaw {
|
||||
message_id: message_id.into(),
|
||||
document: self.raw_document_record_from_envelope(envelope)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_raw_document_put_message<T>(
|
||||
&self,
|
||||
message_id: impl Into<String>,
|
||||
record_key: impl Into<String>,
|
||||
value: &T,
|
||||
options: CultNetDocumentPutOptions,
|
||||
) -> Result<CultNetMessage>
|
||||
where
|
||||
T: DatabaseEntry + Serialize,
|
||||
{
|
||||
let binding = self.require_binding(T::TYPE)?;
|
||||
Ok(CultNetMessage::DocumentPutRaw {
|
||||
message_id: message_id.into(),
|
||||
document: CultNetRawDocumentRecord {
|
||||
schema_id: binding.schema_id.clone(),
|
||||
record_key: record_key.into(),
|
||||
stored_at: options.stored_at.unwrap_or_else(now_utc_second),
|
||||
payload_encoding: CultNetRawPayloadEncoding::Messagepack,
|
||||
payload: rmp_serde::to_vec(value)?,
|
||||
source_runtime_id: options.source_runtime_id,
|
||||
source_agent_id: options.source_agent_id,
|
||||
source_role: options.source_role,
|
||||
tags: options.tags,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_snapshot_response(
|
||||
&self,
|
||||
cache: &CultCache,
|
||||
message_id: impl Into<String>,
|
||||
schema_ids: Option<&[String]>,
|
||||
record_keys: Option<&[String]>,
|
||||
) -> Result<CultNetMessage> {
|
||||
let requested_schema_ids = schema_ids.map(|items| items.iter().collect::<BTreeSet<_>>());
|
||||
let requested_record_keys = record_keys.map(|items| items.iter().collect::<BTreeSet<_>>());
|
||||
let mut documents = Vec::new();
|
||||
for envelope in cache.snapshot() {
|
||||
let binding = self.require_binding(&envelope.r#type)?;
|
||||
if requested_schema_ids
|
||||
.as_ref()
|
||||
.is_some_and(|ids| !ids.contains(&binding.schema_id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if requested_record_keys
|
||||
.as_ref()
|
||||
.is_some_and(|keys| !keys.contains(&envelope.key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
documents.push(self.document_record_from_envelope(&envelope)?);
|
||||
}
|
||||
Ok(CultNetMessage::SnapshotResponse {
|
||||
message_id: message_id.into(),
|
||||
documents,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_raw_snapshot_response(
|
||||
&self,
|
||||
cache: &CultCache,
|
||||
message_id: impl Into<String>,
|
||||
schema_ids: Option<&[String]>,
|
||||
record_keys: Option<&[String]>,
|
||||
) -> Result<CultNetMessage> {
|
||||
let requested_schema_ids = schema_ids.map(|items| items.iter().collect::<BTreeSet<_>>());
|
||||
let requested_record_keys = record_keys.map(|items| items.iter().collect::<BTreeSet<_>>());
|
||||
let mut documents = Vec::new();
|
||||
for envelope in cache.snapshot() {
|
||||
let binding = self.require_binding(&envelope.r#type)?;
|
||||
if requested_schema_ids
|
||||
.as_ref()
|
||||
.is_some_and(|ids| !ids.contains(&binding.schema_id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if requested_record_keys
|
||||
.as_ref()
|
||||
.is_some_and(|keys| !keys.contains(&envelope.key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
documents.push(self.raw_document_record_from_envelope(&envelope)?);
|
||||
}
|
||||
Ok(CultNetMessage::SnapshotResponseRaw {
|
||||
message_id: message_id.into(),
|
||||
documents,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn apply_document_put_message<T>(
|
||||
&self,
|
||||
cache: &mut CultCache,
|
||||
message: &CultNetMessage,
|
||||
) -> Result<T>
|
||||
where
|
||||
T: DatabaseEntry + Serialize + DeserializeOwned,
|
||||
{
|
||||
let CultNetMessage::DocumentPut { document, .. } = message else {
|
||||
return Err(anyhow!("expected cultnet.document_put.v0"));
|
||||
};
|
||||
let binding = self.require_binding(T::TYPE)?;
|
||||
if document.schema_id != binding.schema_id {
|
||||
return Err(anyhow!(
|
||||
"schema id {:?} does not match registered Rust type {:?} schema {:?}",
|
||||
document.schema_id,
|
||||
T::TYPE,
|
||||
binding.schema_id
|
||||
));
|
||||
}
|
||||
let value: T = serde_json::from_value(document.payload.clone()).with_context(|| {
|
||||
format!(
|
||||
"failed to decode CultNet payload schema {:?} as {}",
|
||||
binding.schema_id,
|
||||
T::SCHEMA_NAME
|
||||
)
|
||||
})?;
|
||||
cache.put(&document.record_key, &value)
|
||||
}
|
||||
|
||||
pub fn apply_document_delete_message<T>(
|
||||
&self,
|
||||
cache: &mut CultCache,
|
||||
message: &CultNetMessage,
|
||||
) -> Result<bool>
|
||||
where
|
||||
T: DatabaseEntry,
|
||||
{
|
||||
let CultNetMessage::DocumentDelete {
|
||||
schema_id,
|
||||
record_key,
|
||||
..
|
||||
} = message
|
||||
else {
|
||||
return Err(anyhow!("expected cultnet.document_delete.v0"));
|
||||
};
|
||||
let binding = self.require_binding(T::TYPE)?;
|
||||
if schema_id != &binding.schema_id {
|
||||
return Err(anyhow!(
|
||||
"schema id {:?} does not match registered Rust type {:?} schema {:?}",
|
||||
schema_id,
|
||||
T::TYPE,
|
||||
binding.schema_id
|
||||
));
|
||||
}
|
||||
cache.delete::<T>(record_key)
|
||||
}
|
||||
|
||||
pub fn apply_raw_document_put_message<T>(
|
||||
&self,
|
||||
cache: &mut CultCache,
|
||||
message: &CultNetMessage,
|
||||
) -> Result<T>
|
||||
where
|
||||
T: DatabaseEntry + Serialize + DeserializeOwned,
|
||||
{
|
||||
let CultNetMessage::DocumentPutRaw { document, .. } = message else {
|
||||
return Err(anyhow!("expected cultnet.document_put_raw.v0"));
|
||||
};
|
||||
let binding = self.require_binding(T::TYPE)?;
|
||||
if document.schema_id != binding.schema_id {
|
||||
return Err(anyhow!(
|
||||
"schema id {:?} does not match registered Rust type {:?} schema {:?}",
|
||||
document.schema_id,
|
||||
T::TYPE,
|
||||
binding.schema_id
|
||||
));
|
||||
}
|
||||
cache.put_envelope::<T>(CultCacheEnvelope {
|
||||
key: document.record_key.clone(),
|
||||
r#type: binding.document_type.clone(),
|
||||
payload: document.payload.clone(),
|
||||
stored_at: document.stored_at.clone(),
|
||||
schema_id: Some(binding.schema_id.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn apply_snapshot_response<T>(
|
||||
&self,
|
||||
cache: &mut CultCache,
|
||||
response: &CultNetMessage,
|
||||
) -> Result<Vec<T>>
|
||||
where
|
||||
T: DatabaseEntry + Serialize + DeserializeOwned,
|
||||
{
|
||||
let CultNetMessage::SnapshotResponse { documents, .. } = response else {
|
||||
return Err(anyhow!("expected cultnet.snapshot_response.v0"));
|
||||
};
|
||||
let mut applied = Vec::new();
|
||||
let binding = self.require_binding(T::TYPE)?;
|
||||
for document in documents {
|
||||
if document.schema_id != binding.schema_id {
|
||||
continue;
|
||||
}
|
||||
applied.push(self.apply_document_put_message::<T>(
|
||||
cache,
|
||||
&CultNetMessage::DocumentPut {
|
||||
message_id: "snapshot-apply".to_string(),
|
||||
document: document.clone(),
|
||||
},
|
||||
)?);
|
||||
}
|
||||
Ok(applied)
|
||||
}
|
||||
|
||||
pub fn apply_raw_snapshot_response<T>(
|
||||
&self,
|
||||
cache: &mut CultCache,
|
||||
response: &CultNetMessage,
|
||||
) -> Result<Vec<T>>
|
||||
where
|
||||
T: DatabaseEntry + Serialize + DeserializeOwned,
|
||||
{
|
||||
let CultNetMessage::SnapshotResponseRaw { documents, .. } = response else {
|
||||
return Err(anyhow!("expected cultnet.snapshot_response_raw.v0"));
|
||||
};
|
||||
let mut applied = Vec::new();
|
||||
let binding = self.require_binding(T::TYPE)?;
|
||||
for document in documents {
|
||||
if document.schema_id != binding.schema_id {
|
||||
continue;
|
||||
}
|
||||
applied.push(self.apply_raw_document_put_message::<T>(
|
||||
cache,
|
||||
&CultNetMessage::DocumentPutRaw {
|
||||
message_id: "snapshot-apply-raw".to_string(),
|
||||
document: document.clone(),
|
||||
},
|
||||
)?);
|
||||
}
|
||||
Ok(applied)
|
||||
}
|
||||
|
||||
fn document_record_from_envelope(
|
||||
&self,
|
||||
envelope: &CultCacheEnvelope,
|
||||
) -> Result<CultNetDocumentRecord<Value>> {
|
||||
let binding = self.require_binding(&envelope.r#type)?;
|
||||
let payload: Value = rmp_serde::from_slice(&envelope.payload).with_context(|| {
|
||||
format!(
|
||||
"failed to decode CultCache envelope {:?} at {:?} as generic CultNet payload",
|
||||
envelope.r#type, envelope.key
|
||||
)
|
||||
})?;
|
||||
Ok(CultNetDocumentRecord {
|
||||
schema_id: binding.schema_id.clone(),
|
||||
record_key: envelope.key.clone(),
|
||||
stored_at: envelope.stored_at.clone(),
|
||||
payload,
|
||||
source_runtime_id: None,
|
||||
source_agent_id: None,
|
||||
source_role: None,
|
||||
tags: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn raw_document_record_from_envelope(
|
||||
&self,
|
||||
envelope: &CultCacheEnvelope,
|
||||
) -> Result<CultNetRawDocumentRecord> {
|
||||
let binding = self.require_binding(&envelope.r#type)?;
|
||||
Ok(CultNetRawDocumentRecord {
|
||||
schema_id: binding.schema_id.clone(),
|
||||
record_key: envelope.key.clone(),
|
||||
stored_at: envelope.stored_at.clone(),
|
||||
payload_encoding: CultNetRawPayloadEncoding::Messagepack,
|
||||
payload: envelope.payload.clone(),
|
||||
source_runtime_id: None,
|
||||
source_agent_id: None,
|
||||
source_role: None,
|
||||
tags: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn require_binding(&self, document_type: &str) -> Result<&CultNetDocumentBinding> {
|
||||
self.binding(document_type).ok_or_else(|| {
|
||||
anyhow!("No CultNet document binding is registered for {document_type:?}")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn round_trip_typed<T>(value: &T) -> Result<T>
|
||||
where
|
||||
T: Serialize + DeserializeOwned,
|
||||
{
|
||||
Ok(rmp_serde::from_slice(&rmp_serde::to_vec(value)?)?)
|
||||
}
|
||||
|
||||
fn now_utc_second() -> String {
|
||||
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
|
||||
}
|
||||
389
packages/cultnet-rs/src/schema_discovery.rs
Normal file
389
packages/cultnet-rs/src/schema_discovery.rs
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use serde_json::Value;
|
||||
use sha2::Digest;
|
||||
use sha2::Sha256;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use crate::CultNetMessage;
|
||||
use crate::CultNetSchemaDescriptor;
|
||||
use crate::CultNetSchemaKind;
|
||||
use crate::CultNetWireContract;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CultNetSchemaRegistration {
|
||||
pub schema_id: String,
|
||||
pub kind: CultNetSchemaKind,
|
||||
pub wire_contracts: Vec<CultNetWireContract>,
|
||||
pub schema_version: Option<String>,
|
||||
pub document_type: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub schema_json: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct CultNetSchemaCatalogOptions {
|
||||
pub include_schema_json: bool,
|
||||
pub schema_ids: Option<Vec<String>>,
|
||||
pub kinds: Option<Vec<CultNetSchemaKind>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct CultNetSchemaRegistry {
|
||||
entries: BTreeMap<String, CultNetSchemaDescriptor>,
|
||||
}
|
||||
|
||||
impl CultNetSchemaRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn register(&mut self, registration: CultNetSchemaRegistration) -> Result<()> {
|
||||
if registration.schema_id.trim().is_empty() {
|
||||
return Err(anyhow!("schema_id must be non-empty"));
|
||||
}
|
||||
if registration.wire_contracts.is_empty() {
|
||||
return Err(anyhow!("wire_contracts must contain at least one entry"));
|
||||
}
|
||||
|
||||
let content_hash = if let Some(schema_json) = registration.schema_json.as_deref() {
|
||||
canonical_schema_hash(schema_json)?
|
||||
} else {
|
||||
return Err(anyhow!(
|
||||
"schema_json is required so discovery can advertise canonical hashes"
|
||||
));
|
||||
};
|
||||
|
||||
self.entries.insert(
|
||||
registration.schema_id.clone(),
|
||||
CultNetSchemaDescriptor {
|
||||
schema_id: registration.schema_id,
|
||||
kind: registration.kind,
|
||||
schema_version: registration.schema_version,
|
||||
document_type: registration.document_type,
|
||||
title: registration.title,
|
||||
wire_contracts: registration.wire_contracts,
|
||||
content_hash,
|
||||
schema_json: registration.schema_json,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get(
|
||||
&self,
|
||||
schema_id: &str,
|
||||
include_schema_json: bool,
|
||||
) -> Option<CultNetSchemaDescriptor> {
|
||||
self.entries
|
||||
.get(schema_id)
|
||||
.map(|entry| sanitize_descriptor(entry, include_schema_json))
|
||||
}
|
||||
|
||||
pub fn list(&self, options: &CultNetSchemaCatalogOptions) -> Vec<CultNetSchemaDescriptor> {
|
||||
let requested_ids = options
|
||||
.schema_ids
|
||||
.as_ref()
|
||||
.map(|values| values.iter().cloned().collect::<BTreeSet<_>>());
|
||||
let requested_kinds = options
|
||||
.kinds
|
||||
.as_ref()
|
||||
.map(|values| values.iter().copied().collect::<BTreeSet<_>>());
|
||||
|
||||
self.entries
|
||||
.values()
|
||||
.filter(|entry| {
|
||||
if let Some(requested_ids) = &requested_ids
|
||||
&& !requested_ids.contains(&entry.schema_id)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(requested_kinds) = &requested_kinds
|
||||
&& !requested_kinds.contains(&entry.kind)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
})
|
||||
.map(|entry| sanitize_descriptor(entry, options.include_schema_json))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn create_catalog_response(&self, request: &CultNetMessage) -> Result<CultNetMessage> {
|
||||
let CultNetMessage::SchemaCatalogRequest {
|
||||
message_id,
|
||||
include_schema_json,
|
||||
schema_ids,
|
||||
kinds,
|
||||
} = request
|
||||
else {
|
||||
return Err(anyhow!(
|
||||
"expected cultnet.schema_catalog_request.v0 for schema discovery"
|
||||
));
|
||||
};
|
||||
|
||||
Ok(CultNetMessage::SchemaCatalogResponse {
|
||||
message_id: message_id.clone(),
|
||||
schemas: self.list(&CultNetSchemaCatalogOptions {
|
||||
include_schema_json: include_schema_json.unwrap_or(false),
|
||||
schema_ids: schema_ids.clone(),
|
||||
kinds: kinds.clone(),
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_descriptor(
|
||||
descriptor: &CultNetSchemaDescriptor,
|
||||
include_schema_json: bool,
|
||||
) -> CultNetSchemaDescriptor {
|
||||
let mut clone = descriptor.clone();
|
||||
if !include_schema_json {
|
||||
clone.schema_json = None;
|
||||
}
|
||||
clone
|
||||
}
|
||||
|
||||
fn canonical_schema_hash(schema_json: &str) -> Result<String> {
|
||||
let value: Value = serde_json::from_str(schema_json)?;
|
||||
let canonical = stable_stringify(&value);
|
||||
Ok(format!("{:x}", Sha256::digest(canonical.as_bytes())))
|
||||
}
|
||||
|
||||
fn stable_stringify(value: &Value) -> String {
|
||||
match value {
|
||||
Value::Null => "null".to_string(),
|
||||
Value::Bool(value) => value.to_string(),
|
||||
Value::Number(value) => value.to_string(),
|
||||
Value::String(value) => serde_json::to_string(value).expect("string serialization"),
|
||||
Value::Array(values) => format!(
|
||||
"[{}]",
|
||||
values
|
||||
.iter()
|
||||
.map(stable_stringify)
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
),
|
||||
Value::Object(values) => {
|
||||
let mut entries = values.iter().collect::<Vec<_>>();
|
||||
entries.sort_by(|(left, _), (right, _)| left.cmp(right));
|
||||
format!(
|
||||
"{{{}}}",
|
||||
entries
|
||||
.into_iter()
|
||||
.map(|(key, value)| {
|
||||
format!(
|
||||
"{}:{}",
|
||||
serde_json::to_string(key).expect("key serialization"),
|
||||
stable_stringify(value)
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn builtin_schema_registry() -> Result<CultNetSchemaRegistry> {
|
||||
let mut registry = CultNetSchemaRegistry::new();
|
||||
|
||||
for registration in [
|
||||
schema_registration(
|
||||
include_str!("../contracts/cultnet.hello.schema.json"),
|
||||
CultNetSchemaKind::WireMessage,
|
||||
vec![CultNetWireContract::CultNetSchemaV0],
|
||||
Some("cultnet.hello.v0"),
|
||||
None,
|
||||
)?,
|
||||
schema_registration(
|
||||
include_str!("../contracts/cultnet.document-mutation-contract.schema.json"),
|
||||
CultNetSchemaKind::SharedContract,
|
||||
vec![CultNetWireContract::CultNetSchemaV0],
|
||||
None,
|
||||
None,
|
||||
)?,
|
||||
schema_registration(
|
||||
include_str!("../contracts/cultnet.login.schema.json"),
|
||||
CultNetSchemaKind::WireMessage,
|
||||
vec![
|
||||
CultNetWireContract::CultNetSchemaV0,
|
||||
CultNetWireContract::GameCultNetworkingV0,
|
||||
],
|
||||
Some("cultnet.login.v0"),
|
||||
None,
|
||||
)?,
|
||||
schema_registration(
|
||||
include_str!("../contracts/cultnet.register.schema.json"),
|
||||
CultNetSchemaKind::WireMessage,
|
||||
vec![
|
||||
CultNetWireContract::CultNetSchemaV0,
|
||||
CultNetWireContract::GameCultNetworkingV0,
|
||||
],
|
||||
Some("cultnet.register.v0"),
|
||||
None,
|
||||
)?,
|
||||
schema_registration(
|
||||
include_str!("../contracts/cultnet.verify.schema.json"),
|
||||
CultNetSchemaKind::WireMessage,
|
||||
vec![
|
||||
CultNetWireContract::CultNetSchemaV0,
|
||||
CultNetWireContract::GameCultNetworkingV0,
|
||||
],
|
||||
Some("cultnet.verify.v0"),
|
||||
None,
|
||||
)?,
|
||||
schema_registration(
|
||||
include_str!("../contracts/cultnet.login-success.schema.json"),
|
||||
CultNetSchemaKind::WireMessage,
|
||||
vec![
|
||||
CultNetWireContract::CultNetSchemaV0,
|
||||
CultNetWireContract::GameCultNetworkingV0,
|
||||
],
|
||||
Some("cultnet.login_success.v0"),
|
||||
None,
|
||||
)?,
|
||||
schema_registration(
|
||||
include_str!("../contracts/cultnet.error.schema.json"),
|
||||
CultNetSchemaKind::WireMessage,
|
||||
vec![
|
||||
CultNetWireContract::CultNetSchemaV0,
|
||||
CultNetWireContract::GameCultNetworkingV0,
|
||||
],
|
||||
Some("cultnet.error.v0"),
|
||||
None,
|
||||
)?,
|
||||
schema_registration(
|
||||
include_str!("../contracts/cultnet.sample-change-name.schema.json"),
|
||||
CultNetSchemaKind::WireMessage,
|
||||
vec![
|
||||
CultNetWireContract::CultNetSchemaV0,
|
||||
CultNetWireContract::GameCultNetworkingV0,
|
||||
],
|
||||
Some("cultnet.sample.change_name.v0"),
|
||||
None,
|
||||
)?,
|
||||
schema_registration(
|
||||
include_str!("../contracts/cultnet.sample-chat.schema.json"),
|
||||
CultNetSchemaKind::WireMessage,
|
||||
vec![
|
||||
CultNetWireContract::CultNetSchemaV0,
|
||||
CultNetWireContract::GameCultNetworkingV0,
|
||||
],
|
||||
Some("cultnet.sample.chat.v0"),
|
||||
None,
|
||||
)?,
|
||||
schema_registration(
|
||||
include_str!("../contracts/cultnet.document-put.schema.json"),
|
||||
CultNetSchemaKind::WireMessage,
|
||||
vec![CultNetWireContract::CultNetSchemaV0],
|
||||
Some("cultnet.document_put.v0"),
|
||||
None,
|
||||
)?,
|
||||
schema_registration(
|
||||
include_str!("../contracts/cultnet.document-delete.schema.json"),
|
||||
CultNetSchemaKind::WireMessage,
|
||||
vec![CultNetWireContract::CultNetSchemaV0],
|
||||
Some("cultnet.document_delete.v0"),
|
||||
None,
|
||||
)?,
|
||||
schema_registration(
|
||||
include_str!("../contracts/cultnet.raw-document-record.schema.json"),
|
||||
CultNetSchemaKind::SharedContract,
|
||||
vec![CultNetWireContract::CultNetSchemaV0],
|
||||
None,
|
||||
None,
|
||||
)?,
|
||||
schema_registration(
|
||||
include_str!("../contracts/cultnet.document-put-raw.schema.json"),
|
||||
CultNetSchemaKind::WireMessage,
|
||||
vec![CultNetWireContract::CultNetSchemaV0],
|
||||
Some("cultnet.document_put_raw.v0"),
|
||||
None,
|
||||
)?,
|
||||
schema_registration(
|
||||
include_str!("../contracts/cultnet.snapshot-request.schema.json"),
|
||||
CultNetSchemaKind::WireMessage,
|
||||
vec![CultNetWireContract::CultNetSchemaV0],
|
||||
Some("cultnet.snapshot_request.v0"),
|
||||
None,
|
||||
)?,
|
||||
schema_registration(
|
||||
include_str!("../contracts/cultnet.snapshot-response.schema.json"),
|
||||
CultNetSchemaKind::WireMessage,
|
||||
vec![CultNetWireContract::CultNetSchemaV0],
|
||||
Some("cultnet.snapshot_response.v0"),
|
||||
None,
|
||||
)?,
|
||||
schema_registration(
|
||||
include_str!("../contracts/cultnet.snapshot-response-raw.schema.json"),
|
||||
CultNetSchemaKind::WireMessage,
|
||||
vec![CultNetWireContract::CultNetSchemaV0],
|
||||
Some("cultnet.snapshot_response_raw.v0"),
|
||||
None,
|
||||
)?,
|
||||
schema_registration(
|
||||
include_str!("../contracts/cultnet.schema-catalog-request.schema.json"),
|
||||
CultNetSchemaKind::WireMessage,
|
||||
vec![
|
||||
CultNetWireContract::CultNetSchemaV0,
|
||||
CultNetWireContract::GameCultNetworkingV0,
|
||||
],
|
||||
Some("cultnet.schema_catalog_request.v0"),
|
||||
None,
|
||||
)?,
|
||||
schema_registration(
|
||||
include_str!("../contracts/cultnet.schema-catalog-response.schema.json"),
|
||||
CultNetSchemaKind::WireMessage,
|
||||
vec![
|
||||
CultNetWireContract::CultNetSchemaV0,
|
||||
CultNetWireContract::GameCultNetworkingV0,
|
||||
],
|
||||
Some("cultnet.schema_catalog_response.v0"),
|
||||
None,
|
||||
)?,
|
||||
schema_registration(
|
||||
include_str!("../contracts/ghostlight.agent-state.schema.json"),
|
||||
CultNetSchemaKind::DocumentPayload,
|
||||
vec![CultNetWireContract::CultNetSchemaV0],
|
||||
Some("ghostlight.agent_state.v0"),
|
||||
Some("ghostlight.agent-state"),
|
||||
)?,
|
||||
] {
|
||||
registry.register(registration)?;
|
||||
}
|
||||
|
||||
Ok(registry)
|
||||
}
|
||||
|
||||
fn schema_registration(
|
||||
schema_json: &str,
|
||||
kind: CultNetSchemaKind,
|
||||
wire_contracts: Vec<CultNetWireContract>,
|
||||
schema_version: Option<&str>,
|
||||
document_type: Option<&str>,
|
||||
) -> Result<CultNetSchemaRegistration> {
|
||||
let value: Value = serde_json::from_str(schema_json)?;
|
||||
let schema_id = value
|
||||
.get("$id")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| anyhow!("schema is missing $id"))?;
|
||||
let title = value
|
||||
.get("title")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string);
|
||||
|
||||
Ok(CultNetSchemaRegistration {
|
||||
schema_id: schema_id.to_string(),
|
||||
kind,
|
||||
wire_contracts,
|
||||
schema_version: schema_version.map(str::to_string),
|
||||
document_type: document_type.map(str::to_string),
|
||||
title,
|
||||
schema_json: Some(schema_json.to_string()),
|
||||
})
|
||||
}
|
||||
270
packages/cultnet-rs/src/security.rs
Normal file
270
packages/cultnet-rs/src/security.rs
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
use aes_gcm::Aes256Gcm;
|
||||
use aes_gcm::KeyInit;
|
||||
use aes_gcm::Nonce;
|
||||
use aes_gcm::aead::Aead;
|
||||
use aes_gcm::aead::Payload;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use chrono::DateTime;
|
||||
use chrono::TimeZone;
|
||||
use chrono::Utc;
|
||||
use hmac::Hmac;
|
||||
use hmac::Mac;
|
||||
use rand::RngCore;
|
||||
use sha2::Digest;
|
||||
use sha2::Sha256;
|
||||
|
||||
const NONCE_LENGTH: usize = 12;
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CultNetClientSecurityOptions {
|
||||
pub connection_key: String,
|
||||
encryption_key: [u8; 32],
|
||||
}
|
||||
|
||||
impl CultNetClientSecurityOptions {
|
||||
pub fn new(connection_key: impl Into<String>) -> Result<Self> {
|
||||
let connection_key = connection_key.into();
|
||||
if connection_key.trim().is_empty() {
|
||||
return Err(anyhow!("Connection key must be provided"));
|
||||
}
|
||||
let encryption_key = sha256(connection_key.as_bytes());
|
||||
Ok(Self {
|
||||
connection_key,
|
||||
encryption_key,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn development() -> Self {
|
||||
Self::new("gamecult-dev-connection-key").expect("development key is valid")
|
||||
}
|
||||
|
||||
pub fn encryption_key(&self) -> [u8; 32] {
|
||||
self.encryption_key
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CultNetServerSecurityOptions {
|
||||
client: CultNetClientSecurityOptions,
|
||||
pub session_signing_secret: String,
|
||||
session_signing_key: [u8; 32],
|
||||
pub is_development: bool,
|
||||
}
|
||||
|
||||
impl CultNetServerSecurityOptions {
|
||||
pub fn new(
|
||||
connection_key: impl Into<String>,
|
||||
session_signing_secret: impl Into<String>,
|
||||
is_development: bool,
|
||||
) -> Result<Self> {
|
||||
let session_signing_secret = session_signing_secret.into();
|
||||
if session_signing_secret.trim().is_empty() {
|
||||
return Err(anyhow!("Session signing secret must be provided"));
|
||||
}
|
||||
Ok(Self {
|
||||
client: CultNetClientSecurityOptions::new(connection_key)?,
|
||||
session_signing_key: sha256(session_signing_secret.as_bytes()),
|
||||
session_signing_secret,
|
||||
is_development,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn development() -> Self {
|
||||
Self::new(
|
||||
"gamecult-dev-connection-key",
|
||||
"gamecult-dev-session-signing-secret",
|
||||
true,
|
||||
)
|
||||
.expect("development security config is valid")
|
||||
}
|
||||
|
||||
pub fn to_client_options(&self) -> CultNetClientSecurityOptions {
|
||||
self.client.clone()
|
||||
}
|
||||
|
||||
pub fn encryption_key(&self) -> [u8; 32] {
|
||||
self.client.encryption_key()
|
||||
}
|
||||
|
||||
pub fn session_signing_key(&self) -> [u8; 32] {
|
||||
self.session_signing_key
|
||||
}
|
||||
}
|
||||
|
||||
pub trait CultNetEncryptionOptions {
|
||||
fn encryption_key(&self) -> [u8; 32];
|
||||
}
|
||||
|
||||
impl CultNetEncryptionOptions for CultNetClientSecurityOptions {
|
||||
fn encryption_key(&self) -> [u8; 32] {
|
||||
self.encryption_key()
|
||||
}
|
||||
}
|
||||
|
||||
impl CultNetEncryptionOptions for CultNetServerSecurityOptions {
|
||||
fn encryption_key(&self) -> [u8; 32] {
|
||||
self.encryption_key()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ValidatedCultNetSessionToken {
|
||||
pub user_id: String,
|
||||
pub expires_at_utc: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub struct CultNetSecret;
|
||||
|
||||
impl CultNetSecret {
|
||||
pub fn new_nonce() -> [u8; NONCE_LENGTH] {
|
||||
let mut nonce = [0_u8; NONCE_LENGTH];
|
||||
rand::rng().fill_bytes(&mut nonce);
|
||||
nonce
|
||||
}
|
||||
|
||||
pub fn encrypt_string(
|
||||
input: Option<&str>,
|
||||
nonce: &[u8],
|
||||
options: &impl CultNetEncryptionOptions,
|
||||
) -> Result<Option<Vec<u8>>> {
|
||||
let Some(input) = input.filter(|value| !value.is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(Self::encrypt_bytes(input.as_bytes(), nonce, options)?))
|
||||
}
|
||||
|
||||
pub fn decrypt_string(
|
||||
encrypted: Option<&[u8]>,
|
||||
nonce: Option<&[u8]>,
|
||||
options: &impl CultNetEncryptionOptions,
|
||||
) -> Result<Option<String>> {
|
||||
let (Some(encrypted), Some(nonce)) = (encrypted, nonce) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let bytes = Self::decrypt_bytes(encrypted, nonce, options)?;
|
||||
Ok(Some(String::from_utf8(bytes)?))
|
||||
}
|
||||
|
||||
pub fn encrypt_bytes(
|
||||
input: &[u8],
|
||||
nonce: &[u8],
|
||||
options: &impl CultNetEncryptionOptions,
|
||||
) -> Result<Vec<u8>> {
|
||||
let nonce = validate_nonce(nonce)?;
|
||||
let cipher = Aes256Gcm::new_from_slice(&options.encryption_key())?;
|
||||
let encrypted = cipher
|
||||
.encrypt(
|
||||
Nonce::from_slice(nonce),
|
||||
Payload {
|
||||
msg: input,
|
||||
aad: &[],
|
||||
},
|
||||
)
|
||||
.map_err(|_| anyhow!("AES-GCM encryption failed"))?;
|
||||
Ok(encrypted)
|
||||
}
|
||||
|
||||
pub fn decrypt_bytes(
|
||||
encrypted: &[u8],
|
||||
nonce: &[u8],
|
||||
options: &impl CultNetEncryptionOptions,
|
||||
) -> Result<Vec<u8>> {
|
||||
let nonce = validate_nonce(nonce)?;
|
||||
let cipher = Aes256Gcm::new_from_slice(&options.encryption_key())?;
|
||||
cipher
|
||||
.decrypt(
|
||||
Nonce::from_slice(nonce),
|
||||
Payload {
|
||||
msg: encrypted,
|
||||
aad: &[],
|
||||
},
|
||||
)
|
||||
.map_err(|_| anyhow!("AES-GCM decryption failed"))
|
||||
}
|
||||
|
||||
pub fn create_session_token(
|
||||
user_id: &str,
|
||||
expires_at_utc: DateTime<Utc>,
|
||||
options: &CultNetServerSecurityOptions,
|
||||
) -> Result<String> {
|
||||
let payload = format!("{}|{}", user_id, expires_at_utc.timestamp());
|
||||
let signature = hmac_sha256(&options.session_signing_key(), payload.as_bytes())?;
|
||||
Ok(format!(
|
||||
"{}.{}",
|
||||
URL_SAFE_NO_PAD.encode(payload.as_bytes()),
|
||||
URL_SAFE_NO_PAD.encode(signature)
|
||||
))
|
||||
}
|
||||
|
||||
pub fn try_validate_session_token(
|
||||
token: Option<&str>,
|
||||
options: &CultNetServerSecurityOptions,
|
||||
) -> Result<Option<ValidatedCultNetSessionToken>> {
|
||||
let Some(token) = token.filter(|value| !value.trim().is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some((payload_part, signature_part)) = token.split_once('.') else {
|
||||
return Ok(None);
|
||||
};
|
||||
let payload = URL_SAFE_NO_PAD.decode(payload_part)?;
|
||||
let signature = URL_SAFE_NO_PAD.decode(signature_part)?;
|
||||
let expected = hmac_sha256(&options.session_signing_key(), &payload)?;
|
||||
if !constant_time_eq(&signature, &expected) {
|
||||
return Ok(None);
|
||||
}
|
||||
let payload = String::from_utf8(payload)?;
|
||||
let Some((user_id, expires_at)) = payload.split_once('|') else {
|
||||
return Ok(None);
|
||||
};
|
||||
let expires_at_seconds: i64 = expires_at.parse()?;
|
||||
let Some(expires_at_utc) = Utc.timestamp_opt(expires_at_seconds, 0).single() else {
|
||||
return Ok(None);
|
||||
};
|
||||
if expires_at_utc <= Utc::now() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(ValidatedCultNetSessionToken {
|
||||
user_id: user_id.to_string(),
|
||||
expires_at_utc,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn to_base64_url(input: &[u8]) -> String {
|
||||
URL_SAFE_NO_PAD.encode(input)
|
||||
}
|
||||
|
||||
pub fn from_base64_url(input: &str) -> Result<Vec<u8>> {
|
||||
Ok(URL_SAFE_NO_PAD.decode(input)?)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_nonce(nonce: &[u8]) -> Result<&[u8]> {
|
||||
if nonce.len() != NONCE_LENGTH {
|
||||
return Err(anyhow!("Invalid nonce"));
|
||||
}
|
||||
Ok(nonce)
|
||||
}
|
||||
|
||||
fn sha256(input: &[u8]) -> [u8; 32] {
|
||||
Sha256::digest(input).into()
|
||||
}
|
||||
|
||||
fn hmac_sha256(key: &[u8], input: &[u8]) -> Result<Vec<u8>> {
|
||||
let mut mac = <HmacSha256 as Mac>::new_from_slice(key)?;
|
||||
mac.update(input);
|
||||
Ok(mac.finalize().into_bytes().to_vec())
|
||||
}
|
||||
|
||||
fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
|
||||
if left.len() != right.len() {
|
||||
return false;
|
||||
}
|
||||
left.iter()
|
||||
.zip(right)
|
||||
.fold(0_u8, |acc, (left, right)| acc | (left ^ right))
|
||||
== 0
|
||||
}
|
||||
397
packages/cultnet-rs/tests/cultnet.rs
Normal file
397
packages/cultnet-rs/tests/cultnet.rs
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
use anyhow::Result;
|
||||
use chrono::Duration;
|
||||
use cultcache_rs::CultCache;
|
||||
use cultcache_rs::DatabaseEntry;
|
||||
use cultcache_rs::SingleFileMessagePackBackingStore;
|
||||
use cultnet_rs::CultNetClientSecurityOptions;
|
||||
use cultnet_rs::CultNetDocumentBinding;
|
||||
use cultnet_rs::CultNetDocumentMutationContract;
|
||||
use cultnet_rs::CultNetDocumentOperation;
|
||||
use cultnet_rs::CultNetDocumentPutOptions;
|
||||
use cultnet_rs::CultNetDocumentRegistry;
|
||||
use cultnet_rs::CultNetMessage;
|
||||
use cultnet_rs::CultNetMutationAuthority;
|
||||
use cultnet_rs::CultNetSchemaKind;
|
||||
use cultnet_rs::CultNetSchemaRegistry;
|
||||
use cultnet_rs::CultNetSecret;
|
||||
use cultnet_rs::CultNetServerSecurityOptions;
|
||||
use cultnet_rs::CultNetWireContract;
|
||||
use cultnet_rs::LengthPrefixedMessageFramer;
|
||||
use cultnet_rs::builtin_schema_registry;
|
||||
use cultnet_rs::decode_cultnet_message_from_slice;
|
||||
use cultnet_rs::encode_cultnet_message_for_wire;
|
||||
use cultnet_rs::encode_cultnet_message_to_vec;
|
||||
use cultnet_rs::encode_frame;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
const TS_HELLO_FRAME: &[u8] = include_bytes!("fixtures/cultnet-ts-hello.frame");
|
||||
const TS_LEGACY_LOGIN_FRAME: &[u8] = include_bytes!("fixtures/cultnet-ts-legacy-login.frame");
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DatabaseEntry)]
|
||||
#[cultcache(
|
||||
type = "ghostlight.agent-state",
|
||||
schema = "GhostlightAgentStateFixture"
|
||||
)]
|
||||
struct GhostlightAgentStateFixture {
|
||||
#[cultcache(key = 0)]
|
||||
schema_version: String,
|
||||
#[cultcache(key = 1)]
|
||||
agent_id: String,
|
||||
#[cultcache(key = 2)]
|
||||
display_name: String,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn security_helpers_round_trip_encrypted_strings_and_validate_sessions() -> Result<()> {
|
||||
let server_security = CultNetServerSecurityOptions::development();
|
||||
let client_security = server_security.to_client_options();
|
||||
let nonce = CultNetSecret::new_nonce();
|
||||
let encrypted = CultNetSecret::encrypt_string(Some("hello"), &nonce, &client_security)?
|
||||
.expect("non-empty input encrypts");
|
||||
assert_eq!(
|
||||
CultNetSecret::decrypt_string(Some(&encrypted), Some(&nonce), &server_security)?,
|
||||
Some("hello".to_string())
|
||||
);
|
||||
|
||||
let token = CultNetSecret::create_session_token(
|
||||
"runtime-face",
|
||||
chrono::Utc::now() + Duration::minutes(1),
|
||||
&server_security,
|
||||
)?;
|
||||
let validated = CultNetSecret::try_validate_session_token(Some(&token), &server_security)?
|
||||
.expect("token validates before expiry");
|
||||
assert_eq!(validated.user_id, "runtime-face");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cultnet_schema_messages_round_trip_through_messagepack_frames() -> Result<()> {
|
||||
let message = CultNetMessage::Hello {
|
||||
runtime_id: "voidbot-main".to_string(),
|
||||
runtime_kind: "rust-worker".to_string(),
|
||||
agent_id: Some("void".to_string()),
|
||||
role: None,
|
||||
display_name: Some("Void".to_string()),
|
||||
supported_document_types: Some(vec!["ghostlight.agent-state".to_string()]),
|
||||
supported_mutation_contracts: Some(vec![CultNetDocumentMutationContract {
|
||||
document_type: "ghostlight.agent-state".to_string(),
|
||||
payload_schema_version: Some("ghostlight.agent_state.v0".to_string()),
|
||||
operations: vec![
|
||||
CultNetDocumentOperation::Snapshot,
|
||||
CultNetDocumentOperation::DocumentPut,
|
||||
],
|
||||
authority: CultNetMutationAuthority::Coordinator,
|
||||
intent_document_types: Some(vec!["ghostlight.agent-state.intent.v0".to_string()]),
|
||||
receipt_document_types: Some(vec!["ghostlight.agent-state.receipt.v0".to_string()]),
|
||||
notes: Some(vec![
|
||||
"Face may request bounded memory mutation; coordinator reviews authority."
|
||||
.to_string(),
|
||||
]),
|
||||
}]),
|
||||
supported_message_versions: None,
|
||||
supports_schema_catalog: Some(true),
|
||||
};
|
||||
let payload = encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0)?;
|
||||
let frame = encode_frame(&payload)?;
|
||||
assert_eq!(&frame[..4], &(payload.len() as u32).to_be_bytes());
|
||||
|
||||
let mut framer = LengthPrefixedMessageFramer::new();
|
||||
assert!(framer.push(&frame[..2]).is_empty());
|
||||
let frames = framer.push(&frame[2..]);
|
||||
assert_eq!(frames.len(), 1);
|
||||
let decoded =
|
||||
decode_cultnet_message_from_slice(&frames[0], CultNetWireContract::CultNetSchemaV0)?;
|
||||
assert_eq!(decoded, message);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_gamecult_networking_contract_round_trips_login_union() -> Result<()> {
|
||||
let message = CultNetMessage::Login {
|
||||
nonce: CultNetSecret::to_base64_url(b"nonce"),
|
||||
auth: CultNetSecret::to_base64_url(b"auth"),
|
||||
password: CultNetSecret::to_base64_url(b"password"),
|
||||
};
|
||||
let wire =
|
||||
encode_cultnet_message_for_wire(&message, CultNetWireContract::GameCultNetworkingV0)?;
|
||||
let items = wire.as_array().expect("legacy union is an array");
|
||||
assert_eq!(items[0].as_i64(), Some(0));
|
||||
assert_eq!(
|
||||
items[1].as_array().unwrap()[0].as_slice(),
|
||||
Some(&b"nonce"[..])
|
||||
);
|
||||
|
||||
let bytes = rmp_serde::to_vec(&wire)?;
|
||||
let decoded =
|
||||
decode_cultnet_message_from_slice(&bytes, CultNetWireContract::GameCultNetworkingV0)?;
|
||||
assert_eq!(decoded, message);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rust_decodes_typescript_generated_cultnet_frames() -> Result<()> {
|
||||
let mut framer = LengthPrefixedMessageFramer::new();
|
||||
let hello_frames = framer.push(TS_HELLO_FRAME);
|
||||
assert_eq!(hello_frames.len(), 1);
|
||||
let hello =
|
||||
decode_cultnet_message_from_slice(&hello_frames[0], CultNetWireContract::CultNetSchemaV0)?;
|
||||
assert_eq!(
|
||||
hello,
|
||||
CultNetMessage::Hello {
|
||||
runtime_id: "voidbot-main".to_string(),
|
||||
runtime_kind: "node-worker".to_string(),
|
||||
agent_id: Some("void".to_string()),
|
||||
role: None,
|
||||
display_name: Some("Void".to_string()),
|
||||
supported_document_types: Some(vec!["ghostlight.agent-state".to_string()]),
|
||||
supported_mutation_contracts: None,
|
||||
supported_message_versions: None,
|
||||
supports_schema_catalog: None,
|
||||
}
|
||||
);
|
||||
|
||||
let mut framer = LengthPrefixedMessageFramer::new();
|
||||
let login_frames = framer.push(TS_LEGACY_LOGIN_FRAME);
|
||||
assert_eq!(login_frames.len(), 1);
|
||||
let login = decode_cultnet_message_from_slice(
|
||||
&login_frames[0],
|
||||
CultNetWireContract::GameCultNetworkingV0,
|
||||
)?;
|
||||
assert_eq!(
|
||||
login,
|
||||
CultNetMessage::Login {
|
||||
nonce: "bm9uY2U".to_string(),
|
||||
auth: "YXV0aA".to_string(),
|
||||
password: "cGFzc3dvcmQ".to_string(),
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_registry_advertises_mutation_contracts_with_bindings() -> Result<()> {
|
||||
let mut registry = CultNetDocumentRegistry::new();
|
||||
let contract = CultNetDocumentMutationContract {
|
||||
document_type: "ghostlight.agent-state".to_string(),
|
||||
payload_schema_version: Some("ghostlight.agent_state.v0".to_string()),
|
||||
operations: vec![
|
||||
CultNetDocumentOperation::Snapshot,
|
||||
CultNetDocumentOperation::IntentSubmit,
|
||||
CultNetDocumentOperation::ReceiptWatch,
|
||||
],
|
||||
authority: CultNetMutationAuthority::Coordinator,
|
||||
intent_document_types: Some(vec!["ghostlight.agent-state.intent.v0".to_string()]),
|
||||
receipt_document_types: Some(vec!["ghostlight.agent-state.receipt.v0".to_string()]),
|
||||
notes: Some(vec![
|
||||
"Intent documents are reviewed before cache mutation.".to_string(),
|
||||
]),
|
||||
};
|
||||
registry.register(
|
||||
CultNetDocumentBinding::for_entry::<GhostlightAgentStateFixture>(
|
||||
"ghostlight.agent_state.v0".to_string(),
|
||||
)
|
||||
.with_mutation_contract(contract.clone()),
|
||||
);
|
||||
|
||||
assert_eq!(registry.mutation_contracts(), vec![contract]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_registry_replicates_typed_cultcache_state() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let origin_store = temp.path().join("origin.msgpack");
|
||||
let target_store = temp.path().join("target.msgpack");
|
||||
let payload = GhostlightAgentStateFixture {
|
||||
schema_version: "ghostlight.agent_state.v0".to_string(),
|
||||
agent_id: "epiphany.face".to_string(),
|
||||
display_name: "Face".to_string(),
|
||||
};
|
||||
|
||||
let mut registry = CultNetDocumentRegistry::new();
|
||||
registry.register(CultNetDocumentBinding::for_entry::<
|
||||
GhostlightAgentStateFixture,
|
||||
>("ghostlight.agent_state.v0".to_string()));
|
||||
|
||||
let mut origin = CultCache::new();
|
||||
origin.register_entry_type::<GhostlightAgentStateFixture>()?;
|
||||
origin.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&origin_store));
|
||||
origin.pull_all_backing_stores()?;
|
||||
origin.put("epiphany.face", &payload)?;
|
||||
|
||||
let snapshot = registry.create_snapshot_response(&origin, "snapshot-1", None, None)?;
|
||||
|
||||
let mut target = CultCache::new();
|
||||
target.register_entry_type::<GhostlightAgentStateFixture>()?;
|
||||
target.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&target_store));
|
||||
target.pull_all_backing_stores()?;
|
||||
let applied =
|
||||
registry.apply_snapshot_response::<GhostlightAgentStateFixture>(&mut target, &snapshot)?;
|
||||
assert_eq!(applied, vec![payload.clone()]);
|
||||
assert_eq!(
|
||||
target.get_required::<GhostlightAgentStateFixture>("epiphany.face")?,
|
||||
payload
|
||||
);
|
||||
|
||||
let direct_put = registry.create_document_put_message(
|
||||
"put-1",
|
||||
"epiphany.face",
|
||||
&GhostlightAgentStateFixture {
|
||||
display_name: "Face Prime".to_string(),
|
||||
..payload
|
||||
},
|
||||
CultNetDocumentPutOptions::default(),
|
||||
)?;
|
||||
let updated = registry
|
||||
.apply_document_put_message::<GhostlightAgentStateFixture>(&mut target, &direct_put)?;
|
||||
assert_eq!(updated.display_name, "Face Prime");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_snapshot_replication_preserves_messagepack_payload_bytes() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let origin_store = temp.path().join("origin-raw.msgpack");
|
||||
let target_store = temp.path().join("target-raw.msgpack");
|
||||
let payload = GhostlightAgentStateFixture {
|
||||
schema_version: "ghostlight.agent_state.v0".to_string(),
|
||||
agent_id: "epiphany.face".to_string(),
|
||||
display_name: "Face".to_string(),
|
||||
};
|
||||
|
||||
let mut registry = CultNetDocumentRegistry::new();
|
||||
registry.register(CultNetDocumentBinding::for_entry::<
|
||||
GhostlightAgentStateFixture,
|
||||
>("ghostlight.agent_state.v0".to_string()));
|
||||
|
||||
let mut origin = CultCache::new();
|
||||
origin.register_entry_type::<GhostlightAgentStateFixture>()?;
|
||||
origin.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&origin_store));
|
||||
origin.pull_all_backing_stores()?;
|
||||
origin.put("epiphany.face", &payload)?;
|
||||
|
||||
let raw_snapshot =
|
||||
registry.create_raw_snapshot_response(&origin, "raw-snapshot-1", None, None)?;
|
||||
let source_envelope =
|
||||
origin.get_required_envelope::<GhostlightAgentStateFixture>("epiphany.face")?;
|
||||
|
||||
let mut target = CultCache::new();
|
||||
target.register_entry_type::<GhostlightAgentStateFixture>()?;
|
||||
target.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&target_store));
|
||||
target.pull_all_backing_stores()?;
|
||||
let applied = registry
|
||||
.apply_raw_snapshot_response::<GhostlightAgentStateFixture>(&mut target, &raw_snapshot)?;
|
||||
let target_envelope =
|
||||
target.get_required_envelope::<GhostlightAgentStateFixture>("epiphany.face")?;
|
||||
|
||||
assert_eq!(applied, vec![payload.clone()]);
|
||||
assert_eq!(target_envelope.payload, source_envelope.payload);
|
||||
assert_eq!(
|
||||
target.get_required::<GhostlightAgentStateFixture>("epiphany.face")?,
|
||||
payload
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_security_keeps_the_connection_key_visible_without_exposing_cipher_logic() -> Result<()> {
|
||||
let options = CultNetClientSecurityOptions::development();
|
||||
assert_eq!(options.connection_key, "gamecult-dev-connection-key");
|
||||
assert_ne!(options.encryption_key(), [0_u8; 32]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_schema_registry_advertises_canonical_ghostlight_schema_without_inline_body_by_default()
|
||||
-> Result<()> {
|
||||
let registry = builtin_schema_registry()?;
|
||||
let response = registry.create_catalog_response(&CultNetMessage::SchemaCatalogRequest {
|
||||
message_id: "catalog-1".to_string(),
|
||||
include_schema_json: None,
|
||||
schema_ids: None,
|
||||
kinds: None,
|
||||
})?;
|
||||
|
||||
let CultNetMessage::SchemaCatalogResponse { schemas, .. } = response else {
|
||||
panic!("expected catalog response");
|
||||
};
|
||||
|
||||
let ghostlight = schemas
|
||||
.iter()
|
||||
.find(|schema| schema.document_type.as_deref() == Some("ghostlight.agent-state"))
|
||||
.expect("ghostlight agent-state schema is advertised");
|
||||
|
||||
assert_eq!(ghostlight.kind, CultNetSchemaKind::DocumentPayload);
|
||||
assert_eq!(
|
||||
ghostlight.schema_version.as_deref(),
|
||||
Some("ghostlight.agent_state.v0")
|
||||
);
|
||||
assert_eq!(ghostlight.schema_json, None);
|
||||
assert!(!ghostlight.content_hash.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_discovery_round_trips_over_legacy_gamecult_contract_when_inline_schemas_are_requested()
|
||||
-> Result<()> {
|
||||
let registry = {
|
||||
let mut registry = CultNetSchemaRegistry::new();
|
||||
registry.register(cultnet_rs::CultNetSchemaRegistration {
|
||||
schema_id: "https://example.test/contracts/example.schema.json".to_string(),
|
||||
kind: CultNetSchemaKind::SharedContract,
|
||||
wire_contracts: vec![
|
||||
CultNetWireContract::CultNetSchemaV0,
|
||||
CultNetWireContract::GameCultNetworkingV0,
|
||||
],
|
||||
schema_version: None,
|
||||
document_type: None,
|
||||
title: Some("Example Schema".to_string()),
|
||||
schema_json: Some(
|
||||
r#"{
|
||||
"$schema":"https://json-schema.org/draft/2020-12/schema",
|
||||
"$id":"https://example.test/contracts/example.schema.json",
|
||||
"title":"Example Schema",
|
||||
"type":"object",
|
||||
"properties":{"value":{"type":"string"}},
|
||||
"required":["value"],
|
||||
"additionalProperties":false
|
||||
}"#
|
||||
.to_string(),
|
||||
),
|
||||
})?;
|
||||
registry
|
||||
};
|
||||
|
||||
let response = registry.create_catalog_response(&CultNetMessage::SchemaCatalogRequest {
|
||||
message_id: "catalog-legacy".to_string(),
|
||||
include_schema_json: Some(true),
|
||||
schema_ids: None,
|
||||
kinds: None,
|
||||
})?;
|
||||
let wire =
|
||||
encode_cultnet_message_for_wire(&response, CultNetWireContract::GameCultNetworkingV0)?;
|
||||
let bytes = rmp_serde::to_vec(&wire)?;
|
||||
let decoded =
|
||||
decode_cultnet_message_from_slice(&bytes, CultNetWireContract::GameCultNetworkingV0)?;
|
||||
|
||||
let CultNetMessage::SchemaCatalogResponse {
|
||||
message_id,
|
||||
schemas,
|
||||
} = decoded
|
||||
else {
|
||||
panic!("expected legacy schema catalog response");
|
||||
};
|
||||
|
||||
assert_eq!(message_id, "catalog-legacy");
|
||||
assert_eq!(
|
||||
schemas[0].schema_id,
|
||||
"https://example.test/contracts/example.schema.json"
|
||||
);
|
||||
assert!(
|
||||
schemas[0]
|
||||
.schema_json
|
||||
.as_deref()
|
||||
.is_some_and(|schema| schema.contains("\"value\""))
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
BIN
packages/cultnet-rs/tests/fixtures/cultnet-ts-hello.frame
vendored
Normal file
BIN
packages/cultnet-rs/tests/fixtures/cultnet-ts-hello.frame
vendored
Normal file
Binary file not shown.
BIN
packages/cultnet-rs/tests/fixtures/cultnet-ts-legacy-login.frame
vendored
Normal file
BIN
packages/cultnet-rs/tests/fixtures/cultnet-ts-legacy-login.frame
vendored
Normal file
Binary file not shown.
4
packages/cultnet-ts/.gitignore
vendored
Normal file
4
packages/cultnet-ts/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
node_modules/
|
||||
dist/
|
||||
dist-test/
|
||||
*.tgz
|
||||
259
packages/cultnet-ts/README.md
Normal file
259
packages/cultnet-ts/README.md
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
# CultNet TS
|
||||
|
||||
`cultnet-ts` is the TypeScript port of the useful shape of GameCult's
|
||||
networking layer: typed MessagePack messages, direct-pipe framing, CultLib-style
|
||||
security/session helpers, and state replication that hands typed values into a
|
||||
`CultCache` instead of making consumer code paw raw database envelopes.
|
||||
|
||||
This is not an HTTP wrapper wearing a fake mustache. It is for runtimes that
|
||||
already trust a direct channel and want a compact shared contract over it.
|
||||
|
||||
## Scope
|
||||
|
||||
The library currently includes:
|
||||
|
||||
- JSON-schema-backed wire contracts for the first CultNet message set
|
||||
- a MessagePack codec plus 4-byte length-prefixed framing for direct pipes
|
||||
- a `CultNetPeer` wrapper over any Node `Duplex`
|
||||
- CultLib-style client/server security options and `Secret` helpers
|
||||
- session-token signing/validation compatible with the C# CultLib semantics
|
||||
- explicit wire-contract selection instead of heuristic payload guessing
|
||||
- `CultCacheTS` replication helpers for document put/delete/snapshot flows
|
||||
- schema discovery catalog messages and a registry for asking what contracts a
|
||||
peer can actually exchange safely
|
||||
- generated TypeScript contracts and runtime verifiers from canonical JSON
|
||||
schemas owned elsewhere in the swarm
|
||||
- an exact mirrored runtime validator and generated TS contract surface for
|
||||
Ghostlight's canonical `ghostlight.agent_state.v0` payload
|
||||
|
||||
## Why This Exists
|
||||
|
||||
We want:
|
||||
|
||||
- one shared agent-state vocabulary across `Ghostlight`, `EpiphanyAgent`,
|
||||
`VoidBot`, and whatever other little laboratory creature appears next
|
||||
- typed persistence through `CultCache`
|
||||
- direct pipes instead of every consumer inventing bespoke JSON fog
|
||||
- authentication and session semantics that match the original CultLib intent
|
||||
|
||||
So the split is:
|
||||
|
||||
- `cultcache-ts`: typed local persistence
|
||||
- `cultnet-ts`: typed transport, auth, and state replication
|
||||
|
||||
Same family. Different organs. Nobody needs a second spine growing out of the
|
||||
transport layer.
|
||||
|
||||
## Contracts
|
||||
|
||||
Wire contracts live under `contracts/` as JSON Schema files.
|
||||
|
||||
Current message families:
|
||||
|
||||
- `cultnet.hello.v0`
|
||||
- `cultnet.login.v0`
|
||||
- `cultnet.register.v0`
|
||||
- `cultnet.verify.v0`
|
||||
- `cultnet.login_success.v0`
|
||||
- `cultnet.error.v0`
|
||||
- `cultnet.document_put.v0`
|
||||
- `cultnet.document_delete.v0`
|
||||
- `cultnet.document_put_raw.v0`
|
||||
- `cultnet.snapshot_request.v0`
|
||||
- `cultnet.snapshot_response.v0`
|
||||
- `cultnet.snapshot_response_raw.v0`
|
||||
- `cultnet.sample.change_name.v0`
|
||||
- `cultnet.sample.chat.v0`
|
||||
|
||||
For shared agent state, the first payload contract is the existing Ghostlight
|
||||
fixture format:
|
||||
|
||||
- payload schema version: `ghostlight.agent_state.v0`
|
||||
- mirrored validator: `contracts/ghostlight.agent-state.schema.json`
|
||||
|
||||
That means `EpiphanyAgent` role dossiers and Ghostlight fixtures can travel over
|
||||
CultNet without inventing a second ontology first.
|
||||
|
||||
## Generated Swarm Contracts
|
||||
|
||||
`cultnet-ts` can consume canonical JSON Schema files and generate:
|
||||
|
||||
- TypeScript contract classes/interfaces
|
||||
- runtime schema verifiers
|
||||
- parse-compatible schema objects that can be handed directly to
|
||||
`CultCacheTS`
|
||||
|
||||
The manifest lives at [contracts/swarm-contracts.manifest.json](contracts/swarm-contracts.manifest.json).
|
||||
Right now it points at Ghostlight's canonical agent-state schema, because
|
||||
Ghostlight owns the swarm's most important mind-shape contract.
|
||||
|
||||
Generate the contract layer with:
|
||||
|
||||
```bash
|
||||
npm run contracts:generate
|
||||
```
|
||||
|
||||
That emits [src/generated/swarm-contracts.generated.ts](src/generated/swarm-contracts.generated.ts),
|
||||
which exports:
|
||||
|
||||
- the generated TS contract shape
|
||||
- named runtime verifier helpers such as
|
||||
`validateGhostlightAgentStateGenerated(...)` and
|
||||
`parseGhostlightAgentStateGenerated(...)`
|
||||
- a runtime `validate(...)` / `parse(...)` contract object via
|
||||
`defineJsonSchemaContract(...)`
|
||||
- a contract object that `CultCacheTS` can persist directly without a second
|
||||
hand-written Zod mirror
|
||||
|
||||
## Wire Contracts
|
||||
|
||||
`cultnet-ts` does not sniff random payloads and pretend that counts as rigor.
|
||||
You choose the contract explicitly.
|
||||
|
||||
Current wire-contract options:
|
||||
|
||||
- `cultnet.schema.v0`
|
||||
- the newer schema-versioned object contract for CultNet-native message
|
||||
families such as hello, document replication, and snapshots
|
||||
- `gamecult.networking.v0`
|
||||
- the legacy C# `GameCult.Networking` MessagePack union contract for
|
||||
login/register/verify/session/error plus the sample `ChangeNameMessage` and
|
||||
`ChatMessage` payloads
|
||||
|
||||
So the transport can stay developer-friendly while the binary stops freelancing
|
||||
away from the C# lineage.
|
||||
|
||||
## Local Fast Lane
|
||||
|
||||
For direct-pipe neighbors that already share the same CultCache formatter
|
||||
contract, `cultnet-ts` now has a raw replication lane:
|
||||
|
||||
- `cultnet.document_put_raw.v0`
|
||||
- `cultnet.snapshot_response_raw.v0`
|
||||
|
||||
Those messages carry the canonical persisted MessagePack payload bytes plus the
|
||||
typed envelope metadata. Paired with `CultCacheTS.putEnvelope(...)`, that means
|
||||
the receiving cache can ingest the exact payload bytes directly instead of
|
||||
decoding them into generic object soup and then re-encoding the same document
|
||||
again.
|
||||
|
||||
This is cheaper. It is not magic:
|
||||
|
||||
- framing still copies
|
||||
- MessagePack decoding still happens once on ingest
|
||||
- ordinary `CultNetPeer` send/receive still materializes messages
|
||||
|
||||
But for local or near-local runtimes sharing the same contract, the fast lane
|
||||
cuts out the dumbest part of the trip.
|
||||
|
||||
## Schema Discovery
|
||||
|
||||
Apps sharing a CultNet space should be able to ask each other what they can
|
||||
exchange without resorting to folklore.
|
||||
|
||||
`cultnet-ts` now includes:
|
||||
|
||||
- `cultnet.schema_catalog_request.v0`
|
||||
- `cultnet.schema_catalog_response.v0`
|
||||
- `CultNetSchemaRegistry`
|
||||
- `cultNetBuiltinSchemaRegistry`
|
||||
|
||||
Descriptors include:
|
||||
|
||||
- canonical `schemaId`
|
||||
- discovery `kind`
|
||||
- optional `schemaVersion`
|
||||
- optional `documentType`
|
||||
- supported `wireContracts`
|
||||
- a SHA-256 `contentHash`
|
||||
- optional inline `schemaJson`
|
||||
|
||||
The inline schema body travels as canonical JSON text, not as a loose object
|
||||
graph. Discovery is supposed to reduce ambiguity, not smuggle in a cute new
|
||||
polymorphic attack surface.
|
||||
|
||||
## Quick Example
|
||||
|
||||
```ts
|
||||
import { CultCache, defineDocumentType } from "cultcache-ts";
|
||||
import {
|
||||
CultNetDocumentRegistry,
|
||||
CultNetPeer,
|
||||
defineCultNetDocumentBinding,
|
||||
ghostlightAgentStateGeneratedContract,
|
||||
} from "cultnet-ts";
|
||||
|
||||
const ghostlightStateDefinition = defineDocumentType({
|
||||
type: "ghostlight.agent-state",
|
||||
schema: ghostlightAgentStateGeneratedContract,
|
||||
});
|
||||
|
||||
const registry = new CultNetDocumentRegistry([
|
||||
defineCultNetDocumentBinding({
|
||||
definition: ghostlightStateDefinition,
|
||||
payloadSchemaVersion: "ghostlight.agent_state.v0",
|
||||
}),
|
||||
]);
|
||||
|
||||
const peer = new CultNetPeer(stream, {
|
||||
wireContract: "cultnet.schema.v0",
|
||||
});
|
||||
```
|
||||
|
||||
## Security Model
|
||||
|
||||
The security helpers intentionally mirror CultLib's shape:
|
||||
|
||||
- shared connection key -> SHA-256 derived AES-GCM key
|
||||
- server-only session signing secret -> HMAC-SHA256 session token signing
|
||||
- 12-byte nonce
|
||||
- authentication tag prefixed onto ciphertext
|
||||
- session token format: `base64url(payload).base64url(signature)`
|
||||
|
||||
That means a TypeScript runtime can follow the same cryptographic and session
|
||||
story as the C# version instead of inventing one more house religion around
|
||||
"trust me, this pipe is probably fine."
|
||||
|
||||
For the legacy `gamecult.networking.v0` contract, `cultnet-ts` normalizes the
|
||||
encrypted binary fields as base64url strings in the ergonomic TS surface and
|
||||
maps them back to the exact C# union payload shape at the wire boundary.
|
||||
|
||||
## Current Limits
|
||||
|
||||
- This repo currently implements the client-side wire contract and pipe
|
||||
ergonomics, not a full account server.
|
||||
- The Ghostlight agent-state mirror is an exact copy of Ghostlight's current
|
||||
canonical schema. If Ghostlight changes the contract, this mirror must be
|
||||
updated in lockstep instead of playing coy with partial validation.
|
||||
- The direct transport is any Node `Duplex`; named-pipe/socket server wrappers
|
||||
can be added without changing the message contract.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run contracts:generate
|
||||
npm run build
|
||||
npm run typecheck
|
||||
npm run test
|
||||
```
|
||||
|
||||
## Interop CI
|
||||
|
||||
The cross-runtime interop harness is meant to run in GitHub Actions on
|
||||
`windows-latest`.
|
||||
|
||||
It expects these sibling checkouts inside the same workspace:
|
||||
|
||||
- `CultNetTS`
|
||||
- `cultnet-rs`
|
||||
- `cultcache-rs`
|
||||
- `CultLib`
|
||||
|
||||
The workflow lives at
|
||||
[.github/workflows/cultnet-interop.yml](.github/workflows/cultnet-interop.yml)
|
||||
and drives the whole ritual through one command:
|
||||
|
||||
```bash
|
||||
npm run test:interop
|
||||
```
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-delete.schema.json",
|
||||
"title": "CultNet Document Delete Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"messageId",
|
||||
"schemaId",
|
||||
"recordKey"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.document_delete.v0" },
|
||||
"messageId": { "type": "string", "minLength": 1 },
|
||||
"schemaId": { "type": "string", "minLength": 1 },
|
||||
"recordKey": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-mutation-contract.schema.json",
|
||||
"title": "CultNet Document Mutation Contract",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"documentType",
|
||||
"operations",
|
||||
"authority"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"documentType": { "type": "string", "minLength": 1 },
|
||||
"payloadSchemaVersion": { "type": "string", "minLength": 1 },
|
||||
"operations": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["snapshot", "documentPut", "documentDelete", "intentSubmit", "receiptWatch"]
|
||||
}
|
||||
},
|
||||
"authority": {
|
||||
"type": "string",
|
||||
"enum": ["readOnly", "localUser", "coordinator", "runtime", "denied"]
|
||||
},
|
||||
"intentDocumentTypes": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"receiptDocumentTypes": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"notes": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-put-raw.schema.json",
|
||||
"title": "CultNet Raw Document Put Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"messageId",
|
||||
"document"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.document_put_raw.v0" },
|
||||
"messageId": { "type": "string", "minLength": 1 },
|
||||
"document": {
|
||||
"$ref": "./cultnet.raw-document-record.schema.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-put.schema.json",
|
||||
"title": "CultNet Document Put Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"messageId",
|
||||
"document"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.document_put.v0" },
|
||||
"messageId": { "type": "string", "minLength": 1 },
|
||||
"document": {
|
||||
"$ref": "./cultnet.document-record.schema.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-record.schema.json",
|
||||
"title": "CultNet Document Record",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaId",
|
||||
"recordKey",
|
||||
"storedAt",
|
||||
"payload"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaId": { "type": "string", "minLength": 1 },
|
||||
"recordKey": { "type": "string", "minLength": 1 },
|
||||
"storedAt": { "type": "string", "minLength": 1 },
|
||||
"payload": {},
|
||||
"sourceRuntimeId": { "type": "string", "minLength": 1 },
|
||||
"sourceAgentId": { "type": "string", "minLength": 1 },
|
||||
"sourceRole": { "type": "string", "minLength": 1 },
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
15
packages/cultnet-ts/contracts/cultnet.error.schema.json
Normal file
15
packages/cultnet-ts/contracts/cultnet.error.schema.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.error.schema.json",
|
||||
"title": "CultNet Error Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"error"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.error.v0" },
|
||||
"error": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
35
packages/cultnet-ts/contracts/cultnet.hello.schema.json
Normal file
35
packages/cultnet-ts/contracts/cultnet.hello.schema.json
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.hello.schema.json",
|
||||
"title": "CultNet Hello Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"runtimeId",
|
||||
"runtimeKind"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.hello.v0" },
|
||||
"runtimeId": { "type": "string", "minLength": 1 },
|
||||
"runtimeKind": { "type": "string", "minLength": 1 },
|
||||
"agentId": { "type": "string", "minLength": 1 },
|
||||
"role": { "type": "string", "minLength": 1 },
|
||||
"displayName": { "type": "string", "minLength": 1 },
|
||||
"supportedDocumentTypes": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"supportedMutationContracts": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "./cultnet.document-mutation-contract.schema.json"
|
||||
}
|
||||
},
|
||||
"supportedMessageVersions": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"supportsSchemaCatalog": { "type": "boolean" }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.login-success.schema.json",
|
||||
"title": "CultNet Login Success Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"nonce",
|
||||
"session"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.login_success.v0" },
|
||||
"nonce": { "type": "string", "minLength": 1 },
|
||||
"session": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
19
packages/cultnet-ts/contracts/cultnet.login.schema.json
Normal file
19
packages/cultnet-ts/contracts/cultnet.login.schema.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.login.schema.json",
|
||||
"title": "CultNet Login Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"nonce",
|
||||
"auth",
|
||||
"password"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.login.v0" },
|
||||
"nonce": { "type": "string", "minLength": 1 },
|
||||
"auth": { "type": "string", "minLength": 1 },
|
||||
"password": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.raw-document-record.schema.json",
|
||||
"title": "CultNet Raw Document Record",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaId",
|
||||
"recordKey",
|
||||
"storedAt",
|
||||
"payloadEncoding",
|
||||
"payload"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaId": { "type": "string", "minLength": 1 },
|
||||
"recordKey": { "type": "string", "minLength": 1 },
|
||||
"storedAt": { "type": "string", "minLength": 1 },
|
||||
"payloadEncoding": { "const": "messagepack" },
|
||||
"payload": {
|
||||
"type": "string",
|
||||
"contentEncoding": "base64",
|
||||
"x-cult-runtime-type": "bytes"
|
||||
},
|
||||
"sourceRuntimeId": { "type": "string", "minLength": 1 },
|
||||
"sourceAgentId": { "type": "string", "minLength": 1 },
|
||||
"sourceRole": { "type": "string", "minLength": 1 },
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
21
packages/cultnet-ts/contracts/cultnet.register.schema.json
Normal file
21
packages/cultnet-ts/contracts/cultnet.register.schema.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.register.schema.json",
|
||||
"title": "CultNet Register Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"nonce",
|
||||
"email",
|
||||
"password",
|
||||
"name"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.register.v0" },
|
||||
"nonce": { "type": "string", "minLength": 1 },
|
||||
"email": { "type": "string", "minLength": 1 },
|
||||
"password": { "type": "string", "minLength": 1 },
|
||||
"name": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.sample-change-name.schema.json",
|
||||
"title": "CultNet Sample Change Name Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"name"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.sample.change_name.v0" },
|
||||
"name": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.sample-chat.schema.json",
|
||||
"title": "CultNet Sample Chat Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"text"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.sample.chat.v0" },
|
||||
"text": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.schema-catalog-request.schema.json",
|
||||
"title": "CultNet Schema Catalog Request Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"messageId"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.schema_catalog_request.v0" },
|
||||
"messageId": { "type": "string", "minLength": 1 },
|
||||
"includeSchemaJson": { "type": "boolean" },
|
||||
"schemaIds": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"kinds": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"wire_message",
|
||||
"document_payload",
|
||||
"shared_contract"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.schema-catalog-response.schema.json",
|
||||
"title": "CultNet Schema Catalog Response Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"messageId",
|
||||
"schemas"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.schema_catalog_response.v0" },
|
||||
"messageId": { "type": "string", "minLength": 1 },
|
||||
"schemas": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "./cultnet.schema-descriptor.schema.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.schema-descriptor.schema.json",
|
||||
"title": "CultNet Schema Descriptor",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaId",
|
||||
"kind",
|
||||
"wireContracts",
|
||||
"contentHash"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaId": { "type": "string", "minLength": 1 },
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"wire_message",
|
||||
"document_payload",
|
||||
"shared_contract"
|
||||
]
|
||||
},
|
||||
"schemaVersion": { "type": "string", "minLength": 1 },
|
||||
"documentType": { "type": "string", "minLength": 1 },
|
||||
"title": { "type": "string", "minLength": 1 },
|
||||
"wireContracts": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"cultnet.schema.v0",
|
||||
"gamecult.networking.v0"
|
||||
]
|
||||
}
|
||||
},
|
||||
"contentHash": { "type": "string", "minLength": 1 },
|
||||
"schemaJson": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.snapshot-request.schema.json",
|
||||
"title": "CultNet Snapshot Request Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"messageId"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.snapshot_request.v0" },
|
||||
"messageId": { "type": "string", "minLength": 1 },
|
||||
"schemaIds": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"recordKeys": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"shardId": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"shardEpoch": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.snapshot-response-raw.schema.json",
|
||||
"title": "CultNet Raw Snapshot Response Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"messageId",
|
||||
"documents"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.snapshot_response_raw.v0" },
|
||||
"messageId": { "type": "string", "minLength": 1 },
|
||||
"documents": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "./cultnet.raw-document-record.schema.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.snapshot-response.schema.json",
|
||||
"title": "CultNet Snapshot Response Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"messageId",
|
||||
"documents"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.snapshot_response.v0" },
|
||||
"messageId": { "type": "string", "minLength": 1 },
|
||||
"documents": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "./cultnet.document-record.schema.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
17
packages/cultnet-ts/contracts/cultnet.verify.schema.json
Normal file
17
packages/cultnet-ts/contracts/cultnet.verify.schema.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.verify.schema.json",
|
||||
"title": "CultNet Verify Message",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"nonce",
|
||||
"session"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.verify.v0" },
|
||||
"nonce": { "type": "string", "minLength": 1 },
|
||||
"session": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
845
packages/cultnet-ts/contracts/ghostlight.agent-state.schema.json
Normal file
845
packages/cultnet-ts/contracts/ghostlight.agent-state.schema.json
Normal file
|
|
@ -0,0 +1,845 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/Ghostlight/schemas/agent-state.schema.json",
|
||||
"title": "Ghostlight Agent State Schema",
|
||||
"description": "First canonical contract for Ghostlight agent, perception, relationship, event, scene, and dialogue-context state.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schema_version",
|
||||
"world",
|
||||
"agents",
|
||||
"relationships",
|
||||
"events",
|
||||
"scenes"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schema_version": {
|
||||
"const": "ghostlight.agent_state.v0"
|
||||
},
|
||||
"world": {
|
||||
"$ref": "#/$defs/world_state"
|
||||
},
|
||||
"agents": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/agent"
|
||||
}
|
||||
},
|
||||
"relationships": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/relationship"
|
||||
}
|
||||
},
|
||||
"events": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/event"
|
||||
}
|
||||
},
|
||||
"scenes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/scene"
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"world_state": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"world_id",
|
||||
"setting",
|
||||
"time",
|
||||
"canon_context"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"world_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"setting": {
|
||||
"type": "string"
|
||||
},
|
||||
"time": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"label"
|
||||
],
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"canon_context": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"ambient_pressures": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/pressure"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"agent": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"agent_id",
|
||||
"identity",
|
||||
"canonical_state",
|
||||
"goals",
|
||||
"memories",
|
||||
"perceived_state_overlays"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"agent_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"identity": {
|
||||
"$ref": "#/$defs/identity"
|
||||
},
|
||||
"canonical_state": {
|
||||
"$ref": "#/$defs/canonical_state"
|
||||
},
|
||||
"goals": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/goal"
|
||||
}
|
||||
},
|
||||
"memories": {
|
||||
"$ref": "#/$defs/memory_bundle"
|
||||
},
|
||||
"perceived_state_overlays": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/perceived_state_overlay"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"identity": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"roles",
|
||||
"origin",
|
||||
"public_description"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"roles": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"origin": {
|
||||
"type": "string"
|
||||
},
|
||||
"public_description": {
|
||||
"type": "string"
|
||||
},
|
||||
"private_notes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"canonical_state": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"underlying_organization",
|
||||
"stable_dispositions",
|
||||
"behavioral_dimensions",
|
||||
"presentation_strategy",
|
||||
"voice_style",
|
||||
"situational_state",
|
||||
"values"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"underlying_organization": {
|
||||
"$ref": "#/$defs/variable_map"
|
||||
},
|
||||
"stable_dispositions": {
|
||||
"$ref": "#/$defs/variable_map"
|
||||
},
|
||||
"behavioral_dimensions": {
|
||||
"$ref": "#/$defs/variable_map"
|
||||
},
|
||||
"presentation_strategy": {
|
||||
"$ref": "#/$defs/variable_map"
|
||||
},
|
||||
"situational_state": {
|
||||
"$ref": "#/$defs/variable_map"
|
||||
},
|
||||
"values": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/value_commitment"
|
||||
}
|
||||
},
|
||||
"voice_style": {
|
||||
"$ref": "#/$defs/variable_map"
|
||||
}
|
||||
}
|
||||
},
|
||||
"variable_map": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/$defs/state_variable"
|
||||
}
|
||||
},
|
||||
"state_variable": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"mean",
|
||||
"plasticity",
|
||||
"current_activation"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"mean": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"plasticity": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"current_activation": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"evidence": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"value_commitment": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"value_id",
|
||||
"label",
|
||||
"priority",
|
||||
"unforgivable_if_betrayed"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"value_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"priority": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"unforgivable_if_betrayed": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"goal": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"goal_id",
|
||||
"description",
|
||||
"scope",
|
||||
"priority",
|
||||
"emotional_stake",
|
||||
"status"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"goal_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"scope": {
|
||||
"enum": [
|
||||
"immediate",
|
||||
"scene",
|
||||
"case",
|
||||
"arc",
|
||||
"life"
|
||||
]
|
||||
},
|
||||
"priority": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"emotional_stake": {
|
||||
"type": "string"
|
||||
},
|
||||
"blockers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"enum": [
|
||||
"active",
|
||||
"blocked",
|
||||
"dormant",
|
||||
"resolved",
|
||||
"abandoned"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"memory_bundle": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"episodic",
|
||||
"semantic",
|
||||
"relationship_summaries"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"episodic": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/memory"
|
||||
}
|
||||
},
|
||||
"semantic": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/memory"
|
||||
}
|
||||
},
|
||||
"relationship_summaries": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/memory"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"memory": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"memory_id",
|
||||
"summary",
|
||||
"salience",
|
||||
"confidence"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"memory_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"summary": {
|
||||
"type": "string"
|
||||
},
|
||||
"salience": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"confidence": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"linked_event_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"linked_relationship_id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"perceived_state_overlay": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"observer_agent_id",
|
||||
"target_agent_id",
|
||||
"perceived_dimensions",
|
||||
"beliefs",
|
||||
"distortions"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"observer_agent_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"target_agent_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"perceived_dimensions": {
|
||||
"$ref": "#/$defs/inferred_variable_map"
|
||||
},
|
||||
"beliefs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/belief"
|
||||
}
|
||||
},
|
||||
"distortions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"enum": [
|
||||
"hostile_attribution",
|
||||
"false_reassurance",
|
||||
"overread_warmth",
|
||||
"underread_threat",
|
||||
"false_mask_detection",
|
||||
"missed_mask_detection",
|
||||
"status_injury",
|
||||
"threat_amplification",
|
||||
"institutional_suspicion_transfer",
|
||||
"care_underread",
|
||||
"mask_overfocus"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"belief": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"belief_id",
|
||||
"claim",
|
||||
"confidence"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"belief_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"claim": {
|
||||
"type": "string"
|
||||
},
|
||||
"confidence": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"subject_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"claim_type": {
|
||||
"enum": [
|
||||
"fact",
|
||||
"identity_inference",
|
||||
"motive_inference",
|
||||
"prediction",
|
||||
"norm",
|
||||
"value_read",
|
||||
"relationship_read"
|
||||
]
|
||||
},
|
||||
"evidence_event_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"visibility": {
|
||||
"enum": [
|
||||
"private",
|
||||
"shared",
|
||||
"public",
|
||||
"rumor"
|
||||
]
|
||||
},
|
||||
"emotional_charge": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"relationship": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"relationship_id",
|
||||
"source_id",
|
||||
"target_id",
|
||||
"stance",
|
||||
"summary"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"relationship_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"source_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"target_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"stance": {
|
||||
"$ref": "#/$defs/variable_map"
|
||||
},
|
||||
"summary": {
|
||||
"type": "string"
|
||||
},
|
||||
"unresolved_incident_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"event": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"event_id",
|
||||
"kind",
|
||||
"summary",
|
||||
"participants",
|
||||
"pressure_tags"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"event_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"kind": {
|
||||
"enum": [
|
||||
"dialogue",
|
||||
"action",
|
||||
"memory",
|
||||
"relationship_shift",
|
||||
"scene_outcome",
|
||||
"author_note"
|
||||
]
|
||||
},
|
||||
"summary": {
|
||||
"type": "string"
|
||||
},
|
||||
"participants": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"pressure_tags": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"observed_exchange": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/event_exchange_turn"
|
||||
}
|
||||
},
|
||||
"private_interpretations": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/event_private_interpretation"
|
||||
}
|
||||
},
|
||||
"event_effects": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/event_effect"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"scene": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"scene_id",
|
||||
"location",
|
||||
"participants",
|
||||
"public_stakes",
|
||||
"hidden_stakes",
|
||||
"dialogue_context_packs"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"scene_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"location": {
|
||||
"type": "string"
|
||||
},
|
||||
"participants": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"public_stakes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"hidden_stakes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"dialogue_context_packs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/dialogue_context_pack"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"dialogue_context_pack": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"speaker_agent_id",
|
||||
"listener_ids",
|
||||
"speaker_local_truth",
|
||||
"speaker_beliefs",
|
||||
"active_memories",
|
||||
"active_goals",
|
||||
"presentation_constraints"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"speaker_agent_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"listener_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"speaker_local_truth": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"speaker_beliefs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"active_memories": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"active_goals": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"presentation_constraints": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"pressure": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"pressure_id",
|
||||
"label",
|
||||
"intensity"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"pressure_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"intensity": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"inferred_variable_map": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/$defs/inferred_state_variable"
|
||||
}
|
||||
},
|
||||
"inferred_state_variable": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"observed_activation",
|
||||
"attributed_disposition",
|
||||
"confidence"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"observed_activation": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"attributed_disposition": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"confidence": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"evidence": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"event_exchange_turn": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"speaker_id",
|
||||
"utterance_summary",
|
||||
"dialogue_function"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"speaker_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"utterance_summary": {
|
||||
"type": "string"
|
||||
},
|
||||
"dialogue_function": {
|
||||
"type": "string"
|
||||
},
|
||||
"observable_cues": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"event_private_interpretation": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"agent_id",
|
||||
"interpretation",
|
||||
"confidence"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"agent_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"interpretation": {
|
||||
"type": "string"
|
||||
},
|
||||
"attributed_motive": {
|
||||
"type": "string"
|
||||
},
|
||||
"appraisal_tags": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"confidence": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"event_effect": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"agent_id",
|
||||
"summary"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"agent_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"summary": {
|
||||
"type": "string"
|
||||
},
|
||||
"state_deltas": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/state_delta"
|
||||
}
|
||||
},
|
||||
"new_belief_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"memory_update_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"state_delta": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"path",
|
||||
"delta"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
"delta": {
|
||||
"type": "number",
|
||||
"minimum": -1,
|
||||
"maximum": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
packages/cultnet-ts/contracts/swarm-contracts.manifest.json
Normal file
11
packages/cultnet-ts/contracts/swarm-contracts.manifest.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"contracts": [
|
||||
{
|
||||
"schemaPath": "./ghostlight.agent-state.schema.json",
|
||||
"typeName": "GhostlightAgentStateShape",
|
||||
"contractExportName": "ghostlightAgentStateGeneratedContract",
|
||||
"validateExportName": "validateGhostlightAgentStateGenerated",
|
||||
"parseExportName": "parseGhostlightAgentStateGenerated"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/GameCult/cultnet-ts/integration/contracts/cultnet.discovery-announce.schema.json",
|
||||
"title": "CultNet Discovery Announce",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"messageId",
|
||||
"runtimeId",
|
||||
"runtimeKind",
|
||||
"displayName",
|
||||
"tcpHost",
|
||||
"tcpPort",
|
||||
"wireContract",
|
||||
"supportedDocumentTypes",
|
||||
"supportsSchemaCatalog"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultnet.discovery_announce.v0" },
|
||||
"messageId": { "type": "string", "minLength": 1 },
|
||||
"runtimeId": { "type": "string", "minLength": 1 },
|
||||
"runtimeKind": { "type": "string", "minLength": 1 },
|
||||
"displayName": { "type": "string", "minLength": 1 },
|
||||
"agentId": { "type": "string", "minLength": 1 },
|
||||
"tcpHost": { "type": "string", "minLength": 1 },
|
||||
"tcpPort": { "type": "integer", "minimum": 1, "maximum": 65535 },
|
||||
"wireContract": { "const": "cultnet.schema.v0" },
|
||||
"supportedDocumentTypes": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"supportsSchemaCatalog": { "type": "boolean" }
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue