Interop: an element id crosses C#, TypeScript, Python and Rust

The C# cultcache interop peer gains an object-list document (a deck of marks).
C# writes it with cache-minted ids; TypeScript, Python and Rust read the ids as
ordinary members, and TypeScript-written ids survive a C# read.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-29 15:03:09 +02:00
commit 640ad9a437
3 changed files with 116 additions and 0 deletions

View file

@ -26,6 +26,38 @@ struct CultCacheInteropNote {
tags: Vec<String>,
}
// An object list: each mark is a positional array [label, id], the id an ordinary member at slot 1.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
struct CultCacheInteropMark(String, String);
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "cultcache.interop-deck", schema = "CultCacheInteropDeck")]
struct CultCacheInteropDeck {
#[cultcache(key = 0)]
document_id: String,
#[cultcache(key = 1, default)]
marks: Vec<CultCacheInteropMark>,
}
fn read_deck(file: &str) -> Result<()> {
let mut cache = CultCache::new();
cache.register_entry_type::<CultCacheInteropDeck>()?;
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(file))?;
cache.pull_all_backing_stores()?;
let decks = cache.get_all::<CultCacheInteropDeck>()?;
let deck = decks
.first()
.ok_or_else(|| anyhow!("no cultcache.interop-deck records found"))?;
println!(
"{}",
serde_json::to_string(&json!({
"documentId": deck.document_id,
"ids": deck.marks.iter().map(|mark| mark.1.clone()).collect::<Vec<_>>(),
}))?
);
Ok(())
}
fn main() -> Result<()> {
let mut args = std::env::args().skip(1);
let mode = args
@ -37,6 +69,7 @@ fn main() -> Result<()> {
match mode.as_str() {
"write" => write_note(file, require_arg(&options, "runtime-id")?)?,
"read" => read_note(file)?,
"read-deck" => read_deck(file)?,
_ => return Err(anyhow!("unknown mode {mode}")),
}
Ok(())

View file

@ -1173,6 +1173,43 @@ test("CultCache v1 MessagePack stores are readable across TS, Rust, C#, and Pyth
}
});
test("CultCache element ids cross C#, TypeScript, Python and Rust as ordinary members", async () => {
await buildInteropPeers();
const tempDir = await mkdtemp(join(tmpdir(), "cultcache-deck-"));
const file = join(tempDir, "deck.cc");
const written = await runJsonCommand("csharp-write-deck", dotnetCommand, [
csharpInteropDll, "write-deck", "--file", file,
], cultLibRoot);
assert.equal(written.ids.length, 3);
assert.equal(new Set(written.ids).size, 3, "two look-alike marks got distinct ids");
for (const id of written.ids) assert.match(id, /^[0-9a-f]{12}$/u);
// TypeScript: the id is slot 1 of each mark, read from the raw payload.
const store = decode(await readFile(file)) as any[];
const payload = decode(store[2][0][3]) as any[];
assert.deepEqual(payload[1].map((mark: any[]) => mark[1]), written.ids);
// Python and Rust read the same store.
const python = await runJsonCommand("python-deck", pythonCommand, [
"-c",
"import msgpack,json,sys;s=msgpack.unpackb(open(sys.argv[1],'rb').read(),raw=False);p=msgpack.unpackb(s[2][0][3],raw=False);print(json.dumps({'ids':[m[1] for m in p[1]]}))",
file,
], cultcachePyRoot);
assert.deepEqual(python.ids, written.ids);
const rust = await runJsonCommand("rust-read-deck", rustInteropBinary, ["read-deck", "--file", file], cultcacheRsRoot);
assert.deepEqual(rust.ids, written.ids);
// TypeScript writes the ids; C# keeps them.
const tsIds = ["t00000000001", "t00000000002", "t00000000003"];
store[2][0][3] = encode(["deck:csharp", [["twin", tsIds[0]], ["twin", tsIds[1]], ["other", tsIds[2]]]]);
const tsFile = join(tempDir, "ts-deck.cc");
await writeFile(tsFile, encode(store));
const back = await runJsonCommand("csharp-read-deck", dotnetCommand, [
csharpInteropDll, "read-deck", "--file", tsFile,
], cultLibRoot);
assert.deepEqual(back.ids, tsIds);
});
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");

View file

@ -38,6 +38,12 @@ static async Task<int> ProgramMainAsync(string[] args)
case "read":
await ReadAsync(file);
return 0;
case "write-deck":
await WriteDeckAsync(file);
return 0;
case "read-deck":
await ReadDeckAsync(file);
return 0;
default:
throw new InvalidOperationException($"Unknown mode {mode}.");
}
@ -77,6 +83,31 @@ static async Task ReadAsync(string file)
WriteJsonLine(note);
}
// A record with an object list: the cache mints each mark's element id, and every runtime reads it as an ordinary member.
static async Task WriteDeckAsync(string file)
{
var cache = BuildCache(file);
await cache.PullAllBackingStoresAsync();
var deck = new CultCacheInteropDeck
{
DocumentId = "deck:csharp",
Marks = [new CultCacheInteropMark { Label = "twin" }, new CultCacheInteropMark { Label = "twin" }, new CultCacheInteropMark { Label = "other" }]
};
await cache.AddAsync(deck, new CultRecordHandle<CultCacheInteropDeck>(new CultRecordKey(deck.DocumentId)));
cache.FlushAllBackingStores();
WriteDeckJsonLine(deck);
}
static async Task ReadDeckAsync(string file)
{
var cache = BuildCache(file);
await cache.PullAllBackingStoresAsync();
WriteDeckJsonLine(cache.AllEntries.OfType<CultCacheInteropDeck>().Single());
}
static void WriteDeckJsonLine(CultCacheInteropDeck deck) =>
Console.Out.WriteLine(JsonSerializer.Serialize(new { documentId = deck.DocumentId, ids = deck.Marks.Select(mark => mark.Id).ToArray() }));
// Two routed single-file stores: each file is a complete single-store snapshot holding only its own type.
static void WriteRouted(string catalogFile, string runFile)
{
@ -176,3 +207,18 @@ public sealed class CultCacheInteropRunNote
[Key(2)] public string AuthorRuntimeId { get; set; } = string.Empty;
[Key(3)] public string Body { get; set; } = string.Empty;
}
[CultDocument("cultcache.interop-deck", "cultcache.interop_deck.v1")]
[MessagePackObject]
public sealed class CultCacheInteropDeck
{
[Key(0)] [CultName] public string DocumentId { get; set; } = string.Empty;
[Key(1)] public List<CultCacheInteropMark> Marks { get; set; } = new();
}
[MessagePackObject]
public sealed class CultCacheInteropMark
{
[Key(0)] public string Label { get; set; } = string.Empty;
[Key(1)] [CultElementId] public string Id { get; set; } = string.Empty;
}