Bring language packages into CultLib monorepo

This commit is contained in:
Meta Vens 2026-05-30 00:39:39 +01:00
commit 5c69bfc581
121 changed files with 24581 additions and 0 deletions

19
.gitignore vendored
View file

@ -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
View 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
View file

@ -0,0 +1,2 @@
/target
Cargo.lock

View 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"

View 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.

View 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"] }

View 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,
}

View 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(&note.document_id, &note)?;
print_note(&note)
}
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}"))
}

View 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
View file

@ -0,0 +1,5 @@
node_modules/
dist-test/
dist-inspector/
release/
release-inspector/

View 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.

View 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"]
}
}

View 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();
}
});

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

File diff suppressed because it is too large Load diff

View 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);
}

View 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

File diff suppressed because it is too large Load diff

View 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"
}
}

View 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;
}

View 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);
}
}

View 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,
});
}

View 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";

View file

@ -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;
}

View 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[];
}

View 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);
}

View 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"
]
}

View 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"
]
}

View file

@ -0,0 +1,14 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "dist-test",
"declaration": false,
"declarationMap": false
},
"include": [
"src/**/*.ts",
"test/**/*.ts"
],
"exclude": []
}

View 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
}
});

View 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"
}
}

View 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.`);
}
}

View 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);
});

View 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"
]
}

View 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
View file

@ -0,0 +1,2 @@
/target
Cargo.lock

View 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"

View 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.

View file

@ -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 }
}
}

View file

@ -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 }
}
}
}

View file

@ -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"
}
}
}

View file

@ -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"
}
}
}

View file

@ -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 }
}
}
}

View 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 }
}
}

View 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" }
}
}

View file

@ -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 }
}
}

View 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 }
}
}

View file

@ -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 }
}
}
}

View 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 }
}
}

View file

@ -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 }
}
}

View file

@ -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 }
}
}

View file

@ -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"
]
}
}
}
}

View file

@ -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"
}
}
}
}

View file

@ -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 }
}
}

View file

@ -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 }
}
}
}

View file

@ -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"
}
}
}
}

View file

@ -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"
}
}
}
}

View 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 }
}
}

View 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
}
}
}
}
}

View 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(&note.document_id, &note)?;
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, &note)?,
"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(&note.document_id, &note)?;
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(),
&note,
options,
)?;
send_message(stream, &receipt_message)?;
send_message(stream, &note_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(())
}

File diff suppressed because it is too large Load diff

View 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
}
}

View 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::*;

View 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)
}

View 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()),
})
}

View 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
}

View 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(())
}

Binary file not shown.

Binary file not shown.

4
packages/cultnet-ts/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
node_modules/
dist/
dist-test/
*.tgz

View 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
```

View file

@ -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 }
}
}

View file

@ -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 }
}
}
}

View file

@ -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"
}
}
}

View file

@ -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"
}
}
}

View file

@ -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 }
}
}
}

View 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 }
}
}

View 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" }
}
}

View file

@ -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 }
}
}

View 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 }
}
}

View file

@ -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 }
}
}
}

View 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 }
}
}

View file

@ -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 }
}
}

View file

@ -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 }
}
}

View file

@ -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"
]
}
}
}
}

View file

@ -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"
}
}
}
}

View file

@ -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 }
}
}

View file

@ -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"
}
}
}

View file

@ -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"
}
}
}
}

View file

@ -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"
}
}
}
}

View 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 }
}
}

View 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
}
}
}
}
}

View file

@ -0,0 +1,11 @@
{
"contracts": [
{
"schemaPath": "./ghostlight.agent-state.schema.json",
"typeName": "GhostlightAgentStateShape",
"contractExportName": "ghostlightAgentStateGeneratedContract",
"validateExportName": "validateGhostlightAgentStateGenerated",
"parseExportName": "parseGhostlightAgentStateGenerated"
}
]
}

View file

@ -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